run pre-commit on all

This commit is contained in:
Simon 2025-01-06 21:08:51 +07:00
parent cf54f6d7fc
commit bc74bf80f4
No known key found for this signature in database
GPG Key ID: 2C15AA5E89985DD4
58 changed files with 8962 additions and 8942 deletions

View File

@ -18,4 +18,4 @@ venv/
assets/* assets/*
# for local testing only # for local testing only
testing.sh testing.sh

2
.gitattributes vendored
View File

@ -1 +1 @@
docker_assets\run.sh eol=lf docker_assets\run.sh eol=lf

2
.github/FUNDING.yml vendored
View File

@ -1,3 +1,3 @@
github: bbilly1 github: bbilly1
ko_fi: bbilly1 ko_fi: bbilly1
custom: https://paypal.me/bbilly1 custom: https://paypal.me/bbilly1

View File

@ -6,7 +6,7 @@ body:
- type: checkboxes - type: checkboxes
id: block id: block
attributes: attributes:
label: "This project doesn't accept any new feature requests for the forseeable future. There is no shortage of ideas and the next development steps are clear for years to come." label: "This project doesn't accept any new feature requests for the foreseeable future. There is no shortage of ideas and the next development steps are clear for years to come."
options: options:
- label: I understand that this issue will be closed without comment. - label: I understand that this issue will be closed without comment.
required: true required: true

48
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,48 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: end-of-file-fixer
- repo: https://github.com/psf/black
rev: 24.10.0
hooks:
- id: black
alias: python
files: ^backend/
args: ["--line-length=79"]
- repo: https://github.com/pycqa/isort
rev: 5.13.2
hooks:
- id: isort
name: isort (python)
alias: python
files: ^backend/
args: ["--profile", "black", "-l 79"]
- repo: https://github.com/pycqa/flake8
rev: 7.1.1
hooks:
- id: flake8
alias: python
files: ^backend/
args: [ "--max-complexity=10", "--max-line-length=79" ]
- repo: https://github.com/codespell-project/codespell
rev: v2.3.0
hooks:
- id: codespell
exclude: ^frontend/package-lock.json
# - repo: https://github.com/pre-commit/mirrors-eslint
# rev: v9.17.0
# hooks:
# - id: eslint
# name: eslint
# entry: npm run --prefix ./frontend lint
# pass_filenames: false
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v4.0.0-alpha.8
hooks:
- id: prettier
entry: npm run --prefix ./frontend prettier
args: ["--write", "."]
pass_filenames: false
exclude: '.*(\.svg|/migrations/).*'

View File

@ -684,4 +684,4 @@
} }
} }
] ]
} }

View File

@ -1,9 +1,6 @@
-r requirements.txt -r requirements.txt
black==24.10.0
codespell==2.3.0
flake8==7.1.1
ipython==8.31.0 ipython==8.31.0
isort==5.13.2 pre-commit==4.0.1
pylint-django==2.6.1 pylint-django==2.6.1
pylint==3.3.3 pylint==3.3.3
pytest-django==4.9.0 pytest-django==4.9.0

View File

@ -25,7 +25,10 @@ class Migration(migrations.Migration):
verbose_name="ID", verbose_name="ID",
), ),
), ),
("password", models.CharField(max_length=128, verbose_name="password")), (
"password",
models.CharField(max_length=128, verbose_name="password"),
),
( (
"last_login", "last_login",
models.DateTimeField( models.DateTimeField(

View File

@ -53,4 +53,4 @@ server {
location / { location / {
try_files $uri $uri/ /index.html =404; try_files $uri $uri/ /index.html =404;
} }
} }

View File

@ -2,4 +2,4 @@
build build
dist dist
coverage coverage
node_modules node_modules

View File

@ -1,26 +1,26 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" /> <link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png" /> <link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png" /> <link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png" />
<link rel="manifest" href="/favicon/site.webmanifest" /> <link rel="manifest" href="/favicon/site.webmanifest" />
<link rel="mask-icon" href="/favicon/safari-pinned-tab.svg" color="#01202e" /> <link rel="mask-icon" href="/favicon/safari-pinned-tab.svg" color="#01202e" />
<link rel="shortcut icon" href="/favicon/favicon.ico" /> <link rel="shortcut icon" href="/favicon/favicon.ico" />
<meta name="apple-mobile-web-app-title" content="TubeArchivist" /> <meta name="apple-mobile-web-app-title" content="TubeArchivist" />
<meta name="application-name" content="TubeArchivist" /> <meta name="application-name" content="TubeArchivist" />
<meta name="msapplication-TileColor" content="#01202e" /> <meta name="msapplication-TileColor" content="#01202e" />
<meta name="msapplication-config" content="/favicon/browserconfig.xml" /> <meta name="msapplication-config" content="/favicon/browserconfig.xml" />
<meta name="theme-color" content="#01202e" /> <meta name="theme-color" content="#01202e" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TubeArchivist</title> <title>TubeArchivist</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

File diff suppressed because it is too large Load Diff

View File

@ -1,33 +1,34 @@
{ {
"name": "tubearchivist-frontend", "name": "tubearchivist-frontend",
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"build:deploy": "vite build", "build:deploy": "vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview" "prettier": "prettier --write .",
}, "preview": "vite preview"
"dependencies": { },
"dompurify": "^3.2.3", "dependencies": {
"react": "^19.0.0", "dompurify": "^3.2.3",
"react-dom": "^19.0.0", "react": "^19.0.0",
"react-router-dom": "^7.0.2", "react-dom": "^19.0.0",
"zustand": "^5.0.2" "react-router-dom": "^7.0.2",
}, "zustand": "^5.0.2"
"devDependencies": { },
"@types/react": "^19.0.1", "devDependencies": {
"@types/react-dom": "^19.0.2", "@types/react": "^19.0.1",
"@typescript-eslint/eslint-plugin": "^8.18.0", "@types/react-dom": "^19.0.2",
"@typescript-eslint/parser": "^8.18.0", "@typescript-eslint/eslint-plugin": "^8.18.0",
"@vitejs/plugin-react-swc": "^3.7.2", "@typescript-eslint/parser": "^8.18.0",
"eslint": "^9.16.0", "@vitejs/plugin-react-swc": "^3.7.2",
"eslint-config-prettier": "^9.1.0", "eslint": "^9.16.0",
"eslint-plugin-react-hooks": "^5.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-react-refresh": "^0.4.16", "eslint-plugin-react-hooks": "^5.1.0",
"prettier": "3.4.2", "eslint-plugin-react-refresh": "^0.4.16",
"typescript": "^5.7.2", "prettier": "3.4.2",
"vite": "^6.0.3" "typescript": "^5.7.2",
} "vite": "^6.0.3"
} }
}

View File

@ -1,42 +1,42 @@
import defaultHeaders from '../../configuration/defaultHeaders'; import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl'; import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials'; import getFetchCredentials from '../../configuration/getFetchCredentials';
import getCookie from '../../functions/getCookie'; import getCookie from '../../functions/getCookie';
const updateChannelSubscription = async (channelIds: string, status: boolean) => { const updateChannelSubscription = async (channelIds: string, status: boolean) => {
const apiUrl = getApiUrl(); const apiUrl = getApiUrl();
const csrfCookie = getCookie('csrftoken'); const csrfCookie = getCookie('csrftoken');
const channels = []; const channels = [];
const containsMultiple = channelIds.includes('\n'); const containsMultiple = channelIds.includes('\n');
if (containsMultiple) { if (containsMultiple) {
const youtubeChannelIds = channelIds.split('\n'); const youtubeChannelIds = channelIds.split('\n');
youtubeChannelIds.forEach(channelId => { youtubeChannelIds.forEach(channelId => {
channels.push({ channel_id: channelId, channel_subscribed: status }); channels.push({ channel_id: channelId, channel_subscribed: status });
}); });
} else { } else {
channels.push({ channel_id: channelIds, channel_subscribed: status }); channels.push({ channel_id: channelIds, channel_subscribed: status });
} }
const response = await fetch(`${apiUrl}/api/channel/`, { const response = await fetch(`${apiUrl}/api/channel/`, {
method: 'POST', method: 'POST',
headers: { headers: {
...defaultHeaders, ...defaultHeaders,
'X-CSRFToken': csrfCookie || '', 'X-CSRFToken': csrfCookie || '',
}, },
credentials: getFetchCredentials(), credentials: getFetchCredentials(),
body: JSON.stringify({ body: JSON.stringify({
data: [...channels], data: [...channels],
}), }),
}); });
const channelSubscription = await response.json(); const channelSubscription = await response.json();
console.log('updateChannelSubscription', channelSubscription); console.log('updateChannelSubscription', channelSubscription);
return channelSubscription; return channelSubscription;
}; };
export default updateChannelSubscription; export default updateChannelSubscription;

View File

@ -1,33 +1,33 @@
import defaultHeaders from '../../configuration/defaultHeaders'; import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl'; import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials'; import getFetchCredentials from '../../configuration/getFetchCredentials';
import getCookie from '../../functions/getCookie'; import getCookie from '../../functions/getCookie';
export type ValidatedCookieType = { export type ValidatedCookieType = {
cookie_enabled: boolean; cookie_enabled: boolean;
status: boolean; status: boolean;
validated: number; validated: number;
validated_str: string; validated_str: string;
cookie_validated?: boolean; cookie_validated?: boolean;
}; };
const updateCookie = async (): Promise<ValidatedCookieType> => { const updateCookie = async (): Promise<ValidatedCookieType> => {
const apiUrl = getApiUrl(); const apiUrl = getApiUrl();
const csrfCookie = getCookie('csrftoken'); const csrfCookie = getCookie('csrftoken');
const response = await fetch(`${apiUrl}/api/appsettings/cookie/`, { const response = await fetch(`${apiUrl}/api/appsettings/cookie/`, {
method: 'POST', method: 'POST',
headers: { headers: {
...defaultHeaders, ...defaultHeaders,
'X-CSRFToken': csrfCookie || '', 'X-CSRFToken': csrfCookie || '',
}, },
credentials: getFetchCredentials(), credentials: getFetchCredentials(),
}); });
const validatedCookie = await response.json(); const validatedCookie = await response.json();
console.log('updateCookie', validatedCookie); console.log('updateCookie', validatedCookie);
return validatedCookie; return validatedCookie;
}; };
export default updateCookie; export default updateCookie;

View File

@ -1,47 +1,47 @@
import defaultHeaders from '../../configuration/defaultHeaders'; import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl'; import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials'; import getFetchCredentials from '../../configuration/getFetchCredentials';
import getCookie from '../../functions/getCookie'; import getCookie from '../../functions/getCookie';
const updateDownloadQueue = async (youtubeIdStrings: string, autostart: boolean) => { const updateDownloadQueue = async (youtubeIdStrings: string, autostart: boolean) => {
const apiUrl = getApiUrl(); const apiUrl = getApiUrl();
const csrfCookie = getCookie('csrftoken'); const csrfCookie = getCookie('csrftoken');
const urls = []; const urls = [];
const containsMultiple = youtubeIdStrings.includes('\n'); const containsMultiple = youtubeIdStrings.includes('\n');
if (containsMultiple) { if (containsMultiple) {
const youtubeIds = youtubeIdStrings.split('\n'); const youtubeIds = youtubeIdStrings.split('\n');
youtubeIds.forEach(youtubeId => { youtubeIds.forEach(youtubeId => {
urls.push({ youtube_id: youtubeId, status: 'pending' }); urls.push({ youtube_id: youtubeId, status: 'pending' });
}); });
} else { } else {
urls.push({ youtube_id: youtubeIdStrings, status: 'pending' }); urls.push({ youtube_id: youtubeIdStrings, status: 'pending' });
} }
let params = ''; let params = '';
if (autostart) { if (autostart) {
params = '?autostart=true'; params = '?autostart=true';
} }
const response = await fetch(`${apiUrl}/api/download/${params}`, { const response = await fetch(`${apiUrl}/api/download/${params}`, {
method: 'POST', method: 'POST',
headers: { headers: {
...defaultHeaders, ...defaultHeaders,
'X-CSRFToken': csrfCookie || '', 'X-CSRFToken': csrfCookie || '',
}, },
credentials: getFetchCredentials(), credentials: getFetchCredentials(),
body: JSON.stringify({ body: JSON.stringify({
data: [...urls], data: [...urls],
}), }),
}); });
const downloadState = await response.json(); const downloadState = await response.json();
console.log('updateDownloadQueue', downloadState); console.log('updateDownloadQueue', downloadState);
return downloadState; return downloadState;
}; };
export default updateDownloadQueue; export default updateDownloadQueue;

View File

@ -1,42 +1,42 @@
import defaultHeaders from '../../configuration/defaultHeaders'; import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl'; import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials'; import getFetchCredentials from '../../configuration/getFetchCredentials';
import getCookie from '../../functions/getCookie'; import getCookie from '../../functions/getCookie';
const updatePlaylistSubscription = async (playlistIds: string, status: boolean) => { const updatePlaylistSubscription = async (playlistIds: string, status: boolean) => {
const apiUrl = getApiUrl(); const apiUrl = getApiUrl();
const csrfCookie = getCookie('csrftoken'); const csrfCookie = getCookie('csrftoken');
const playlists = []; const playlists = [];
const containsMultiple = playlistIds.includes('\n'); const containsMultiple = playlistIds.includes('\n');
if (containsMultiple) { if (containsMultiple) {
const youtubePlaylistIds = playlistIds.split('\n'); const youtubePlaylistIds = playlistIds.split('\n');
youtubePlaylistIds.forEach(playlistId => { youtubePlaylistIds.forEach(playlistId => {
playlists.push({ playlist_id: playlistId, playlist_subscribed: status }); playlists.push({ playlist_id: playlistId, playlist_subscribed: status });
}); });
} else { } else {
playlists.push({ playlist_id: playlistIds, playlist_subscribed: status }); playlists.push({ playlist_id: playlistIds, playlist_subscribed: status });
} }
const response = await fetch(`${apiUrl}/api/playlist/`, { const response = await fetch(`${apiUrl}/api/playlist/`, {
method: 'POST', method: 'POST',
headers: { headers: {
...defaultHeaders, ...defaultHeaders,
'X-CSRFToken': csrfCookie || '', 'X-CSRFToken': csrfCookie || '',
}, },
credentials: getFetchCredentials(), credentials: getFetchCredentials(),
body: JSON.stringify({ body: JSON.stringify({
data: [...playlists], data: [...playlists],
}), }),
}); });
const playlistSubscription = await response.json(); const playlistSubscription = await response.json();
console.log('updatePlaylistSubscription', playlistSubscription); console.log('updatePlaylistSubscription', playlistSubscription);
return playlistSubscription; return playlistSubscription;
}; };
export default updatePlaylistSubscription; export default updatePlaylistSubscription;

View File

@ -1,36 +1,36 @@
import defaultHeaders from '../../configuration/defaultHeaders'; import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl'; import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials'; import getFetchCredentials from '../../configuration/getFetchCredentials';
import getCookie from '../../functions/getCookie'; import getCookie from '../../functions/getCookie';
import isDevEnvironment from '../../functions/isDevEnvironment'; import isDevEnvironment from '../../functions/isDevEnvironment';
type ApiTokenResponse = { type ApiTokenResponse = {
token: string; token: string;
}; };
const loadApiToken = async (): Promise<ApiTokenResponse> => { const loadApiToken = async (): Promise<ApiTokenResponse> => {
const apiUrl = getApiUrl(); const apiUrl = getApiUrl();
const csrfCookie = getCookie('csrftoken'); const csrfCookie = getCookie('csrftoken');
try { try {
const response = await fetch(`${apiUrl}/api/appsettings/token/`, { const response = await fetch(`${apiUrl}/api/appsettings/token/`, {
headers: { headers: {
...defaultHeaders, ...defaultHeaders,
'X-CSRFToken': csrfCookie || '', 'X-CSRFToken': csrfCookie || '',
}, },
credentials: getFetchCredentials(), credentials: getFetchCredentials(),
}); });
const apiToken = await response.json(); const apiToken = await response.json();
if (isDevEnvironment()) { if (isDevEnvironment()) {
console.log('loadApiToken', apiToken); console.log('loadApiToken', apiToken);
} }
return apiToken; return apiToken;
} catch (e) { } catch (e) {
return { token: '' }; return { token: '' };
} }
}; };
export default loadApiToken; export default loadApiToken;

View File

@ -1,54 +1,54 @@
import defaultHeaders from '../../configuration/defaultHeaders'; import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl'; import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials'; import getFetchCredentials from '../../configuration/getFetchCredentials';
import isDevEnvironment from '../../functions/isDevEnvironment'; import isDevEnvironment from '../../functions/isDevEnvironment';
export type AppSettingsConfigType = { export type AppSettingsConfigType = {
subscriptions: { subscriptions: {
channel_size: number; channel_size: number;
live_channel_size: number; live_channel_size: number;
shorts_channel_size: number; shorts_channel_size: number;
auto_start: boolean; auto_start: boolean;
}; };
downloads: { downloads: {
limit_speed: false | number; limit_speed: false | number;
sleep_interval: number; sleep_interval: number;
autodelete_days: number; autodelete_days: number;
format: number | 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: string | number; comment_max: string | number;
comment_sort: string; comment_sort: string;
cookie_import: boolean; cookie_import: boolean;
throttledratelimit: false | number; throttledratelimit: false | number;
extractor_lang: boolean | string; extractor_lang: boolean | string;
integrate_ryd: boolean; integrate_ryd: boolean;
integrate_sponsorblock: boolean; integrate_sponsorblock: boolean;
}; };
application: { application: {
enable_snapshot: boolean; enable_snapshot: boolean;
}; };
}; };
const loadAppsettingsConfig = async (): Promise<AppSettingsConfigType> => { const loadAppsettingsConfig = async (): Promise<AppSettingsConfigType> => {
const apiUrl = getApiUrl(); const apiUrl = getApiUrl();
const response = await fetch(`${apiUrl}/api/appsettings/config/`, { const response = await fetch(`${apiUrl}/api/appsettings/config/`, {
headers: defaultHeaders, headers: defaultHeaders,
credentials: getFetchCredentials(), credentials: getFetchCredentials(),
}); });
const appSettingsConfig = await response.json(); const appSettingsConfig = await response.json();
if (isDevEnvironment()) { if (isDevEnvironment()) {
console.log('loadApplicationConfig', appSettingsConfig); console.log('loadApplicationConfig', appSettingsConfig);
} }
return appSettingsConfig; return appSettingsConfig;
}; };
export default loadAppsettingsConfig; export default loadAppsettingsConfig;

View File

@ -1,74 +1,74 @@
import defaultHeaders from '../../configuration/defaultHeaders'; import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl'; import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials'; import getFetchCredentials from '../../configuration/getFetchCredentials';
import isDevEnvironment from '../../functions/isDevEnvironment'; import isDevEnvironment from '../../functions/isDevEnvironment';
import { ConfigType, SortByType, SortOrderType, VideoType } from '../../pages/Home'; import { ConfigType, SortByType, SortOrderType, VideoType } from '../../pages/Home';
import { PaginationType } from '../../components/Pagination'; import { PaginationType } from '../../components/Pagination';
export type VideoListByFilterResponseType = { export type VideoListByFilterResponseType = {
data?: VideoType[]; data?: VideoType[];
config?: ConfigType; config?: ConfigType;
paginate?: PaginationType; paginate?: PaginationType;
}; };
type WatchTypes = 'watched' | 'unwatched' | 'continue'; type WatchTypes = 'watched' | 'unwatched' | 'continue';
export type VideoTypes = 'videos' | 'streams' | 'shorts'; export type VideoTypes = 'videos' | 'streams' | 'shorts';
type FilterType = { type FilterType = {
page?: number; page?: number;
playlist?: string; playlist?: string;
channel?: string; channel?: string;
watch?: WatchTypes; watch?: WatchTypes;
sort?: SortByType; sort?: SortByType;
order?: SortOrderType; order?: SortOrderType;
type?: VideoTypes; type?: VideoTypes;
}; };
const loadVideoListByFilter = async ( const loadVideoListByFilter = async (
filter: FilterType, filter: FilterType,
): Promise<VideoListByFilterResponseType> => { ): Promise<VideoListByFilterResponseType> => {
const apiUrl = getApiUrl(); const apiUrl = getApiUrl();
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
if (filter.page) { if (filter.page) {
searchParams.append('page', filter.page.toString()); searchParams.append('page', filter.page.toString());
} }
if (filter.playlist) { if (filter.playlist) {
searchParams.append('playlist', filter.playlist); searchParams.append('playlist', filter.playlist);
} else if (filter.channel) { } else if (filter.channel) {
searchParams.append('channel', filter.channel); searchParams.append('channel', filter.channel);
} }
if (filter.watch) { if (filter.watch) {
searchParams.append('watch', filter.watch); searchParams.append('watch', filter.watch);
} }
if (filter.sort) { if (filter.sort) {
searchParams.append('sort', filter.sort); searchParams.append('sort', filter.sort);
} }
if (filter.order) { if (filter.order) {
searchParams.append('order', filter.order); searchParams.append('order', filter.order);
} }
if (filter.type) { if (filter.type) {
searchParams.append('type', filter.type); searchParams.append('type', filter.type);
} }
const response = await fetch(`${apiUrl}/api/video/?${searchParams.toString()}`, { const response = await fetch(`${apiUrl}/api/video/?${searchParams.toString()}`, {
headers: defaultHeaders, headers: defaultHeaders,
credentials: getFetchCredentials(), credentials: getFetchCredentials(),
}); });
const videos = await response.json(); const videos = await response.json();
if (isDevEnvironment()) { if (isDevEnvironment()) {
console.log('loadVideoListByFilter', filter, videos); console.log('loadVideoListByFilter', filter, videos);
} }
return videos; return videos;
}; };
export default loadVideoListByFilter; export default loadVideoListByFilter;

View File

@ -1,42 +1,42 @@
import { ReactNode } from 'react'; 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 | ReactNode | ReactNode[]; label?: string | ReactNode | ReactNode[];
children?: string | ReactNode | ReactNode[]; children?: string | ReactNode | ReactNode[];
value?: string; value?: string;
title?: string; title?: string;
onClick?: () => void; onClick?: () => void;
} }
const Button = ({ const Button = ({
id, id,
name, name,
className, className,
type, type,
label, label,
children, children,
value, value,
title, title,
onClick, onClick,
}: ButtonProps) => { }: ButtonProps) => {
return ( return (
<button <button
id={id} id={id}
name={name} name={name}
className={className} className={className}
type={type} type={type}
value={value} value={value}
title={title} title={title}
onClick={onClick} onClick={onClick}
> >
{label} {label}
{children} {children}
</button> </button>
); );
}; };
export default Button; export default Button;

View File

@ -1,22 +1,22 @@
import getApiUrl from '../configuration/getApiUrl'; 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 = {
channelId: string; channelId: string;
channelBannerUrl: string | undefined; channelBannerUrl: string | undefined;
}; };
const ChannelBanner = ({ channelId, channelBannerUrl }: ChannelIconProps) => { const ChannelBanner = ({ channelId, channelBannerUrl }: ChannelIconProps) => {
return ( return (
<img <img
src={`${getApiUrl()}${channelBannerUrl}`} src={`${getApiUrl()}${channelBannerUrl}`}
alt={`${channelId}-banner`} alt={`${channelId}-banner`}
onError={({ currentTarget }) => { onError={({ currentTarget }) => {
currentTarget.onerror = null; // prevents looping currentTarget.onerror = null; // prevents looping
currentTarget.src = defaultChannelImage; currentTarget.src = defaultChannelImage;
}} }}
/> />
); );
}; };
export default ChannelBanner; export default ChannelBanner;

View File

@ -1,22 +1,22 @@
import getApiUrl from '../configuration/getApiUrl'; 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 = {
channelId: string; channelId: string;
channelThumbUrl: string | undefined; channelThumbUrl: string | undefined;
}; };
const ChannelIcon = ({ channelId, channelThumbUrl }: ChannelIconProps) => { const ChannelIcon = ({ channelId, channelThumbUrl }: ChannelIconProps) => {
return ( return (
<img <img
src={`${getApiUrl()}${channelThumbUrl}`} src={`${getApiUrl()}${channelThumbUrl}`}
alt={`${channelId}-thumb`} alt={`${channelId}-thumb`}
onError={({ currentTarget }) => { onError={({ currentTarget }) => {
currentTarget.onerror = null; // prevents looping currentTarget.onerror = null; // prevents looping
currentTarget.src = defaultChannelIcon; currentTarget.src = defaultChannelIcon;
}} }}
/> />
); );
}; };
export default ChannelIcon; export default ChannelIcon;

View File

@ -1,98 +1,97 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { ChannelType } from '../pages/Channels'; import { ChannelType } from '../pages/Channels';
import Routes from '../configuration/routes/RouteList'; import Routes from '../configuration/routes/RouteList';
import updateChannelSubscription from '../api/actions/updateChannelSubscription'; import updateChannelSubscription from '../api/actions/updateChannelSubscription';
import formatDate from '../functions/formatDates'; import formatDate from '../functions/formatDates';
import FormattedNumber from './FormattedNumber'; import FormattedNumber from './FormattedNumber';
import Button from './Button'; import Button from './Button';
import ChannelIcon from './ChannelIcon'; import ChannelIcon from './ChannelIcon';
import ChannelBanner from './ChannelBanner'; import ChannelBanner from './ChannelBanner';
import { useUserConfigStore } from '../stores/UserConfigStore'; import { useUserConfigStore } from '../stores/UserConfigStore';
type ChannelListProps = { type ChannelListProps = {
channelList: ChannelType[] | undefined; channelList: ChannelType[] | undefined;
refreshChannelList: (refresh: boolean) => void; refreshChannelList: (refresh: boolean) => void;
}; };
const ChannelList = ({ channelList, refreshChannelList }: ChannelListProps) => { const ChannelList = ({ channelList, refreshChannelList }: ChannelListProps) => {
const { userConfig } = useUserConfigStore();
const { userConfig } = useUserConfigStore(); const viewLayout = userConfig.config.view_style_channel;
const viewLayout = userConfig.config.view_style_channel;
if (!channelList || channelList.length === 0) {
if (!channelList || channelList.length === 0) { return <p>No channels found.</p>;
return <p>No channels found.</p>; }
}
return (
return ( <>
<> {channelList.map(channel => {
{channelList.map(channel => { return (
return ( <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
<ChannelBanner channelId={channel.channel_id}
channelId={channel.channel_id} channelBannerUrl={channel.channel_banner_url}
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
<ChannelIcon channelId={channel.channel_id}
channelId={channel.channel_id} channelThumbUrl={channel.channel_thumb_url}
channelThumbUrl={channel.channel_thumb_url} />
/> </Link>
</Link> </div>
</div> <div>
<div> <h3>
<h3> <Link to={Routes.Channel(channel.channel_id)}>{channel.channel_name}</Link>
<Link to={Routes.Channel(channel.channel_id)}>{channel.channel_name}</Link> </h3>
</h3> <FormattedNumber text="Subscribers:" number={channel.channel_subs} />
<FormattedNumber text="Subscribers:" number={channel.channel_subs} /> </div>
</div> </div>
</div> <div className="info-box-item">
<div className="info-box-item"> <div>
<div> <p>Last refreshed: {formatDate(channel.channel_last_refresh)}</p>
<p>Last refreshed: {formatDate(channel.channel_last_refresh)}</p> {channel.channel_subscribed && (
{channel.channel_subscribed && ( <Button
<Button label="Unsubscribe"
label="Unsubscribe" className="unsubscribe"
className="unsubscribe" type="button"
type="button" title={`Unsubscribe from ${channel.channel_name}`}
title={`Unsubscribe from ${channel.channel_name}`} onClick={async () => {
onClick={async () => { await updateChannelSubscription(channel.channel_id, false);
await updateChannelSubscription(channel.channel_id, false); setTimeout(() => {
setTimeout(() => { refreshChannelList(true);
refreshChannelList(true); }, 1000);
}, 1000); }}
}} />
/> )}
)}
{!channel.channel_subscribed && (
{!channel.channel_subscribed && ( <Button
<Button label="Subscribe"
label="Subscribe" type="button"
type="button" 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(() => {
setTimeout(() => { refreshChannelList(true);
refreshChannelList(true); }, 500);
}, 500); }}
}} />
/> )}
)} </div>
</div> </div>
</div> </div>
</div> </div>
</div> );
); })}
})} </>
</> );
); };
};
export default ChannelList;
export default ChannelList;

View File

@ -1,78 +1,78 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import Routes from '../configuration/routes/RouteList'; import Routes from '../configuration/routes/RouteList';
import updateChannelSubscription from '../api/actions/updateChannelSubscription'; import updateChannelSubscription from '../api/actions/updateChannelSubscription';
import FormattedNumber from './FormattedNumber'; import FormattedNumber from './FormattedNumber';
import Button from './Button'; import Button from './Button';
import ChannelIcon from './ChannelIcon'; import ChannelIcon from './ChannelIcon';
type ChannelOverviewProps = { type ChannelOverviewProps = {
channelId: string; channelId: string;
channelname: string; channelname: string;
channelSubs: number; channelSubs: number;
channelSubscribed: boolean; channelSubscribed: boolean;
channelThumbUrl: string; channelThumbUrl: string;
showSubscribeButton?: boolean; showSubscribeButton?: boolean;
isUserAdmin?: boolean; isUserAdmin?: boolean;
setRefresh: (status: boolean) => void; setRefresh: (status: boolean) => void;
}; };
const ChannelOverview = ({ const ChannelOverview = ({
channelId, channelId,
channelSubs, channelSubs,
channelSubscribed, channelSubscribed,
channelname, channelname,
channelThumbUrl, channelThumbUrl,
showSubscribeButton = false, showSubscribeButton = false,
isUserAdmin, isUserAdmin,
setRefresh, setRefresh,
}: ChannelOverviewProps) => { }: ChannelOverviewProps) => {
return ( return (
<> <>
<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 channelId={channelId} channelThumbUrl={channelThumbUrl} /> <ChannelIcon channelId={channelId} channelThumbUrl={channelThumbUrl} />
</Link> </Link>
</div> </div>
<div> <div>
<h3> <h3>
<Link to={Routes.ChannelVideo(channelId)}>{channelname}</Link> <Link to={Routes.ChannelVideo(channelId)}>{channelname}</Link>
</h3> </h3>
<FormattedNumber text="Subscribers:" number={channelSubs} /> <FormattedNumber text="Subscribers:" number={channelSubs} />
{showSubscribeButton && ( {showSubscribeButton && (
<> <>
{channelSubscribed && isUserAdmin && ( {channelSubscribed && isUserAdmin && (
<Button <Button
label="Unsubscribe" label="Unsubscribe"
className="unsubscribe" className="unsubscribe"
type="button" type="button"
title={`Unsubscribe from ${channelname}`} title={`Unsubscribe from ${channelname}`}
onClick={async () => { onClick={async () => {
await updateChannelSubscription(channelId, false); await updateChannelSubscription(channelId, false);
setRefresh(true); setRefresh(true);
}} }}
/> />
)} )}
{!channelSubscribed && ( {!channelSubscribed && (
<Button <Button
label="Subscribe" label="Subscribe"
type="button" type="button"
title={`Subscribe to ${channelname}`} title={`Subscribe to ${channelname}`}
onClick={async () => { onClick={async () => {
await updateChannelSubscription(channelId, true); await updateChannelSubscription(channelId, true);
setRefresh(true); setRefresh(true);
}} }}
/> />
)} )}
</> </>
)} )}
</div> </div>
</div> </div>
</> </>
); );
}; };
export default ChannelOverview; export default ChannelOverview;

View File

@ -15,7 +15,6 @@ type DownloadListItemProps = {
}; };
const DownloadListItem = ({ download, setRefresh }: DownloadListItemProps) => { const DownloadListItem = ({ download, setRefresh }: DownloadListItemProps) => {
const { userConfig } = useUserConfigStore(); const { userConfig } = useUserConfigStore();
const view = userConfig.config.view_style_downloads; const view = userConfig.config.view_style_downloads;
const showIgnored = userConfig.config.show_ignored_only; const showIgnored = userConfig.config.show_ignored_only;

View File

@ -14,15 +14,10 @@ type FilterbarProps = {
setRefresh?: (status: boolean) => void; setRefresh?: (status: boolean) => void;
}; };
const Filterbar = ({ const Filterbar = ({ hideToggleText, viewStyleName, setRefresh }: FilterbarProps) => {
hideToggleText,
viewStyleName,
setRefresh,
}: FilterbarProps) => {
const { userConfig, setPartialConfig } = useUserConfigStore(); const { userConfig, setPartialConfig } = useUserConfigStore();
const [showHidden, setShowHidden] = useState(false); const [showHidden, setShowHidden] = useState(false);
const isGridView = userConfig.config.view_style_home === ViewStyles.grid const isGridView = userConfig.config.view_style_home === ViewStyles.grid;
return ( return (
<div className="view-controls three"> <div className="view-controls three">
@ -35,7 +30,7 @@ const Filterbar = ({
checked={userConfig.config.hide_watched} checked={userConfig.config.hide_watched}
onChange={() => { onChange={() => {
setRefresh?.(true); setRefresh?.(true);
setPartialConfig({hide_watched: !userConfig.config.hide_watched}) setPartialConfig({ hide_watched: !userConfig.config.hide_watched });
}} }}
/> />
@ -48,7 +43,6 @@ const Filterbar = ({
Off Off
</label> </label>
)} )}
</div> </div>
</div> </div>
@ -62,7 +56,7 @@ const Filterbar = ({
value={userConfig.config.sort_by} value={userConfig.config.sort_by}
onChange={event => { onChange={event => {
setRefresh?.(true); setRefresh?.(true);
setPartialConfig({sort_by: event.target.value as SortByType}); setPartialConfig({ sort_by: event.target.value as SortByType });
}} }}
> >
<option value="published">date published</option> <option value="published">date published</option>
@ -78,7 +72,7 @@ const Filterbar = ({
value={userConfig.config.sort_order} value={userConfig.config.sort_order}
onChange={event => { onChange={event => {
setRefresh?.(true); setRefresh?.(true);
setPartialConfig({sort_order: event.target.value as SortOrderType}) setPartialConfig({ sort_order: event.target.value as SortOrderType });
}} }}
> >
<option value="asc">asc</option> <option value="asc">asc</option>
@ -106,7 +100,7 @@ const Filterbar = ({
<img <img
src={iconAdd} src={iconAdd}
onClick={() => { onClick={() => {
setPartialConfig({grid_items: userConfig.config.grid_items + 1}); setPartialConfig({ grid_items: userConfig.config.grid_items + 1 });
}} }}
alt="grid plus row" alt="grid plus row"
/> />
@ -115,7 +109,7 @@ const Filterbar = ({
<img <img
src={iconSubstract} src={iconSubstract}
onClick={() => { onClick={() => {
setPartialConfig({grid_items: userConfig.config.grid_items - 1}); setPartialConfig({ grid_items: userConfig.config.grid_items - 1 });
}} }}
alt="grid minus row" alt="grid minus row"
/> />
@ -125,14 +119,14 @@ const Filterbar = ({
<img <img
src={iconGridView} src={iconGridView}
onClick={() => { onClick={() => {
setPartialConfig({[viewStyleName]: 'grid'}); setPartialConfig({ [viewStyleName]: 'grid' });
}} }}
alt="grid view" alt="grid view"
/> />
<img <img
src={iconListView} src={iconListView}
onClick={() => { onClick={() => {
setPartialConfig({[viewStyleName]: 'list'}); setPartialConfig({ [viewStyleName]: 'list' });
}} }}
alt="list view" alt="list view"
/> />

View File

@ -5,8 +5,8 @@ import { useAuthStore } from '../stores/AuthDataStore';
const Footer = () => { const Footer = () => {
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const { auth } = useAuthStore(); const { auth } = useAuthStore();
const version = auth?.version const version = auth?.version;
const taUpdate = auth?.ta_update const taUpdate = auth?.ta_update;
return ( return (
<div className="footer"> <div className="footer">

View File

@ -1,226 +1,226 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
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';
import watchedThreshold from '../functions/watchedThreshold'; import watchedThreshold from '../functions/watchedThreshold';
const getURL = () => { const getURL = () => {
return window.location.origin; return window.location.origin;
}; };
function shiftCurrentTime(contentCurrentTime: number | undefined) { function shiftCurrentTime(contentCurrentTime: number | undefined) {
console.log(contentCurrentTime); console.log(contentCurrentTime);
if (contentCurrentTime === undefined) { if (contentCurrentTime === undefined) {
return 0; return 0;
} }
// Shift media back 3 seconds to prevent missing some of the content // Shift media back 3 seconds to prevent missing some of the content
if (contentCurrentTime > 5) { if (contentCurrentTime > 5) {
return contentCurrentTime - 3; return contentCurrentTime - 3;
} else { } else {
return 0; return 0;
} }
} }
async function castVideoProgress( async function castVideoProgress(
player: { player: {
mediaInfo: { contentId: string | string[] }; mediaInfo: { contentId: string | string[] };
currentTime: number; currentTime: number;
duration: number; duration: number;
}, },
video: VideoType | undefined, video: VideoType | undefined,
) { ) {
if (!video) { if (!video) {
console.log('castVideoProgress: Video to cast not found...'); console.log('castVideoProgress: Video to cast not found...');
return; return;
} }
const videoId = video.youtube_id; const videoId = video.youtube_id;
if (player.mediaInfo.contentId.includes(videoId)) { if (player.mediaInfo.contentId.includes(videoId)) {
const currentTime = player.currentTime; const currentTime = player.currentTime;
const duration = player.duration; const duration = player.duration;
if (currentTime % 10 <= 1.0 && currentTime !== 0 && duration !== 0) { if (currentTime % 10 <= 1.0 && currentTime !== 0 && duration !== 0) {
// Check progress every 10 seconds or else progress is checked a few times a second // Check progress every 10 seconds or else progress is checked a few times a second
await updateVideoProgressById({ await updateVideoProgressById({
youtubeId: videoId, youtubeId: videoId,
currentProgress: currentTime, currentProgress: currentTime,
}); });
if (!video.player.watched) { if (!video.player.watched) {
// Check if video is already marked as watched // Check if video is already marked as watched
if (watchedThreshold(currentTime, duration)) { if (watchedThreshold(currentTime, duration)) {
await updateWatchedState({ await updateWatchedState({
id: videoId, id: videoId,
is_watched: true, is_watched: true,
}); });
} }
} }
} }
} }
} }
async function castVideoPaused( async function castVideoPaused(
player: { player: {
currentTime: number; currentTime: number;
duration: number; duration: number;
mediaInfo: { contentId: string | string[] } | null; mediaInfo: { contentId: string | string[] } | null;
}, },
video: VideoType | undefined, video: VideoType | undefined,
) { ) {
if (!video) { if (!video) {
console.log('castVideoPaused: Video to cast not found...'); console.log('castVideoPaused: Video to cast not found...');
return; return;
} }
const videoId = video?.youtube_id; const videoId = video?.youtube_id;
const currentTime = player.currentTime; const currentTime = player.currentTime;
const duration = player.duration; const duration = player.duration;
if (player.mediaInfo != null) { if (player.mediaInfo != null) {
if (player.mediaInfo.contentId.includes(videoId)) { if (player.mediaInfo.contentId.includes(videoId)) {
if (currentTime !== 0 && duration !== 0) { if (currentTime !== 0 && duration !== 0) {
await updateVideoProgressById({ await updateVideoProgressById({
youtubeId: videoId, youtubeId: videoId,
currentProgress: currentTime, currentProgress: currentTime,
}); });
} }
} }
} }
} }
type GoogleCastProps = { type GoogleCastProps = {
video?: VideoType; video?: VideoType;
setRefresh?: () => void; setRefresh?: () => void;
}; };
const GoogleCast = ({ video, setRefresh }: GoogleCastProps) => { const GoogleCast = ({ video, setRefresh }: GoogleCastProps) => {
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
const setup = useCallback(() => { const setup = useCallback(() => {
const cast = globalThis.cast; const cast = globalThis.cast;
const chrome = globalThis.chrome; const chrome = globalThis.chrome;
cast.framework.CastContext.getInstance().setOptions({ cast.framework.CastContext.getInstance().setOptions({
receiverApplicationId: chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID, // Use built in receiver app on cast device, see https://developers.google.com/cast/docs/styled_receiver if you want to be able to add a theme, splash screen or watermark. Has a $5 one time fee. receiverApplicationId: chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID, // Use built in receiver app on cast device, see https://developers.google.com/cast/docs/styled_receiver if you want to be able to add a theme, splash screen or watermark. Has a $5 one time fee.
autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED, autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED,
}); });
const player = new cast.framework.RemotePlayer(); const player = new cast.framework.RemotePlayer();
const playerController = new cast.framework.RemotePlayerController(player); const playerController = new cast.framework.RemotePlayerController(player);
// Add event listerner to check if a connection to a cast device is initiated // Add event listerner to check if a connection to a cast device is initiated
playerController.addEventListener( playerController.addEventListener(
cast.framework.RemotePlayerEventType.IS_CONNECTED_CHANGED, cast.framework.RemotePlayerEventType.IS_CONNECTED_CHANGED,
function () { function () {
setIsConnected(player.isConnected); setIsConnected(player.isConnected);
}, },
); );
playerController.addEventListener( playerController.addEventListener(
cast.framework.RemotePlayerEventType.CURRENT_TIME_CHANGED, cast.framework.RemotePlayerEventType.CURRENT_TIME_CHANGED,
function () { function () {
castVideoProgress(player, video); castVideoProgress(player, video);
}, },
); );
playerController.addEventListener( playerController.addEventListener(
cast.framework.RemotePlayerEventType.IS_PAUSED_CHANGED, cast.framework.RemotePlayerEventType.IS_PAUSED_CHANGED,
function () { function () {
castVideoPaused(player, video); castVideoPaused(player, video);
setRefresh?.(); setRefresh?.();
}, },
); );
}, [setRefresh, video]); }, [setRefresh, video]);
const startPlayback = useCallback(() => { const startPlayback = useCallback(() => {
const chrome = globalThis.chrome; const chrome = globalThis.chrome;
const cast = globalThis.cast; const cast = globalThis.cast;
const castSession = cast.framework.CastContext.getInstance().getCurrentSession(); const castSession = cast.framework.CastContext.getInstance().getCurrentSession();
const mediaUrl = video?.media_url; const mediaUrl = video?.media_url;
const vidThumbUrl = video?.vid_thumb_url; const vidThumbUrl = video?.vid_thumb_url;
const contentTitle = video?.title; const contentTitle = video?.title;
const contentId = `${getURL()}${mediaUrl}`; const contentId = `${getURL()}${mediaUrl}`;
const contentImage = `${getURL()}${vidThumbUrl}`; const contentImage = `${getURL()}${vidThumbUrl}`;
const contentType = 'video/mp4'; // Set content type, only videos right now so it is hard coded const contentType = 'video/mp4'; // Set content type, only videos right now so it is hard coded
const contentSubtitles = []; const contentSubtitles = [];
const videoSubtitles = video?.subtitles; // Array of subtitles const videoSubtitles = video?.subtitles; // Array of subtitles
if (typeof videoSubtitles !== 'undefined') { if (typeof videoSubtitles !== 'undefined') {
for (let i = 0; i < videoSubtitles.length; i++) { for (let i = 0; i < videoSubtitles.length; i++) {
const subtitle = new chrome.cast.media.Track(i, chrome.cast.media.TrackType.TEXT); const subtitle = new chrome.cast.media.Track(i, chrome.cast.media.TrackType.TEXT);
subtitle.trackContentId = videoSubtitles[i].media_url; subtitle.trackContentId = videoSubtitles[i].media_url;
subtitle.trackContentType = 'text/vtt'; subtitle.trackContentType = 'text/vtt';
subtitle.subtype = chrome.cast.media.TextTrackType.SUBTITLES; subtitle.subtype = chrome.cast.media.TextTrackType.SUBTITLES;
subtitle.name = videoSubtitles[i].name; subtitle.name = videoSubtitles[i].name;
subtitle.language = videoSubtitles[i].lang; subtitle.language = videoSubtitles[i].lang;
subtitle.customData = null; subtitle.customData = null;
contentSubtitles.push(subtitle); contentSubtitles.push(subtitle);
} }
} }
const mediaInfo = new chrome.cast.media.MediaInfo(contentId, contentType); // Create MediaInfo var that contains url and content type const mediaInfo = new chrome.cast.media.MediaInfo(contentId, contentType); // Create MediaInfo var that contains url and content type
// mediaInfo.streamType = chrome.cast.media.StreamType.BUFFERED; // Set type of stream, BUFFERED, LIVE, OTHER // mediaInfo.streamType = chrome.cast.media.StreamType.BUFFERED; // Set type of stream, BUFFERED, LIVE, OTHER
mediaInfo.metadata = new chrome.cast.media.GenericMediaMetadata(); // Create metadata var and add it to MediaInfo mediaInfo.metadata = new chrome.cast.media.GenericMediaMetadata(); // Create metadata var and add it to MediaInfo
mediaInfo.metadata.title = contentTitle?.replace('&amp;', '&'); // Set the video title mediaInfo.metadata.title = contentTitle?.replace('&amp;', '&'); // Set the video title
mediaInfo.metadata.images = [new chrome.cast.Image(contentImage)]; // Set the video thumbnail mediaInfo.metadata.images = [new chrome.cast.Image(contentImage)]; // Set the video thumbnail
// mediaInfo.textTrackStyle = new chrome.cast.media.TextTrackStyle(); // mediaInfo.textTrackStyle = new chrome.cast.media.TextTrackStyle();
mediaInfo.tracks = contentSubtitles; mediaInfo.tracks = contentSubtitles;
const request = new chrome.cast.media.LoadRequest(mediaInfo); // Create request with the previously set MediaInfo. const request = new chrome.cast.media.LoadRequest(mediaInfo); // Create request with the previously set MediaInfo.
// request.queueData = new chrome.cast.media.QueueData(); // See https://developers.google.com/cast/docs/reference/web_sender/chrome.cast.media.QueueData for playlist support. // request.queueData = new chrome.cast.media.QueueData(); // See https://developers.google.com/cast/docs/reference/web_sender/chrome.cast.media.QueueData for playlist support.
request.currentTime = shiftCurrentTime(video?.player?.position); // Set video start position based on the browser video position request.currentTime = shiftCurrentTime(video?.player?.position); // Set video start position based on the browser video position
// request.activeTrackIds = contentActiveSubtitle; // Set active subtitle based on video player // request.activeTrackIds = contentActiveSubtitle; // Set active subtitle based on video player
castSession.loadMedia(request).then( castSession.loadMedia(request).then(
function () { function () {
console.log('media loaded'); console.log('media loaded');
}, },
function (error: { code: string }) { function (error: { code: string }) {
console.log('Error', error, 'Error code: ' + error.code); console.log('Error', error, 'Error code: ' + error.code);
}, },
); // Send request to cast device ); // Send request to cast device
// Do not add videoProgress?.position, this will cause loops! // Do not add videoProgress?.position, this will cause loops!
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [video?.media_url, video?.subtitles, video?.title, video?.vid_thumb_url]); }, [video?.media_url, video?.subtitles, video?.title, video?.vid_thumb_url]);
useEffect(() => { useEffect(() => {
// @ts-expect-error __onGCastApiAvailable is the google cast window hook ( source: https://developers.google.com/cast/docs/web_sender/integrate ) // @ts-expect-error __onGCastApiAvailable is the google cast window hook ( source: https://developers.google.com/cast/docs/web_sender/integrate )
window['__onGCastApiAvailable'] = function (isAvailable: boolean) { window['__onGCastApiAvailable'] = function (isAvailable: boolean) {
if (isAvailable) { if (isAvailable) {
setup(); setup();
} }
}; };
}, [setup]); }, [setup]);
useEffect(() => { useEffect(() => {
console.log('isConnected', isConnected); console.log('isConnected', isConnected);
if (isConnected) { if (isConnected) {
startPlayback(); startPlayback();
} }
}, [isConnected, startPlayback]); }, [isConnected, startPlayback]);
if (!video) { if (!video) {
return <p>Video for cast not found...</p>; return <p>Video for cast not found...</p>;
} }
return ( return (
<> <>
<> <>
<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>
{/* @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>
</> </>
</> </>
); );
}; };
export default GoogleCast; export default GoogleCast;

View File

@ -8,7 +8,6 @@ import logOut from '../api/actions/logOut';
import loadIsAdmin from '../functions/getIsAdmin'; import loadIsAdmin from '../functions/getIsAdmin';
const Navigation = () => { const Navigation = () => {
const isAdmin = loadIsAdmin(); const isAdmin = loadIsAdmin();
const navigate = useNavigate(); const navigate = useNavigate();
const handleLogout = async (event: { preventDefault: () => void }) => { const handleLogout = async (event: { preventDefault: () => void }) => {

View File

@ -1,101 +1,101 @@
import { Fragment, useEffect, useState } from 'react'; import { Fragment, useEffect, useState } from 'react';
import loadNotifications, { NotificationPages } from '../api/loader/loadNotifications'; import loadNotifications, { NotificationPages } from '../api/loader/loadNotifications';
import iconStop from '/img/icon-stop.svg'; import iconStop from '/img/icon-stop.svg';
import stopTaskByName from '../api/actions/stopTaskByName'; import stopTaskByName from '../api/actions/stopTaskByName';
type NotificationType = { type NotificationType = {
title: string; title: string;
group: string; group: string;
api_stop: boolean; api_stop: boolean;
level: string; level: string;
id: string; id: string;
command: boolean | string; command: boolean | string;
messages: string[]; messages: string[];
progress: number; progress: number;
}; };
type NotificationResponseType = NotificationType[]; type NotificationResponseType = NotificationType[];
type NotificationsProps = { type NotificationsProps = {
pageName: NotificationPages; pageName: NotificationPages;
includeReindex?: boolean; includeReindex?: boolean;
update?: boolean; update?: boolean;
setShouldRefresh?: (isDone: boolean) => void; setShouldRefresh?: (isDone: boolean) => void;
}; };
const Notifications = ({ const Notifications = ({
pageName, pageName,
includeReindex = false, includeReindex = false,
update, update,
setShouldRefresh, setShouldRefresh,
}: NotificationsProps) => { }: NotificationsProps) => {
const [notificationResponse, setNotificationResponse] = useState<NotificationResponseType>([]); const [notificationResponse, setNotificationResponse] = useState<NotificationResponseType>([]);
useEffect(() => { useEffect(() => {
const intervalId = setInterval(async () => { const intervalId = setInterval(async () => {
const notifications = await loadNotifications(pageName, includeReindex); const notifications = await loadNotifications(pageName, includeReindex);
if (notifications.length === 0) { if (notifications.length === 0) {
setNotificationResponse(notifications); setNotificationResponse(notifications);
clearInterval(intervalId); clearInterval(intervalId);
setShouldRefresh?.(true); setShouldRefresh?.(true);
return; return;
} else { } else {
setShouldRefresh?.(false); setShouldRefresh?.(false);
} }
setNotificationResponse(notifications); setNotificationResponse(notifications);
}, 500); }, 500);
return () => { return () => {
clearInterval(intervalId); clearInterval(intervalId);
}; };
}, [pageName, update, setShouldRefresh, includeReindex]); }, [pageName, update, setShouldRefresh, includeReindex]);
if (notificationResponse.length === 0) { if (notificationResponse.length === 0) {
return []; return [];
} }
return ( return (
<> <>
{notificationResponse.map(notification => ( {notificationResponse.map(notification => (
<div <div
id={notification.id} id={notification.id}
className={`notification ${notification.level}`} className={`notification ${notification.level}`}
key={notification.id} key={notification.id}
> >
<h3>{notification.title}</h3> <h3>{notification.title}</h3>
<p> <p>
{notification.messages.map?.(message => { {notification.messages.map?.(message => {
return ( return (
<Fragment key={message}> <Fragment key={message}>
{message} {message}
<br /> <br />
</Fragment> </Fragment>
); );
}) || notification.messages} }) || notification.messages}
</p> </p>
<div className="task-control-icons"> <div className="task-control-icons">
{notification['api_stop'] && notification.command !== 'STOP' && ( {notification['api_stop'] && notification.command !== 'STOP' && (
<img <img
src={iconStop} src={iconStop}
id="stop-icon" id="stop-icon"
title="Stop Task" title="Stop Task"
alt="stop icon" alt="stop icon"
onClick={async () => { onClick={async () => {
await stopTaskByName(notification.id); await stopTaskByName(notification.id);
}} }}
/> />
)} )}
</div> </div>
<div <div
className="notification-progress-bar" className="notification-progress-bar"
style={{ width: `${notification.progress * 100 || 0}%` }} style={{ width: `${notification.progress * 100 || 0}%` }}
></div> ></div>
</div> </div>
))} ))}
</> </>
); );
}; };
export default Notifications; export default Notifications;

View File

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

View File

@ -1,90 +1,89 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import Routes from '../configuration/routes/RouteList'; import Routes from '../configuration/routes/RouteList';
import { PlaylistType } from '../pages/Playlist'; 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 PlaylistThumbnail from './PlaylistThumbnail'; import PlaylistThumbnail from './PlaylistThumbnail';
import { useUserConfigStore } from '../stores/UserConfigStore'; import { useUserConfigStore } from '../stores/UserConfigStore';
type PlaylistListProps = { type PlaylistListProps = {
playlistList: PlaylistType[] | undefined; playlistList: PlaylistType[] | undefined;
setRefresh: (status: boolean) => void; setRefresh: (status: boolean) => void;
}; };
const PlaylistList = ({ playlistList, setRefresh }: PlaylistListProps) => { const PlaylistList = ({ playlistList, setRefresh }: PlaylistListProps) => {
const { userConfig } = useUserConfigStore();
const { userConfig } = useUserConfigStore(); const viewLayout = userConfig.config.view_style_playlist;
const viewLayout = userConfig.config.view_style_playlist;
if (!playlistList || playlistList.length === 0) {
if (!playlistList || playlistList.length === 0) { return <p>No playlists found.</p>;
return <p>No playlists found.</p>; }
}
return (
return ( <>
<> {playlistList.map((playlist: PlaylistType) => {
{playlistList.map((playlist: PlaylistType) => { return (
return ( <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)}> <PlaylistThumbnail
<PlaylistThumbnail playlistId={playlist.playlist_id}
playlistId={playlist.playlist_id} playlistThumbnail={playlist.playlist_thumbnail}
playlistThumbnail={playlist.playlist_thumbnail} />
/> </Link>
</Link> </div>
</div> <div className={`playlist-desc ${viewLayout}`}>
<div className={`playlist-desc ${viewLayout}`}> {playlist.playlist_type != 'custom' && (
{playlist.playlist_type != 'custom' && ( <Link to={Routes.Channel(playlist.playlist_channel_id)}>
<Link to={Routes.Channel(playlist.playlist_channel_id)}> <h3>{playlist.playlist_channel}</h3>
<h3>{playlist.playlist_channel}</h3> </Link>
</Link> )}
)}
<Link to={Routes.Playlist(playlist.playlist_id)}>
<Link to={Routes.Playlist(playlist.playlist_id)}> <h2>{playlist.playlist_name}</h2>
<h2>{playlist.playlist_name}</h2> </Link>
</Link>
<p>Last refreshed: {formatDate(playlist.playlist_last_refresh)}</p>
<p>Last refreshed: {formatDate(playlist.playlist_last_refresh)}</p>
{playlist.playlist_type != 'custom' && (
{playlist.playlist_type != 'custom' && ( <>
<> {playlist.playlist_subscribed && (
{playlist.playlist_subscribed && ( <Button
<Button label="Unsubscribe"
label="Unsubscribe" className="unsubscribe"
className="unsubscribe" type="button"
type="button" title={`Unsubscribe from ${playlist.playlist_name}`}
title={`Unsubscribe from ${playlist.playlist_name}`} onClick={async () => {
onClick={async () => { await updatePlaylistSubscription(playlist.playlist_id, false);
await updatePlaylistSubscription(playlist.playlist_id, false);
setRefresh(true);
setRefresh(true); }}
}} />
/> )}
)}
{!playlist.playlist_subscribed && (
{!playlist.playlist_subscribed && ( <Button
<Button label="Subscribe"
label="Subscribe" type="button"
type="button" title={`Subscribe to ${playlist.playlist_name}`}
title={`Subscribe to ${playlist.playlist_name}`} onClick={async () => {
onClick={async () => { await updatePlaylistSubscription(playlist.playlist_id, true);
await updatePlaylistSubscription(playlist.playlist_id, true);
setTimeout(() => {
setTimeout(() => { setRefresh(true);
setRefresh(true); }, 500);
}, 500); }}
}} />
/> )}
)} </>
</> )}
)} </div>
</div> </div>
</div> );
); })}
})} </>
</> );
); };
};
export default PlaylistList;
export default PlaylistList;

View File

@ -1,262 +1,262 @@
import updateVideoProgressById from '../api/actions/updateVideoProgressById'; import updateVideoProgressById from '../api/actions/updateVideoProgressById';
import updateWatchedState from '../api/actions/updateWatchedState'; import updateWatchedState from '../api/actions/updateWatchedState';
import { SponsorBlockSegmentType, SponsorBlockType, VideoResponseType } from '../pages/Video'; import { SponsorBlockSegmentType, SponsorBlockType, VideoResponseType } from '../pages/Video';
import watchedThreshold from '../functions/watchedThreshold'; import watchedThreshold from '../functions/watchedThreshold';
import Notifications from './Notifications'; import Notifications from './Notifications';
import { Dispatch, Fragment, SetStateAction, SyntheticEvent, useState } from 'react'; import { Dispatch, Fragment, SetStateAction, SyntheticEvent, useState } from 'react';
import formatTime from '../functions/formatTime'; import formatTime from '../functions/formatTime';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import getApiUrl from '../configuration/getApiUrl'; import getApiUrl from '../configuration/getApiUrl';
type VideoTag = SyntheticEvent<HTMLVideoElement, Event>; type VideoTag = SyntheticEvent<HTMLVideoElement, Event>;
export type SkippedSegmentType = { export type SkippedSegmentType = {
from: number; from: number;
to: number; to: number;
}; };
export type SponsorSegmentsSkippedType = Record<string, SkippedSegmentType>; export type SponsorSegmentsSkippedType = Record<string, SkippedSegmentType>;
type Subtitle = { type Subtitle = {
name: string; name: string;
source: string; source: string;
lang: string; lang: string;
media_url: string; media_url: string;
}; };
type SubtitlesProp = { type SubtitlesProp = {
subtitles: Subtitle[]; subtitles: Subtitle[];
}; };
const Subtitles = ({ subtitles }: SubtitlesProp) => { const Subtitles = ({ subtitles }: SubtitlesProp) => {
return subtitles.map((subtitle: Subtitle) => { return subtitles.map((subtitle: Subtitle) => {
let label = subtitle.name; let label = subtitle.name;
if (subtitle.source === 'auto') { if (subtitle.source === 'auto') {
label += ' - auto'; label += ' - auto';
} }
return ( return (
<track <track
key={subtitle.name} key={subtitle.name}
label={label} label={label}
kind="subtitles" kind="subtitles"
srcLang={subtitle.lang} srcLang={subtitle.lang}
src={`${getApiUrl()}${subtitle.media_url}`} src={`${getApiUrl()}${subtitle.media_url}`}
/> />
); );
}); });
}; };
const handleTimeUpdate = const handleTimeUpdate =
( (
youtubeId: string, youtubeId: string,
duration: number, duration: number,
watched: boolean, watched: boolean,
sponsorBlock?: SponsorBlockType, sponsorBlock?: SponsorBlockType,
setSponsorSegmentSkipped?: Dispatch<SetStateAction<SponsorSegmentsSkippedType>>, setSponsorSegmentSkipped?: Dispatch<SetStateAction<SponsorSegmentsSkippedType>>,
) => ) =>
async (videoTag: VideoTag) => { async (videoTag: VideoTag) => {
const currentTime = Number(videoTag.currentTarget.currentTime); const currentTime = Number(videoTag.currentTarget.currentTime);
if (sponsorBlock && sponsorBlock.segments) { if (sponsorBlock && sponsorBlock.segments) {
sponsorBlock.segments.forEach((segment: SponsorBlockSegmentType) => { sponsorBlock.segments.forEach((segment: SponsorBlockSegmentType) => {
const [from, to] = segment.segment; const [from, to] = segment.segment;
if (currentTime >= from && currentTime <= from + 0.3) { if (currentTime >= from && currentTime <= from + 0.3) {
videoTag.currentTarget.currentTime = to; videoTag.currentTarget.currentTime = to;
setSponsorSegmentSkipped?.((segments: SponsorSegmentsSkippedType) => { setSponsorSegmentSkipped?.((segments: SponsorSegmentsSkippedType) => {
return { ...segments, [segment.UUID]: { from, to } }; return { ...segments, [segment.UUID]: { from, to } };
}); });
} }
if (currentTime > to + 10) { if (currentTime > to + 10) {
setSponsorSegmentSkipped?.((segments: SponsorSegmentsSkippedType) => { setSponsorSegmentSkipped?.((segments: SponsorSegmentsSkippedType) => {
return { ...segments, [segment.UUID]: { from: 0, to: 0 } }; return { ...segments, [segment.UUID]: { from: 0, to: 0 } };
}); });
} }
}); });
} }
if (currentTime < 10) return; if (currentTime < 10) return;
if (Number((currentTime % 10).toFixed(1)) <= 0.2) { if (Number((currentTime % 10).toFixed(1)) <= 0.2) {
// Check progress every 10 seconds or else progress is checked a few times a second // Check progress every 10 seconds or else progress is checked a few times a second
await updateVideoProgressById({ await updateVideoProgressById({
youtubeId, youtubeId,
currentProgress: currentTime, currentProgress: currentTime,
}); });
if (!watched) { if (!watched) {
// Check if video is already marked as watched // Check if video is already marked as watched
if (watchedThreshold(currentTime, duration)) { if (watchedThreshold(currentTime, duration)) {
await updateWatchedState({ await updateWatchedState({
id: youtubeId, id: youtubeId,
is_watched: true, is_watched: true,
}); });
} }
} }
} }
}; };
type VideoPlayerProps = { type VideoPlayerProps = {
video: VideoResponseType; video: VideoResponseType;
sponsorBlock?: SponsorBlockType; sponsorBlock?: SponsorBlockType;
embed?: boolean; embed?: boolean;
autoplay?: boolean; autoplay?: boolean;
onVideoEnd?: () => void; onVideoEnd?: () => void;
}; };
const VideoPlayer = ({ const VideoPlayer = ({
video, video,
sponsorBlock, sponsorBlock,
embed, embed,
autoplay = false, autoplay = false,
onVideoEnd, onVideoEnd,
}: VideoPlayerProps) => { }: VideoPlayerProps) => {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const searchParamVideoProgress = searchParams.get('t'); const searchParamVideoProgress = searchParams.get('t');
const [skippedSegments, setSkippedSegments] = useState<SponsorSegmentsSkippedType>({}); const [skippedSegments, setSkippedSegments] = useState<SponsorSegmentsSkippedType>({});
const videoId = video.data.youtube_id; const videoId = video.data.youtube_id;
const videoUrl = video.data.media_url; const videoUrl = video.data.media_url;
const videoThumbUrl = video.data.vid_thumb_url; const videoThumbUrl = video.data.vid_thumb_url;
const watched = video.data.player.watched; const watched = video.data.player.watched;
const duration = video.data.player.duration; const duration = video.data.player.duration;
const videoSubtitles = video.data.subtitles; const videoSubtitles = video.data.subtitles;
let videoSrcProgress = let videoSrcProgress =
Number(video.data.player?.position) > 0 ? Number(video.data.player?.position) : ''; Number(video.data.player?.position) > 0 ? Number(video.data.player?.position) : '';
if (searchParamVideoProgress !== null) { if (searchParamVideoProgress !== null) {
videoSrcProgress = searchParamVideoProgress; videoSrcProgress = searchParamVideoProgress;
} }
const handleVideoEnd = const handleVideoEnd =
( (
youtubeId: string, youtubeId: string,
watched: boolean, watched: boolean,
setSponsorSegmentSkipped?: Dispatch<SetStateAction<SponsorSegmentsSkippedType>>, setSponsorSegmentSkipped?: Dispatch<SetStateAction<SponsorSegmentsSkippedType>>,
) => ) =>
async () => { async () => {
if (!watched) { if (!watched) {
// Check if video is already marked as watched // Check if video is already marked as watched
await updateWatchedState({ id: youtubeId, is_watched: true }); await updateWatchedState({ id: youtubeId, is_watched: true });
} }
setSponsorSegmentSkipped?.((segments: SponsorSegmentsSkippedType) => { setSponsorSegmentSkipped?.((segments: SponsorSegmentsSkippedType) => {
const keys = Object.keys(segments); const keys = Object.keys(segments);
keys.forEach(uuid => { keys.forEach(uuid => {
segments[uuid] = { from: 0, to: 0 }; segments[uuid] = { from: 0, to: 0 };
}); });
return segments; return segments;
}); });
onVideoEnd?.(); onVideoEnd?.();
}; };
return ( return (
<> <>
<div id="player" className={embed ? '' : 'player-wrapper'}> <div id="player" className={embed ? '' : 'player-wrapper'}>
<div className={embed ? '' : 'video-main'}> <div className={embed ? '' : 'video-main'}>
<video <video
poster={`${getApiUrl()}${videoThumbUrl}`} poster={`${getApiUrl()}${videoThumbUrl}`}
onVolumeChange={(videoTag: VideoTag) => { onVolumeChange={(videoTag: VideoTag) => {
localStorage.setItem('playerVolume', videoTag.currentTarget.volume.toString()); localStorage.setItem('playerVolume', videoTag.currentTarget.volume.toString());
}} }}
onRateChange={(videoTag: VideoTag) => { onRateChange={(videoTag: VideoTag) => {
localStorage.setItem('playerSpeed', videoTag.currentTarget.playbackRate.toString()); 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( videoTag.currentTarget.playbackRate = Number(
localStorage.getItem('playerSpeed') ?? 1, localStorage.getItem('playerSpeed') ?? 1,
); );
}} }}
onTimeUpdate={handleTimeUpdate( onTimeUpdate={handleTimeUpdate(
videoId, videoId,
duration, duration,
watched, watched,
sponsorBlock, sponsorBlock,
setSkippedSegments, setSkippedSegments,
)} )}
onPause={async (videoTag: VideoTag) => { onPause={async (videoTag: VideoTag) => {
const currentTime = Number(videoTag.currentTarget.currentTime); const currentTime = Number(videoTag.currentTarget.currentTime);
if (currentTime < 10) return; if (currentTime < 10) return;
await updateVideoProgressById({ await updateVideoProgressById({
youtubeId: videoId, youtubeId: videoId,
currentProgress: currentTime, currentProgress: currentTime,
}); });
}} }}
onEnded={handleVideoEnd(videoId, watched)} onEnded={handleVideoEnd(videoId, watched)}
autoPlay={autoplay} autoPlay={autoplay}
controls controls
width="100%" width="100%"
playsInline playsInline
id="video-item" id="video-item"
> >
<source <source
src={`${getApiUrl()}${videoUrl}#t=${videoSrcProgress}`} src={`${getApiUrl()}${videoUrl}#t=${videoSrcProgress}`}
type="video/mp4" type="video/mp4"
id="video-source" id="video-source"
/> />
{videoSubtitles && <Subtitles subtitles={videoSubtitles} />} {videoSubtitles && <Subtitles subtitles={videoSubtitles} />}
</video> </video>
</div> </div>
</div> </div>
<Notifications pageName="all" /> <Notifications pageName="all" />
<div className="sponsorblock" id="sponsorblock"> <div className="sponsorblock" id="sponsorblock">
{sponsorBlock?.is_enabled && ( {sponsorBlock?.is_enabled && (
<> <>
{sponsorBlock.segments.length == 0 && ( {sponsorBlock.segments.length == 0 && (
<h4> <h4>
This video doesn't have any sponsor segments added. To add a segment go to{' '} This video doesn't have any sponsor segments added. To add a segment go to{' '}
<u> <u>
<a href={`https://www.youtube.com/watch?v=${videoId}`}>this video on YouTube</a> <a href={`https://www.youtube.com/watch?v=${videoId}`}>this video on YouTube</a>
</u>{' '} </u>{' '}
and add a segment using the{' '} and add a segment using the{' '}
<u> <u>
<a href="https://sponsor.ajay.app/">SponsorBlock</a> <a href="https://sponsor.ajay.app/">SponsorBlock</a>
</u>{' '} </u>{' '}
extension. extension.
</h4> </h4>
)} )}
{sponsorBlock.has_unlocked && ( {sponsorBlock.has_unlocked && (
<h4> <h4>
This video has unlocked sponsor segments. Go to{' '} This video has unlocked sponsor segments. Go to{' '}
<u> <u>
<a href={`https://www.youtube.com/watch?v=${videoId}`}>this video on YouTube</a> <a href={`https://www.youtube.com/watch?v=${videoId}`}>this video on YouTube</a>
</u>{' '} </u>{' '}
and vote on the segments using the{' '} and vote on the segments using the{' '}
<u> <u>
<a href="https://sponsor.ajay.app/">SponsorBlock</a> <a href="https://sponsor.ajay.app/">SponsorBlock</a>
</u>{' '} </u>{' '}
extension. extension.
</h4> </h4>
)} )}
{Object.values(skippedSegments).map(({ from, to }, index) => { {Object.values(skippedSegments).map(({ from, to }, index) => {
return ( return (
<Fragment key={`${from}-${to}-${index}`}> <Fragment key={`${from}-${to}-${index}`}>
{from !== 0 && to !== 0 && ( {from !== 0 && to !== 0 && (
<h3> <h3>
Skipped sponsor segment from {formatTime(from)} to {formatTime(to)}. Skipped sponsor segment from {formatTime(from)} to {formatTime(to)}.
</h3> </h3>
)} )}
</Fragment> </Fragment>
); );
})} })}
</> </>
)} )}
</div> </div>
</> </>
); );
}; };
export default VideoPlayer; export default VideoPlayer;

View File

@ -8,9 +8,8 @@ export const ColourConstant = {
}; };
const importColours = () => { const importColours = () => {
const { userConfig } = useUserConfigStore(); const { userConfig } = useUserConfigStore();
const stylesheet = userConfig?.config.stylesheet const stylesheet = userConfig?.config.stylesheet;
switch (stylesheet) { switch (stylesheet) {
case ColourConstant.Dark: case ColourConstant.Dark:

View File

@ -1,7 +1,7 @@
import { useUserConfigStore } from '../stores/UserConfigStore'; import { useUserConfigStore } from '../stores/UserConfigStore';
const loadIsAdmin = () => { const loadIsAdmin = () => {
const { userConfig } = useUserConfigStore() const { userConfig } = useUserConfigStore();
const isAdmin = userConfig?.is_staff || userConfig?.is_superuser; const isAdmin = userConfig?.is_staff || userConfig?.is_superuser;
return isAdmin; return isAdmin;

View File

@ -1,144 +1,144 @@
import * as React from 'react'; import * as React from 'react';
import * as ReactDOM from 'react-dom/client'; import * as ReactDOM from 'react-dom/client';
import { createBrowserRouter, redirect, RouterProvider } from 'react-router-dom'; import { createBrowserRouter, redirect, RouterProvider } from 'react-router-dom';
import Routes from './configuration/routes/RouteList'; import Routes from './configuration/routes/RouteList';
import './style.css'; import './style.css';
import Base from './pages/Base'; import Base from './pages/Base';
import About from './pages/About'; import About from './pages/About';
import Channels from './pages/Channels'; import Channels from './pages/Channels';
import ErrorPage from './pages/ErrorPage'; import ErrorPage from './pages/ErrorPage';
import Home from './pages/Home'; import Home from './pages/Home';
import Playlist from './pages/Playlist'; import Playlist from './pages/Playlist';
import Playlists from './pages/Playlists'; import Playlists from './pages/Playlists';
import Search from './pages/Search'; import Search from './pages/Search';
import SettingsDashboard from './pages/SettingsDashboard'; import SettingsDashboard from './pages/SettingsDashboard';
import Video from './pages/Video'; import Video from './pages/Video';
import Login from './pages/Login'; import Login from './pages/Login';
import SettingsActions from './pages/SettingsActions'; import SettingsActions from './pages/SettingsActions';
import SettingsApplication from './pages/SettingsApplication'; import SettingsApplication from './pages/SettingsApplication';
import SettingsScheduling from './pages/SettingsScheduling'; import SettingsScheduling from './pages/SettingsScheduling';
import SettingsUser from './pages/SettingsUser'; import SettingsUser from './pages/SettingsUser';
import loadUserMeConfig from './api/loader/loadUserConfig'; import loadUserMeConfig from './api/loader/loadUserConfig';
import loadAuth from './api/loader/loadAuth'; import loadAuth from './api/loader/loadAuth';
import ChannelBase from './pages/ChannelBase'; 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 Download from './pages/Download'; import Download from './pages/Download';
const router = createBrowserRouter( const router = createBrowserRouter(
[ [
{ {
path: Routes.Home, path: Routes.Home,
loader: async () => { loader: async () => {
console.log('------------ after reload'); console.log('------------ after reload');
const auth = await loadAuth(); const auth = await loadAuth();
if (auth.status === 403) { if (auth.status === 403) {
return redirect(Routes.Login); return redirect(Routes.Login);
} }
const authData = await auth.json(); const authData = await auth.json();
const userConfig = await loadUserMeConfig(); const userConfig = await loadUserMeConfig();
return { userConfig, auth: authData }; return { userConfig, auth: authData };
}, },
element: <Base />, element: <Base />,
errorElement: <ErrorPage />, errorElement: <ErrorPage />,
children: [ children: [
{ {
index: true, index: true,
element: <Home />, element: <Home />,
}, },
{ {
path: Routes.Video(':videoId'), path: Routes.Video(':videoId'),
element: <Video />, element: <Video />,
}, },
{ {
path: Routes.Channels, path: Routes.Channels,
element: <Channels />, element: <Channels />,
}, },
{ {
path: Routes.Channel(':channelId'), path: Routes.Channel(':channelId'),
element: <ChannelBase />, element: <ChannelBase />,
children: [ children: [
{ {
index: true, index: true,
path: Routes.ChannelVideo(':channelId'), path: Routes.ChannelVideo(':channelId'),
element: <ChannelVideo videoType="videos" />, element: <ChannelVideo videoType="videos" />,
}, },
{ {
path: Routes.ChannelStream(':channelId'), path: Routes.ChannelStream(':channelId'),
element: <ChannelVideo videoType="streams" />, element: <ChannelVideo videoType="streams" />,
}, },
{ {
path: Routes.ChannelShorts(':channelId'), path: Routes.ChannelShorts(':channelId'),
element: <ChannelVideo videoType="shorts" />, element: <ChannelVideo videoType="shorts" />,
}, },
{ {
path: Routes.ChannelPlaylist(':channelId'), path: Routes.ChannelPlaylist(':channelId'),
element: <ChannelPlaylist />, element: <ChannelPlaylist />,
}, },
{ {
path: Routes.ChannelAbout(':channelId'), path: Routes.ChannelAbout(':channelId'),
element: <ChannelAbout />, element: <ChannelAbout />,
}, },
], ],
}, },
{ {
path: Routes.Playlists, path: Routes.Playlists,
element: <Playlists />, element: <Playlists />,
}, },
{ {
path: Routes.Playlist(':playlistId'), path: Routes.Playlist(':playlistId'),
element: <Playlist />, element: <Playlist />,
}, },
{ {
path: Routes.Downloads, path: Routes.Downloads,
element: <Download />, element: <Download />,
}, },
{ {
path: Routes.Search, path: Routes.Search,
element: <Search />, element: <Search />,
}, },
{ {
path: Routes.SettingsDashboard, path: Routes.SettingsDashboard,
element: <SettingsDashboard />, element: <SettingsDashboard />,
}, },
{ {
path: Routes.SettingsActions, path: Routes.SettingsActions,
element: <SettingsActions />, element: <SettingsActions />,
}, },
{ {
path: Routes.SettingsApplication, path: Routes.SettingsApplication,
element: <SettingsApplication />, element: <SettingsApplication />,
}, },
{ {
path: Routes.SettingsScheduling, path: Routes.SettingsScheduling,
element: <SettingsScheduling />, element: <SettingsScheduling />,
}, },
{ {
path: Routes.SettingsUser, path: Routes.SettingsUser,
element: <SettingsUser />, element: <SettingsUser />,
}, },
{ {
path: Routes.About, path: Routes.About,
element: <About />, element: <About />,
}, },
], ],
}, },
{ {
path: Routes.Login, path: Routes.Login,
element: <Login />, element: <Login />,
errorElement: <ErrorPage />, errorElement: <ErrorPage />,
}, },
], ],
{ basename: import.meta.env.BASE_URL }, { basename: import.meta.env.BASE_URL },
); );
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<RouterProvider router={router} /> <RouterProvider router={router} />
</React.StrictMode>, </React.StrictMode>,
); );

View File

@ -1,60 +1,60 @@
const About = () => { const About = () => {
return ( return (
<> <>
<title>TA | About</title> <title>TA | About</title>
<div className="boxed-content"> <div className="boxed-content">
<div className="title-bar"> <div className="title-bar">
<h1>About The Tube Archivist</h1> <h1>About The Tube Archivist</h1>
</div> </div>
<div className="about-section"> <div className="about-section">
<h2>Useful Links</h2> <h2>Useful Links</h2>
<p> <p>
This project is in active and constant development, take a look at the{' '} This project is in active and constant development, take a look at the{' '}
<a href="https://github.com/tubearchivist/tubearchivist#roadmap" target="_blank"> <a href="https://github.com/tubearchivist/tubearchivist#roadmap" target="_blank">
roadmap roadmap
</a>{' '} </a>{' '}
for a overview. for a overview.
</p> </p>
<p> <p>
All functionality is documented in our up-to-date{' '} All functionality is documented in our up-to-date{' '}
<a href="https://docs.tubearchivist.com" target="_blank"> <a href="https://docs.tubearchivist.com" target="_blank">
user guide user guide
</a> </a>
. .
</p> </p>
<p> <p>
All contributions are welcome: Open an{' '} All contributions are welcome: Open an{' '}
<a href="https://github.com/tubearchivist/tubearchivist/issues" target="_blank"> <a href="https://github.com/tubearchivist/tubearchivist/issues" target="_blank">
issue issue
</a>{' '} </a>{' '}
for any bugs and errors, join us on{' '} for any bugs and errors, join us on{' '}
<a href="https://www.tubearchivist.com/discord" target="_blank"> <a href="https://www.tubearchivist.com/discord" target="_blank">
Discord Discord
</a>{' '} </a>{' '}
to discuss details. The{' '} to discuss details. The{' '}
<a <a
href="https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md" href="https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md"
target="_blank" target="_blank"
> >
contributing contributing
</a>{' '} </a>{' '}
page is a good place to get started. page is a good place to get started.
</p> </p>
</div> </div>
<div className="about-section"> <div className="about-section">
<h2>Donate</h2> <h2>Donate</h2>
<p> <p>
Here are{' '} Here are{' '}
<a href="https://github.com/tubearchivist/tubearchivist#donate" target="_blank"> <a href="https://github.com/tubearchivist/tubearchivist#donate" target="_blank">
some links some links
</a> </a>
, if you want to buy the developer a coffee. Thank you for your support! , if you want to buy the developer a coffee. Thank you for your support!
</p> </p>
</div> </div>
</div> </div>
</> </>
); );
}; };
export default About; export default About;

View File

@ -31,7 +31,7 @@ export type OutletContextType = {
const Base = () => { const Base = () => {
const { setAuth } = useAuthStore(); const { setAuth } = useAuthStore();
const { setUserConfig } = useUserConfigStore() const { setUserConfig } = useUserConfigStore();
const { userConfig, auth } = useLoaderData() as BaseLoaderData; const { userConfig, auth } = useLoaderData() as BaseLoaderData;
const location = useLocation(); const location = useLocation();
@ -46,7 +46,7 @@ const Base = () => {
useEffect(() => { useEffect(() => {
setAuth(auth); setAuth(auth);
setUserConfig(userConfig); setUserConfig(userConfig);
}, []) }, []);
useEffect(() => { useEffect(() => {
if (currentPageFromUrl !== currentPage) { if (currentPageFromUrl !== currentPage) {

View File

@ -1,105 +1,105 @@
import { Link, Outlet, useOutletContext, useParams } from 'react-router-dom'; import { Link, Outlet, useOutletContext, useParams } from 'react-router-dom';
import Routes from '../configuration/routes/RouteList'; import Routes from '../configuration/routes/RouteList';
import { ChannelType } from './Channels'; import { ChannelType } from './Channels';
import { ConfigType } from './Home'; import { ConfigType } from './Home';
import { OutletContextType } from './Base'; import { OutletContextType } from './Base';
import Notifications from '../components/Notifications'; 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'; import loadChannelById from '../api/loader/loadChannelById';
import loadIsAdmin from '../functions/getIsAdmin'; import loadIsAdmin from '../functions/getIsAdmin';
type ChannelParams = { type ChannelParams = {
channelId: string; channelId: string;
}; };
export type ChannelResponseType = { export type ChannelResponseType = {
data: ChannelType; data: ChannelType;
config: ConfigType; config: ConfigType;
}; };
const ChannelBase = () => { const ChannelBase = () => {
const { channelId } = useParams() as ChannelParams; const { channelId } = useParams() as ChannelParams;
const { currentPage, setCurrentPage } = useOutletContext() as OutletContextType; const { currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
const isAdmin = loadIsAdmin(); const isAdmin = loadIsAdmin();
const [channelResponse, setChannelResponse] = useState<ChannelResponseType>(); 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 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); const channelResponse = await loadChannelById(channelId);
setChannelResponse(channelResponse); setChannelResponse(channelResponse);
setChannelNav(channelNavResponse); setChannelNav(channelNavResponse);
})(); })();
}, [channelId]); }, [channelId]);
if (!channelId) { if (!channelId) {
return []; return [];
} }
return ( return (
<> <>
<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 channelId={channelId} channelBannerUrl={channel?.channel_banner_url} /> <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">
<Link to={Routes.ChannelVideo(channelId)}> <Link to={Routes.ChannelVideo(channelId)}>
<h3>Videos</h3> <h3>Videos</h3>
</Link> </Link>
{has_streams && ( {has_streams && (
<Link to={Routes.ChannelStream(channelId)}> <Link to={Routes.ChannelStream(channelId)}>
<h3>Streams</h3> <h3>Streams</h3>
</Link> </Link>
)} )}
{has_shorts && ( {has_shorts && (
<Link to={Routes.ChannelShorts(channelId)}> <Link to={Routes.ChannelShorts(channelId)}>
<h3>Shorts</h3> <h3>Shorts</h3>
</Link> </Link>
)} )}
{has_playlists && ( {has_playlists && (
<Link to={Routes.ChannelPlaylist(channelId)}> <Link to={Routes.ChannelPlaylist(channelId)}>
<h3>Playlists</h3> <h3>Playlists</h3>
</Link> </Link>
)} )}
<Link to={Routes.ChannelAbout(channelId)}> <Link to={Routes.ChannelAbout(channelId)}>
<h3>About</h3> <h3>About</h3>
</Link> </Link>
{has_pending && isAdmin && ( {has_pending && isAdmin && (
<Link to={Routes.DownloadsByChannelId(channelId)}> <Link to={Routes.DownloadsByChannelId(channelId)}>
<h3>Downloads</h3> <h3>Downloads</h3>
</Link> </Link>
)} )}
</div> </div>
<Notifications <Notifications
pageName="channel" pageName="channel"
includeReindex={true} includeReindex={true}
update={startNotification} update={startNotification}
setShouldRefresh={() => setStartNotification(false)} setShouldRefresh={() => setStartNotification(false)}
/> />
</div> </div>
<Outlet <Outlet
context={{ context={{
currentPage, currentPage,
setCurrentPage, setCurrentPage,
startNotification, startNotification,
setStartNotification, setStartNotification,
}} }}
/> />
</> </>
); );
}; };
export default ChannelBase; export default ChannelBase;

View File

@ -1,107 +1,104 @@
import { useOutletContext, useParams } from 'react-router-dom'; import { useOutletContext, useParams } from 'react-router-dom';
import Notifications from '../components/Notifications'; import Notifications from '../components/Notifications';
import PlaylistList from '../components/PlaylistList'; import PlaylistList from '../components/PlaylistList';
import { useEffect, useState } from 'react'; 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 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';
import iconListView from '/img/icon-listview.svg'; import iconListView from '/img/icon-listview.svg';
import { useUserConfigStore } from '../stores/UserConfigStore'; import { useUserConfigStore } from '../stores/UserConfigStore';
const ChannelPlaylist = () => { const ChannelPlaylist = () => {
const { channelId } = useParams(); const { channelId } = useParams();
const { userConfig, setPartialConfig } = useUserConfigStore(); const { userConfig, setPartialConfig } = useUserConfigStore();
const { currentPage, setCurrentPage } = useOutletContext() as OutletContextType; const { currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
const [refreshPlaylists, setRefreshPlaylists] = useState(false); const [refreshPlaylists, setRefreshPlaylists] = useState(false);
const [playlistsResponse, setPlaylistsResponse] = useState<PlaylistsResponseType>(); const [playlistsResponse, setPlaylistsResponse] = useState<PlaylistsResponseType>();
const playlistList = playlistsResponse?.data; const playlistList = playlistsResponse?.data;
const pagination = playlistsResponse?.paginate; const pagination = playlistsResponse?.paginate;
const view = userConfig.config.view_style_playlist; const view = userConfig.config.view_style_playlist;
const showSubedOnly = userConfig.config.show_subed_only; const showSubedOnly = userConfig.config.show_subed_only;
useEffect(() => { useEffect(() => {
(async () => { (async () => {
const playlists = await loadPlaylistList({ const playlists = await loadPlaylistList({
channel: channelId, channel: channelId,
subscribed: showSubedOnly, subscribed: showSubedOnly,
}); });
setPlaylistsResponse(playlists); setPlaylistsResponse(playlists);
setRefreshPlaylists(false); setRefreshPlaylists(false);
})(); })();
}, [channelId, refreshPlaylists, showSubedOnly, currentPage]); }, [channelId, refreshPlaylists, showSubedOnly, currentPage]);
return ( return (
<> <>
<title>TA | Channel: Playlists</title> <title>TA | Channel: Playlists</title>
<ScrollToTopOnNavigate /> <ScrollToTopOnNavigate />
<div className='boxed-content'> <div className="boxed-content">
<Notifications pageName="channel" includeReindex={true} /> <Notifications pageName="channel" includeReindex={true} />
<div className="view-controls"> <div className="view-controls">
<div className="toggle"> <div className="toggle">
<span>Show subscribed only:</span> <span>Show subscribed only:</span>
<div className="toggleBox"> <div className="toggleBox">
<input <input
checked={showSubedOnly} checked={showSubedOnly}
onChange={() => { onChange={() => {
setPartialConfig({show_subed_only: !showSubedOnly}); setPartialConfig({ show_subed_only: !showSubedOnly });
setRefreshPlaylists(true); setRefreshPlaylists(true);
}} }}
type="checkbox" type="checkbox"
/> />
{!showSubedOnly && ( {!showSubedOnly && (
<label htmlFor="" className="ofbtn"> <label htmlFor="" className="ofbtn">
Off Off
</label> </label>
)} )}
{showSubedOnly && ( {showSubedOnly && (
<label htmlFor="" className="onbtn"> <label htmlFor="" className="onbtn">
On On
</label> </label>
)} )}
</div> </div>
</div> </div>
<div className="view-icons"> <div className="view-icons">
<img <img
src={iconGridView} src={iconGridView}
onClick={() => { onClick={() => {
setPartialConfig({view_style_playlist: 'grid'}); setPartialConfig({ view_style_playlist: 'grid' });
}} }}
alt="grid view" alt="grid view"
/> />
<img <img
src={iconListView} src={iconListView}
onClick={() => { onClick={() => {
setPartialConfig({view_style_playlist: 'list'}); setPartialConfig({ view_style_playlist: 'list' });
}} }}
alt="list view" alt="list view"
/> />
</div> </div>
</div> </div>
</div> </div>
<div className={`boxed-content`}> <div className={`boxed-content`}>
<div className={`playlist-list ${view}`}> <div className={`playlist-list ${view}`}>
<PlaylistList <PlaylistList playlistList={playlistList} setRefresh={setRefreshPlaylists} />
playlistList={playlistList} </div>
setRefresh={setRefreshPlaylists} </div>
/>
</div> <div className="boxed-content">
</div> {pagination && <Pagination pagination={pagination} setPage={setCurrentPage} />}
</div>
<div className="boxed-content"> </>
{pagination && <Pagination pagination={pagination} setPage={setCurrentPage} />} );
</div> };
</>
); export default ChannelPlaylist;
};
export default ChannelPlaylist;

View File

@ -1,193 +1,188 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { import { Link, useOutletContext, useParams, useSearchParams } from 'react-router-dom';
Link, import { OutletContextType } from './Base';
useOutletContext, import VideoList from '../components/VideoList';
useParams, import Routes from '../configuration/routes/RouteList';
useSearchParams, import Pagination from '../components/Pagination';
} from 'react-router-dom'; import Filterbar from '../components/Filterbar';
import { OutletContextType } from './Base'; import { ViewStyleNames, ViewStyles } from '../configuration/constants/ViewStyle';
import VideoList from '../components/VideoList'; import ChannelOverview from '../components/ChannelOverview';
import Routes from '../configuration/routes/RouteList'; import loadChannelById from '../api/loader/loadChannelById';
import Pagination from '../components/Pagination'; import { ChannelResponseType } from './ChannelBase';
import Filterbar from '../components/Filterbar'; import ScrollToTopOnNavigate from '../components/ScrollToTop';
import { ViewStyleNames, ViewStyles } from '../configuration/constants/ViewStyle'; import EmbeddableVideoPlayer from '../components/EmbeddableVideoPlayer';
import ChannelOverview from '../components/ChannelOverview'; import updateWatchedState from '../api/actions/updateWatchedState';
import loadChannelById from '../api/loader/loadChannelById'; import Button from '../components/Button';
import { ChannelResponseType } from './ChannelBase'; import loadVideoListByFilter, {
import ScrollToTopOnNavigate from '../components/ScrollToTop'; VideoListByFilterResponseType,
import EmbeddableVideoPlayer from '../components/EmbeddableVideoPlayer'; VideoTypes,
import updateWatchedState from '../api/actions/updateWatchedState'; } from '../api/loader/loadVideoListByPage';
import Button from '../components/Button'; import loadChannelAggs, { ChannelAggsType } from '../api/loader/loadChannelAggs';
import loadVideoListByFilter, { import humanFileSize from '../functions/humanFileSize';
VideoListByFilterResponseType, import { useUserConfigStore } from '../stores/UserConfigStore';
VideoTypes,
} from '../api/loader/loadVideoListByPage'; type ChannelParams = {
import loadChannelAggs, { ChannelAggsType } from '../api/loader/loadChannelAggs'; channelId: string;
import humanFileSize from '../functions/humanFileSize'; };
import { useUserConfigStore } from '../stores/UserConfigStore';
type ChannelVideoProps = {
type ChannelParams = { videoType: VideoTypes;
channelId: string; };
};
const ChannelVideo = ({ videoType }: ChannelVideoProps) => {
type ChannelVideoProps = { const { channelId } = useParams() as ChannelParams;
videoType: VideoTypes; const { userConfig } = useUserConfigStore();
}; const { currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
const [searchParams] = useSearchParams();
const ChannelVideo = ({ videoType }: ChannelVideoProps) => { const videoId = searchParams.get('videoId');
const { channelId } = useParams() as ChannelParams;
const { userConfig } = useUserConfigStore(); const [refresh, setRefresh] = useState(false);
const { currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
const [searchParams] = useSearchParams(); const [channelResponse, setChannelResponse] = useState<ChannelResponseType>();
const videoId = searchParams.get('videoId'); const [videoResponse, setVideoReponse] = useState<VideoListByFilterResponseType>();
const [videoAggsResponse, setVideoAggsResponse] = useState<ChannelAggsType>();
const [refresh, setRefresh] = useState(false);
const channel = channelResponse?.data;
const [channelResponse, setChannelResponse] = useState<ChannelResponseType>(); const videoList = videoResponse?.data;
const [videoResponse, setVideoReponse] = useState<VideoListByFilterResponseType>(); const pagination = videoResponse?.paginate;
const [videoAggsResponse, setVideoAggsResponse] = useState<ChannelAggsType>();
const hasVideos = videoResponse?.data?.length !== 0;
const channel = channelResponse?.data; const showEmbeddedVideo = videoId !== null;
const videoList = videoResponse?.data;
const pagination = videoResponse?.paginate; const view = userConfig.config.view_style_home;
const isGridView = view === ViewStyles.grid;
const hasVideos = videoResponse?.data?.length !== 0; const gridView = isGridView ? `boxed-${userConfig.config.grid_items}` : '';
const showEmbeddedVideo = videoId !== null; const gridViewGrid = isGridView ? `grid-${userConfig.config.grid_items}` : '';
const view = userConfig.config.view_style_home useEffect(() => {
const isGridView = view === ViewStyles.grid; (async () => {
const gridView = isGridView ? `boxed-${userConfig.config.grid_items}` : ''; const channelResponse = await loadChannelById(channelId);
const gridViewGrid = isGridView ? `grid-${userConfig.config.grid_items}` : ''; const videos = await loadVideoListByFilter({
channel: channelId,
useEffect(() => { page: currentPage,
(async () => { watch: userConfig.config.hide_watched ? 'unwatched' : undefined,
const channelResponse = await loadChannelById(channelId); sort: userConfig.config.sort_by,
const videos = await loadVideoListByFilter({ order: userConfig.config.sort_order,
channel: channelId, type: videoType,
page: currentPage, });
watch: userConfig.config.hide_watched ? 'unwatched' : undefined, const channelAggs = await loadChannelAggs(channelId);
sort: userConfig.config.sort_by,
order: userConfig.config.sort_order, setChannelResponse(channelResponse);
type: videoType, setVideoReponse(videos);
}); setVideoAggsResponse(channelAggs);
const channelAggs = await loadChannelAggs(channelId); setRefresh(false);
})();
setChannelResponse(channelResponse); // eslint-disable-next-line react-hooks/exhaustive-deps
setVideoReponse(videos); }, [
setVideoAggsResponse(channelAggs); refresh,
setRefresh(false); userConfig.config.sort_by,
})(); userConfig.config.sort_order,
// eslint-disable-next-line react-hooks/exhaustive-deps userConfig.config.hide_watched,
}, [ currentPage,
refresh, channelId,
userConfig.config.sort_by, pagination?.current_page,
userConfig.config.sort_order, videoType,
userConfig.config.hide_watched, ]);
currentPage,
channelId, if (!channel) {
pagination?.current_page, return (
videoType, <div className="boxed-content">
]); <br />
<h2>Channel {channelId} not found!</h2>
if (!channel) { </div>
return ( );
<div className="boxed-content"> }
<br />
<h2>Channel {channelId} not found!</h2> return (
</div> <>
); <title>{`TA | Channel: ${channel.channel_name}`}</title>
} <ScrollToTopOnNavigate />
<div className="boxed-content">
return ( <div className="info-box info-box-2">
<> <ChannelOverview
<title>{`TA | Channel: ${channel.channel_name}`}</title> channelId={channel.channel_id}
<ScrollToTopOnNavigate /> channelname={channel.channel_name}
<div className="boxed-content"> channelSubs={channel.channel_subs}
<div className="info-box info-box-2"> channelSubscribed={channel.channel_subscribed}
<ChannelOverview channelThumbUrl={channel.channel_thumb_url}
channelId={channel.channel_id} showSubscribeButton={true}
channelname={channel.channel_name} setRefresh={setRefresh}
channelSubs={channel.channel_subs} />
channelSubscribed={channel.channel_subscribed} <div className="info-box-item">
channelThumbUrl={channel.channel_thumb_url} {videoAggsResponse && (
showSubscribeButton={true} <>
setRefresh={setRefresh} <p>
/> {videoAggsResponse.total_items.value} videos{' '}
<div className="info-box-item"> <span className="space-carrot">|</span>{' '}
{videoAggsResponse && ( {videoAggsResponse.total_duration.value_str} playback{' '}
<> <span className="space-carrot">|</span> Total size{' '}
<p> {humanFileSize(videoAggsResponse.total_size.value, true)}
{videoAggsResponse.total_items.value} videos{' '} </p>
<span className="space-carrot">|</span>{' '} <div className="button-box">
{videoAggsResponse.total_duration.value_str} playback{' '} <Button
<span className="space-carrot">|</span> Total size{' '} label="Mark as watched"
{humanFileSize(videoAggsResponse.total_size.value, true)} id="watched-button"
</p> type="button"
<div className="button-box"> title={`Mark all videos from ${channel.channel_name} as watched`}
<Button onClick={async () => {
label="Mark as watched" await updateWatchedState({
id="watched-button" id: channel.channel_id,
type="button" is_watched: true,
title={`Mark all videos from ${channel.channel_name} as watched`} });
onClick={async () => {
await updateWatchedState({ setRefresh(true);
id: channel.channel_id, }}
is_watched: true, />{' '}
}); <Button
label="Mark as unwatched"
setRefresh(true); id="unwatched-button"
}} type="button"
/>{' '} title={`Mark all videos from ${channel.channel_name} as unwatched`}
<Button onClick={async () => {
label="Mark as unwatched" await updateWatchedState({
id="unwatched-button" id: channel.channel_id,
type="button" is_watched: false,
title={`Mark all videos from ${channel.channel_name} as unwatched`} });
onClick={async () => {
await updateWatchedState({ setRefresh(true);
id: channel.channel_id, }}
is_watched: false, />
}); </div>
</>
setRefresh(true); )}
}} </div>
/> </div>
</div> </div>
</> <div className={`boxed-content ${gridView}`}>
)} <Filterbar
</div> hideToggleText={'Hide watched videos:'}
</div> viewStyleName={ViewStyleNames.home}
</div> setRefresh={setRefresh}
<div className={`boxed-content ${gridView}`}> />
<Filterbar </div>
hideToggleText={'Hide watched videos:'} {showEmbeddedVideo && <EmbeddableVideoPlayer videoId={videoId} />}
viewStyleName={ViewStyleNames.home} <div className={`boxed-content ${gridView}`}>
setRefresh={setRefresh} <div className={`video-list ${view} ${gridViewGrid}`}>
/> {!hasVideos && (
</div> <>
{showEmbeddedVideo && <EmbeddableVideoPlayer videoId={videoId} />} <h2>No videos found...</h2>
<div className={`boxed-content ${gridView}`}> <p>
<div className={`video-list ${view} ${gridViewGrid}`}> Try going to the <Link to={Routes.Downloads}>downloads page</Link> to start the scan
{!hasVideos && ( and download tasks.
<> </p>
<h2>No videos found...</h2> </>
<p> )}
Try going to the <Link to={Routes.Downloads}>downloads page</Link> to start the scan
and download tasks. <VideoList videoList={videoList} viewLayout={view} refreshVideoList={setRefresh} />
</p> </div>
</> </div>
)} {pagination && (
<div className="boxed-content">
<VideoList videoList={videoList} viewLayout={view} refreshVideoList={setRefresh} /> <Pagination pagination={pagination} setPage={setCurrentPage} />
</div> </div>
</div> )}
{pagination && ( </>
<div className="boxed-content"> );
<Pagination pagination={pagination} setPage={setCurrentPage} /> };
</div>
)} export default ChannelVideo;
</>
);
};
export default ChannelVideo;

View File

@ -1,189 +1,190 @@
import { useOutletContext } from 'react-router-dom'; import { useOutletContext } from 'react-router-dom';
import loadChannelList from '../api/loader/loadChannelList'; import loadChannelList from '../api/loader/loadChannelList';
import iconGridView from '/img/icon-gridview.svg'; import iconGridView from '/img/icon-gridview.svg';
import iconListView from '/img/icon-listview.svg'; import iconListView from '/img/icon-listview.svg';
import iconAdd from '/img/icon-add.svg'; import iconAdd from '/img/icon-add.svg';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import Pagination, { PaginationType } from '../components/Pagination'; import Pagination, { PaginationType } from '../components/Pagination';
import { ConfigType } from './Home'; import { ConfigType } from './Home';
import { OutletContextType } from './Base'; 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 Button from '../components/Button'; import Button from '../components/Button';
import updateChannelSubscription from '../api/actions/updateChannelSubscription'; import updateChannelSubscription from '../api/actions/updateChannelSubscription';
import loadIsAdmin from '../functions/getIsAdmin'; import loadIsAdmin from '../functions/getIsAdmin';
import { useUserConfigStore } from '../stores/UserConfigStore'; import { useUserConfigStore } from '../stores/UserConfigStore';
type ChannelOverwritesType = { type ChannelOverwritesType = {
download_format?: string; download_format?: string;
autodelete_days?: number; autodelete_days?: number;
index_playlists?: boolean; index_playlists?: boolean;
integrate_sponsorblock?: boolean; integrate_sponsorblock?: boolean;
subscriptions_channel_size?: number; subscriptions_channel_size?: number;
subscriptions_live_channel_size?: number; subscriptions_live_channel_size?: number;
subscriptions_shorts_channel_size?: number; subscriptions_shorts_channel_size?: number;
}; };
export type ChannelType = { export type ChannelType = {
channel_active: boolean; channel_active: boolean;
channel_banner_url: string; channel_banner_url: string;
channel_description: string; channel_description: string;
channel_id: string; channel_id: string;
channel_last_refresh: string; channel_last_refresh: string;
channel_name: string; channel_name: string;
channel_overwrites?: ChannelOverwritesType; channel_overwrites?: ChannelOverwritesType;
channel_subs: number; channel_subs: number;
channel_subscribed: boolean; channel_subscribed: boolean;
channel_tags: string[]; channel_tags: string[];
channel_thumb_url: string; channel_thumb_url: string;
channel_tvart_url: string; channel_tvart_url: string;
channel_views: number; channel_views: number;
}; };
type ChannelsListResponse = { type ChannelsListResponse = {
data: ChannelType[]; data: ChannelType[];
paginate: PaginationType; paginate: PaginationType;
config?: ConfigType; config?: ConfigType;
}; };
const Channels = () => { const Channels = () => {
const { userConfig, setPartialConfig } = useUserConfigStore(); const { userConfig, setPartialConfig } = useUserConfigStore();
const { currentPage, setCurrentPage } = useOutletContext() as OutletContextType; const { currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
const isAdmin = loadIsAdmin(); const isAdmin = loadIsAdmin();
const [channelListResponse, setChannelListResponse] = useState<ChannelsListResponse>(); const [channelListResponse, setChannelListResponse] = useState<ChannelsListResponse>();
const [showAddForm, setShowAddForm] = useState(false); const [showAddForm, setShowAddForm] = useState(false);
const [refresh, setRefresh] = useState(false); const [refresh, setRefresh] = useState(false);
const [channelsToSubscribeTo, setChannelsToSubscribeTo] = useState(''); const [channelsToSubscribeTo, setChannelsToSubscribeTo] = useState('');
const channels = channelListResponse?.data; const channels = channelListResponse?.data;
const pagination = channelListResponse?.paginate; const pagination = channelListResponse?.paginate;
const channelCount = pagination?.total_hits; const channelCount = pagination?.total_hits;
const hasChannels = channels?.length !== 0; const hasChannels = channels?.length !== 0;
useEffect(() => { useEffect(() => {
(async () => { (async () => {
const channelListResponse = await loadChannelList(currentPage, userConfig.config.show_subed_only); const channelListResponse = await loadChannelList(
setChannelListResponse(channelListResponse); currentPage,
})(); userConfig.config.show_subed_only,
}, [refresh, userConfig.config.show_subed_only, currentPage, pagination?.current_page]); );
setChannelListResponse(channelListResponse);
return ( })();
<> }, [refresh, userConfig.config.show_subed_only, currentPage, pagination?.current_page]);
<title>TA | Channels</title>
<ScrollToTopOnNavigate /> return (
<div className="boxed-content"> <>
<div className="title-split"> <title>TA | Channels</title>
<div className="title-bar"> <ScrollToTopOnNavigate />
<h1>Channels</h1> <div className="boxed-content">
</div> <div className="title-split">
{isAdmin && ( <div className="title-bar">
<div className="title-split-form"> <h1>Channels</h1>
<img </div>
id="animate-icon" {isAdmin && (
onClick={() => { <div className="title-split-form">
setShowAddForm(!showAddForm); <img
}} id="animate-icon"
src={iconAdd} onClick={() => {
alt="add-icon" setShowAddForm(!showAddForm);
title="Subscribe to Channels" }}
/> src={iconAdd}
{showAddForm && ( alt="add-icon"
<div className="show-form"> title="Subscribe to Channels"
<div> />
<label>Subscribe to channels:</label> {showAddForm && (
<textarea <div className="show-form">
value={channelsToSubscribeTo} <div>
onChange={e => { <label>Subscribe to channels:</label>
setChannelsToSubscribeTo(e.currentTarget.value); <textarea
}} value={channelsToSubscribeTo}
rows={3} onChange={e => {
placeholder="Input channel ID, URL or Video of a channel" setChannelsToSubscribeTo(e.currentTarget.value);
/> }}
</div> rows={3}
placeholder="Input channel ID, URL or Video of a channel"
<Button />
label="Subscribe" </div>
type="submit"
onClick={async () => { <Button
await updateChannelSubscription(channelsToSubscribeTo, true); label="Subscribe"
type="submit"
setRefresh(true); onClick={async () => {
}} await updateChannelSubscription(channelsToSubscribeTo, true);
/>
</div> setRefresh(true);
)} }}
</div> />
)} </div>
</div> )}
</div>
<Notifications pageName="all" /> )}
</div>
<div className="view-controls">
<div className="toggle"> <Notifications pageName="all" />
<span>Show subscribed only:</span>
<div className="toggleBox"> <div className="view-controls">
<input <div className="toggle">
id="show_subed_only" <span>Show subscribed only:</span>
onChange={async () => { <div className="toggleBox">
setPartialConfig({show_subed_only: !userConfig.config.show_subed_only}); <input
setRefresh(true); id="show_subed_only"
}} onChange={async () => {
type="checkbox" setPartialConfig({ show_subed_only: !userConfig.config.show_subed_only });
checked={userConfig.config.show_subed_only} setRefresh(true);
/> }}
{!userConfig.config.show_subed_only && ( type="checkbox"
<label htmlFor="" className="ofbtn"> checked={userConfig.config.show_subed_only}
Off />
</label> {!userConfig.config.show_subed_only && (
)} <label htmlFor="" className="ofbtn">
{userConfig.config.show_subed_only && ( Off
<label htmlFor="" className="onbtn"> </label>
On )}
</label> {userConfig.config.show_subed_only && (
)} <label htmlFor="" className="onbtn">
</div> On
</div> </label>
<div className="view-icons"> )}
<img </div>
src={iconGridView} </div>
onClick={() => { <div className="view-icons">
setPartialConfig({view_style_channel: 'grid'}); <img
}} src={iconGridView}
data-origin="channel" onClick={() => {
data-value="grid" setPartialConfig({ view_style_channel: 'grid' });
alt="grid view" }}
/> data-origin="channel"
<img data-value="grid"
src={iconListView} alt="grid view"
onClick={() => { />
setPartialConfig({view_style_channel: 'list'}); <img
}} src={iconListView}
data-origin="channel" onClick={() => {
data-value="list" setPartialConfig({ view_style_channel: 'list' });
alt="list view" }}
/> data-origin="channel"
</div> data-value="list"
</div> alt="list view"
{hasChannels && <h2>Total channels: {channelCount}</h2>} />
</div>
<div className={`channel-list ${userConfig.config.view_style_channel}`}> </div>
{!hasChannels && <h2>No channels found...</h2>} {hasChannels && <h2>Total channels: {channelCount}</h2>}
{hasChannels && ( <div className={`channel-list ${userConfig.config.view_style_channel}`}>
<ChannelList channelList={channels} refreshChannelList={setRefresh} /> {!hasChannels && <h2>No channels found...</h2>}
)}
</div> {hasChannels && <ChannelList channelList={channels} refreshChannelList={setRefresh} />}
</div>
{pagination && (
<div className="boxed-content"> {pagination && (
<Pagination pagination={pagination} setPage={setCurrentPage} /> <div className="boxed-content">
</div> <Pagination pagination={pagination} setPage={setCurrentPage} />
)} </div>
</div> )}
</> </div>
); </>
}; );
};
export default Channels;
export default Channels;

View File

@ -206,7 +206,7 @@ const Download = () => {
<input <input
id="showIgnored" id="showIgnored"
onChange={() => { onChange={() => {
setPartialConfig({show_ignored_only: !showIgnored}); setPartialConfig({ show_ignored_only: !showIgnored });
setRefresh(true); setRefresh(true);
}} }}
type="checkbox" type="checkbox"
@ -262,7 +262,7 @@ const Download = () => {
<img <img
src={iconAdd} src={iconAdd}
onClick={() => { onClick={() => {
setPartialConfig({grid_items: gridItems + 1}); setPartialConfig({ grid_items: gridItems + 1 });
}} }}
alt="grid plus row" alt="grid plus row"
/> />
@ -271,7 +271,7 @@ const Download = () => {
<img <img
src={iconSubstract} src={iconSubstract}
onClick={() => { onClick={() => {
setPartialConfig({grid_items: gridItems - 1}); setPartialConfig({ grid_items: gridItems - 1 });
}} }}
alt="grid minus row" alt="grid minus row"
/> />
@ -282,14 +282,14 @@ const Download = () => {
<img <img
src={iconGridView} src={iconGridView}
onClick={() => { onClick={() => {
setPartialConfig({view_style_downloads: 'grid'}); setPartialConfig({ view_style_downloads: 'grid' });
}} }}
alt="grid view" alt="grid view"
/> />
<img <img
src={iconListView} src={iconListView}
onClick={() => { onClick={() => {
setPartialConfig({view_style_downloads: 'list'}); setPartialConfig({ view_style_downloads: 'list' });
}} }}
alt="list view" alt="list view"
/> />
@ -313,10 +313,7 @@ const Download = () => {
downloadList?.map(download => { downloadList?.map(download => {
return ( return (
<Fragment key={`${download.channel_id}_${download.timestamp}`}> <Fragment key={`${download.channel_id}_${download.timestamp}`}>
<DownloadListItem <DownloadListItem download={download} setRefresh={setRefresh} />
download={download}
setRefresh={setRefresh}
/>
</Fragment> </Fragment>
); );
})} })}

View File

@ -1,33 +1,32 @@
import { useRouteError } from 'react-router-dom'; import { useRouteError } from 'react-router-dom';
import importColours from '../configuration/colours/getColours'; import importColours from '../configuration/colours/getColours';
// This is not always the correct response
// This is not always the correct response type ErrorType = {
type ErrorType = { statusText: string;
statusText: string; message: string;
message: string; };
};
const ErrorPage = () => {
const ErrorPage = () => { const error = useRouteError() as ErrorType;
const error = useRouteError() as ErrorType; importColours();
importColours();
console.error('ErrorPage', error);
console.error('ErrorPage', error);
return (
return ( <>
<> <title>TA | Oops!</title>
<title>TA | Oops!</title>
<div id="error-page" style={{ margin: '10%' }}>
<div id="error-page" style={{ margin: '10%' }}> <h1>Oops!</h1>
<h1>Oops!</h1> <p>Sorry, an unexpected error has occurred.</p>
<p>Sorry, an unexpected error has occurred.</p> <p>
<p> <i>{error?.statusText}</i>
<i>{error?.statusText}</i> <i>{error?.message}</i>
<i>{error?.message}</i> </p>
</p> </div>
</div> </>
</> );
); };
};
export default ErrorPage;
export default ErrorPage;

View File

@ -1,232 +1,232 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link, useOutletContext, useSearchParams } from 'react-router-dom'; import { Link, useOutletContext, useSearchParams } from 'react-router-dom';
import Routes from '../configuration/routes/RouteList'; import Routes from '../configuration/routes/RouteList';
import Pagination from '../components/Pagination'; import Pagination from '../components/Pagination';
import loadVideoListByFilter, { import loadVideoListByFilter, {
VideoListByFilterResponseType, VideoListByFilterResponseType,
} from '../api/loader/loadVideoListByPage'; } from '../api/loader/loadVideoListByPage';
import VideoList from '../components/VideoList'; import VideoList from '../components/VideoList';
import { ChannelType } from './Channels'; import { ChannelType } from './Channels';
import { OutletContextType } from './Base'; import { OutletContextType } from './Base';
import Filterbar from '../components/Filterbar'; 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 { SponsorBlockType } from './Video'; import { SponsorBlockType } from './Video';
import { useUserConfigStore } from '../stores/UserConfigStore'; import { useUserConfigStore } from '../stores/UserConfigStore';
export type PlayerType = { export type PlayerType = {
watched: boolean; watched: boolean;
duration: number; duration: number;
duration_str: string; duration_str: string;
progress: number; progress: number;
position: number; position: number;
}; };
export type StatsType = { export type StatsType = {
view_count: number; view_count: number;
like_count: number; like_count: number;
dislike_count: number; dislike_count: number;
average_rating: number; average_rating: number;
}; };
export type StreamType = { export type StreamType = {
type: string; type: string;
index: number; index: number;
codec: string; codec: string;
width?: number; width?: number;
height?: number; height?: number;
bitrate: number; bitrate: number;
}; };
export type Subtitles = { export type Subtitles = {
ext: string; ext: string;
url: string; url: string;
name: string; name: string;
lang: string; lang: string;
source: string; source: string;
media_url: string; media_url: string;
}; };
export type VideoType = { export type VideoType = {
active: boolean; active: boolean;
category: string[]; category: string[];
channel: ChannelType; channel: ChannelType;
date_downloaded: number; date_downloaded: number;
description: string; description: string;
comment_count?: number; comment_count?: number;
media_size: number; media_size: number;
media_url: string; media_url: string;
player: PlayerType; player: PlayerType;
published: string; published: string;
sponsorblock?: SponsorBlockType; sponsorblock?: SponsorBlockType;
playlist?: string[]; playlist?: string[];
stats: StatsType; stats: StatsType;
streams: StreamType[]; streams: StreamType[];
subtitles: Subtitles[]; subtitles: Subtitles[];
tags: string[]; tags: string[];
title: string; title: string;
vid_last_refresh: string; vid_last_refresh: string;
vid_thumb_base64: boolean; vid_thumb_base64: boolean;
vid_thumb_url: string; vid_thumb_url: string;
vid_type: string; vid_type: string;
youtube_id: string; youtube_id: string;
}; };
export type DownloadsType = { export type DownloadsType = {
limit_speed: boolean; limit_speed: boolean;
sleep_interval: number; sleep_interval: number;
autodelete_days: boolean; autodelete_days: boolean;
format: boolean; format: boolean;
format_sort: boolean; format_sort: boolean;
add_metadata: boolean; add_metadata: boolean;
add_thumbnail: boolean; add_thumbnail: boolean;
subtitle: boolean; subtitle: boolean;
subtitle_source: boolean; subtitle_source: boolean;
subtitle_index: boolean; subtitle_index: boolean;
comment_max: boolean; comment_max: boolean;
comment_sort: string; comment_sort: string;
cookie_import: boolean; cookie_import: boolean;
throttledratelimit: boolean; throttledratelimit: boolean;
extractor_lang: boolean; extractor_lang: boolean;
integrate_ryd: boolean; integrate_ryd: boolean;
integrate_sponsorblock: boolean; integrate_sponsorblock: boolean;
}; };
export type ConfigType = { export type ConfigType = {
enable_cast: boolean; enable_cast: boolean;
downloads: DownloadsType; downloads: DownloadsType;
}; };
export type SortByType = 'published' | 'downloaded' | 'views' | 'likes' | 'duration' | 'mediasize'; export type SortByType = 'published' | 'downloaded' | 'views' | 'likes' | 'duration' | 'mediasize';
export type SortOrderType = 'asc' | 'desc'; export type SortOrderType = 'asc' | 'desc';
export type ViewLayoutType = 'grid' | 'list'; export type ViewLayoutType = 'grid' | 'list';
const Home = () => { const Home = () => {
const { userConfig } = useUserConfigStore(); const { userConfig } = useUserConfigStore();
const { currentPage, setCurrentPage } = useOutletContext() as OutletContextType; const { currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const videoId = searchParams.get('videoId'); const videoId = searchParams.get('videoId');
const userMeConfig = userConfig.config; const userMeConfig = userConfig.config;
const [refreshVideoList, setRefreshVideoList] = useState(false); const [refreshVideoList, setRefreshVideoList] = useState(false);
const [videoResponse, setVideoReponse] = useState<VideoListByFilterResponseType>(); const [videoResponse, setVideoReponse] = useState<VideoListByFilterResponseType>();
const [continueVideoResponse, setContinueVideoResponse] = const [continueVideoResponse, setContinueVideoResponse] =
useState<VideoListByFilterResponseType>(); useState<VideoListByFilterResponseType>();
const videoList = videoResponse?.data; const videoList = videoResponse?.data;
const pagination = videoResponse?.paginate; const pagination = videoResponse?.paginate;
const continueVideos = continueVideoResponse?.data; const continueVideos = continueVideoResponse?.data;
const hasVideos = videoResponse?.data?.length !== 0; const hasVideos = videoResponse?.data?.length !== 0;
const showEmbeddedVideo = videoId !== null; const showEmbeddedVideo = videoId !== null;
const isGridView = userMeConfig.view_style_home === ViewStyles.grid; const isGridView = userMeConfig.view_style_home === ViewStyles.grid;
const gridView = isGridView ? `boxed-${userMeConfig.grid_items}` : ''; const gridView = isGridView ? `boxed-${userMeConfig.grid_items}` : '';
const gridViewGrid = isGridView ? `grid-${userMeConfig.grid_items}` : ''; const gridViewGrid = isGridView ? `grid-${userMeConfig.grid_items}` : '';
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if ( if (
refreshVideoList || refreshVideoList ||
pagination?.current_page === undefined || pagination?.current_page === undefined ||
currentPage !== pagination?.current_page currentPage !== pagination?.current_page
) { ) {
const videos = await loadVideoListByFilter({ const videos = await loadVideoListByFilter({
page: currentPage, page: currentPage,
watch: userMeConfig.hide_watched ? 'unwatched' : undefined, watch: userMeConfig.hide_watched ? 'unwatched' : undefined,
sort: userMeConfig.sort_by, sort: userMeConfig.sort_by,
order: userMeConfig.sort_order, order: userMeConfig.sort_order,
}); });
try { try {
const continueVideoResponse = await loadVideoListByFilter({ watch: 'continue' }); const continueVideoResponse = await loadVideoListByFilter({ watch: 'continue' });
setContinueVideoResponse(continueVideoResponse); setContinueVideoResponse(continueVideoResponse);
} catch (error) { } catch (error) {
console.log('Server error on continue vids?'); console.log('Server error on continue vids?');
} }
setVideoReponse(videos); setVideoReponse(videos);
setRefreshVideoList(false); setRefreshVideoList(false);
} }
})(); })();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ }, [
refreshVideoList, refreshVideoList,
userMeConfig.sort_by, userMeConfig.sort_by,
userMeConfig.sort_order, userMeConfig.sort_order,
userMeConfig.hide_watched, userMeConfig.hide_watched,
currentPage, currentPage,
pagination?.current_page pagination?.current_page,
]); ]);
return ( return (
<> <>
<title>TubeArchivist</title> <title>TubeArchivist</title>
<ScrollToTopOnNavigate /> <ScrollToTopOnNavigate />
{showEmbeddedVideo && <EmbeddableVideoPlayer videoId={videoId} />} {showEmbeddedVideo && <EmbeddableVideoPlayer videoId={videoId} />}
<div className={`boxed-content ${gridView}`}> <div className={`boxed-content ${gridView}`}>
{continueVideos && continueVideos.length > 0 && ( {continueVideos && continueVideos.length > 0 && (
<> <>
<div className="title-bar"> <div className="title-bar">
<h1>Continue Watching</h1> <h1>Continue Watching</h1>
</div> </div>
<div className={`video-list ${userMeConfig.view_style_home} ${gridViewGrid}`}> <div className={`video-list ${userMeConfig.view_style_home} ${gridViewGrid}`}>
<VideoList <VideoList
videoList={continueVideos} videoList={continueVideos}
viewLayout={userMeConfig.view_style_home} viewLayout={userMeConfig.view_style_home}
refreshVideoList={setRefreshVideoList} refreshVideoList={setRefreshVideoList}
/> />
</div> </div>
</> </>
)} )}
<div className="title-bar"> <div className="title-bar">
<h1>Recent Videos</h1> <h1>Recent Videos</h1>
</div> </div>
<Filterbar <Filterbar
hideToggleText="Hide watched:" hideToggleText="Hide watched:"
viewStyleName={ViewStyleNames.home} viewStyleName={ViewStyleNames.home}
setRefresh={setRefreshVideoList} setRefresh={setRefreshVideoList}
/> />
</div> </div>
<div className={`boxed-content ${gridView}`}> <div className={`boxed-content ${gridView}`}>
<div className={`video-list ${userMeConfig.view_style_home} ${gridViewGrid}`}> <div className={`video-list ${userMeConfig.view_style_home} ${gridViewGrid}`}>
{!hasVideos && ( {!hasVideos && (
<> <>
<h2>No videos found...</h2> <h2>No videos found...</h2>
<p> <p>
If you've already added a channel or playlist, try going to the{' '} If you've already added a channel or playlist, try going to the{' '}
<Link to={Routes.Downloads}>downloads page</Link> to start the scan and download <Link to={Routes.Downloads}>downloads page</Link> to start the scan and download
tasks. tasks.
</p> </p>
</> </>
)} )}
{hasVideos && ( {hasVideos && (
<VideoList <VideoList
videoList={videoList} videoList={videoList}
viewLayout={userMeConfig.view_style_home} viewLayout={userMeConfig.view_style_home}
refreshVideoList={setRefreshVideoList} refreshVideoList={setRefreshVideoList}
/> />
)} )}
</div> </div>
</div> </div>
{pagination && ( {pagination && (
<div className="boxed-content"> <div className="boxed-content">
<Pagination pagination={pagination} setPage={setCurrentPage} /> <Pagination pagination={pagination} setPage={setCurrentPage} />
</div> </div>
)} )}
</> </>
); );
}; };
export default Home; export default Home;

View File

@ -1,103 +1,103 @@
import { useState } from 'react'; 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 from '../configuration/colours/getColours'; import importColours from '../configuration/colours/getColours';
import Button from '../components/Button'; import Button from '../components/Button';
import signIn from '../api/actions/signIn'; import signIn from '../api/actions/signIn';
const Login = () => { const Login = () => {
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [saveLogin, setSaveLogin] = useState(false); const [saveLogin, setSaveLogin] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
importColours(); importColours();
const form_error = false; const form_error = false;
const handleSubmit = async (event: { preventDefault: () => void }) => { const handleSubmit = async (event: { preventDefault: () => void }) => {
event.preventDefault(); event.preventDefault();
const loginResponse = await signIn(username, password, saveLogin); const loginResponse = await signIn(username, password, saveLogin);
const signedIn = loginResponse.status === 200; const signedIn = loginResponse.status === 200;
if (signedIn) { if (signedIn) {
navigate(Routes.Home); navigate(Routes.Home);
} else { } else {
navigate(Routes.Login); navigate(Routes.Login);
} }
}; };
return ( return (
<> <>
<title>TA | Welcome</title> <title>TA | Welcome</title>
<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>
<h2>Your Self Hosted YouTube Media Server</h2> <h2>Your Self Hosted YouTube Media Server</h2>
{form_error && <p className="danger-zone">Failed to login.</p>} {form_error && <p className="danger-zone">Failed to login.</p>}
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<input <input
type="text" type="text"
name="username" name="username"
id="id_username" id="id_username"
placeholder="Username" placeholder="Username"
autoComplete="username" autoComplete="username"
maxLength={150} maxLength={150}
required={true} required={true}
value={username} value={username}
onChange={event => setUsername(event.target.value)} onChange={event => setUsername(event.target.value)}
/> />
<br /> <br />
<input <input
type="password" type="password"
name="password" name="password"
id="id_password" id="id_password"
placeholder="Password" placeholder="Password"
autoComplete="current-password" autoComplete="current-password"
required={true} required={true}
value={password} value={password}
onChange={event => setPassword(event.target.value)} onChange={event => setPassword(event.target.value)}
/> />
<br /> <br />
<p> <p>
Remember me:{' '} Remember me:{' '}
<input <input
type="checkbox" type="checkbox"
name="remember_me" name="remember_me"
id="id_remember_me" id="id_remember_me"
checked={saveLogin} checked={saveLogin}
onChange={() => { onChange={() => {
setSaveLogin(!saveLogin); setSaveLogin(!saveLogin);
}} }}
/> />
</p> </p>
<input type="hidden" name="next" value={Routes.Home} /> <input type="hidden" name="next" value={Routes.Home} />
<Button label="Login" type="submit" /> <Button label="Login" type="submit" />
</form> </form>
<p className="login-links"> <p className="login-links">
<span> <span>
<a href="https://github.com/tubearchivist/tubearchivist" target="_blank"> <a href="https://github.com/tubearchivist/tubearchivist" target="_blank">
Github Github
</a> </a>
</span>{' '} </span>{' '}
<span> <span>
<a href="https://github.com/tubearchivist/tubearchivist#donate" target="_blank"> <a href="https://github.com/tubearchivist/tubearchivist#donate" target="_blank">
Donate Donate
</a> </a>
</span> </span>
</p> </p>
</div> </div>
<div className="footer-colors"> <div className="footer-colors">
<div className="col-1"></div> <div className="col-1"></div>
<div className="col-2"></div> <div className="col-2"></div>
<div className="col-3"></div> <div className="col-3"></div>
</div> </div>
</> </>
); );
}; };
export default Login; export default Login;

View File

@ -1,385 +1,379 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { import { Link, useNavigate, useOutletContext, useParams, useSearchParams } from 'react-router-dom';
Link, import loadPlaylistById from '../api/loader/loadPlaylistById';
useNavigate, import { OutletContextType } from './Base';
useOutletContext, import { ConfigType, VideoType, ViewLayoutType } from './Home';
useParams, import Filterbar from '../components/Filterbar';
useSearchParams, import { PlaylistEntryType } from './Playlists';
} from 'react-router-dom'; import loadChannelById from '../api/loader/loadChannelById';
import loadPlaylistById from '../api/loader/loadPlaylistById'; import VideoList from '../components/VideoList';
import { OutletContextType } from './Base'; import Pagination, { PaginationType } from '../components/Pagination';
import { ConfigType, VideoType, ViewLayoutType } from './Home'; import ChannelOverview from '../components/ChannelOverview';
import Filterbar from '../components/Filterbar'; import Linkify from '../components/Linkify';
import { PlaylistEntryType } from './Playlists'; import { ViewStyleNames, ViewStyles } from '../configuration/constants/ViewStyle';
import loadChannelById from '../api/loader/loadChannelById'; import updatePlaylistSubscription from '../api/actions/updatePlaylistSubscription';
import VideoList from '../components/VideoList'; import deletePlaylist from '../api/actions/deletePlaylist';
import Pagination, { PaginationType } from '../components/Pagination'; import Routes from '../configuration/routes/RouteList';
import ChannelOverview from '../components/ChannelOverview'; import { ChannelResponseType } from './ChannelBase';
import Linkify from '../components/Linkify'; import formatDate from '../functions/formatDates';
import { ViewStyleNames, ViewStyles } from '../configuration/constants/ViewStyle'; import queueReindex from '../api/actions/queueReindex';
import updatePlaylistSubscription from '../api/actions/updatePlaylistSubscription'; import updateWatchedState from '../api/actions/updateWatchedState';
import deletePlaylist from '../api/actions/deletePlaylist'; import ScrollToTopOnNavigate from '../components/ScrollToTop';
import Routes from '../configuration/routes/RouteList'; import EmbeddableVideoPlayer from '../components/EmbeddableVideoPlayer';
import { ChannelResponseType } from './ChannelBase'; import Button from '../components/Button';
import formatDate from '../functions/formatDates'; import loadVideoListByFilter from '../api/loader/loadVideoListByPage';
import queueReindex from '../api/actions/queueReindex'; import loadIsAdmin from '../functions/getIsAdmin';
import updateWatchedState from '../api/actions/updateWatchedState'; import { useUserConfigStore } from '../stores/UserConfigStore';
import ScrollToTopOnNavigate from '../components/ScrollToTop';
import EmbeddableVideoPlayer from '../components/EmbeddableVideoPlayer'; export type PlaylistType = {
import Button from '../components/Button'; playlist_active: boolean;
import loadVideoListByFilter from '../api/loader/loadVideoListByPage'; playlist_channel: string;
import loadIsAdmin from '../functions/getIsAdmin'; playlist_channel_id: string;
import { useUserConfigStore } from '../stores/UserConfigStore'; playlist_description: string;
playlist_entries: PlaylistEntryType[];
export type PlaylistType = { playlist_id: string;
playlist_active: boolean; playlist_last_refresh: string;
playlist_channel: string; playlist_name: string;
playlist_channel_id: string; playlist_subscribed: boolean;
playlist_description: string; playlist_thumbnail: string;
playlist_entries: PlaylistEntryType[]; playlist_type: string;
playlist_id: string; _index: string;
playlist_last_refresh: string; _score: number;
playlist_name: string; };
playlist_subscribed: boolean;
playlist_thumbnail: string; export type PlaylistResponseType = {
playlist_type: string; data?: PlaylistType;
_index: string; config?: ConfigType;
_score: number; };
};
export type VideoResponseType = {
export type PlaylistResponseType = { data?: VideoType[];
data?: PlaylistType; config?: ConfigType;
config?: ConfigType; paginate?: PaginationType;
}; };
export type VideoResponseType = { const Playlist = () => {
data?: VideoType[]; const { playlistId } = useParams();
config?: ConfigType; const navigate = useNavigate();
paginate?: PaginationType; const [searchParams] = useSearchParams();
}; const videoId = searchParams.get('videoId');
const Playlist = () => { const { userConfig } = useUserConfigStore();
const { playlistId } = useParams(); const { currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
const navigate = useNavigate(); const isAdmin = loadIsAdmin();
const [searchParams] = useSearchParams();
const videoId = searchParams.get('videoId'); const userMeConfig = userConfig.config;
const { userConfig } = useUserConfigStore(); const [hideWatched, setHideWatched] = useState(userMeConfig.hide_watched || false);
const { currentPage, setCurrentPage } = useOutletContext() as OutletContextType; const [view, setView] = useState<ViewLayoutType>(userMeConfig.view_style_home || 'grid');
const isAdmin = loadIsAdmin(); const [gridItems, setGridItems] = useState(userMeConfig.grid_items || 3);
const [descriptionExpanded, setDescriptionExpanded] = useState(false);
const userMeConfig = userConfig.config; const [refresh, setRefresh] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [hideWatched, setHideWatched] = useState(userMeConfig.hide_watched || false); const [reindex, setReindex] = useState(false);
const [view, setView] = useState<ViewLayoutType>(userMeConfig.view_style_home || 'grid');
const [gridItems, setGridItems] = useState(userMeConfig.grid_items || 3); const [playlistResponse, setPlaylistResponse] = useState<PlaylistResponseType>();
const [descriptionExpanded, setDescriptionExpanded] = useState(false); const [channelResponse, setChannelResponse] = useState<ChannelResponseType>();
const [refresh, setRefresh] = useState(false); const [videoResponse, setVideoResponse] = useState<VideoResponseType>();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [reindex, setReindex] = useState(false); const playlist = playlistResponse?.data;
const channel = channelResponse?.data;
const [playlistResponse, setPlaylistResponse] = useState<PlaylistResponseType>(); const videos = videoResponse?.data;
const [channelResponse, setChannelResponse] = useState<ChannelResponseType>(); const pagination = videoResponse?.paginate;
const [videoResponse, setVideoResponse] = useState<VideoResponseType>();
const palylistEntries = playlistResponse?.data?.playlist_entries;
const playlist = playlistResponse?.data; const videoArchivedCount = Number(palylistEntries?.filter(video => video.downloaded).length);
const channel = channelResponse?.data; const videoInPlaylistCount = pagination?.total_hits;
const videos = videoResponse?.data; const showEmbeddedVideo = videoId !== null;
const pagination = videoResponse?.paginate;
const isGridView = view === ViewStyles.grid;
const palylistEntries = playlistResponse?.data?.playlist_entries; const gridView = isGridView ? `boxed-${gridItems}` : '';
const videoArchivedCount = Number(palylistEntries?.filter(video => video.downloaded).length); const gridViewGrid = isGridView ? `grid-${gridItems}` : '';
const videoInPlaylistCount = pagination?.total_hits;
const showEmbeddedVideo = videoId !== null; useEffect(() => {
(async () => {
const isGridView = view === ViewStyles.grid; if (
const gridView = isGridView ? `boxed-${gridItems}` : ''; refresh ||
const gridViewGrid = isGridView ? `grid-${gridItems}` : ''; pagination?.current_page === undefined ||
currentPage !== pagination?.current_page
useEffect(() => { ) {
(async () => { const playlist = await loadPlaylistById(playlistId);
if ( const video = await loadVideoListByFilter({
refresh || playlist: playlistId,
pagination?.current_page === undefined || page: currentPage,
currentPage !== pagination?.current_page watch: hideWatched ? 'unwatched' : undefined,
) { sort: 'downloaded', // downloaded or published? or playlist sort order?
const playlist = await loadPlaylistById(playlistId); });
const video = await loadVideoListByFilter({
playlist: playlistId, const isCustomPlaylist = playlist?.data?.playlist_type === 'custom';
page: currentPage, if (!isCustomPlaylist) {
watch: hideWatched ? 'unwatched' : undefined, const channel = await loadChannelById(playlist.data.playlist_channel_id);
sort: 'downloaded', // downloaded or published? or playlist sort order?
}); setChannelResponse(channel);
}
const isCustomPlaylist = playlist?.data?.playlist_type === 'custom';
if (!isCustomPlaylist) { setPlaylistResponse(playlist);
const channel = await loadChannelById(playlist.data.playlist_channel_id); setVideoResponse(video);
setRefresh(false);
setChannelResponse(channel); }
} })();
// Do not add hideWatched this will not work as expected!
setPlaylistResponse(playlist); // eslint-disable-next-line react-hooks/exhaustive-deps
setVideoResponse(video); }, [playlistId, refresh, currentPage, pagination?.current_page]);
setRefresh(false);
} if (!playlistId || !playlist) {
})(); return `Playlist ${playlistId} not found!`;
// Do not add hideWatched this will not work as expected! }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playlistId, refresh, currentPage, pagination?.current_page]); const isCustomPlaylist = playlist.playlist_type === 'custom';
if (!playlistId || !playlist) { return (
return `Playlist ${playlistId} not found!`; <>
} <title>{`TA | Playlist: ${playlist.playlist_name}`}</title>
<ScrollToTopOnNavigate />
const isCustomPlaylist = playlist.playlist_type === 'custom'; <div className="boxed-content">
<div className="title-bar">
return ( <h1>{playlist.playlist_name}</h1>
<> </div>
<title>{`TA | Playlist: ${playlist.playlist_name}`}</title> <div className="info-box info-box-3">
<ScrollToTopOnNavigate /> {!isCustomPlaylist && channel && (
<div className="boxed-content"> <ChannelOverview
<div className="title-bar"> channelId={channel?.channel_id}
<h1>{playlist.playlist_name}</h1> channelname={channel?.channel_name}
</div> channelSubs={channel?.channel_subs}
<div className="info-box info-box-3"> channelSubscribed={channel?.channel_subscribed}
{!isCustomPlaylist && channel && ( channelThumbUrl={channel.channel_thumb_url}
<ChannelOverview setRefresh={setRefresh}
channelId={channel?.channel_id} />
channelname={channel?.channel_name} )}
channelSubs={channel?.channel_subs}
channelSubscribed={channel?.channel_subscribed} <div className="info-box-item">
channelThumbUrl={channel.channel_thumb_url} <div>
setRefresh={setRefresh} <p>Last refreshed: {formatDate(playlist.playlist_last_refresh)}</p>
/> {!isCustomPlaylist && (
)} <>
<p>
<div className="info-box-item"> Playlist:
<div> {playlist.playlist_subscribed && (
<p>Last refreshed: {formatDate(playlist.playlist_last_refresh)}</p> <>
{!isCustomPlaylist && ( {isAdmin && (
<> <Button
<p> label="Unsubscribe"
Playlist: className="unsubscribe"
{playlist.playlist_subscribed && ( type="button"
<> title={`Unsubscribe from ${playlist.playlist_name}`}
{isAdmin && ( onClick={async () => {
<Button await updatePlaylistSubscription(playlistId, false);
label="Unsubscribe"
className="unsubscribe" setRefresh(true);
type="button" }}
title={`Unsubscribe from ${playlist.playlist_name}`} />
onClick={async () => { )}
await updatePlaylistSubscription(playlistId, false); </>
)}{' '}
setRefresh(true); {!playlist.playlist_subscribed && (
}} <Button
/> label="Subscribe"
)} type="button"
</> title={`Subscribe to ${playlist.playlist_name}`}
)}{' '} onClick={async () => {
{!playlist.playlist_subscribed && ( await updatePlaylistSubscription(playlistId, true);
<Button
label="Subscribe" setRefresh(true);
type="button" }}
title={`Subscribe to ${playlist.playlist_name}`} />
onClick={async () => { )}
await updatePlaylistSubscription(playlistId, true); </p>
{playlist.playlist_active && (
setRefresh(true); <p>
}} Youtube:{' '}
/> <a
)} href={`https://www.youtube.com/playlist?list=${playlist.playlist_id}`}
</p> target="_blank"
{playlist.playlist_active && ( >
<p> Active
Youtube:{' '} </a>
<a </p>
href={`https://www.youtube.com/playlist?list=${playlist.playlist_id}`} )}
target="_blank" {!playlist.playlist_active && <p>Youtube: Deactivated</p>}
> </>
Active )}
</a>
</p> {!showDeleteConfirm && (
)} <Button
{!playlist.playlist_active && <p>Youtube: Deactivated</p>} label="Delete Playlist"
</> id="delete-item"
)} onClick={() => setShowDeleteConfirm(!showDeleteConfirm)}
/>
{!showDeleteConfirm && ( )}
<Button
label="Delete Playlist" {showDeleteConfirm && (
id="delete-item" <div className="delete-confirm" id="delete-button">
onClick={() => setShowDeleteConfirm(!showDeleteConfirm)} <span>Delete {playlist.playlist_name}?</span>
/>
)} <Button
label="Delete metadata"
{showDeleteConfirm && ( onClick={async () => {
<div className="delete-confirm" id="delete-button"> await deletePlaylist(playlistId, false);
<span>Delete {playlist.playlist_name}?</span> navigate(Routes.Playlists);
}}
<Button />
label="Delete metadata"
onClick={async () => { <Button
await deletePlaylist(playlistId, false); label="Delete all"
navigate(Routes.Playlists); className="danger-button"
}} onClick={async () => {
/> await deletePlaylist(playlistId, true);
navigate(Routes.Playlists);
<Button }}
label="Delete all" />
className="danger-button"
onClick={async () => { <br />
await deletePlaylist(playlistId, true); <Button label="Cancel" onClick={() => setShowDeleteConfirm(!showDeleteConfirm)} />
navigate(Routes.Playlists); </div>
}} )}
/> </div>
</div>
<br /> <div className="info-box-item">
<Button label="Cancel" onClick={() => setShowDeleteConfirm(!showDeleteConfirm)} /> <div>
</div> {videoArchivedCount > 0 && (
)} <>
</div> <p>
</div> Total Videos archived: {videoArchivedCount}/{videoInPlaylistCount}
<div className="info-box-item"> </p>
<div> <div id="watched-button" className="button-box">
{videoArchivedCount > 0 && ( <Button
<> label="Mark as watched"
<p> title={`Mark all videos from ${playlist.playlist_name} as watched`}
Total Videos archived: {videoArchivedCount}/{videoInPlaylistCount} type="button"
</p> onClick={async () => {
<div id="watched-button" className="button-box"> await updateWatchedState({
<Button id: playlistId,
label="Mark as watched" is_watched: true,
title={`Mark all videos from ${playlist.playlist_name} as watched`} });
type="button"
onClick={async () => { setRefresh(true);
await updateWatchedState({ }}
id: playlistId, />{' '}
is_watched: true, <Button
}); label="Mark as unwatched"
title={`Mark all videos from ${playlist.playlist_name} as unwatched`}
setRefresh(true); type="button"
}} onClick={async () => {
/>{' '} await updateWatchedState({
<Button id: playlistId,
label="Mark as unwatched" is_watched: false,
title={`Mark all videos from ${playlist.playlist_name} as unwatched`} });
type="button"
onClick={async () => { setRefresh(true);
await updateWatchedState({ }}
id: playlistId, />
is_watched: false, </div>
}); </>
)}
setRefresh(true);
}} {reindex && <p>Reindex scheduled</p>}
/> {!reindex && (
</div> <div id="reindex-button" className="button-box">
</> {!isCustomPlaylist && (
)} <Button
label="Reindex"
{reindex && <p>Reindex scheduled</p>} title={`Reindex Playlist ${playlist.playlist_name}`}
{!reindex && ( onClick={async () => {
<div id="reindex-button" className="button-box"> setReindex(true);
{!isCustomPlaylist && (
<Button await queueReindex(playlist.playlist_id, 'playlist');
label="Reindex" }}
title={`Reindex Playlist ${playlist.playlist_name}`} />
onClick={async () => { )}{' '}
setReindex(true); <Button
label="Reindex Videos"
await queueReindex(playlist.playlist_id, 'playlist'); title={`Reindex Videos of ${playlist.playlist_name}`}
}} onClick={async () => {
/> setReindex(true);
)}{' '}
<Button await queueReindex(playlist.playlist_id, 'playlist', true);
label="Reindex Videos" }}
title={`Reindex Videos of ${playlist.playlist_name}`} />
onClick={async () => { </div>
setReindex(true); )}
</div>
await queueReindex(playlist.playlist_id, 'playlist', true); </div>
}} </div>
/>
</div> {playlist.playlist_description && (
)} <div className="description-box">
</div> <p
</div> id={descriptionExpanded ? 'text-expand-expanded' : 'text-expand'}
</div> className="description-text"
>
{playlist.playlist_description && ( <Linkify>{playlist.playlist_description}</Linkify>
<div className="description-box"> </p>
<p
id={descriptionExpanded ? 'text-expand-expanded' : 'text-expand'} <Button
className="description-text" label="Show more"
> id="text-expand-button"
<Linkify>{playlist.playlist_description}</Linkify> onClick={() => setDescriptionExpanded(!descriptionExpanded)}
</p> />
</div>
<Button )}
label="Show more" </div>
id="text-expand-button"
onClick={() => setDescriptionExpanded(!descriptionExpanded)} <div className={`boxed-content ${gridView}`}>
/> <Filterbar
</div> hideToggleText="Hide watched videos:"
)} hideWatched={hideWatched}
</div> isGridView={isGridView}
view={view}
<div className={`boxed-content ${gridView}`}> gridItems={gridItems}
<Filterbar userMeConfig={userMeConfig}
hideToggleText="Hide watched videos:" setHideWatched={setHideWatched}
hideWatched={hideWatched} setView={setView}
isGridView={isGridView} setGridItems={setGridItems}
view={view} viewStyleName={ViewStyleNames.playlist}
gridItems={gridItems} setRefresh={setRefresh}
userMeConfig={userMeConfig} />
setHideWatched={setHideWatched} </div>
setView={setView}
setGridItems={setGridItems} {showEmbeddedVideo && <EmbeddableVideoPlayer videoId={videoId} />}
viewStyleName={ViewStyleNames.playlist}
setRefresh={setRefresh} <div className={`boxed-content ${gridView}`}>
/> <div className={`video-list ${view} ${gridViewGrid}`}>
</div> {videoInPlaylistCount === 0 && (
<>
{showEmbeddedVideo && <EmbeddableVideoPlayer videoId={videoId} />} <h2>No videos found...</h2>
{isCustomPlaylist && (
<div className={`boxed-content ${gridView}`}> <p>
<div className={`video-list ${view} ${gridViewGrid}`}> Try going to the <a href="{% url 'home' %}">home page</a> to add videos to this
{videoInPlaylistCount === 0 && ( playlist.
<> </p>
<h2>No videos found...</h2> )}
{isCustomPlaylist && (
<p> {!isCustomPlaylist && (
Try going to the <a href="{% url 'home' %}">home page</a> to add videos to this <p>
playlist. Try going to the <Link to={Routes.Downloads}>downloads page</Link> to start the
</p> scan and download tasks.
)} </p>
)}
{!isCustomPlaylist && ( </>
<p> )}
Try going to the <Link to={Routes.Downloads}>downloads page</Link> to start the {videoInPlaylistCount !== 0 && (
scan and download tasks. <VideoList
</p> videoList={videos}
)} viewLayout={view}
</> playlistId={playlistId}
)} showReorderButton={isCustomPlaylist}
{videoInPlaylistCount !== 0 && ( refreshVideoList={setRefresh}
<VideoList />
videoList={videos} )}
viewLayout={view} </div>
playlistId={playlistId} </div>
showReorderButton={isCustomPlaylist}
refreshVideoList={setRefresh} <div className="boxed-content">
/> {pagination && <Pagination pagination={pagination} setPage={setCurrentPage} />}
)} </div>
</div> </>
</div> );
};
<div className="boxed-content">
{pagination && <Pagination pagination={pagination} setPage={setCurrentPage} />} export default Playlist;
</div>
</>
);
};
export default Playlist;

View File

@ -1,196 +1,194 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useOutletContext } from 'react-router-dom'; import { useOutletContext } from 'react-router-dom';
import iconAdd from '/img/icon-add.svg'; import iconAdd from '/img/icon-add.svg';
import iconGridView from '/img/icon-gridview.svg'; import iconGridView from '/img/icon-gridview.svg';
import iconListView from '/img/icon-listview.svg'; import iconListView from '/img/icon-listview.svg';
import { OutletContextType } from './Base'; import { OutletContextType } from './Base';
import loadPlaylistList from '../api/loader/loadPlaylistList'; import loadPlaylistList from '../api/loader/loadPlaylistList';
import { ConfigType } from './Home'; import { ConfigType } from './Home';
import Pagination, { PaginationType } from '../components/Pagination'; import Pagination, { PaginationType } from '../components/Pagination';
import PlaylistList from '../components/PlaylistList'; import PlaylistList from '../components/PlaylistList';
import { PlaylistType } from './Playlist'; 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 Button from '../components/Button'; import Button from '../components/Button';
import loadIsAdmin from '../functions/getIsAdmin'; import loadIsAdmin from '../functions/getIsAdmin';
import { useUserConfigStore } from '../stores/UserConfigStore'; import { useUserConfigStore } from '../stores/UserConfigStore';
export type PlaylistEntryType = { export type PlaylistEntryType = {
youtube_id: string; youtube_id: string;
title: string; title: string;
uploader: string; uploader: string;
idx: number; idx: number;
downloaded: boolean; downloaded: boolean;
}; };
export type PlaylistsResponseType = { export type PlaylistsResponseType = {
data?: PlaylistType[]; data?: PlaylistType[];
config?: ConfigType; config?: ConfigType;
paginate?: PaginationType; paginate?: PaginationType;
}; };
const Playlists = () => { const Playlists = () => {
const { userConfig, setPartialConfig } = useUserConfigStore(); const { userConfig, setPartialConfig } = useUserConfigStore();
const { currentPage, setCurrentPage } = useOutletContext() as OutletContextType; const { currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
const isAdmin = loadIsAdmin(); const isAdmin = loadIsAdmin();
const [showAddForm, setShowAddForm] = useState(false); const [showAddForm, setShowAddForm] = useState(false);
const [refresh, setRefresh] = useState(false); const [refresh, setRefresh] = useState(false);
const [playlistsToAddText, setPlaylistsToAddText] = useState(''); const [playlistsToAddText, setPlaylistsToAddText] = useState('');
const [customPlaylistsToAddText, setCustomPlaylistsToAddText] = useState(''); const [customPlaylistsToAddText, setCustomPlaylistsToAddText] = useState('');
const [playlistResponse, setPlaylistReponse] = useState<PlaylistsResponseType>(); const [playlistResponse, setPlaylistReponse] = useState<PlaylistsResponseType>();
const playlistList = playlistResponse?.data; const playlistList = playlistResponse?.data;
const pagination = playlistResponse?.paginate; const pagination = playlistResponse?.paginate;
const hasPlaylists = playlistResponse?.data?.length !== 0; const hasPlaylists = playlistResponse?.data?.length !== 0;
const view = userConfig.config.view_style_playlist; const view = userConfig.config.view_style_playlist;
const showSubedOnly = userConfig.config.show_subed_only; const showSubedOnly = userConfig.config.show_subed_only;
useEffect(() => { useEffect(() => {
(async () => { (async () => {
const playlist = await loadPlaylistList({ const playlist = await loadPlaylistList({
page: currentPage, page: currentPage,
subscribed: showSubedOnly, subscribed: showSubedOnly,
}); });
setPlaylistReponse(playlist); setPlaylistReponse(playlist);
setRefresh(false); setRefresh(false);
})(); })();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [refresh, userConfig.config.show_subed_only, currentPage, pagination?.current_page]); }, [refresh, userConfig.config.show_subed_only, currentPage, pagination?.current_page]);
return ( return (
<> <>
<title>TA | Playlists</title> <title>TA | Playlists</title>
<ScrollToTopOnNavigate /> <ScrollToTopOnNavigate />
<div className="boxed-content"> <div className="boxed-content">
<div className="title-split"> <div className="title-split">
<div className="title-bar"> <div className="title-bar">
<h1>Playlists</h1> <h1>Playlists</h1>
</div> </div>
{isAdmin && ( {isAdmin && (
<div className="title-split-form"> <div className="title-split-form">
<img <img
onClick={() => { onClick={() => {
setShowAddForm(!showAddForm); setShowAddForm(!showAddForm);
}} }}
src={iconAdd} src={iconAdd}
alt="add-icon" alt="add-icon"
title="Subscribe to Playlists" title="Subscribe to Playlists"
/> />
{showAddForm && ( {showAddForm && (
<div className="show-form"> <div className="show-form">
<div> <div>
<label>Subscribe to playlists:</label> <label>Subscribe to playlists:</label>
<textarea <textarea
value={playlistsToAddText} value={playlistsToAddText}
onChange={event => { onChange={event => {
setPlaylistsToAddText(event.target.value); setPlaylistsToAddText(event.target.value);
}} }}
rows={3} rows={3}
cols={40} cols={40}
placeholder="Input playlist IDs or URLs" placeholder="Input playlist IDs or URLs"
/> />
<Button <Button
label="Subscribe" label="Subscribe"
type="submit" type="submit"
onClick={async () => { onClick={async () => {
await updatePlaylistSubscription(playlistsToAddText, true); await updatePlaylistSubscription(playlistsToAddText, true);
setRefresh(true); setRefresh(true);
}} }}
/> />
</div> </div>
<br /> <br />
<div> <div>
<label>Or create custom playlist:</label> <label>Or create custom playlist:</label>
<textarea <textarea
rows={1} rows={1}
cols={40} cols={40}
placeholder="Input playlist name" placeholder="Input playlist name"
value={customPlaylistsToAddText} value={customPlaylistsToAddText}
onChange={event => { onChange={event => {
setCustomPlaylistsToAddText(event.target.value); setCustomPlaylistsToAddText(event.target.value);
}} }}
/> />
<Button <Button
label="Create" label="Create"
type="submit" type="submit"
onClick={async () => { onClick={async () => {
await createCustomPlaylist(customPlaylistsToAddText); await createCustomPlaylist(customPlaylistsToAddText);
}} }}
/> />
</div> </div>
</div> </div>
)} )}
</div> </div>
)} )}
</div> </div>
<div id="notifications"></div> <div id="notifications"></div>
<div className="view-controls"> <div className="view-controls">
<div className="toggle"> <div className="toggle">
<span>Show subscribed only:</span> <span>Show subscribed only:</span>
<div className="toggleBox"> <div className="toggleBox">
<input <input
checked={showSubedOnly} checked={showSubedOnly}
onChange={() => { onChange={() => {
setPartialConfig({show_subed_only: !showSubedOnly}); setPartialConfig({ show_subed_only: !showSubedOnly });
}} }}
type="checkbox" type="checkbox"
/> />
{!showSubedOnly && ( {!showSubedOnly && (
<label htmlFor="" className="ofbtn"> <label htmlFor="" className="ofbtn">
Off Off
</label> </label>
)} )}
{showSubedOnly && ( {showSubedOnly && (
<label htmlFor="" className="onbtn"> <label htmlFor="" className="onbtn">
On On
</label> </label>
)} )}
</div> </div>
</div> </div>
<div className="view-icons"> <div className="view-icons">
<img <img
src={iconGridView} src={iconGridView}
onClick={() => { onClick={() => {
setPartialConfig({view_style_playlist: 'grid'}); setPartialConfig({ view_style_playlist: 'grid' });
}} }}
alt="grid view" alt="grid view"
/> />
<img <img
src={iconListView} src={iconListView}
onClick={() => { onClick={() => {
setPartialConfig({view_style_playlist: 'list'}); setPartialConfig({ view_style_playlist: 'list' });
}} }}
alt="list view" alt="list view"
/> />
</div> </div>
</div> </div>
<div className={`playlist-list ${view}`}> <div className={`playlist-list ${view}`}>
{!hasPlaylists && <h2>No playlists found...</h2>} {!hasPlaylists && <h2>No playlists found...</h2>}
{hasPlaylists && ( {hasPlaylists && <PlaylistList playlistList={playlistList} setRefresh={setRefresh} />}
<PlaylistList playlistList={playlistList} setRefresh={setRefresh} /> </div>
)} </div>
</div>
</div> <div className="boxed-content">
{pagination && <Pagination pagination={pagination} setPage={setCurrentPage} />}
<div className="boxed-content"> </div>
{pagination && <Pagination pagination={pagination} setPage={setCurrentPage} />} </>
</div> );
</> };
);
}; export default Playlists;
export default Playlists;

View File

@ -1,163 +1,164 @@
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { VideoType } from './Home'; import { VideoType } from './Home';
import loadSearch from '../api/loader/loadSearch'; import loadSearch from '../api/loader/loadSearch';
import { PlaylistType } from './Playlist'; import { PlaylistType } from './Playlist';
import { ChannelType } from './Channels'; import { ChannelType } from './Channels';
import VideoList from '../components/VideoList'; import VideoList from '../components/VideoList';
import ChannelList from '../components/ChannelList'; import ChannelList from '../components/ChannelList';
import PlaylistList from '../components/PlaylistList'; 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 SearchExampleQueries from '../components/SearchExampleQueries'; import SearchExampleQueries from '../components/SearchExampleQueries';
import { useUserConfigStore } from '../stores/UserConfigStore'; import { useUserConfigStore } from '../stores/UserConfigStore';
const EmptySearchResponse: SearchResultsType = { const EmptySearchResponse: SearchResultsType = {
results: { results: {
video_results: [], video_results: [],
channel_results: [], channel_results: [],
playlist_results: [], playlist_results: [],
fulltext_results: [], fulltext_results: [],
}, },
queryType: 'simple', queryType: 'simple',
}; };
type SearchResultType = { type SearchResultType = {
video_results: VideoType[]; video_results: VideoType[];
channel_results: ChannelType[]; channel_results: ChannelType[];
playlist_results: PlaylistType[]; playlist_results: PlaylistType[];
fulltext_results: []; fulltext_results: [];
}; };
type SearchResultsType = { type SearchResultsType = {
results: SearchResultType; results: SearchResultType;
queryType: string; queryType: string;
}; };
const Search = () => { const Search = () => {
const { userConfig } = useUserConfigStore(); const { userConfig } = useUserConfigStore();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const videoId = searchParams.get('videoId'); const videoId = searchParams.get('videoId');
const userMeConfig = userConfig.config; const userMeConfig = userConfig.config;
const viewVideos = userMeConfig.view_style_home; const viewVideos = userMeConfig.view_style_home;
const viewChannels = userMeConfig.view_style_channel; const viewChannels = userMeConfig.view_style_channel;
const viewPlaylists = userMeConfig.view_style_playlist; const viewPlaylists = userMeConfig.view_style_playlist;
const gridItems = userMeConfig.grid_items || 3; const gridItems = userMeConfig.grid_items || 3;
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<SearchResultsType>(); const [searchResults, setSearchResults] = useState<SearchResultsType>();
const [refresh, setRefresh] = useState(false); const [refresh, setRefresh] = useState(false);
const videoList = searchResults?.results.video_results; const videoList = searchResults?.results.video_results;
const channelList = searchResults?.results.channel_results; const channelList = searchResults?.results.channel_results;
const playlistList = searchResults?.results.playlist_results; const playlistList = searchResults?.results.playlist_results;
const fulltextList = searchResults?.results.fulltext_results; const fulltextList = searchResults?.results.fulltext_results;
const queryType = searchResults?.queryType; const queryType = searchResults?.queryType;
const showEmbeddedVideo = videoId !== null; const showEmbeddedVideo = videoId !== null;
const hasSearchQuery = searchQuery.length > 0; const hasSearchQuery = searchQuery.length > 0;
const hasVideos = Number(videoList?.length) > 0; const hasVideos = Number(videoList?.length) > 0;
const hasChannels = Number(channelList?.length) > 0; const hasChannels = Number(channelList?.length) > 0;
const hasPlaylist = Number(playlistList?.length) > 0; const hasPlaylist = Number(playlistList?.length) > 0;
const hasFulltext = Number(fulltextList?.length) > 0; const hasFulltext = Number(fulltextList?.length) > 0;
const isSimpleQuery = queryType === 'simple'; const isSimpleQuery = queryType === 'simple';
const isVideoQuery = queryType === 'video' || isSimpleQuery; const isVideoQuery = queryType === 'video' || isSimpleQuery;
const isChannelQuery = queryType === 'channel' || isSimpleQuery; const isChannelQuery = queryType === 'channel' || isSimpleQuery;
const isPlaylistQuery = queryType === 'playlist' || isSimpleQuery; const isPlaylistQuery = queryType === 'playlist' || isSimpleQuery;
const isFullTextQuery = queryType === 'full' || isSimpleQuery; const isFullTextQuery = queryType === 'full' || isSimpleQuery;
const isGridView = viewVideos === ViewStyles.grid; const isGridView = viewVideos === ViewStyles.grid;
const gridView = isGridView ? `boxed-${gridItems}` : ''; const gridView = isGridView ? `boxed-${gridItems}` : '';
const gridViewGrid = isGridView ? `grid-${gridItems}` : ''; const gridViewGrid = isGridView ? `grid-${gridItems}` : '';
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (!hasSearchQuery) { if (!hasSearchQuery) {
setSearchResults(EmptySearchResponse); setSearchResults(EmptySearchResponse);
return; return;
} }
const searchResults = await loadSearch(searchQuery); const searchResults = await loadSearch(searchQuery);
setSearchResults(searchResults); setSearchResults(searchResults);
setRefresh(false); setRefresh(false);
})(); })();
}, [searchQuery, refresh, hasSearchQuery]); }, [searchQuery, refresh, hasSearchQuery]);
return ( return (
<> <>
<title>TubeArchivist</title> <title>TubeArchivist</title>
{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">
<h1>Search your Archive</h1> <h1>Search your Archive</h1>
</div> </div>
<div className="multi-search-box"> <div className="multi-search-box">
<div> <div>
<input <input
type="text" type="text"
autoFocus autoFocus
autoComplete="off" autoComplete="off"
value={searchQuery} value={searchQuery}
onChange={event => { onChange={event => {
setSearchQuery(event.target.value); setSearchQuery(event.target.value);
}} }}
/> />
</div> </div>
</div> </div>
<div id="multi-search-results"> <div id="multi-search-results">
{hasSearchQuery && isVideoQuery && ( {hasSearchQuery && isVideoQuery && (
<div className="multi-search-result"> <div className="multi-search-result">
<h2>Video Results</h2> <h2>Video Results</h2>
<div id="video-results" className={`video-list ${viewVideos} ${gridViewGrid}`}> <div id="video-results" className={`video-list ${viewVideos} ${gridViewGrid}`}>
<VideoList videoList={videoList} viewLayout={viewVideos} refreshVideoList={setRefresh} /> <VideoList
</div> videoList={videoList}
</div> viewLayout={viewVideos}
)} refreshVideoList={setRefresh}
/>
{hasSearchQuery && isChannelQuery && ( </div>
<div className="multi-search-result"> </div>
<h2>Channel Results</h2> )}
<div id="channel-results" className={`channel-list ${viewChannels} ${gridViewGrid}`}>
<ChannelList {hasSearchQuery && isChannelQuery && (
channelList={channelList} <div className="multi-search-result">
refreshChannelList={setRefresh} <h2>Channel Results</h2>
/> <div id="channel-results" className={`channel-list ${viewChannels} ${gridViewGrid}`}>
</div> <ChannelList channelList={channelList} refreshChannelList={setRefresh} />
</div> </div>
)} </div>
)}
{hasSearchQuery && isPlaylistQuery && (
<div className="multi-search-result"> {hasSearchQuery && isPlaylistQuery && (
<h2>Playlist Results</h2> <div className="multi-search-result">
<div id="playlist-results" className={`playlist-list ${viewPlaylists} ${gridViewGrid}`}> <h2>Playlist Results</h2>
<PlaylistList <div
playlistList={playlistList} id="playlist-results"
setRefresh={setRefresh} className={`playlist-list ${viewPlaylists} ${gridViewGrid}`}
/> >
</div> <PlaylistList playlistList={playlistList} setRefresh={setRefresh} />
</div> </div>
)} </div>
)}
{hasSearchQuery && isFullTextQuery && (
<div className="multi-search-result"> {hasSearchQuery && isFullTextQuery && (
<h2>Fulltext Results</h2> <div className="multi-search-result">
<div id="fulltext-results" className="video-list list"> <h2>Fulltext Results</h2>
<SubtitleList subtitleList={fulltextList} /> <div id="fulltext-results" className="video-list list">
</div> <SubtitleList subtitleList={fulltextList} />
</div> </div>
)} </div>
</div> )}
</div>
{!hasVideos && !hasChannels && !hasPlaylist && !hasFulltext && <SearchExampleQueries />}
</div> {!hasVideos && !hasChannels && !hasPlaylist && !hasFulltext && <SearchExampleQueries />}
</> </div>
); </>
}; );
};
export default Search;
export default Search;

View File

@ -1,242 +1,242 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import loadBackupList from '../api/loader/loadBackupList'; import loadBackupList from '../api/loader/loadBackupList';
import SettingsNavigation from '../components/SettingsNavigation'; import SettingsNavigation from '../components/SettingsNavigation';
import deleteDownloadQueueByFilter from '../api/actions/deleteDownloadQueueByFilter'; import deleteDownloadQueueByFilter from '../api/actions/deleteDownloadQueueByFilter';
import updateTaskByName from '../api/actions/updateTaskByName'; 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 Button from '../components/Button'; import Button from '../components/Button';
type Backup = { type Backup = {
filename: string; filename: string;
file_path: string; file_path: string;
file_size: number; file_size: number;
timestamp: string; timestamp: string;
reason: string; reason: string;
}; };
type BackupListType = Backup[]; type BackupListType = Backup[];
const SettingsActions = () => { const SettingsActions = () => {
const [deleteIgnored, setDeleteIgnored] = useState(false); const [deleteIgnored, setDeleteIgnored] = useState(false);
const [deletePending, setDeletePending] = useState(false); const [deletePending, setDeletePending] = useState(false);
const [processingImports, setProcessingImports] = useState(false); const [processingImports, setProcessingImports] = useState(false);
const [reEmbed, setReEmbed] = useState(false); const [reEmbed, setReEmbed] = useState(false);
const [backupStarted, setBackupStarted] = useState(false); const [backupStarted, setBackupStarted] = useState(false);
const [isRestoringBackup, setIsRestoringBackup] = useState(false); const [isRestoringBackup, setIsRestoringBackup] = useState(false);
const [reScanningFileSystem, setReScanningFileSystem] = useState(false); const [reScanningFileSystem, setReScanningFileSystem] = useState(false);
const [backupListResponse, setBackupListResponse] = useState<BackupListType>(); const [backupListResponse, setBackupListResponse] = useState<BackupListType>();
const backups = backupListResponse; const backups = backupListResponse;
const hasBackups = !!backups && backups?.length > 0; const hasBackups = !!backups && backups?.length > 0;
useEffect(() => { useEffect(() => {
(async () => { (async () => {
const backupListResponse = await loadBackupList(); const backupListResponse = await loadBackupList();
setBackupListResponse(backupListResponse); setBackupListResponse(backupListResponse);
})(); })();
}, []); }, []);
return ( return (
<> <>
<title>TA | Actions</title> <title>TA | Actions</title>
<div className="boxed-content"> <div className="boxed-content">
<SettingsNavigation /> <SettingsNavigation />
<Notifications <Notifications
pageName={'all'} pageName={'all'}
update={ update={
deleteIgnored || deleteIgnored ||
deletePending || deletePending ||
processingImports || processingImports ||
reEmbed || reEmbed ||
backupStarted || backupStarted ||
isRestoringBackup || isRestoringBackup ||
reScanningFileSystem reScanningFileSystem
} }
setShouldRefresh={() => { setShouldRefresh={() => {
setDeleteIgnored(false); setDeleteIgnored(false);
setDeletePending(false); setDeletePending(false);
setProcessingImports(false); setProcessingImports(false);
setReEmbed(false); setReEmbed(false);
setBackupStarted(false); setBackupStarted(false);
setIsRestoringBackup(false); setIsRestoringBackup(false);
setReScanningFileSystem(false); setReScanningFileSystem(false);
}} }}
/> />
<div className="title-bar"> <div className="title-bar">
<h1>Actions</h1> <h1>Actions</h1>
</div> </div>
<div className="settings-group"> <div className="settings-group">
<h2>Delete download queue</h2> <h2>Delete download queue</h2>
<p>Delete your pending or previously ignored videos from your download queue.</p> <p>Delete your pending or previously ignored videos from your download queue.</p>
{deleteIgnored && <p>Deleting download queue: ignored</p>} {deleteIgnored && <p>Deleting download queue: ignored</p>}
{!deleteIgnored && ( {!deleteIgnored && (
<Button <Button
label="Delete all ignored" label="Delete all ignored"
title="Delete all previously ignored videos from the queue" title="Delete all previously ignored videos from the queue"
onClick={async () => { onClick={async () => {
await deleteDownloadQueueByFilter('ignore'); await deleteDownloadQueueByFilter('ignore');
setDeleteIgnored(true); setDeleteIgnored(true);
}} }}
/> />
)}{' '} )}{' '}
{deletePending && <p>Deleting download queue: pending</p>} {deletePending && <p>Deleting download queue: pending</p>}
{!deletePending && ( {!deletePending && (
<Button <Button
label="Delete all queued" label="Delete all queued"
title="Delete all pending videos from the queue" title="Delete all pending videos from the queue"
onClick={async () => { onClick={async () => {
await deleteDownloadQueueByFilter('pending'); await deleteDownloadQueueByFilter('pending');
setDeletePending(true); setDeletePending(true);
}} }}
/> />
)} )}
</div> </div>
<div className="settings-group"> <div className="settings-group">
<h2>Manual media files import.</h2> <h2>Manual media files import.</h2>
<p> <p>
Add files to the <span className="settings-current">cache/import</span> folder. Make Add files to the <span className="settings-current">cache/import</span> folder. Make
sure to follow the instructions in the Github{' '} sure to follow the instructions in the Github{' '}
<a <a
href="https://docs.tubearchivist.com/settings/actions/#manual-media-files-import" href="https://docs.tubearchivist.com/settings/actions/#manual-media-files-import"
target="_blank" target="_blank"
> >
Wiki Wiki
</a> </a>
. .
</p> </p>
<div id="manual-import"> <div id="manual-import">
{processingImports && <p>Processing import</p>} {processingImports && <p>Processing import</p>}
{!processingImports && ( {!processingImports && (
<Button <Button
label="Start import" label="Start import"
onClick={async () => { onClick={async () => {
await updateTaskByName('manual_import'); await updateTaskByName('manual_import');
setProcessingImports(true); setProcessingImports(true);
}} }}
/> />
)} )}
</div> </div>
</div> </div>
<div className="settings-group"> <div className="settings-group">
<h2>Embed thumbnails into media file.</h2> <h2>Embed thumbnails into media file.</h2>
<p>Set extracted youtube thumbnail as cover art of the media file.</p> <p>Set extracted youtube thumbnail as cover art of the media file.</p>
<div id="re-embed"> <div id="re-embed">
{reEmbed && <p>Processing thumbnails</p>} {reEmbed && <p>Processing thumbnails</p>}
{!reEmbed && ( {!reEmbed && (
<Button <Button
label="Start process" label="Start process"
onClick={async () => { onClick={async () => {
await updateTaskByName('resync_thumbs'); await updateTaskByName('resync_thumbs');
setReEmbed(true); setReEmbed(true);
}} }}
/> />
)} )}
</div> </div>
</div> </div>
<div className="settings-group"> <div className="settings-group">
<h2>ZIP file index backup</h2> <h2>ZIP file index backup</h2>
<p> <p>
Export your database to a zip file stored at{' '} Export your database to a zip file stored at{' '}
<span className="settings-current">cache/backup</span>. <span className="settings-current">cache/backup</span>.
</p> </p>
<p> <p>
<i> <i>
Zip file backups are very slow for large archives and consistency is not guaranteed, Zip file backups are very slow for large archives and consistency is not guaranteed,
use snapshots instead. Make sure no other tasks are running when creating a Zip file use snapshots instead. Make sure no other tasks are running when creating a Zip file
backup. backup.
</i> </i>
</p> </p>
<div id="db-backup"> <div id="db-backup">
{backupStarted && <p>Backing up archive</p>} {backupStarted && <p>Backing up archive</p>}
{!backupStarted && ( {!backupStarted && (
<Button <Button
label="Start backup" label="Start backup"
onClick={async () => { onClick={async () => {
await queueBackup(); await queueBackup();
setBackupStarted(true); setBackupStarted(true);
}} }}
/> />
)} )}
</div> </div>
</div> </div>
<div className="settings-group"> <div className="settings-group">
<h2>Restore from backup</h2> <h2>Restore from backup</h2>
<p> <p>
<span className="danger-zone">Danger Zone</span>: This will replace your existing index <span className="danger-zone">Danger Zone</span>: This will replace your existing index
with the backup. with the backup.
</p> </p>
<p> <p>
Restore from available backup files from{' '} Restore from available backup files from{' '}
<span className="settings-current">cache/backup</span>. <span className="settings-current">cache/backup</span>.
</p> </p>
{!hasBackups && <p>No backups found.</p>} {!hasBackups && <p>No backups found.</p>}
{hasBackups && ( {hasBackups && (
<> <>
<div className="backup-grid-row"> <div className="backup-grid-row">
<span></span> <span></span>
<span>Timestamp</span> <span>Timestamp</span>
<span>Source</span> <span>Source</span>
<span>Filename</span> <span>Filename</span>
</div> </div>
{isRestoringBackup && <p>Restoring from backup</p>} {isRestoringBackup && <p>Restoring from backup</p>}
{!isRestoringBackup && {!isRestoringBackup &&
backups.map(backup => { backups.map(backup => {
return ( return (
<div key={backup.filename} id={backup.filename} className="backup-grid-row"> <div key={backup.filename} id={backup.filename} className="backup-grid-row">
<Button <Button
label="Restore" label="Restore"
onClick={async () => { onClick={async () => {
await restoreBackup(backup.filename); await restoreBackup(backup.filename);
setIsRestoringBackup(true); setIsRestoringBackup(true);
}} }}
/> />
<span>{backup.timestamp}</span> <span>{backup.timestamp}</span>
<span>{backup.reason}</span> <span>{backup.reason}</span>
<span>{backup.filename}</span> <span>{backup.filename}</span>
</div> </div>
); );
})} })}
</> </>
)} )}
</div> </div>
<div className="settings-group"> <div className="settings-group">
<h2>Rescan filesystem</h2> <h2>Rescan filesystem</h2>
<p> <p>
<span className="danger-zone">Danger Zone</span>: This will delete the metadata of <span className="danger-zone">Danger Zone</span>: This will delete the metadata of
deleted videos from the filesystem. deleted videos from the filesystem.
</p> </p>
<p> <p>
Rescan your media folder looking for missing videos and clean up index. More infos on Rescan your media folder looking for missing videos and clean up index. More infos on
the Github{' '} the Github{' '}
<a <a
href="https://docs.tubearchivist.com/settings/actions/#rescan-filesystem" href="https://docs.tubearchivist.com/settings/actions/#rescan-filesystem"
target="_blank" target="_blank"
> >
Wiki Wiki
</a> </a>
. .
</p> </p>
<div id="fs-rescan"> <div id="fs-rescan">
{reScanningFileSystem && <p>File system scan in progress</p>} {reScanningFileSystem && <p>File system scan in progress</p>}
{!reScanningFileSystem && ( {!reScanningFileSystem && (
<Button <Button
label="Rescan filesystem" label="Rescan filesystem"
onClick={async () => { onClick={async () => {
await updateTaskByName('rescan_filesystem'); await updateTaskByName('rescan_filesystem');
setReScanningFileSystem(true); setReScanningFileSystem(true);
}} }}
/> />
)} )}
</div> </div>
</div> </div>
</div> </div>
</> </>
); );
}; };
export default SettingsActions; export default SettingsActions;

File diff suppressed because it is too large Load Diff

View File

@ -1,260 +1,260 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import SettingsNavigation from '../components/SettingsNavigation'; import SettingsNavigation from '../components/SettingsNavigation';
import loadStatsVideo from '../api/loader/loadStatsVideo'; import loadStatsVideo from '../api/loader/loadStatsVideo';
import loadStatsChannel from '../api/loader/loadStatsChannel'; import loadStatsChannel from '../api/loader/loadStatsChannel';
import loadStatsPlaylist from '../api/loader/loadStatsPlaylist'; import loadStatsPlaylist from '../api/loader/loadStatsPlaylist';
import loadStatsDownload from '../api/loader/loadStatsDownload'; import loadStatsDownload from '../api/loader/loadStatsDownload';
import loadStatsWatchProgress from '../api/loader/loadStatsWatchProgress'; import loadStatsWatchProgress from '../api/loader/loadStatsWatchProgress';
import loadStatsDownloadHistory from '../api/loader/loadStatsDownloadHistory'; import loadStatsDownloadHistory from '../api/loader/loadStatsDownloadHistory';
import loadStatsBiggestChannels from '../api/loader/loadStatsBiggestChannels'; import loadStatsBiggestChannels from '../api/loader/loadStatsBiggestChannels';
import OverviewStats from '../components/OverviewStats'; import OverviewStats from '../components/OverviewStats';
import VideoTypeStats from '../components/VideoTypeStats'; import VideoTypeStats from '../components/VideoTypeStats';
import ApplicationStats from '../components/ApplicationStats'; import ApplicationStats from '../components/ApplicationStats';
import WatchProgressStats from '../components/WatchProgressStats'; import WatchProgressStats from '../components/WatchProgressStats';
import DownloadHistoryStats from '../components/DownloadHistoryStats'; 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';
export type VideoStatsType = { export type VideoStatsType = {
doc_count: number; doc_count: number;
media_size: number; media_size: number;
duration: number; duration: number;
duration_str: string; duration_str: string;
type_videos: { type_videos: {
doc_count: number; doc_count: number;
media_size: number; media_size: number;
duration: number; duration: number;
duration_str: string; duration_str: string;
}; };
type_shorts: { type_shorts: {
doc_count: number; doc_count: number;
media_size: number; media_size: number;
duration: number; duration: number;
duration_str: string; duration_str: string;
}; };
active_true: { active_true: {
doc_count: number; doc_count: number;
media_size: number; media_size: number;
duration: number; duration: number;
duration_str: string; duration_str: string;
}; };
active_false: { active_false: {
doc_count: number; doc_count: number;
media_size: number; media_size: number;
duration: number; duration: number;
duration_str: string; duration_str: string;
}; };
type_streams: { type_streams: {
doc_count: number; doc_count: number;
media_size: number; media_size: number;
duration: number; duration: number;
duration_str: string; duration_str: string;
}; };
}; };
export type ChannelStatsType = { export type ChannelStatsType = {
doc_count: number; doc_count: number;
active_true: number; active_true: number;
subscribed_true: number; subscribed_true: number;
}; };
export type PlaylistStatsType = { export type PlaylistStatsType = {
doc_count: number; doc_count: number;
active_false: number; active_false: number;
active_true: number; active_true: number;
subscribed_true: number; subscribed_true: number;
}; };
export type DownloadStatsType = { export type DownloadStatsType = {
pending: number; pending: number;
pending_videos: number; pending_videos: number;
pending_shorts: number; pending_shorts: number;
pending_streams: number; pending_streams: number;
}; };
export type WatchProgressStatsType = { export type WatchProgressStatsType = {
total: { total: {
duration: number; duration: number;
duration_str: string; duration_str: string;
items: number; items: number;
}; };
unwatched: { unwatched: {
duration: number; duration: number;
duration_str: string; duration_str: string;
progress: number; progress: number;
items: number; items: number;
}; };
watched: { watched: {
duration: number; duration: number;
duration_str: string; duration_str: string;
progress: number; progress: number;
items: number; items: number;
}; };
}; };
type DownloadHistoryType = { type DownloadHistoryType = {
date: string; date: string;
count: number; count: number;
media_size: number; media_size: number;
}; };
export type DownloadHistoryStatsType = DownloadHistoryType[]; export type DownloadHistoryStatsType = DownloadHistoryType[];
type BiggestChannelsType = { type BiggestChannelsType = {
id: string; id: string;
name: string; name: string;
doc_count: number; doc_count: number;
duration: number; duration: number;
duration_str: string; duration_str: string;
media_size: number; media_size: number;
}; };
export type BiggestChannelsStatsType = BiggestChannelsType[]; export type BiggestChannelsStatsType = BiggestChannelsType[];
type DashboardStatsReponses = { type DashboardStatsReponses = {
videoStats?: VideoStatsType; videoStats?: VideoStatsType;
channelStats?: ChannelStatsType; channelStats?: ChannelStatsType;
playlistStats?: PlaylistStatsType; playlistStats?: PlaylistStatsType;
downloadStats?: DownloadStatsType; downloadStats?: DownloadStatsType;
watchProgressStats?: WatchProgressStatsType; watchProgressStats?: WatchProgressStatsType;
downloadHistoryStats?: DownloadHistoryStatsType; downloadHistoryStats?: DownloadHistoryStatsType;
biggestChannelsStatsByCount?: BiggestChannelsStatsType; biggestChannelsStatsByCount?: BiggestChannelsStatsType;
biggestChannelsStatsByDuration?: BiggestChannelsStatsType; biggestChannelsStatsByDuration?: BiggestChannelsStatsType;
biggestChannelsStatsByMediaSize?: BiggestChannelsStatsType; biggestChannelsStatsByMediaSize?: BiggestChannelsStatsType;
}; };
const SettingsDashboard = () => { const SettingsDashboard = () => {
const [useSi, setUseSi] = useState(false); const [useSi, setUseSi] = useState(false);
const [response, setResponse] = useState<DashboardStatsReponses>({ const [response, setResponse] = useState<DashboardStatsReponses>({
videoStats: undefined, videoStats: undefined,
}); });
const videoStats = response?.videoStats; const videoStats = response?.videoStats;
const channelStats = response?.channelStats; const channelStats = response?.channelStats;
const playlistStats = response?.playlistStats; const playlistStats = response?.playlistStats;
const downloadStats = response?.downloadStats; const downloadStats = response?.downloadStats;
const watchProgressStats = response?.watchProgressStats; const watchProgressStats = response?.watchProgressStats;
const downloadHistoryStats = response?.downloadHistoryStats; const downloadHistoryStats = response?.downloadHistoryStats;
const biggestChannelsStatsByCount = response?.biggestChannelsStatsByCount; const biggestChannelsStatsByCount = response?.biggestChannelsStatsByCount;
const biggestChannelsStatsByDuration = response?.biggestChannelsStatsByDuration; const biggestChannelsStatsByDuration = response?.biggestChannelsStatsByDuration;
const biggestChannelsStatsByMediaSize = response?.biggestChannelsStatsByMediaSize; const biggestChannelsStatsByMediaSize = response?.biggestChannelsStatsByMediaSize;
useEffect(() => { useEffect(() => {
(async () => { (async () => {
const all = await Promise.all([ const all = await Promise.all([
await loadStatsVideo(), await loadStatsVideo(),
await loadStatsChannel(), await loadStatsChannel(),
await loadStatsPlaylist(), await loadStatsPlaylist(),
await loadStatsDownload(), await loadStatsDownload(),
await loadStatsWatchProgress(), await loadStatsWatchProgress(),
await loadStatsDownloadHistory(), await loadStatsDownloadHistory(),
await loadStatsBiggestChannels('doc_count'), await loadStatsBiggestChannels('doc_count'),
await loadStatsBiggestChannels('duration'), await loadStatsBiggestChannels('duration'),
await loadStatsBiggestChannels('media_size'), await loadStatsBiggestChannels('media_size'),
]); ]);
const [ const [
videoStats, videoStats,
channelStats, channelStats,
playlistStats, playlistStats,
downloadStats, downloadStats,
watchProgressStats, watchProgressStats,
downloadHistoryStats, downloadHistoryStats,
biggestChannelsStatsByCount, biggestChannelsStatsByCount,
biggestChannelsStatsByDuration, biggestChannelsStatsByDuration,
biggestChannelsStatsByMediaSize, biggestChannelsStatsByMediaSize,
] = all; ] = all;
setResponse({ setResponse({
videoStats, videoStats,
channelStats, channelStats,
playlistStats, playlistStats,
downloadStats, downloadStats,
watchProgressStats, watchProgressStats,
downloadHistoryStats, downloadHistoryStats,
biggestChannelsStatsByCount, biggestChannelsStatsByCount,
biggestChannelsStatsByDuration, biggestChannelsStatsByDuration,
biggestChannelsStatsByMediaSize, biggestChannelsStatsByMediaSize,
}); });
})(); })();
}, []); }, []);
return ( return (
<> <>
<title>TA | Settings Dashboard</title> <title>TA | Settings Dashboard</title>
<div className="boxed-content"> <div className="boxed-content">
<SettingsNavigation /> <SettingsNavigation />
<Notifications pageName={'all'} /> <Notifications pageName={'all'} />
<div className="title-bar"> <div className="title-bar">
<h1>Your Archive</h1> <h1>Your Archive</h1>
</div> </div>
<p> <p>
File Sizes in: File Sizes in:
<select <select
value={useSi ? 'true' : 'false'} value={useSi ? 'true' : 'false'}
onChange={event => { onChange={event => {
const value = event.target.value; const value = event.target.value;
console.log(value); console.log(value);
setUseSi(value === 'true'); setUseSi(value === 'true');
}} }}
> >
<option value="true">SI units</option> <option value="true">SI units</option>
<option value="false">Binary units</option> <option value="false">Binary units</option>
</select> </select>
</p> </p>
<div className="settings-item"> <div className="settings-item">
<h2>Overview</h2> <h2>Overview</h2>
<div className="info-box info-box-3"> <div className="info-box info-box-3">
<OverviewStats videoStats={videoStats} useSI={useSi} /> <OverviewStats videoStats={videoStats} useSI={useSi} />
</div> </div>
</div> </div>
<div className="settings-item"> <div className="settings-item">
<h2>Video Type</h2> <h2>Video Type</h2>
<div className="info-box info-box-3"> <div className="info-box info-box-3">
<VideoTypeStats videoStats={videoStats} useSI={useSi} /> <VideoTypeStats videoStats={videoStats} useSI={useSi} />
</div> </div>
</div> </div>
<div className="settings-item"> <div className="settings-item">
<h2>Application</h2> <h2>Application</h2>
<div className="info-box info-box-3"> <div className="info-box info-box-3">
<ApplicationStats <ApplicationStats
channelStats={channelStats} channelStats={channelStats}
playlistStats={playlistStats} playlistStats={playlistStats}
downloadStats={downloadStats} downloadStats={downloadStats}
/> />
</div> </div>
</div> </div>
<div className="settings-item"> <div className="settings-item">
<h2>Watch Progress</h2> <h2>Watch Progress</h2>
<div className="info-box info-box-2"> <div className="info-box info-box-2">
<WatchProgressStats watchProgressStats={watchProgressStats} /> <WatchProgressStats watchProgressStats={watchProgressStats} />
</div> </div>
</div> </div>
<div className="settings-item"> <div className="settings-item">
<h2>Download History</h2> <h2>Download History</h2>
<div className="info-box info-box-4"> <div className="info-box info-box-4">
<DownloadHistoryStats downloadHistoryStats={downloadHistoryStats} useSI={false} /> <DownloadHistoryStats downloadHistoryStats={downloadHistoryStats} useSI={false} />
</div> </div>
</div> </div>
<div className="settings-item"> <div className="settings-item">
<h2>Biggest Channels</h2> <h2>Biggest Channels</h2>
<div className="info-box info-box-3"> <div className="info-box info-box-3">
<BiggestChannelsStats <BiggestChannelsStats
biggestChannelsStatsByCount={biggestChannelsStatsByCount} biggestChannelsStatsByCount={biggestChannelsStatsByCount}
biggestChannelsStatsByDuration={biggestChannelsStatsByDuration} biggestChannelsStatsByDuration={biggestChannelsStatsByDuration}
biggestChannelsStatsByMediaSize={biggestChannelsStatsByMediaSize} biggestChannelsStatsByMediaSize={biggestChannelsStatsByMediaSize}
useSI={useSi} useSI={useSi}
/> />
</div> </div>
</div> </div>
</div> </div>
<PaginationDummy /> <PaginationDummy />
</> </>
); );
}; };
export default SettingsDashboard; export default SettingsDashboard;

View File

@ -1,495 +1,495 @@
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'; import PaginationDummy from '../components/PaginationDummy';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import loadSchedule, { ScheduleResponseType } from '../api/loader/loadSchedule'; import loadSchedule, { ScheduleResponseType } from '../api/loader/loadSchedule';
import loadAppriseNotification, { import loadAppriseNotification, {
AppriseNotificationType, AppriseNotificationType,
} from '../api/loader/loadAppriseNotification'; } from '../api/loader/loadAppriseNotification';
import deleteTaskSchedule from '../api/actions/deleteTaskSchedule'; import deleteTaskSchedule from '../api/actions/deleteTaskSchedule';
import createTaskSchedule from '../api/actions/createTaskSchedule'; import createTaskSchedule from '../api/actions/createTaskSchedule';
import createAppriseNotificationUrl, { import createAppriseNotificationUrl, {
AppriseTaskNameType, AppriseTaskNameType,
} from '../api/actions/createAppriseNotificationUrl'; } from '../api/actions/createAppriseNotificationUrl';
import deleteAppriseNotificationUrl from '../api/actions/deleteAppriseNotificationUrl'; import deleteAppriseNotificationUrl from '../api/actions/deleteAppriseNotificationUrl';
const SettingsScheduling = () => { const SettingsScheduling = () => {
const [refresh, setRefresh] = useState(false); const [refresh, setRefresh] = useState(false);
const [scheduleResponse, setScheduleResponse] = useState<ScheduleResponseType>([]); const [scheduleResponse, setScheduleResponse] = useState<ScheduleResponseType>([]);
const [appriseNotification, setAppriseNotification] = useState<AppriseNotificationType>(); const [appriseNotification, setAppriseNotification] = useState<AppriseNotificationType>();
const [updateSubscribed, setUpdateSubscribed] = useState<string | undefined>(); const [updateSubscribed, setUpdateSubscribed] = useState<string | undefined>();
const [downloadPending, setDownloadPending] = useState<string | undefined>(); const [downloadPending, setDownloadPending] = useState<string | undefined>();
const [checkReindex, setCheckReindex] = useState<string | undefined>(); const [checkReindex, setCheckReindex] = useState<string | undefined>();
const [checkReindexDays, setCheckReindexDays] = useState<number | undefined>(); const [checkReindexDays, setCheckReindexDays] = useState<number | undefined>();
const [thumbnailCheck, setThumbnailCheck] = useState<string | undefined>(); const [thumbnailCheck, setThumbnailCheck] = useState<string | undefined>();
const [zipBackup, setZipBackup] = useState<string | undefined>(); const [zipBackup, setZipBackup] = useState<string | undefined>();
const [zipBackupDays, setZipBackupDays] = useState<number | undefined>(); const [zipBackupDays, setZipBackupDays] = useState<number | undefined>();
const [notificationUrl, setNotificationUrl] = useState<string | undefined>(); const [notificationUrl, setNotificationUrl] = useState<string | undefined>();
const [notificationTask, setNotificationTask] = useState<AppriseTaskNameType | string>(''); const [notificationTask, setNotificationTask] = useState<AppriseTaskNameType | string>('');
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (refresh) { if (refresh) {
const scheduleResponse = await loadSchedule(); const scheduleResponse = await loadSchedule();
const appriseNotificationResponse = await loadAppriseNotification(); const appriseNotificationResponse = await loadAppriseNotification();
setScheduleResponse(scheduleResponse); setScheduleResponse(scheduleResponse);
setAppriseNotification(appriseNotificationResponse); setAppriseNotification(appriseNotificationResponse);
setRefresh(false); setRefresh(false);
} }
})(); })();
}, [refresh]); }, [refresh]);
useEffect(() => { useEffect(() => {
setRefresh(true); setRefresh(true);
}, []); }, []);
const groupedSchedules = Object.groupBy(scheduleResponse, ({ name }) => name); const groupedSchedules = Object.groupBy(scheduleResponse, ({ name }) => name);
console.log(groupedSchedules); console.log(groupedSchedules);
const { update_subscribed, download_pending, run_backup, check_reindex, thumbnail_check } = const { update_subscribed, download_pending, run_backup, check_reindex, thumbnail_check } =
groupedSchedules; groupedSchedules;
const updateSubscribedSchedule = update_subscribed?.pop(); const updateSubscribedSchedule = update_subscribed?.pop();
const downloadPendingSchedule = download_pending?.pop(); const downloadPendingSchedule = download_pending?.pop();
const runBackup = run_backup?.pop(); const runBackup = run_backup?.pop();
const checkReindexSchedule = check_reindex?.pop(); const checkReindexSchedule = check_reindex?.pop();
const thumbnailCheckSchedule = thumbnail_check?.pop(); const thumbnailCheckSchedule = thumbnail_check?.pop();
return ( return (
<> <>
<title>TA | Scheduling Settings</title> <title>TA | Scheduling Settings</title>
<div className="boxed-content"> <div className="boxed-content">
<SettingsNavigation /> <SettingsNavigation />
<Notifications pageName={'all'} /> <Notifications pageName={'all'} />
<div className="title-bar"> <div className="title-bar">
<h1>Scheduler Setup</h1> <h1>Scheduler Setup</h1>
<div className="settings-group"> <div className="settings-group">
<p> <p>
Schedule settings expect a cron like format, where the first value is minute, second Schedule settings expect a cron like format, where the first value is minute, second
is hour and third is day of the week. is hour and third is day of the week.
</p> </p>
<p>Examples:</p> <p>Examples:</p>
<ul> <ul>
<li> <li>
<span className="settings-current">0 15 *</span>: Run task every day at 15:00 in the <span className="settings-current">0 15 *</span>: Run task every day at 15:00 in the
afternoon. afternoon.
</li> </li>
<li> <li>
<span className="settings-current">30 8 */2</span>: Run task every second day of the <span className="settings-current">30 8 */2</span>: Run task every second day of the
week (Sun, Tue, Thu, Sat) at 08:30 in the morning. week (Sun, Tue, Thu, Sat) at 08:30 in the morning.
</li> </li>
<li> <li>
<span className="settings-current">auto</span>: Sensible default. <span className="settings-current">auto</span>: Sensible default.
</li> </li>
</ul> </ul>
<p>Note:</p> <p>Note:</p>
<ul> <ul>
<li> <li>
Avoid an unnecessary frequent schedule to not get blocked by YouTube. For that Avoid an unnecessary frequent schedule to not get blocked by YouTube. For that
reason, the scheduler doesn't support schedules that trigger more than once per reason, the scheduler doesn't support schedules that trigger more than once per
hour. hour.
</li> </li>
</ul> </ul>
</div> </div>
</div> </div>
<div className="settings-group"> <div className="settings-group">
<h2>Rescan Subscriptions</h2> <h2>Rescan Subscriptions</h2>
<div className="settings-item"> <div className="settings-item">
<p> <p>
Become a sponsor and join{' '} Become a sponsor and join{' '}
<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 for to get access to <span className="settings-current">real time</span> notifications 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">
{!updateSubscribedSchedule && 'False'} {!updateSubscribedSchedule && 'False'}
{updateSubscribedSchedule && ( {updateSubscribedSchedule && (
<> <>
{updateSubscribedSchedule?.schedule}{' '} {updateSubscribedSchedule?.schedule}{' '}
<Button <Button
label="Delete" label="Delete"
data-schedule="update_subscribed" data-schedule="update_subscribed"
onClick={async () => { onClick={async () => {
await deleteTaskSchedule('update_subscribed'); await deleteTaskSchedule('update_subscribed');
setRefresh(true); setRefresh(true);
}} }}
className="danger-button" className="danger-button"
/> />
</> </>
)} )}
</span> </span>
</p> </p>
<p>Periodically rescan your subscriptions:</p> <p>Periodically rescan your subscriptions:</p>
<input <input
type="text" type="text"
value={updateSubscribed || updateSubscribedSchedule?.schedule || ''} value={updateSubscribed || updateSubscribedSchedule?.schedule || ''}
onChange={e => { onChange={e => {
setUpdateSubscribed(e.currentTarget.value); setUpdateSubscribed(e.currentTarget.value);
}} }}
/> />
<Button <Button
label="Save" label="Save"
onClick={async () => { onClick={async () => {
await createTaskSchedule('update_subscribed', { await createTaskSchedule('update_subscribed', {
schedule: updateSubscribed, schedule: updateSubscribed,
}); });
setUpdateSubscribed(''); setUpdateSubscribed('');
setRefresh(true); setRefresh(true);
}} }}
/> />
</div> </div>
</div> </div>
<div className="settings-group"> <div className="settings-group">
<h2>Start Download</h2> <h2>Start Download</h2>
<div className="settings-item"> <div className="settings-item">
<p> <p>
Current Download schedule:{' '} Current Download schedule:{' '}
<span className="settings-current"> <span className="settings-current">
{!download_pending && 'False'} {!download_pending && 'False'}
{downloadPendingSchedule && ( {downloadPendingSchedule && (
<> <>
{downloadPendingSchedule?.schedule}{' '} {downloadPendingSchedule?.schedule}{' '}
<Button <Button
label="Delete" label="Delete"
className="danger-button" className="danger-button"
onClick={async () => { onClick={async () => {
await deleteTaskSchedule('download_pending'); await deleteTaskSchedule('download_pending');
setRefresh(true); setRefresh(true);
}} }}
/> />
</> </>
)} )}
</span> </span>
</p> </p>
<p>Automatic video download schedule:</p> <p>Automatic video download schedule:</p>
<input <input
type="text" type="text"
value={downloadPending || downloadPendingSchedule?.schedule || ''} value={downloadPending || downloadPendingSchedule?.schedule || ''}
onChange={e => { onChange={e => {
setDownloadPending(e.currentTarget.value); setDownloadPending(e.currentTarget.value);
}} }}
/> />
<Button <Button
label="Save" label="Save"
onClick={async () => { onClick={async () => {
await createTaskSchedule('download_pending', { await createTaskSchedule('download_pending', {
schedule: downloadPending, schedule: downloadPending,
}); });
setDownloadPending(''); setDownloadPending('');
setRefresh(true); 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">
{!checkReindexSchedule && 'False'} {!checkReindexSchedule && 'False'}
{checkReindexSchedule && ( {checkReindexSchedule && (
<> <>
{checkReindexSchedule?.schedule}{' '} {checkReindexSchedule?.schedule}{' '}
<Button <Button
label="Delete" label="Delete"
className="danger-button" className="danger-button"
onClick={async () => { onClick={async () => {
await deleteTaskSchedule('check_reindex'); await deleteTaskSchedule('check_reindex');
setRefresh(true); setRefresh(true);
}} }}
/> />
</> </>
)} )}
</span> </span>
</p> </p>
<p>Daily schedule to refresh metadata from YouTube:</p> <p>Daily schedule to refresh metadata from YouTube:</p>
<input <input
type="text" type="text"
value={checkReindex || checkReindexSchedule?.schedule || ''} value={checkReindex || checkReindexSchedule?.schedule || ''}
onChange={e => { onChange={e => {
setCheckReindex(e.currentTarget.value); setCheckReindex(e.currentTarget.value);
}} }}
/> />
<Button <Button
label="Save" label="Save"
onClick={async () => { onClick={async () => {
await createTaskSchedule('check_reindex', { await createTaskSchedule('check_reindex', {
schedule: checkReindex, schedule: checkReindex,
}); });
setCheckReindex(''); setCheckReindex('');
setRefresh(true); 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">{checkReindexSchedule?.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>
<input <input
type="number" type="number"
value={checkReindexDays || checkReindexSchedule?.config?.days || 0} value={checkReindexDays || checkReindexSchedule?.config?.days || 0}
onChange={e => { onChange={e => {
setCheckReindexDays(Number(e.currentTarget.value)); setCheckReindexDays(Number(e.currentTarget.value));
}} }}
/> />
<Button <Button
label="Save" label="Save"
onClick={async () => { onClick={async () => {
await createTaskSchedule('check_reindex', { await createTaskSchedule('check_reindex', {
config: { config: {
days: checkReindexDays, days: checkReindexDays,
}, },
}); });
setCheckReindexDays(undefined); setCheckReindexDays(undefined);
setRefresh(true); 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">
{!thumbnailCheckSchedule && 'False'} {!thumbnailCheckSchedule && 'False'}
{thumbnailCheckSchedule && ( {thumbnailCheckSchedule && (
<> <>
{thumbnailCheckSchedule?.schedule}{' '} {thumbnailCheckSchedule?.schedule}{' '}
<Button <Button
label="Delete" label="Delete"
className="danger-button" className="danger-button"
onClick={async () => { onClick={async () => {
await deleteTaskSchedule('thumbnail_check'); await deleteTaskSchedule('thumbnail_check');
setRefresh(true); setRefresh(true);
}} }}
/> />
</> </>
)} )}
</span> </span>
</p> </p>
<p>Periodically check and cleanup thumbnails:</p> <p>Periodically check and cleanup thumbnails:</p>
<input <input
type="text" type="text"
value={thumbnailCheck || thumbnailCheckSchedule?.schedule || ''} value={thumbnailCheck || thumbnailCheckSchedule?.schedule || ''}
onChange={e => { onChange={e => {
setThumbnailCheck(e.currentTarget.value); setThumbnailCheck(e.currentTarget.value);
}} }}
/> />
<Button <Button
label="Save" label="Save"
onClick={async () => { onClick={async () => {
await createTaskSchedule('thumbnail_check', { await createTaskSchedule('thumbnail_check', {
schedule: thumbnailCheck, schedule: thumbnailCheck,
}); });
setThumbnailCheck(''); setThumbnailCheck('');
setRefresh(true); setRefresh(true);
}} }}
/> />
</div> </div>
</div> </div>
<div className="settings-group"> <div className="settings-group">
<h2>ZIP file index backup</h2> <h2>ZIP file index backup</h2>
<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 guaranteed, Zip file backups are very slow for large archives and consistency is not guaranteed,
use snapshots instead. Make sure no other tasks are running when creating a Zip file use snapshots instead. Make sure no other tasks are running when 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">
{!runBackup && 'False'} {!runBackup && 'False'}
{runBackup && ( {runBackup && (
<> <>
{runBackup.schedule}{' '} {runBackup.schedule}{' '}
<Button <Button
label="Delete" label="Delete"
className="danger-button" className="danger-button"
onClick={async () => { onClick={async () => {
await deleteTaskSchedule('run_backup'); await deleteTaskSchedule('run_backup');
setRefresh(true); setRefresh(true);
}} }}
/> />
</> </>
)} )}
</span> </span>
</p> </p>
<p>Automatically backup metadata to a zip file:</p> <p>Automatically backup metadata to a zip file:</p>
<input <input
type="text" type="text"
value={zipBackup || runBackup?.schedule || ''} value={zipBackup || runBackup?.schedule || ''}
onChange={e => { onChange={e => {
setZipBackup(e.currentTarget.value); setZipBackup(e.currentTarget.value);
}} }}
/> />
<Button <Button
label="Save" label="Save"
onClick={async () => { onClick={async () => {
await createTaskSchedule('run_backup', { await createTaskSchedule('run_backup', {
schedule: zipBackup, schedule: zipBackup,
}); });
setZipBackup(''); setZipBackup('');
setRefresh(true); 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">{runBackup?.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 <input
type="number" type="number"
value={(zipBackupDays || runBackup?.config?.rotate)?.toString() || 0} value={(zipBackupDays || runBackup?.config?.rotate)?.toString() || 0}
onChange={e => { onChange={e => {
setZipBackupDays(Number(e.currentTarget.value)); setZipBackupDays(Number(e.currentTarget.value));
}} }}
/> />
<Button <Button
label="Save" label="Save"
onClick={async () => { onClick={async () => {
await createTaskSchedule('run_backup', { await createTaskSchedule('run_backup', {
config: { config: {
rotate: zipBackupDays, rotate: zipBackupDays,
}, },
}); });
setZipBackupDays(undefined); setZipBackupDays(undefined);
setRefresh(true); 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">
{!appriseNotification && <p>No notifications stored</p>} {!appriseNotification && <p>No notifications stored</p>}
{appriseNotification && ( {appriseNotification && (
<> <>
<div className="description-text"> <div className="description-text">
{Object.entries(appriseNotification)?.map(([key, { urls, title }]) => { {Object.entries(appriseNotification)?.map(([key, { urls, title }]) => {
return ( return (
<> <>
<h3 key={key}>{title}</h3> <h3 key={key}>{title}</h3>
{urls.map((url: string) => { {urls.map((url: string) => {
return ( return (
<p> <p>
<span>{url} </span> <span>{url} </span>
<Button <Button
type="button" type="button"
className="danger-button" className="danger-button"
label="Delete" label="Delete"
onClick={async () => { onClick={async () => {
await deleteAppriseNotificationUrl(key as AppriseTaskNameType); await deleteAppriseNotificationUrl(key as AppriseTaskNameType);
setRefresh(true); setRefresh(true);
}} }}
/> />
</p> </p>
); );
})} })}
</> </>
); );
})} })}
</div> </div>
</> </>
)} )}
</div> </div>
<div className="settings-item"> <div className="settings-item">
<p> <p>
<i> <i>
Send notification on completed tasks with the help of the{' '} Send notification on completed tasks with the help of the{' '}
<a href="https://github.com/caronc/apprise" target="_blank"> <a href="https://github.com/caronc/apprise" target="_blank">
Apprise Apprise
</a>{' '} </a>{' '}
library. library.
</i> </i>
</p> </p>
<select <select
value={notificationTask} value={notificationTask}
onChange={e => { onChange={e => {
setNotificationTask(e.currentTarget.value); 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>
<option value="download_pending">Downloading</option> <option value="download_pending">Downloading</option>
<option value="check_reindex">Reindex Documents</option> <option value="check_reindex">Reindex Documents</option>
</select> </select>
<input <input
type="text" type="text"
placeholder="Apprise notification URL" placeholder="Apprise notification URL"
value={notificationUrl || ''} value={notificationUrl || ''}
onChange={e => { onChange={e => {
setNotificationUrl(e.currentTarget.value); setNotificationUrl(e.currentTarget.value);
}} }}
/> />
<Button <Button
label="Save" label="Save"
onClick={async () => { onClick={async () => {
await createAppriseNotificationUrl( await createAppriseNotificationUrl(
notificationTask as AppriseTaskNameType, notificationTask as AppriseTaskNameType,
notificationUrl || '', notificationUrl || '',
); );
setRefresh(true); setRefresh(true);
}} }}
/> />
</div> </div>
</div> </div>
<PaginationDummy /> <PaginationDummy />
</div> </div>
</> </>
); );
}; };
export default SettingsScheduling; export default SettingsScheduling;

View File

@ -1,126 +1,126 @@
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { ColourVariants } from '../api/actions/updateUserConfig'; import { ColourVariants } from '../api/actions/updateUserConfig';
import { ColourConstant } from '../configuration/colours/getColours'; import { ColourConstant } 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 Button from '../components/Button'; import Button from '../components/Button';
import loadIsAdmin from '../functions/getIsAdmin'; import loadIsAdmin from '../functions/getIsAdmin';
import { useUserConfigStore } from '../stores/UserConfigStore'; import { useUserConfigStore } from '../stores/UserConfigStore';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
const SettingsUser = () => { const SettingsUser = () => {
const { userConfig, setPartialConfig } = useUserConfigStore(); const { userConfig, setPartialConfig } = useUserConfigStore();
const isAdmin = loadIsAdmin(); const isAdmin = loadIsAdmin();
const navigate = useNavigate(); const navigate = useNavigate();
const [styleSheet, setStyleSheet] = useState<ColourVariants>(userConfig.config.stylesheet); const [styleSheet, setStyleSheet] = useState<ColourVariants>(userConfig.config.stylesheet);
const [styleSheetRefresh, setStyleSheetRefresh] = useState(false); const [styleSheetRefresh, setStyleSheetRefresh] = useState(false);
const [pageSize, setPageSize] = useState<number>(userConfig.config.page_size); const [pageSize, setPageSize] = useState<number>(userConfig.config.page_size);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
setStyleSheet(userConfig.config.stylesheet); setStyleSheet(userConfig.config.stylesheet);
setPageSize(userConfig.config.page_size); setPageSize(userConfig.config.page_size);
})(); })();
}, [userConfig.config.page_size, userConfig.config.stylesheet]); }, [userConfig.config.page_size, userConfig.config.stylesheet]);
const handleStyleSheetChange = async (selectedStyleSheet: ColourVariants) => { const handleStyleSheetChange = async (selectedStyleSheet: ColourVariants) => {
setPartialConfig({stylesheet: selectedStyleSheet}); setPartialConfig({ stylesheet: selectedStyleSheet });
setStyleSheet(selectedStyleSheet); setStyleSheet(selectedStyleSheet);
setStyleSheetRefresh(true); setStyleSheetRefresh(true);
} };
const handlePageSizeChange = async () => { const handlePageSizeChange = async () => {
setPartialConfig({page_size: pageSize}); setPartialConfig({ page_size: pageSize });
} };
const handlePageRefresh = () => { const handlePageRefresh = () => {
navigate(0); navigate(0);
setStyleSheetRefresh(false); setStyleSheetRefresh(false);
} };
return ( return (
<> <>
<title>TA | User Settings</title> <title>TA | User Settings</title>
<div className="boxed-content"> <div className="boxed-content">
<SettingsNavigation /> <SettingsNavigation />
<Notifications pageName={'all'} /> <Notifications pageName={'all'} />
<div className="title-bar"> <div className="title-bar">
<h1>User Configurations</h1> <h1>User Configurations</h1>
</div> </div>
<div className='info-box'> <div className="info-box">
<div className='info-box-item'> <div className="info-box-item">
<h2>Customize user Interface</h2> <h2>Customize user Interface</h2>
<div className='settings-box-wrapper'> <div className="settings-box-wrapper">
<div> <div>
<p>Switch your color scheme</p> <p>Switch your color scheme</p>
</div> </div>
<div> <div>
<select <select
name="stylesheet" name="stylesheet"
id="id_stylesheet" id="id_stylesheet"
value={styleSheet} value={styleSheet}
onChange={event => { onChange={event => {
handleStyleSheetChange(event.target.value as ColourVariants); handleStyleSheetChange(event.target.value as ColourVariants);
}} }}
> >
{Object.entries(ColourConstant).map(([key, value]) => { {Object.entries(ColourConstant).map(([key, value]) => {
return ( return (
<option key={key} value={value}> <option key={key} value={value}>
{key} {key}
</option> </option>
); );
})} })}
</select> </select>
{styleSheetRefresh && ( {styleSheetRefresh && <button onClick={handlePageRefresh}>Refresh</button>}
<button onClick={handlePageRefresh}>Refresh</button> </div>
)} </div>
</div> <div className="settings-box-wrapper">
</div> <div>
<div className='settings-box-wrapper'> <p>Archive view page size</p>
<div> </div>
<p>Archive view page size</p> <div>
</div> <input
<div> type="number"
<input name="page_size"
type="number" id="id_page_size"
name="page_size" value={pageSize || 12}
id="id_page_size" onChange={event => {
value={pageSize || 12} setPageSize(Number(event.target.value));
onChange={event => { }}
setPageSize(Number(event.target.value)); />
}} <div className="button-box">
/> {userConfig.config.page_size !== pageSize && (
<div className='button-box'> <>
{userConfig.config.page_size !== pageSize && ( <button onClick={handlePageSizeChange}>Update</button>
<> <button onClick={() => setPageSize(userConfig.config.page_size)}>
<button onClick={handlePageSizeChange}>Update</button> Cancel
<button onClick={() => setPageSize(userConfig.config.page_size)}>Cancel</button> </button>
</> </>
)} )}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{isAdmin && ( {isAdmin && (
<> <>
<div className="settings-group"> <div className="settings-group">
<h2>User Management</h2> <h2>User Management</h2>
<p> <p>
Access the admin interface for basic user management functionality like adding and Access the admin interface for basic user management functionality like adding and
deleting users, changing passwords and more. deleting users, changing passwords and more.
</p> </p>
<a href="/admin/"> <a href="/admin/">
<Button label="Admin Interface" /> <Button label="Admin Interface" />
</a> </a>
</div> </div>
</> </>
)} )}
</div> </div>
</> </>
); );
}; };
export default SettingsUser; export default SettingsUser;

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,7 @@ interface AuthState {
setAuth: (auth: AuthenticationType) => void; setAuth: (auth: AuthenticationType) => void;
} }
export const useAuthStore = create<AuthState>((set) => ({ export const useAuthStore = create<AuthState>(set => ({
auth: null, auth: null,
setAuth: (auth) => set({ auth }), setAuth: auth => set({ auth }),
})); }));

View File

@ -7,8 +7,7 @@ interface UserConfigState {
setPartialConfig: (userConfig: Partial<UserConfigType>) => void; setPartialConfig: (userConfig: Partial<UserConfigType>) => void;
} }
export const useUserConfigStore = create<UserConfigState>((set) => ({ export const useUserConfigStore = create<UserConfigState>(set => ({
userConfig: { userConfig: {
id: 0, id: 0,
name: '', name: '',
@ -30,15 +29,16 @@ export const useUserConfigStore = create<UserConfigState>((set) => ({
hide_watched: false, hide_watched: false,
show_ignored_only: false, show_ignored_only: false,
show_subed_only: false, show_subed_only: false,
} },
}, },
setUserConfig: (userConfig) => set({ userConfig }), setUserConfig: userConfig => set({ userConfig }),
setPartialConfig: async (userConfig: Partial<UserConfigType>) => { setPartialConfig: async (userConfig: Partial<UserConfigType>) => {
const userConfigResponse = await updateUserConfig(userConfig); const userConfigResponse = await updateUserConfig(userConfig);
set((state) => ({ set(state => ({
userConfig: state.userConfig ? { ...state.userConfig, config: userConfigResponse } : state.userConfig, userConfig: state.userConfig
? { ...state.userConfig, config: userConfigResponse }
: state.userConfig,
})); }));
} },
}));
}))