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/*
# 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
ko_fi: bbilly1
custom: https://paypal.me/bbilly1
custom: https://paypal.me/bbilly1

View File

@ -6,7 +6,7 @@ body:
- type: checkboxes
id: block
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:
- label: I understand that this issue will be closed without comment.
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
black==24.10.0
codespell==2.3.0
flake8==7.1.1
ipython==8.31.0
isort==5.13.2
pre-commit==4.0.1
pylint-django==2.6.1
pylint==3.3.3
pytest-django==4.9.0

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,226 +1,226 @@
import { useCallback, useEffect, useState } from 'react';
import { VideoType } from '../pages/Home';
import updateWatchedState from '../api/actions/updateWatchedState';
import updateVideoProgressById from '../api/actions/updateVideoProgressById';
import watchedThreshold from '../functions/watchedThreshold';
const getURL = () => {
return window.location.origin;
};
function shiftCurrentTime(contentCurrentTime: number | undefined) {
console.log(contentCurrentTime);
if (contentCurrentTime === undefined) {
return 0;
}
// Shift media back 3 seconds to prevent missing some of the content
if (contentCurrentTime > 5) {
return contentCurrentTime - 3;
} else {
return 0;
}
}
async function castVideoProgress(
player: {
mediaInfo: { contentId: string | string[] };
currentTime: number;
duration: number;
},
video: VideoType | undefined,
) {
if (!video) {
console.log('castVideoProgress: Video to cast not found...');
return;
}
const videoId = video.youtube_id;
if (player.mediaInfo.contentId.includes(videoId)) {
const currentTime = player.currentTime;
const duration = player.duration;
if (currentTime % 10 <= 1.0 && currentTime !== 0 && duration !== 0) {
// Check progress every 10 seconds or else progress is checked a few times a second
await updateVideoProgressById({
youtubeId: videoId,
currentProgress: currentTime,
});
if (!video.player.watched) {
// Check if video is already marked as watched
if (watchedThreshold(currentTime, duration)) {
await updateWatchedState({
id: videoId,
is_watched: true,
});
}
}
}
}
}
async function castVideoPaused(
player: {
currentTime: number;
duration: number;
mediaInfo: { contentId: string | string[] } | null;
},
video: VideoType | undefined,
) {
if (!video) {
console.log('castVideoPaused: Video to cast not found...');
return;
}
const videoId = video?.youtube_id;
const currentTime = player.currentTime;
const duration = player.duration;
if (player.mediaInfo != null) {
if (player.mediaInfo.contentId.includes(videoId)) {
if (currentTime !== 0 && duration !== 0) {
await updateVideoProgressById({
youtubeId: videoId,
currentProgress: currentTime,
});
}
}
}
}
type GoogleCastProps = {
video?: VideoType;
setRefresh?: () => void;
};
const GoogleCast = ({ video, setRefresh }: GoogleCastProps) => {
const [isConnected, setIsConnected] = useState(false);
const setup = useCallback(() => {
const cast = globalThis.cast;
const chrome = globalThis.chrome;
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.
autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED,
});
const player = new cast.framework.RemotePlayer();
const playerController = new cast.framework.RemotePlayerController(player);
// Add event listerner to check if a connection to a cast device is initiated
playerController.addEventListener(
cast.framework.RemotePlayerEventType.IS_CONNECTED_CHANGED,
function () {
setIsConnected(player.isConnected);
},
);
playerController.addEventListener(
cast.framework.RemotePlayerEventType.CURRENT_TIME_CHANGED,
function () {
castVideoProgress(player, video);
},
);
playerController.addEventListener(
cast.framework.RemotePlayerEventType.IS_PAUSED_CHANGED,
function () {
castVideoPaused(player, video);
setRefresh?.();
},
);
}, [setRefresh, video]);
const startPlayback = useCallback(() => {
const chrome = globalThis.chrome;
const cast = globalThis.cast;
const castSession = cast.framework.CastContext.getInstance().getCurrentSession();
const mediaUrl = video?.media_url;
const vidThumbUrl = video?.vid_thumb_url;
const contentTitle = video?.title;
const contentId = `${getURL()}${mediaUrl}`;
const contentImage = `${getURL()}${vidThumbUrl}`;
const contentType = 'video/mp4'; // Set content type, only videos right now so it is hard coded
const contentSubtitles = [];
const videoSubtitles = video?.subtitles; // Array of subtitles
if (typeof videoSubtitles !== 'undefined') {
for (let i = 0; i < videoSubtitles.length; i++) {
const subtitle = new chrome.cast.media.Track(i, chrome.cast.media.TrackType.TEXT);
subtitle.trackContentId = videoSubtitles[i].media_url;
subtitle.trackContentType = 'text/vtt';
subtitle.subtype = chrome.cast.media.TextTrackType.SUBTITLES;
subtitle.name = videoSubtitles[i].name;
subtitle.language = videoSubtitles[i].lang;
subtitle.customData = null;
contentSubtitles.push(subtitle);
}
}
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.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.images = [new chrome.cast.Image(contentImage)]; // Set the video thumbnail
// mediaInfo.textTrackStyle = new chrome.cast.media.TextTrackStyle();
mediaInfo.tracks = contentSubtitles;
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.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
castSession.loadMedia(request).then(
function () {
console.log('media loaded');
},
function (error: { code: string }) {
console.log('Error', error, 'Error code: ' + error.code);
},
); // Send request to cast device
// Do not add videoProgress?.position, this will cause loops!
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [video?.media_url, video?.subtitles, video?.title, video?.vid_thumb_url]);
useEffect(() => {
// @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) {
if (isAvailable) {
setup();
}
};
}, [setup]);
useEffect(() => {
console.log('isConnected', isConnected);
if (isConnected) {
startPlayback();
}
}, [isConnected, startPlayback]);
if (!video) {
return <p>Video for cast not found...</p>;
}
return (
<>
<>
<script
type="text/javascript"
src="https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"
></script>
{/* @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>
</>
</>
);
};
export default GoogleCast;
import { useCallback, useEffect, useState } from 'react';
import { VideoType } from '../pages/Home';
import updateWatchedState from '../api/actions/updateWatchedState';
import updateVideoProgressById from '../api/actions/updateVideoProgressById';
import watchedThreshold from '../functions/watchedThreshold';
const getURL = () => {
return window.location.origin;
};
function shiftCurrentTime(contentCurrentTime: number | undefined) {
console.log(contentCurrentTime);
if (contentCurrentTime === undefined) {
return 0;
}
// Shift media back 3 seconds to prevent missing some of the content
if (contentCurrentTime > 5) {
return contentCurrentTime - 3;
} else {
return 0;
}
}
async function castVideoProgress(
player: {
mediaInfo: { contentId: string | string[] };
currentTime: number;
duration: number;
},
video: VideoType | undefined,
) {
if (!video) {
console.log('castVideoProgress: Video to cast not found...');
return;
}
const videoId = video.youtube_id;
if (player.mediaInfo.contentId.includes(videoId)) {
const currentTime = player.currentTime;
const duration = player.duration;
if (currentTime % 10 <= 1.0 && currentTime !== 0 && duration !== 0) {
// Check progress every 10 seconds or else progress is checked a few times a second
await updateVideoProgressById({
youtubeId: videoId,
currentProgress: currentTime,
});
if (!video.player.watched) {
// Check if video is already marked as watched
if (watchedThreshold(currentTime, duration)) {
await updateWatchedState({
id: videoId,
is_watched: true,
});
}
}
}
}
}
async function castVideoPaused(
player: {
currentTime: number;
duration: number;
mediaInfo: { contentId: string | string[] } | null;
},
video: VideoType | undefined,
) {
if (!video) {
console.log('castVideoPaused: Video to cast not found...');
return;
}
const videoId = video?.youtube_id;
const currentTime = player.currentTime;
const duration = player.duration;
if (player.mediaInfo != null) {
if (player.mediaInfo.contentId.includes(videoId)) {
if (currentTime !== 0 && duration !== 0) {
await updateVideoProgressById({
youtubeId: videoId,
currentProgress: currentTime,
});
}
}
}
}
type GoogleCastProps = {
video?: VideoType;
setRefresh?: () => void;
};
const GoogleCast = ({ video, setRefresh }: GoogleCastProps) => {
const [isConnected, setIsConnected] = useState(false);
const setup = useCallback(() => {
const cast = globalThis.cast;
const chrome = globalThis.chrome;
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.
autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED,
});
const player = new cast.framework.RemotePlayer();
const playerController = new cast.framework.RemotePlayerController(player);
// Add event listerner to check if a connection to a cast device is initiated
playerController.addEventListener(
cast.framework.RemotePlayerEventType.IS_CONNECTED_CHANGED,
function () {
setIsConnected(player.isConnected);
},
);
playerController.addEventListener(
cast.framework.RemotePlayerEventType.CURRENT_TIME_CHANGED,
function () {
castVideoProgress(player, video);
},
);
playerController.addEventListener(
cast.framework.RemotePlayerEventType.IS_PAUSED_CHANGED,
function () {
castVideoPaused(player, video);
setRefresh?.();
},
);
}, [setRefresh, video]);
const startPlayback = useCallback(() => {
const chrome = globalThis.chrome;
const cast = globalThis.cast;
const castSession = cast.framework.CastContext.getInstance().getCurrentSession();
const mediaUrl = video?.media_url;
const vidThumbUrl = video?.vid_thumb_url;
const contentTitle = video?.title;
const contentId = `${getURL()}${mediaUrl}`;
const contentImage = `${getURL()}${vidThumbUrl}`;
const contentType = 'video/mp4'; // Set content type, only videos right now so it is hard coded
const contentSubtitles = [];
const videoSubtitles = video?.subtitles; // Array of subtitles
if (typeof videoSubtitles !== 'undefined') {
for (let i = 0; i < videoSubtitles.length; i++) {
const subtitle = new chrome.cast.media.Track(i, chrome.cast.media.TrackType.TEXT);
subtitle.trackContentId = videoSubtitles[i].media_url;
subtitle.trackContentType = 'text/vtt';
subtitle.subtype = chrome.cast.media.TextTrackType.SUBTITLES;
subtitle.name = videoSubtitles[i].name;
subtitle.language = videoSubtitles[i].lang;
subtitle.customData = null;
contentSubtitles.push(subtitle);
}
}
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.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.images = [new chrome.cast.Image(contentImage)]; // Set the video thumbnail
// mediaInfo.textTrackStyle = new chrome.cast.media.TextTrackStyle();
mediaInfo.tracks = contentSubtitles;
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.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
castSession.loadMedia(request).then(
function () {
console.log('media loaded');
},
function (error: { code: string }) {
console.log('Error', error, 'Error code: ' + error.code);
},
); // Send request to cast device
// Do not add videoProgress?.position, this will cause loops!
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [video?.media_url, video?.subtitles, video?.title, video?.vid_thumb_url]);
useEffect(() => {
// @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) {
if (isAvailable) {
setup();
}
};
}, [setup]);
useEffect(() => {
console.log('isConnected', isConnected);
if (isConnected) {
startPlayback();
}
}, [isConnected, startPlayback]);
if (!video) {
return <p>Video for cast not found...</p>;
}
return (
<>
<>
<script
type="text/javascript"
src="https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"
></script>
{/* @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>
</>
</>
);
};
export default GoogleCast;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,242 +1,242 @@
import { useEffect, useState } from 'react';
import loadBackupList from '../api/loader/loadBackupList';
import SettingsNavigation from '../components/SettingsNavigation';
import deleteDownloadQueueByFilter from '../api/actions/deleteDownloadQueueByFilter';
import updateTaskByName from '../api/actions/updateTaskByName';
import queueBackup from '../api/actions/queueBackup';
import restoreBackup from '../api/actions/restoreBackup';
import Notifications from '../components/Notifications';
import Button from '../components/Button';
type Backup = {
filename: string;
file_path: string;
file_size: number;
timestamp: string;
reason: string;
};
type BackupListType = Backup[];
const SettingsActions = () => {
const [deleteIgnored, setDeleteIgnored] = useState(false);
const [deletePending, setDeletePending] = useState(false);
const [processingImports, setProcessingImports] = useState(false);
const [reEmbed, setReEmbed] = useState(false);
const [backupStarted, setBackupStarted] = useState(false);
const [isRestoringBackup, setIsRestoringBackup] = useState(false);
const [reScanningFileSystem, setReScanningFileSystem] = useState(false);
const [backupListResponse, setBackupListResponse] = useState<BackupListType>();
const backups = backupListResponse;
const hasBackups = !!backups && backups?.length > 0;
useEffect(() => {
(async () => {
const backupListResponse = await loadBackupList();
setBackupListResponse(backupListResponse);
})();
}, []);
return (
<>
<title>TA | Actions</title>
<div className="boxed-content">
<SettingsNavigation />
<Notifications
pageName={'all'}
update={
deleteIgnored ||
deletePending ||
processingImports ||
reEmbed ||
backupStarted ||
isRestoringBackup ||
reScanningFileSystem
}
setShouldRefresh={() => {
setDeleteIgnored(false);
setDeletePending(false);
setProcessingImports(false);
setReEmbed(false);
setBackupStarted(false);
setIsRestoringBackup(false);
setReScanningFileSystem(false);
}}
/>
<div className="title-bar">
<h1>Actions</h1>
</div>
<div className="settings-group">
<h2>Delete download queue</h2>
<p>Delete your pending or previously ignored videos from your download queue.</p>
{deleteIgnored && <p>Deleting download queue: ignored</p>}
{!deleteIgnored && (
<Button
label="Delete all ignored"
title="Delete all previously ignored videos from the queue"
onClick={async () => {
await deleteDownloadQueueByFilter('ignore');
setDeleteIgnored(true);
}}
/>
)}{' '}
{deletePending && <p>Deleting download queue: pending</p>}
{!deletePending && (
<Button
label="Delete all queued"
title="Delete all pending videos from the queue"
onClick={async () => {
await deleteDownloadQueueByFilter('pending');
setDeletePending(true);
}}
/>
)}
</div>
<div className="settings-group">
<h2>Manual media files import.</h2>
<p>
Add files to the <span className="settings-current">cache/import</span> folder. Make
sure to follow the instructions in the Github{' '}
<a
href="https://docs.tubearchivist.com/settings/actions/#manual-media-files-import"
target="_blank"
>
Wiki
</a>
.
</p>
<div id="manual-import">
{processingImports && <p>Processing import</p>}
{!processingImports && (
<Button
label="Start import"
onClick={async () => {
await updateTaskByName('manual_import');
setProcessingImports(true);
}}
/>
)}
</div>
</div>
<div className="settings-group">
<h2>Embed thumbnails into media file.</h2>
<p>Set extracted youtube thumbnail as cover art of the media file.</p>
<div id="re-embed">
{reEmbed && <p>Processing thumbnails</p>}
{!reEmbed && (
<Button
label="Start process"
onClick={async () => {
await updateTaskByName('resync_thumbs');
setReEmbed(true);
}}
/>
)}
</div>
</div>
<div className="settings-group">
<h2>ZIP file index backup</h2>
<p>
Export your database to a zip file stored at{' '}
<span className="settings-current">cache/backup</span>.
</p>
<p>
<i>
Zip file backups are very slow for large archives and consistency is not guaranteed,
use snapshots instead. Make sure no other tasks are running when creating a Zip file
backup.
</i>
</p>
<div id="db-backup">
{backupStarted && <p>Backing up archive</p>}
{!backupStarted && (
<Button
label="Start backup"
onClick={async () => {
await queueBackup();
setBackupStarted(true);
}}
/>
)}
</div>
</div>
<div className="settings-group">
<h2>Restore from backup</h2>
<p>
<span className="danger-zone">Danger Zone</span>: This will replace your existing index
with the backup.
</p>
<p>
Restore from available backup files from{' '}
<span className="settings-current">cache/backup</span>.
</p>
{!hasBackups && <p>No backups found.</p>}
{hasBackups && (
<>
<div className="backup-grid-row">
<span></span>
<span>Timestamp</span>
<span>Source</span>
<span>Filename</span>
</div>
{isRestoringBackup && <p>Restoring from backup</p>}
{!isRestoringBackup &&
backups.map(backup => {
return (
<div key={backup.filename} id={backup.filename} className="backup-grid-row">
<Button
label="Restore"
onClick={async () => {
await restoreBackup(backup.filename);
setIsRestoringBackup(true);
}}
/>
<span>{backup.timestamp}</span>
<span>{backup.reason}</span>
<span>{backup.filename}</span>
</div>
);
})}
</>
)}
</div>
<div className="settings-group">
<h2>Rescan filesystem</h2>
<p>
<span className="danger-zone">Danger Zone</span>: This will delete the metadata of
deleted videos from the filesystem.
</p>
<p>
Rescan your media folder looking for missing videos and clean up index. More infos on
the Github{' '}
<a
href="https://docs.tubearchivist.com/settings/actions/#rescan-filesystem"
target="_blank"
>
Wiki
</a>
.
</p>
<div id="fs-rescan">
{reScanningFileSystem && <p>File system scan in progress</p>}
{!reScanningFileSystem && (
<Button
label="Rescan filesystem"
onClick={async () => {
await updateTaskByName('rescan_filesystem');
setReScanningFileSystem(true);
}}
/>
)}
</div>
</div>
</div>
</>
);
};
export default SettingsActions;
import { useEffect, useState } from 'react';
import loadBackupList from '../api/loader/loadBackupList';
import SettingsNavigation from '../components/SettingsNavigation';
import deleteDownloadQueueByFilter from '../api/actions/deleteDownloadQueueByFilter';
import updateTaskByName from '../api/actions/updateTaskByName';
import queueBackup from '../api/actions/queueBackup';
import restoreBackup from '../api/actions/restoreBackup';
import Notifications from '../components/Notifications';
import Button from '../components/Button';
type Backup = {
filename: string;
file_path: string;
file_size: number;
timestamp: string;
reason: string;
};
type BackupListType = Backup[];
const SettingsActions = () => {
const [deleteIgnored, setDeleteIgnored] = useState(false);
const [deletePending, setDeletePending] = useState(false);
const [processingImports, setProcessingImports] = useState(false);
const [reEmbed, setReEmbed] = useState(false);
const [backupStarted, setBackupStarted] = useState(false);
const [isRestoringBackup, setIsRestoringBackup] = useState(false);
const [reScanningFileSystem, setReScanningFileSystem] = useState(false);
const [backupListResponse, setBackupListResponse] = useState<BackupListType>();
const backups = backupListResponse;
const hasBackups = !!backups && backups?.length > 0;
useEffect(() => {
(async () => {
const backupListResponse = await loadBackupList();
setBackupListResponse(backupListResponse);
})();
}, []);
return (
<>
<title>TA | Actions</title>
<div className="boxed-content">
<SettingsNavigation />
<Notifications
pageName={'all'}
update={
deleteIgnored ||
deletePending ||
processingImports ||
reEmbed ||
backupStarted ||
isRestoringBackup ||
reScanningFileSystem
}
setShouldRefresh={() => {
setDeleteIgnored(false);
setDeletePending(false);
setProcessingImports(false);
setReEmbed(false);
setBackupStarted(false);
setIsRestoringBackup(false);
setReScanningFileSystem(false);
}}
/>
<div className="title-bar">
<h1>Actions</h1>
</div>
<div className="settings-group">
<h2>Delete download queue</h2>
<p>Delete your pending or previously ignored videos from your download queue.</p>
{deleteIgnored && <p>Deleting download queue: ignored</p>}
{!deleteIgnored && (
<Button
label="Delete all ignored"
title="Delete all previously ignored videos from the queue"
onClick={async () => {
await deleteDownloadQueueByFilter('ignore');
setDeleteIgnored(true);
}}
/>
)}{' '}
{deletePending && <p>Deleting download queue: pending</p>}
{!deletePending && (
<Button
label="Delete all queued"
title="Delete all pending videos from the queue"
onClick={async () => {
await deleteDownloadQueueByFilter('pending');
setDeletePending(true);
}}
/>
)}
</div>
<div className="settings-group">
<h2>Manual media files import.</h2>
<p>
Add files to the <span className="settings-current">cache/import</span> folder. Make
sure to follow the instructions in the Github{' '}
<a
href="https://docs.tubearchivist.com/settings/actions/#manual-media-files-import"
target="_blank"
>
Wiki
</a>
.
</p>
<div id="manual-import">
{processingImports && <p>Processing import</p>}
{!processingImports && (
<Button
label="Start import"
onClick={async () => {
await updateTaskByName('manual_import');
setProcessingImports(true);
}}
/>
)}
</div>
</div>
<div className="settings-group">
<h2>Embed thumbnails into media file.</h2>
<p>Set extracted youtube thumbnail as cover art of the media file.</p>
<div id="re-embed">
{reEmbed && <p>Processing thumbnails</p>}
{!reEmbed && (
<Button
label="Start process"
onClick={async () => {
await updateTaskByName('resync_thumbs');
setReEmbed(true);
}}
/>
)}
</div>
</div>
<div className="settings-group">
<h2>ZIP file index backup</h2>
<p>
Export your database to a zip file stored at{' '}
<span className="settings-current">cache/backup</span>.
</p>
<p>
<i>
Zip file backups are very slow for large archives and consistency is not guaranteed,
use snapshots instead. Make sure no other tasks are running when creating a Zip file
backup.
</i>
</p>
<div id="db-backup">
{backupStarted && <p>Backing up archive</p>}
{!backupStarted && (
<Button
label="Start backup"
onClick={async () => {
await queueBackup();
setBackupStarted(true);
}}
/>
)}
</div>
</div>
<div className="settings-group">
<h2>Restore from backup</h2>
<p>
<span className="danger-zone">Danger Zone</span>: This will replace your existing index
with the backup.
</p>
<p>
Restore from available backup files from{' '}
<span className="settings-current">cache/backup</span>.
</p>
{!hasBackups && <p>No backups found.</p>}
{hasBackups && (
<>
<div className="backup-grid-row">
<span></span>
<span>Timestamp</span>
<span>Source</span>
<span>Filename</span>
</div>
{isRestoringBackup && <p>Restoring from backup</p>}
{!isRestoringBackup &&
backups.map(backup => {
return (
<div key={backup.filename} id={backup.filename} className="backup-grid-row">
<Button
label="Restore"
onClick={async () => {
await restoreBackup(backup.filename);
setIsRestoringBackup(true);
}}
/>
<span>{backup.timestamp}</span>
<span>{backup.reason}</span>
<span>{backup.filename}</span>
</div>
);
})}
</>
)}
</div>
<div className="settings-group">
<h2>Rescan filesystem</h2>
<p>
<span className="danger-zone">Danger Zone</span>: This will delete the metadata of
deleted videos from the filesystem.
</p>
<p>
Rescan your media folder looking for missing videos and clean up index. More infos on
the Github{' '}
<a
href="https://docs.tubearchivist.com/settings/actions/#rescan-filesystem"
target="_blank"
>
Wiki
</a>
.
</p>
<div id="fs-rescan">
{reScanningFileSystem && <p>File system scan in progress</p>}
{!reScanningFileSystem && (
<Button
label="Rescan filesystem"
onClick={async () => {
await updateTaskByName('rescan_filesystem');
setReScanningFileSystem(true);
}}
/>
)}
</div>
</div>
</div>
</>
);
};
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 SettingsNavigation from '../components/SettingsNavigation';
import loadStatsVideo from '../api/loader/loadStatsVideo';
import loadStatsChannel from '../api/loader/loadStatsChannel';
import loadStatsPlaylist from '../api/loader/loadStatsPlaylist';
import loadStatsDownload from '../api/loader/loadStatsDownload';
import loadStatsWatchProgress from '../api/loader/loadStatsWatchProgress';
import loadStatsDownloadHistory from '../api/loader/loadStatsDownloadHistory';
import loadStatsBiggestChannels from '../api/loader/loadStatsBiggestChannels';
import OverviewStats from '../components/OverviewStats';
import VideoTypeStats from '../components/VideoTypeStats';
import ApplicationStats from '../components/ApplicationStats';
import WatchProgressStats from '../components/WatchProgressStats';
import DownloadHistoryStats from '../components/DownloadHistoryStats';
import BiggestChannelsStats from '../components/BiggestChannelsStats';
import Notifications from '../components/Notifications';
import PaginationDummy from '../components/PaginationDummy';
export type VideoStatsType = {
doc_count: number;
media_size: number;
duration: number;
duration_str: string;
type_videos: {
doc_count: number;
media_size: number;
duration: number;
duration_str: string;
};
type_shorts: {
doc_count: number;
media_size: number;
duration: number;
duration_str: string;
};
active_true: {
doc_count: number;
media_size: number;
duration: number;
duration_str: string;
};
active_false: {
doc_count: number;
media_size: number;
duration: number;
duration_str: string;
};
type_streams: {
doc_count: number;
media_size: number;
duration: number;
duration_str: string;
};
};
export type ChannelStatsType = {
doc_count: number;
active_true: number;
subscribed_true: number;
};
export type PlaylistStatsType = {
doc_count: number;
active_false: number;
active_true: number;
subscribed_true: number;
};
export type DownloadStatsType = {
pending: number;
pending_videos: number;
pending_shorts: number;
pending_streams: number;
};
export type WatchProgressStatsType = {
total: {
duration: number;
duration_str: string;
items: number;
};
unwatched: {
duration: number;
duration_str: string;
progress: number;
items: number;
};
watched: {
duration: number;
duration_str: string;
progress: number;
items: number;
};
};
type DownloadHistoryType = {
date: string;
count: number;
media_size: number;
};
export type DownloadHistoryStatsType = DownloadHistoryType[];
type BiggestChannelsType = {
id: string;
name: string;
doc_count: number;
duration: number;
duration_str: string;
media_size: number;
};
export type BiggestChannelsStatsType = BiggestChannelsType[];
type DashboardStatsReponses = {
videoStats?: VideoStatsType;
channelStats?: ChannelStatsType;
playlistStats?: PlaylistStatsType;
downloadStats?: DownloadStatsType;
watchProgressStats?: WatchProgressStatsType;
downloadHistoryStats?: DownloadHistoryStatsType;
biggestChannelsStatsByCount?: BiggestChannelsStatsType;
biggestChannelsStatsByDuration?: BiggestChannelsStatsType;
biggestChannelsStatsByMediaSize?: BiggestChannelsStatsType;
};
const SettingsDashboard = () => {
const [useSi, setUseSi] = useState(false);
const [response, setResponse] = useState<DashboardStatsReponses>({
videoStats: undefined,
});
const videoStats = response?.videoStats;
const channelStats = response?.channelStats;
const playlistStats = response?.playlistStats;
const downloadStats = response?.downloadStats;
const watchProgressStats = response?.watchProgressStats;
const downloadHistoryStats = response?.downloadHistoryStats;
const biggestChannelsStatsByCount = response?.biggestChannelsStatsByCount;
const biggestChannelsStatsByDuration = response?.biggestChannelsStatsByDuration;
const biggestChannelsStatsByMediaSize = response?.biggestChannelsStatsByMediaSize;
useEffect(() => {
(async () => {
const all = await Promise.all([
await loadStatsVideo(),
await loadStatsChannel(),
await loadStatsPlaylist(),
await loadStatsDownload(),
await loadStatsWatchProgress(),
await loadStatsDownloadHistory(),
await loadStatsBiggestChannels('doc_count'),
await loadStatsBiggestChannels('duration'),
await loadStatsBiggestChannels('media_size'),
]);
const [
videoStats,
channelStats,
playlistStats,
downloadStats,
watchProgressStats,
downloadHistoryStats,
biggestChannelsStatsByCount,
biggestChannelsStatsByDuration,
biggestChannelsStatsByMediaSize,
] = all;
setResponse({
videoStats,
channelStats,
playlistStats,
downloadStats,
watchProgressStats,
downloadHistoryStats,
biggestChannelsStatsByCount,
biggestChannelsStatsByDuration,
biggestChannelsStatsByMediaSize,
});
})();
}, []);
return (
<>
<title>TA | Settings Dashboard</title>
<div className="boxed-content">
<SettingsNavigation />
<Notifications pageName={'all'} />
<div className="title-bar">
<h1>Your Archive</h1>
</div>
<p>
File Sizes in:
<select
value={useSi ? 'true' : 'false'}
onChange={event => {
const value = event.target.value;
console.log(value);
setUseSi(value === 'true');
}}
>
<option value="true">SI units</option>
<option value="false">Binary units</option>
</select>
</p>
<div className="settings-item">
<h2>Overview</h2>
<div className="info-box info-box-3">
<OverviewStats videoStats={videoStats} useSI={useSi} />
</div>
</div>
<div className="settings-item">
<h2>Video Type</h2>
<div className="info-box info-box-3">
<VideoTypeStats videoStats={videoStats} useSI={useSi} />
</div>
</div>
<div className="settings-item">
<h2>Application</h2>
<div className="info-box info-box-3">
<ApplicationStats
channelStats={channelStats}
playlistStats={playlistStats}
downloadStats={downloadStats}
/>
</div>
</div>
<div className="settings-item">
<h2>Watch Progress</h2>
<div className="info-box info-box-2">
<WatchProgressStats watchProgressStats={watchProgressStats} />
</div>
</div>
<div className="settings-item">
<h2>Download History</h2>
<div className="info-box info-box-4">
<DownloadHistoryStats downloadHistoryStats={downloadHistoryStats} useSI={false} />
</div>
</div>
<div className="settings-item">
<h2>Biggest Channels</h2>
<div className="info-box info-box-3">
<BiggestChannelsStats
biggestChannelsStatsByCount={biggestChannelsStatsByCount}
biggestChannelsStatsByDuration={biggestChannelsStatsByDuration}
biggestChannelsStatsByMediaSize={biggestChannelsStatsByMediaSize}
useSI={useSi}
/>
</div>
</div>
</div>
<PaginationDummy />
</>
);
};
export default SettingsDashboard;
import { useEffect, useState } from 'react';
import SettingsNavigation from '../components/SettingsNavigation';
import loadStatsVideo from '../api/loader/loadStatsVideo';
import loadStatsChannel from '../api/loader/loadStatsChannel';
import loadStatsPlaylist from '../api/loader/loadStatsPlaylist';
import loadStatsDownload from '../api/loader/loadStatsDownload';
import loadStatsWatchProgress from '../api/loader/loadStatsWatchProgress';
import loadStatsDownloadHistory from '../api/loader/loadStatsDownloadHistory';
import loadStatsBiggestChannels from '../api/loader/loadStatsBiggestChannels';
import OverviewStats from '../components/OverviewStats';
import VideoTypeStats from '../components/VideoTypeStats';
import ApplicationStats from '../components/ApplicationStats';
import WatchProgressStats from '../components/WatchProgressStats';
import DownloadHistoryStats from '../components/DownloadHistoryStats';
import BiggestChannelsStats from '../components/BiggestChannelsStats';
import Notifications from '../components/Notifications';
import PaginationDummy from '../components/PaginationDummy';
export type VideoStatsType = {
doc_count: number;
media_size: number;
duration: number;
duration_str: string;
type_videos: {
doc_count: number;
media_size: number;
duration: number;
duration_str: string;
};
type_shorts: {
doc_count: number;
media_size: number;
duration: number;
duration_str: string;
};
active_true: {
doc_count: number;
media_size: number;
duration: number;
duration_str: string;
};
active_false: {
doc_count: number;
media_size: number;
duration: number;
duration_str: string;
};
type_streams: {
doc_count: number;
media_size: number;
duration: number;
duration_str: string;
};
};
export type ChannelStatsType = {
doc_count: number;
active_true: number;
subscribed_true: number;
};
export type PlaylistStatsType = {
doc_count: number;
active_false: number;
active_true: number;
subscribed_true: number;
};
export type DownloadStatsType = {
pending: number;
pending_videos: number;
pending_shorts: number;
pending_streams: number;
};
export type WatchProgressStatsType = {
total: {
duration: number;
duration_str: string;
items: number;
};
unwatched: {
duration: number;
duration_str: string;
progress: number;
items: number;
};
watched: {
duration: number;
duration_str: string;
progress: number;
items: number;
};
};
type DownloadHistoryType = {
date: string;
count: number;
media_size: number;
};
export type DownloadHistoryStatsType = DownloadHistoryType[];
type BiggestChannelsType = {
id: string;
name: string;
doc_count: number;
duration: number;
duration_str: string;
media_size: number;
};
export type BiggestChannelsStatsType = BiggestChannelsType[];
type DashboardStatsReponses = {
videoStats?: VideoStatsType;
channelStats?: ChannelStatsType;
playlistStats?: PlaylistStatsType;
downloadStats?: DownloadStatsType;
watchProgressStats?: WatchProgressStatsType;
downloadHistoryStats?: DownloadHistoryStatsType;
biggestChannelsStatsByCount?: BiggestChannelsStatsType;
biggestChannelsStatsByDuration?: BiggestChannelsStatsType;
biggestChannelsStatsByMediaSize?: BiggestChannelsStatsType;
};
const SettingsDashboard = () => {
const [useSi, setUseSi] = useState(false);
const [response, setResponse] = useState<DashboardStatsReponses>({
videoStats: undefined,
});
const videoStats = response?.videoStats;
const channelStats = response?.channelStats;
const playlistStats = response?.playlistStats;
const downloadStats = response?.downloadStats;
const watchProgressStats = response?.watchProgressStats;
const downloadHistoryStats = response?.downloadHistoryStats;
const biggestChannelsStatsByCount = response?.biggestChannelsStatsByCount;
const biggestChannelsStatsByDuration = response?.biggestChannelsStatsByDuration;
const biggestChannelsStatsByMediaSize = response?.biggestChannelsStatsByMediaSize;
useEffect(() => {
(async () => {
const all = await Promise.all([
await loadStatsVideo(),
await loadStatsChannel(),
await loadStatsPlaylist(),
await loadStatsDownload(),
await loadStatsWatchProgress(),
await loadStatsDownloadHistory(),
await loadStatsBiggestChannels('doc_count'),
await loadStatsBiggestChannels('duration'),
await loadStatsBiggestChannels('media_size'),
]);
const [
videoStats,
channelStats,
playlistStats,
downloadStats,
watchProgressStats,
downloadHistoryStats,
biggestChannelsStatsByCount,
biggestChannelsStatsByDuration,
biggestChannelsStatsByMediaSize,
] = all;
setResponse({
videoStats,
channelStats,
playlistStats,
downloadStats,
watchProgressStats,
downloadHistoryStats,
biggestChannelsStatsByCount,
biggestChannelsStatsByDuration,
biggestChannelsStatsByMediaSize,
});
})();
}, []);
return (
<>
<title>TA | Settings Dashboard</title>
<div className="boxed-content">
<SettingsNavigation />
<Notifications pageName={'all'} />
<div className="title-bar">
<h1>Your Archive</h1>
</div>
<p>
File Sizes in:
<select
value={useSi ? 'true' : 'false'}
onChange={event => {
const value = event.target.value;
console.log(value);
setUseSi(value === 'true');
}}
>
<option value="true">SI units</option>
<option value="false">Binary units</option>
</select>
</p>
<div className="settings-item">
<h2>Overview</h2>
<div className="info-box info-box-3">
<OverviewStats videoStats={videoStats} useSI={useSi} />
</div>
</div>
<div className="settings-item">
<h2>Video Type</h2>
<div className="info-box info-box-3">
<VideoTypeStats videoStats={videoStats} useSI={useSi} />
</div>
</div>
<div className="settings-item">
<h2>Application</h2>
<div className="info-box info-box-3">
<ApplicationStats
channelStats={channelStats}
playlistStats={playlistStats}
downloadStats={downloadStats}
/>
</div>
</div>
<div className="settings-item">
<h2>Watch Progress</h2>
<div className="info-box info-box-2">
<WatchProgressStats watchProgressStats={watchProgressStats} />
</div>
</div>
<div className="settings-item">
<h2>Download History</h2>
<div className="info-box info-box-4">
<DownloadHistoryStats downloadHistoryStats={downloadHistoryStats} useSI={false} />
</div>
</div>
<div className="settings-item">
<h2>Biggest Channels</h2>
<div className="info-box info-box-3">
<BiggestChannelsStats
biggestChannelsStatsByCount={biggestChannelsStatsByCount}
biggestChannelsStatsByDuration={biggestChannelsStatsByDuration}
biggestChannelsStatsByMediaSize={biggestChannelsStatsByMediaSize}
useSI={useSi}
/>
</div>
</div>
</div>
<PaginationDummy />
</>
);
};
export default SettingsDashboard;

View File

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

View File

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

View File

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