Refac react frontend (#790)

* Add channel config endpoint

* Add channel aggs

* Add playlist show subscribed only toggle

* Fix refresh always on filterbar toggle

* Add loadingindicator for watchstate change

* Fix missing space in scheduling

* Add schedule request and apprisenotifcation

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

* WIP: Schedule page

* WIP: Schedule page

* Add schedule management ( - notification )

* Fix missing space

* Refac show current selection in input

* Add apprise notifictation url

* Add Stream & Shorts channel pages

* Refac autotarget input on search page

* Fix input requiring 1 instead of 0

* Fix remove unused function

* Chore: npm audit fix

* Refac get channel_overwrites from channelById

* Refac remove defaultvalues form select

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

* Fix styling selection

* Fix lint

* Fix spelling

* Fix remove unused import

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

* Add missing property to ValidatedCookieType

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

* Refac remove unused dependency

* Refac replace react-helmet with react 19 implementation

* Fix Application Settings page

* Chore update dependencies

* Add simple playlist autoplay feature

* Refac use server provided channel images path

* Refac use server provided playlistthumbnail images path

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

View File

@ -1,269 +1,261 @@
"""
Loose collection of helper functions
- don't import AppConfig class here to avoid circular imports
"""
import json
import os
import random
import string
import subprocess
from datetime import datetime
from typing import Any
from urllib.parse import urlparse
import requests
from common.src.env_settings import EnvironmentSettings
from common.src.es_connect import IndexPaginate
def ignore_filelist(filelist: list[str]) -> list[str]:
"""ignore temp files for os.listdir sanitizer"""
to_ignore = [
"@eaDir",
"Icon\r\r",
"Network Trash Folder",
"Temporary Items",
]
cleaned: list[str] = []
for file_name in filelist:
if file_name.startswith(".") or file_name in to_ignore:
continue
cleaned.append(file_name)
return cleaned
def randomizor(length: int) -> str:
"""generate random alpha numeric string"""
pool: str = string.digits + string.ascii_letters
return "".join(random.choice(pool) for i in range(length))
def requests_headers() -> dict[str, str]:
"""build header with random user agent for requests outside of yt-dlp"""
chrome_versions = (
"90.0.4430.212",
"90.0.4430.24",
"90.0.4430.70",
"90.0.4430.72",
"90.0.4430.85",
"90.0.4430.93",
"91.0.4472.101",
"91.0.4472.106",
"91.0.4472.114",
"91.0.4472.124",
"91.0.4472.164",
"91.0.4472.19",
"91.0.4472.77",
"92.0.4515.107",
"92.0.4515.115",
"92.0.4515.131",
"92.0.4515.159",
"92.0.4515.43",
"93.0.4556.0",
"93.0.4577.15",
"93.0.4577.63",
"93.0.4577.82",
"94.0.4606.41",
"94.0.4606.54",
"94.0.4606.61",
"94.0.4606.71",
"94.0.4606.81",
"94.0.4606.85",
"95.0.4638.17",
"95.0.4638.50",
"95.0.4638.54",
"95.0.4638.69",
"95.0.4638.74",
"96.0.4664.18",
"96.0.4664.45",
"96.0.4664.55",
"96.0.4664.93",
"97.0.4692.20",
)
template = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
+ f"Chrome/{random.choice(chrome_versions)} Safari/537.36"
)
return {"User-Agent": template}
def date_parser(timestamp: int | str) -> str:
"""return formatted date string"""
if isinstance(timestamp, int):
date_obj = datetime.fromtimestamp(timestamp)
elif isinstance(timestamp, str):
date_obj = datetime.strptime(timestamp, "%Y-%m-%d")
else:
raise TypeError(f"invalid timestamp: {timestamp}")
return date_obj.date().isoformat()
def time_parser(timestamp: str) -> float:
"""return seconds from timestamp, false on empty"""
if not timestamp:
return False
if timestamp.isnumeric():
return int(timestamp)
hours, minutes, seconds = timestamp.split(":", maxsplit=3)
return int(hours) * 60 * 60 + int(minutes) * 60 + float(seconds)
def clear_dl_cache(cache_dir: str) -> int:
"""clear leftover files from dl cache"""
print("clear download cache")
download_cache_dir = os.path.join(cache_dir, "download")
leftover_files = ignore_filelist(os.listdir(download_cache_dir))
for cached in leftover_files:
to_delete = os.path.join(download_cache_dir, cached)
os.remove(to_delete)
return len(leftover_files)
def get_mapping() -> dict:
"""read index_mapping.json and get expected mapping and settings"""
with open("appsettings/index_mapping.json", "r", encoding="utf-8") as f:
index_config: dict = json.load(f).get("index_config")
return index_config
def is_shorts(youtube_id: str) -> bool:
"""check if youtube_id is a shorts video, bot not it it's not a shorts"""
shorts_url = f"https://www.youtube.com/shorts/{youtube_id}"
cookies = {"SOCS": "CAI"}
response = requests.head(
shorts_url, cookies=cookies, headers=requests_headers(), timeout=10
)
return response.status_code == 200
def get_duration_sec(file_path: str) -> int:
"""get duration of media file from file path"""
duration = subprocess.run(
[
"ffprobe",
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
file_path,
],
capture_output=True,
check=True,
)
duration_raw = duration.stdout.decode().strip()
if duration_raw == "N/A":
return 0
duration_sec = int(float(duration_raw))
return duration_sec
def get_duration_str(seconds: int) -> str:
"""Return a human-readable duration string from seconds."""
if not seconds:
return "NA"
units = [("y", 31536000), ("d", 86400), ("h", 3600), ("m", 60), ("s", 1)]
duration_parts = []
for unit_label, unit_seconds in units:
if seconds >= unit_seconds:
unit_count, seconds = divmod(seconds, unit_seconds)
duration_parts.append(f"{unit_count:02}{unit_label}")
duration_parts[0] = duration_parts[0].lstrip("0")
return " ".join(duration_parts)
def ta_host_parser(ta_host: str) -> tuple[list[str], list[str]]:
"""parse ta_host env var for ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS"""
allowed_hosts: list[str] = [
"localhost",
"tubearchivist",
]
csrf_trusted_origins: list[str] = [
"http://localhost",
"http://tubearchivist",
]
for host in ta_host.split():
host_clean = host.strip()
if not host_clean.startswith("http"):
host_clean = f"http://{host_clean}"
parsed = urlparse(host_clean)
allowed_hosts.append(f"{parsed.hostname}")
csrf_trusted_origins.append(f"{parsed.scheme}://{parsed.hostname}")
return allowed_hosts, csrf_trusted_origins
def get_stylesheets() -> list:
"""Get all valid stylesheets from /static/css"""
app_root = EnvironmentSettings.APP_DIR
try:
stylesheets = os.listdir(os.path.join(app_root, "static/css"))
except FileNotFoundError:
return []
stylesheets.remove("style.css")
stylesheets.sort()
stylesheets = list(filter(lambda x: x.endswith(".css"), stylesheets))
return stylesheets
def check_stylesheet(stylesheet: str):
"""Check if a stylesheet exists. Return dark.css as a fallback"""
if stylesheet in get_stylesheets():
return stylesheet
return "dark.css"
def is_missing(
to_check: str | list[str],
index_name: str = "ta_video,ta_download",
on_key: str = "youtube_id",
) -> list[str]:
"""id or list of ids that are missing from index_name"""
if isinstance(to_check, str):
to_check = [to_check]
data = {
"query": {"terms": {on_key: to_check}},
"_source": [on_key],
}
result = IndexPaginate(index_name, data=data).get_results()
existing_ids = [i[on_key] for i in result]
dl = [i for i in to_check if i not in existing_ids]
return dl
def get_channel_overwrites() -> dict[str, dict[str, Any]]:
"""get overwrites indexed my channel_id"""
data = {
"query": {
"bool": {"must": [{"exists": {"field": "channel_overwrites"}}]}
},
"_source": ["channel_id", "channel_overwrites"],
}
result = IndexPaginate("ta_channel", data).get_results()
overwrites = {i["channel_id"]: i["channel_overwrites"] for i in result}
return overwrites
"""
Loose collection of helper functions
- don't import AppConfig class here to avoid circular imports
"""
import json
import os
import random
import string
import subprocess
from datetime import datetime
from typing import Any
from urllib.parse import urlparse
import requests
from common.src.es_connect import IndexPaginate
def ignore_filelist(filelist: list[str]) -> list[str]:
"""ignore temp files for os.listdir sanitizer"""
to_ignore = [
"@eaDir",
"Icon\r\r",
"Network Trash Folder",
"Temporary Items",
]
cleaned: list[str] = []
for file_name in filelist:
if file_name.startswith(".") or file_name in to_ignore:
continue
cleaned.append(file_name)
return cleaned
def randomizor(length: int) -> str:
"""generate random alpha numeric string"""
pool: str = string.digits + string.ascii_letters
return "".join(random.choice(pool) for i in range(length))
def requests_headers() -> dict[str, str]:
"""build header with random user agent for requests outside of yt-dlp"""
chrome_versions = (
"90.0.4430.212",
"90.0.4430.24",
"90.0.4430.70",
"90.0.4430.72",
"90.0.4430.85",
"90.0.4430.93",
"91.0.4472.101",
"91.0.4472.106",
"91.0.4472.114",
"91.0.4472.124",
"91.0.4472.164",
"91.0.4472.19",
"91.0.4472.77",
"92.0.4515.107",
"92.0.4515.115",
"92.0.4515.131",
"92.0.4515.159",
"92.0.4515.43",
"93.0.4556.0",
"93.0.4577.15",
"93.0.4577.63",
"93.0.4577.82",
"94.0.4606.41",
"94.0.4606.54",
"94.0.4606.61",
"94.0.4606.71",
"94.0.4606.81",
"94.0.4606.85",
"95.0.4638.17",
"95.0.4638.50",
"95.0.4638.54",
"95.0.4638.69",
"95.0.4638.74",
"96.0.4664.18",
"96.0.4664.45",
"96.0.4664.55",
"96.0.4664.93",
"97.0.4692.20",
)
template = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
+ f"Chrome/{random.choice(chrome_versions)} Safari/537.36"
)
return {"User-Agent": template}
def date_parser(timestamp: int | str) -> str:
"""return formatted date string"""
if isinstance(timestamp, int):
date_obj = datetime.fromtimestamp(timestamp)
elif isinstance(timestamp, str):
date_obj = datetime.strptime(timestamp, "%Y-%m-%d")
else:
raise TypeError(f"invalid timestamp: {timestamp}")
return date_obj.date().isoformat()
def time_parser(timestamp: str) -> float:
"""return seconds from timestamp, false on empty"""
if not timestamp:
return False
if timestamp.isnumeric():
return int(timestamp)
hours, minutes, seconds = timestamp.split(":", maxsplit=3)
return int(hours) * 60 * 60 + int(minutes) * 60 + float(seconds)
def clear_dl_cache(cache_dir: str) -> int:
"""clear leftover files from dl cache"""
print("clear download cache")
download_cache_dir = os.path.join(cache_dir, "download")
leftover_files = ignore_filelist(os.listdir(download_cache_dir))
for cached in leftover_files:
to_delete = os.path.join(download_cache_dir, cached)
os.remove(to_delete)
return len(leftover_files)
def get_mapping() -> dict:
"""read index_mapping.json and get expected mapping and settings"""
with open("appsettings/index_mapping.json", "r", encoding="utf-8") as f:
index_config: dict = json.load(f).get("index_config")
return index_config
def is_shorts(youtube_id: str) -> bool:
"""check if youtube_id is a shorts video, bot not it it's not a shorts"""
shorts_url = f"https://www.youtube.com/shorts/{youtube_id}"
cookies = {"SOCS": "CAI"}
response = requests.head(
shorts_url, cookies=cookies, headers=requests_headers(), timeout=10
)
return response.status_code == 200
def get_duration_sec(file_path: str) -> int:
"""get duration of media file from file path"""
duration = subprocess.run(
[
"ffprobe",
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
file_path,
],
capture_output=True,
check=True,
)
duration_raw = duration.stdout.decode().strip()
if duration_raw == "N/A":
return 0
duration_sec = int(float(duration_raw))
return duration_sec
def get_duration_str(seconds: int) -> str:
"""Return a human-readable duration string from seconds."""
if not seconds:
return "NA"
units = [("y", 31536000), ("d", 86400), ("h", 3600), ("m", 60), ("s", 1)]
duration_parts = []
for unit_label, unit_seconds in units:
if seconds >= unit_seconds:
unit_count, seconds = divmod(seconds, unit_seconds)
duration_parts.append(f"{unit_count:02}{unit_label}")
duration_parts[0] = duration_parts[0].lstrip("0")
return " ".join(duration_parts)
def ta_host_parser(ta_host: str) -> tuple[list[str], list[str]]:
"""parse ta_host env var for ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS"""
allowed_hosts: list[str] = [
"localhost",
"tubearchivist",
]
csrf_trusted_origins: list[str] = [
"http://localhost",
"http://tubearchivist",
]
for host in ta_host.split():
host_clean = host.strip()
if not host_clean.startswith("http"):
host_clean = f"http://{host_clean}"
parsed = urlparse(host_clean)
allowed_hosts.append(f"{parsed.hostname}")
csrf_trusted_origins.append(f"{parsed.scheme}://{parsed.hostname}")
return allowed_hosts, csrf_trusted_origins
def get_stylesheets() -> list:
"""Get all valid stylesheets from /static/css"""
stylesheets = ["dark.css", "light.css", "matrix.css", "midnight.css"]
return stylesheets
def check_stylesheet(stylesheet: str):
"""Check if a stylesheet exists. Return dark.css as a fallback"""
if stylesheet in get_stylesheets():
return stylesheet
return "dark.css"
def is_missing(
to_check: str | list[str],
index_name: str = "ta_video,ta_download",
on_key: str = "youtube_id",
) -> list[str]:
"""id or list of ids that are missing from index_name"""
if isinstance(to_check, str):
to_check = [to_check]
data = {
"query": {"terms": {on_key: to_check}},
"_source": [on_key],
}
result = IndexPaginate(index_name, data=data).get_results()
existing_ids = [i[on_key] for i in result]
dl = [i for i in to_check if i not in existing_ids]
return dl
def get_channel_overwrites() -> dict[str, dict[str, Any]]:
"""get overwrites indexed my channel_id"""
data = {
"query": {
"bool": {"must": [{"exists": {"field": "channel_overwrites"}}]}
},
"_source": ["channel_id", "channel_overwrites"],
}
result = IndexPaginate("ta_channel", data).get_results()
overwrites = {i["channel_id"]: i["channel_overwrites"] for i in result}
return overwrites

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,35 +1,32 @@
{
"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.1.6",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-helmet": "^6.1.0",
"react-router-dom": "^6.25.1"
},
"devDependencies": {
"@types/dompurify": "^3.0.5",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/react-helmet": "^6.1.11",
"@typescript-eslint/eslint-plugin": "^7.16.1",
"@typescript-eslint/parser": "^7.16.1",
"@vitejs/plugin-react-swc": "^3.7.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.8",
"prettier": "3.3.3",
"typescript": "^5.5.3",
"vite": "^5.3.4"
}
}
{
"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"
},
"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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,32 +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;
};
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,32 +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');
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;
};
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

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

View File

@ -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: boolean | number;
sleep_interval: number;
autodelete_days: boolean | number;
format: boolean | string;
format_sort: boolean | string;
add_metadata: boolean;
add_thumbnail: boolean;
subtitle: boolean | string;
subtitle_source: boolean | string;
subtitle_index: boolean;
comment_max: boolean | number;
comment_sort: string;
cookie_import: boolean;
throttledratelimit: boolean | 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

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

View File

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

View File

@ -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';
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,40 +1,42 @@
export interface ButtonProps {
id?: string;
name?: string;
className?: string;
type?: 'submit' | 'reset' | 'button' | undefined;
label?: string | JSX.Element | JSX.Element[];
children?: string | JSX.Element | JSX.Element[];
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,21 +1,22 @@
import getApiUrl from '../configuration/getApiUrl';
import defaultChannelImage from '/img/default-channel-banner.jpg';
type ChannelIconProps = {
channel_id: string;
};
const ChannelBanner = ({ channel_id }: ChannelIconProps) => {
return (
<img
src={`${getApiUrl()}/cache/channels/${channel_id}_banner.jpg`}
alt={`${channel_id}-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,21 +1,22 @@
import getApiUrl from '../configuration/getApiUrl';
import defaultChannelIcon from '/img/default-channel-icon.jpg';
type ChannelIconProps = {
channel_id: string;
};
const ChannelIcon = ({ channel_id }: ChannelIconProps) => {
return (
<img
src={`${getApiUrl()}/cache/channels/${channel_id}_thumb.jpg`}
alt="channel-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,83 +1,93 @@
import { Link } from 'react-router-dom';
import { ChannelType } from '../pages/Channels';
import { ViewLayoutType } from '../pages/Home';
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';
type ChannelListProps = {
channelList: ChannelType[] | undefined;
viewLayout: ViewLayoutType;
refreshChannelList: (refresh: boolean) => void;
};
const ChannelList = ({ channelList, viewLayout, refreshChannelList }: ChannelListProps) => {
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 channel_id={channel.channel_id} />
</Link>
</div>
<div className={`info-box info-box-2 ${viewLayout}`}>
<div className="info-box-item">
<div className="round-img">
<Link to={Routes.Channel(channel.channel_id)}>
<ChannelIcon channel_id={channel.channel_id} />
</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);
refreshChannelList(true);
}}
/>
)}
{!channel.channel_subscribed && (
<Button
label="Subscribe"
type="button"
title={`Subscribe to ${channel.channel_name}`}
onClick={async () => {
await updateChannelSubscription(channel.channel_id, true);
refreshChannelList(true);
}}
/>
)}
</div>
</div>
</div>
</div>
);
})}
</>
);
};
export default ChannelList;
import { Link } from 'react-router-dom';
import { ChannelType } from '../pages/Channels';
import { ViewLayoutType } from '../pages/Home';
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';
type ChannelListProps = {
channelList: ChannelType[] | undefined;
viewLayout: ViewLayoutType;
refreshChannelList: (refresh: boolean) => void;
};
const ChannelList = ({ channelList, viewLayout, refreshChannelList }: ChannelListProps) => {
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);
refreshChannelList(true);
}}
/>
)}
{!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,76 +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;
showSubscribeButton?: boolean;
isUserAdmin?: boolean;
setRefresh: (status: boolean) => void;
};
const ChannelOverview = ({
channelId,
channelSubs,
channelSubscribed,
channelname,
showSubscribeButton = false,
isUserAdmin,
setRefresh,
}: ChannelOverviewProps) => {
return (
<>
<div className="info-box-item">
<div className="round-img">
<Link to={Routes.Channel(channelId)}>
<ChannelIcon channel_id={channelId} />
</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

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

View File

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

View File

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

@ -1,9 +1,9 @@
const PaginationDummy = () => {
return (
<div className="boxed-content">
<div className="pagination">{/** dummy pagination for 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,85 +1,87 @@
import { Link } from 'react-router-dom';
import Routes from '../configuration/routes/RouteList';
import { ViewLayoutType } from '../pages/Home';
import { PlaylistType } from '../pages/Playlist';
import updatePlaylistSubscription from '../api/actions/updatePlaylistSubscription';
import formatDate from '../functions/formatDates';
import Button from './Button';
import getApiUrl from '../configuration/getApiUrl';
type PlaylistListProps = {
playlistList: PlaylistType[] | undefined;
viewLayout: ViewLayoutType;
setRefresh: (status: boolean) => void;
};
const PlaylistList = ({ playlistList, viewLayout, setRefresh }: PlaylistListProps) => {
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)}>
<img
src={`${getApiUrl()}/cache/playlists/${playlist.playlist_id}.jpg`}
alt={`${playlist.playlist_id}-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);
setRefresh(true);
}}
/>
)}
</>
)}
</div>
</div>
);
})}
</>
);
};
export default PlaylistList;
import { Link } from 'react-router-dom';
import Routes from '../configuration/routes/RouteList';
import { ViewLayoutType } from '../pages/Home';
import { PlaylistType } from '../pages/Playlist';
import updatePlaylistSubscription from '../api/actions/updatePlaylistSubscription';
import formatDate from '../functions/formatDates';
import Button from './Button';
import PlaylistThumbnail from './PlaylistThumbnail';
type PlaylistListProps = {
playlistList: PlaylistType[] | undefined;
viewLayout: ViewLayoutType;
setRefresh: (status: boolean) => void;
};
const PlaylistList = ({ playlistList, viewLayout, setRefresh }: PlaylistListProps) => {
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

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

View File

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

View File

@ -1,254 +1,268 @@
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, 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,
});
}
}
}
};
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;
});
};
export type VideoProgressType = {
youtube_id: string;
user_id: number;
position: number;
};
type VideoPlayerProps = {
video: VideoResponseType;
videoProgress?: VideoProgressType;
sponsorBlock?: SponsorBlockType;
embed?: boolean;
};
const VideoPlayer = ({ video, videoProgress, sponsorBlock, embed }: VideoPlayerProps) => {
const [searchParams] = useSearchParams();
const searchParamVideoProgress = searchParams.get('t');
const [skippedSegments, setSkippedSegments] = useState<SponsorSegmentsSkippedType>({});
const videoId = video.data.youtube_id;
const videoUrl = video.data.media_url;
const videoThumbUrl = video.data.vid_thumb_url;
const watched = video.data.player.watched;
const duration = video.data.player.duration;
const videoSubtitles = video.data.subtitles;
let videoSrcProgress = Number(videoProgress?.position) > 0 ? Number(videoProgress?.position) : '';
if (searchParamVideoProgress !== null) {
videoSrcProgress = searchParamVideoProgress;
}
const autoplay = false;
return (
<>
<div id="player" className={embed ? '' : 'player-wrapper'}>
<div className={embed ? '' : 'video-main'}>
<video
poster={`${getApiUrl()}${videoThumbUrl}`}
onVolumeChange={(videoTag: VideoTag) => {
localStorage.setItem('playerVolume', videoTag.currentTarget.volume.toString());
}}
onLoadStart={(videoTag: VideoTag) => {
videoTag.currentTarget.volume = Number(localStorage.getItem('playerVolume')) ?? 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 }) => {
return (
<>
{from !== 0 && to !== 0 && (
<h3>
Skipped sponsor segment from {formatTime(from)} to {formatTime(to)}.
</h3>
)}
</>
);
})}
</>
)}
</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, 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,
});
}
}
}
};
export type VideoProgressType = {
youtube_id: string;
user_id: number;
position: number;
};
type VideoPlayerProps = {
video: VideoResponseType;
videoProgress?: VideoProgressType;
sponsorBlock?: SponsorBlockType;
embed?: boolean;
autoplay?: boolean;
onVideoEnd?: () => void;
};
const VideoPlayer = ({
video,
videoProgress,
sponsorBlock,
embed,
autoplay = false,
onVideoEnd,
}: VideoPlayerProps) => {
const [searchParams] = useSearchParams();
const searchParamVideoProgress = searchParams.get('t');
const [skippedSegments, setSkippedSegments] = useState<SponsorSegmentsSkippedType>({});
const videoId = video.data.youtube_id;
const videoUrl = video.data.media_url;
const videoThumbUrl = video.data.vid_thumb_url;
const watched = video.data.player.watched;
const duration = video.data.player.duration;
const videoSubtitles = video.data.subtitles;
let videoSrcProgress = Number(videoProgress?.position) > 0 ? Number(videoProgress?.position) : '';
if (searchParamVideoProgress !== null) {
videoSrcProgress = searchParamVideoProgress;
}
const handleVideoEnd =
(
youtubeId: string,
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 }) => {
return (
<>
{from !== 0 && to !== 0 && (
<h3>
Skipped sponsor segment from {formatTime(from)} to {formatTime(to)}.
</h3>
)}
</>
);
})}
</>
)}
</div>
</>
);
};
export default VideoPlayer;

View File

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

View File

@ -1,314 +1,316 @@
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 Logout from './pages/Logout';
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 ChannelStream from './pages/ChannelStream';
import Download from './pages/Download';
import ChannelShorts from './pages/ChannelShorts';
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 />,
loader: async () => {
const authResponse = await loadAuth();
if (authResponse.status === 403) {
return redirect(Routes.Login);
}
const userConfig = await loadUserMeConfig();
return { userConfig };
},
},
{
path: Routes.Video(':videoId'),
element: <Video />,
loader: async () => {
const authResponse = await loadAuth();
if (authResponse.status === 403) {
return redirect(Routes.Login);
}
return {};
},
},
{
path: Routes.Channels,
element: <Channels />,
loader: async () => {
const authResponse = await loadAuth();
if (authResponse.status === 403) {
return redirect(Routes.Login);
}
const userConfig = await loadUserMeConfig();
return { userConfig };
},
},
{
path: Routes.Channel(':channelId'),
element: <ChannelBase />,
loader: async () => {
const authResponse = await loadAuth();
if (authResponse.status === 403) {
return redirect(Routes.Login);
}
return {};
},
children: [
{
index: true,
path: Routes.ChannelVideo(':channelId'),
element: <ChannelVideo />,
loader: async () => {
const authResponse = await loadAuth();
if (authResponse.status === 403) {
return redirect(Routes.Login);
}
const userConfig = await loadUserMeConfig();
return { userConfig };
},
},
{
path: Routes.ChannelStream(':channelId'),
element: <ChannelStream />,
loader: async () => {
const authResponse = await loadAuth();
if (authResponse.status === 403) {
return redirect(Routes.Login);
}
return {};
},
},
{
path: Routes.ChannelShorts(':channelId'),
element: <ChannelShorts />,
loader: async () => {
const authResponse = await loadAuth();
if (authResponse.status === 403) {
return redirect(Routes.Login);
}
return {};
},
},
{
path: Routes.ChannelPlaylist(':channelId'),
element: <ChannelPlaylist />,
loader: async () => {
const authResponse = await loadAuth();
if (authResponse.status === 403) {
return redirect(Routes.Login);
}
const userConfig = await loadUserMeConfig();
return { userConfig };
},
},
{
path: Routes.ChannelAbout(':channelId'),
element: <ChannelAbout />,
loader: async () => {
const authResponse = await loadAuth();
if (authResponse.status === 403) {
return redirect(Routes.Login);
}
return {};
},
},
],
},
{
path: Routes.Playlists,
element: <Playlists />,
loader: async () => {
const authResponse = await loadAuth();
if (authResponse.status === 403) {
return redirect(Routes.Login);
}
const userConfig = await loadUserMeConfig();
return { userConfig };
},
},
{
path: Routes.Playlist(':playlistId'),
element: <Playlist />,
loader: async () => {
const authResponse = await loadAuth();
if (authResponse.status === 403) {
return redirect(Routes.Login);
}
const userConfig = await loadUserMeConfig();
return { userConfig };
},
},
{
path: Routes.Downloads,
element: <Download />,
loader: async () => {
const authResponse = await loadAuth();
if (authResponse.status === 403) {
return redirect(Routes.Login);
}
const userConfig = await loadUserMeConfig();
return { userConfig };
},
},
{
path: Routes.Search,
element: <Search />,
loader: async () => {
const authResponse = await loadAuth();
if (authResponse.status === 403) {
return redirect(Routes.Login);
}
const userConfig = await loadUserMeConfig();
return { userConfig };
},
},
{
path: Routes.SettingsDashboard,
element: <SettingsDashboard />,
loader: async () => {
const authResponse = await loadAuth();
if (authResponse.status === 403) {
return redirect(Routes.Login);
}
return {};
},
},
{
path: Routes.SettingsActions,
element: <SettingsActions />,
loader: async () => {
const authResponse = await loadAuth();
if (authResponse.status === 403) {
return redirect(Routes.Login);
}
return {};
},
},
{
path: Routes.SettingsApplication,
element: <SettingsApplication />,
loader: async () => {
const authResponse = await loadAuth();
if (authResponse.status === 403) {
return redirect(Routes.Login);
}
return {};
},
},
{
path: Routes.SettingsScheduling,
element: <SettingsScheduling />,
loader: async () => {
const authResponse = await loadAuth();
if (authResponse.status === 403) {
return redirect(Routes.Login);
}
return {};
},
},
{
path: Routes.SettingsUser,
element: <SettingsUser />,
loader: async () => {
const auth = await loadAuth();
if (auth.status === 403) {
return redirect(Routes.Login);
}
const userConfig = await loadUserMeConfig();
return { userConfig };
},
},
{
path: Routes.About,
element: <About />,
},
],
},
{
path: Routes.Login,
element: <Login />,
errorElement: <ErrorPage />,
},
{
path: Routes.Logout,
element: <Logout />,
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 Logout from './pages/Logout';
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 />,
loader: async () => {
const authResponse = await loadAuth();
if (authResponse.status === 403) {
return redirect(Routes.Login);
}
const userConfig = await loadUserMeConfig();
return { userConfig };
},
},
{
path: Routes.Video(':videoId'),
element: <Video />,
loader: async () => {
const authResponse = await loadAuth();
if (authResponse.status === 403) {
return redirect(Routes.Login);
}
return {};
},
},
{
path: Routes.Channels,
element: <Channels />,
loader: async () => {
const authResponse = await loadAuth();
if (authResponse.status === 403) {
return redirect(Routes.Login);
}
const userConfig = await loadUserMeConfig();
return { userConfig };
},
},
{
path: Routes.Channel(':channelId'),
element: <ChannelBase />,
loader: async () => {
const authResponse = await loadAuth();
if (authResponse.status === 403) {
return redirect(Routes.Login);
}
return {};
},
children: [
{
index: true,
path: Routes.ChannelVideo(':channelId'),
element: <ChannelVideo videoType="videos" />,
loader: async () => {
const authResponse = await loadAuth();
if (authResponse.status === 403) {
return redirect(Routes.Login);
}
const userConfig = await loadUserMeConfig();
return { userConfig };
},
},
{
path: Routes.ChannelStream(':channelId'),
element: <ChannelVideo videoType="streams" />,
loader: async () => {
const authResponse = await loadAuth();
if (authResponse.status === 403) {
return redirect(Routes.Login);
}
const userConfig = await loadUserMeConfig();
return { userConfig };
},
},
{
path: Routes.ChannelShorts(':channelId'),
element: <ChannelVideo videoType="shorts" />,
loader: async () => {
const authResponse = await loadAuth();
if (authResponse.status === 403) {
return redirect(Routes.Login);
}
const userConfig = await loadUserMeConfig();
return { userConfig };
},
},
{
path: Routes.ChannelPlaylist(':channelId'),
element: <ChannelPlaylist />,
loader: async () => {
const authResponse = await loadAuth();
if (authResponse.status === 403) {
return redirect(Routes.Login);
}
const userConfig = await loadUserMeConfig();
return { userConfig };
},
},
{
path: Routes.ChannelAbout(':channelId'),
element: <ChannelAbout />,
loader: async () => {
const authResponse = await loadAuth();
if (authResponse.status === 403) {
return redirect(Routes.Login);
}
return {};
},
},
],
},
{
path: Routes.Playlists,
element: <Playlists />,
loader: async () => {
const authResponse = await loadAuth();
if (authResponse.status === 403) {
return redirect(Routes.Login);
}
const userConfig = await loadUserMeConfig();
return { userConfig };
},
},
{
path: Routes.Playlist(':playlistId'),
element: <Playlist />,
loader: async () => {
const authResponse = await loadAuth();
if (authResponse.status === 403) {
return redirect(Routes.Login);
}
const userConfig = await loadUserMeConfig();
return { userConfig };
},
},
{
path: Routes.Downloads,
element: <Download />,
loader: async () => {
const authResponse = await loadAuth();
if (authResponse.status === 403) {
return redirect(Routes.Login);
}
const userConfig = await loadUserMeConfig();
return { userConfig };
},
},
{
path: Routes.Search,
element: <Search />,
loader: async () => {
const authResponse = await loadAuth();
if (authResponse.status === 403) {
return redirect(Routes.Login);
}
const userConfig = await loadUserMeConfig();
return { userConfig };
},
},
{
path: Routes.SettingsDashboard,
element: <SettingsDashboard />,
loader: async () => {
const authResponse = await loadAuth();
if (authResponse.status === 403) {
return redirect(Routes.Login);
}
return {};
},
},
{
path: Routes.SettingsActions,
element: <SettingsActions />,
loader: async () => {
const authResponse = await loadAuth();
if (authResponse.status === 403) {
return redirect(Routes.Login);
}
return {};
},
},
{
path: Routes.SettingsApplication,
element: <SettingsApplication />,
loader: async () => {
const authResponse = await loadAuth();
if (authResponse.status === 403) {
return redirect(Routes.Login);
}
return {};
},
},
{
path: Routes.SettingsScheduling,
element: <SettingsScheduling />,
loader: async () => {
const authResponse = await loadAuth();
if (authResponse.status === 403) {
return redirect(Routes.Login);
}
return {};
},
},
{
path: Routes.SettingsUser,
element: <SettingsUser />,
loader: async () => {
const auth = await loadAuth();
if (auth.status === 403) {
return redirect(Routes.Login);
}
const userConfig = await loadUserMeConfig();
return { userConfig };
},
},
{
path: Routes.About,
element: <About />,
},
],
},
{
path: Routes.Login,
element: <Login />,
errorElement: <ErrorPage />,
},
{
path: Routes.Logout,
element: <Logout />,
errorElement: <ErrorPage />,
},
],
{ basename: import.meta.env.BASE_URL },
);
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>,
);

View File

@ -1,64 +1,60 @@
import { Helmet } from 'react-helmet';
const About = () => {
return (
<>
<Helmet>
<title>TA | About</title>
</Helmet>
<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

@ -1,350 +1,444 @@
import { useNavigate, useOutletContext, useParams } from 'react-router-dom';
import ChannelOverview from '../components/ChannelOverview';
import { useEffect, useState } from 'react';
import loadChannelById from '../api/loader/loadChannelById';
import { ChannelResponseType } from './ChannelBase';
import Linkify from '../components/Linkify';
import deleteChannel from '../api/actions/deleteChannel';
import Routes from '../configuration/routes/RouteList';
import queueReindex, { ReindexType, ReindexTypeEnum } from '../api/actions/queueReindex';
import formatDate from '../functions/formatDates';
import PaginationDummy from '../components/PaginationDummy';
import FormattedNumber from '../components/FormattedNumber';
import { Helmet } from 'react-helmet';
import Button from '../components/Button';
const handleSponsorBlockIntegrationOverwrite = (integration: boolean | undefined) => {
if (integration === undefined) {
return 'False';
}
if (integration) {
return integration;
} else {
return 'Disabled';
}
};
export type ChannelBaseOutletContextType = {
isAdmin: boolean;
currentPage: number;
setCurrentPage: (page: number) => void;
startNotification: boolean;
setStartNotification: (status: boolean) => void;
};
export type OutletContextType = {
isAdmin: boolean;
currentPage: number;
setCurrentPage: (page: number) => void;
};
type ChannelAboutParams = {
channelId: string;
};
const ChannelAbout = () => {
const { channelId } = useParams() as ChannelAboutParams;
const { isAdmin, setStartNotification } = useOutletContext() as ChannelBaseOutletContextType;
const navigate = useNavigate();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [descriptionExpanded, setDescriptionExpanded] = useState(false);
const [reindex, setReindex] = useState(false);
const [refresh, setRefresh] = useState(false);
const [channelResponse, setChannelResponse] = useState<ChannelResponseType>();
const channel = channelResponse?.data;
const channelOverwrites = channel?.channel_overwrites;
const handleSubmit = async (event: { preventDefault: () => void }) => {
event.preventDefault();
//TODO: implement request to about api endpoint ( when implemented )
// `/api/channel/${channel.channel_id}/about/`
};
useEffect(() => {
(async () => {
const channelResponse = await loadChannelById(channelId);
setChannelResponse(channelResponse);
setRefresh(false);
})();
}, [refresh, channelId]);
if (!channel) {
return 'Channel not found!';
}
return (
<>
<Helmet>
<title>TA | Channel: About {channel.channel_name}</title>
</Helmet>
<div className="boxed-content">
<div className="info-box info-box-3">
<ChannelOverview
channelId={channel.channel_id}
channelname={channel.channel_name}
channelSubs={channel.channel_subs}
channelSubscribed={channel.channel_subscribed}
showSubscribeButton={true}
isUserAdmin={isAdmin}
setRefresh={setRefresh}
/>
<div className="info-box-item">
<div>
<p>Last refreshed: {formatDate(channel.channel_last_refresh)}</p>
{channel.channel_active && (
<p>
Youtube:{' '}
<a href={`https://www.youtube.com/channel/${channel.channel_id}`} target="_blank">
Active
</a>
</p>
)}
{!channel.channel_active && <p>Youtube: Deactivated</p>}
</div>
</div>
<div className="info-box-item">
<div>
{channel.channel_views > 0 && (
<FormattedNumber text="Channel views:" number={channel.channel_views} />
)}
{isAdmin && (
<>
<div className="button-box">
{!showDeleteConfirm && (
<Button
label="Delete Channel"
id="delete-item"
onClick={() => setShowDeleteConfirm(!showDeleteConfirm)}
/>
)}
{showDeleteConfirm && (
<div className="delete-confirm" id="delete-button">
<span>Delete {channel.channel_name} including all videos? </span>
<Button
label="Delete"
className="danger-button"
onClick={async () => {
await deleteChannel(channelId);
navigate(Routes.Channels);
}}
/>{' '}
<Button
label="Cancel"
onClick={() => setShowDeleteConfirm(!showDeleteConfirm)}
/>
</div>
)}
</div>
{reindex && <p>Reindex scheduled</p>}
{!reindex && (
<div id="reindex-button" className="button-box">
<Button
label="Reindex"
title={`Reindex Channel ${channel.channel_name}`}
onClick={async () => {
await queueReindex(channelId, ReindexTypeEnum.channel as ReindexType);
setReindex(true);
setStartNotification(true);
}}
/>{' '}
<Button
label="Reindex Videos"
title={`Reindex Videos of ${channel.channel_name}`}
onClick={async () => {
await queueReindex(
channelId,
ReindexTypeEnum.channel as ReindexType,
true,
);
setReindex(true);
setStartNotification(true);
}}
/>
</div>
)}
</>
)}
</div>
</div>
</div>
{channel.channel_description && (
<div className="description-box">
<p
id={descriptionExpanded ? 'text-expand-expanded' : 'text-expand'}
className="description-text"
>
<Linkify>{channel.channel_description}</Linkify>
</p>
<Button
label="Show more"
id="text-expand-button"
onClick={() => setDescriptionExpanded(!descriptionExpanded)}
/>
</div>
)}
{channel.channel_tags && (
<div className="description-box">
<div className="video-tag-box">
{channel.channel_tags.map(tag => {
return (
<span key={tag} className="video-tag">
{tag}
</span>
);
})}
</div>
</div>
)}
{isAdmin && (
<div id="overwrite-form" className="info-box">
<div className="info-box-item">
<h2>Customize {channel.channel_name}</h2>
<form className="overwrite-form" onSubmit={handleSubmit}>
<div className="overwrite-form-item">
<p>
Download format:{' '}
<span className="settings-current">
{channelOverwrites?.download_format || 'False'}
</span>
</p>
<input type="text" name="download_format" id="id_download_format" />
<br />
</div>
<div className="overwrite-form-item">
<p>
Auto delete watched videos after x days:{' '}
<span className="settings-current">
{channelOverwrites?.autodelete_days || 'False'}
</span>
</p>
<input type="number" name="autodelete_days" id="id_autodelete_days" />
<br />
</div>
<div className="overwrite-form-item">
<p>
Index playlists:{' '}
<span className="settings-current">
{channelOverwrites?.index_playlists || 'False'}
</span>
</p>
<select name="index_playlists" id="id_index_playlists" defaultValue="">
<option value="">-- change playlist index --</option>
<option value="false">Disable playlist index</option>
<option value="true">Enable playlist index</option>
</select>
<br />
</div>
<div className="overwrite-form-item">
<p>
Enable{' '}
<a href="https://sponsor.ajay.app/" target="_blank">
SponsorBlock
</a>
:{' '}
<span className="settings-current">
{handleSponsorBlockIntegrationOverwrite(
channelOverwrites?.integrate_sponsorblock,
)}
</span>
</p>
<select
name="integrate_sponsorblock"
id="id_integrate_sponsorblock"
defaultValue=""
>
<option value="">-- change sponsorblock integrations</option>
<option value="disable">disable sponsorblock integration</option>
<option value="true">enable sponsorblock integration</option>
<option value="false">unset sponsorblock integration</option>
</select>
</div>
<h3>Page Size Overrides</h3>
<br />
<p>
Disable standard videos, shorts, or streams for this channel by setting their page
size to 0 (zero).
</p>
<br />
<p>Disable page size overwrite for channel by setting to negative value.</p>
<br />
<div className="overwrite-form-item">
<p>
YouTube page size:{' '}
<span className="settings-current">
{channelOverwrites?.subscriptions_channel_size || 'False'}
</span>
</p>
<i>
Videos to scan to find new items for the <b>Rescan subscriptions</b> task, max
recommended 50.
</i>
<br />
<input type="number" name="channel_size" id="id_channel_size" />
<br />
</div>
<div className="overwrite-form-item">
<p>
YouTube Live page size:{' '}
<span className="settings-current">
{channelOverwrites?.subscriptions_live_channel_size || 'False'}
</span>
</p>
<i>
Live Videos to scan to find new items for the <b>Rescan subscriptions</b> task,
max recommended 50.
</i>
<br />
<input type="number" name="live_channel_size" id="id_live_channel_size" />
<br />
</div>
<div className="overwrite-form-item">
<p>
YouTube Shorts page size:{' '}
<span className="settings-current">
{channelOverwrites?.subscriptions_shorts_channel_size || 'False'}
</span>
</p>
<i>
Shorts Videos to scan to find new items for the <b>Rescan subscriptions</b>{' '}
task, max recommended 50.
</i>
<br />
<input type="number" name="shorts_channel_size" id="id_shorts_channel_size" />
</div>
<br />
<Button type="submit" label="Save Channel Overwrites" />
</form>
</div>
</div>
)}
</div>
<PaginationDummy />
</>
);
};
export default ChannelAbout;
import { useNavigate, useOutletContext, useParams } from 'react-router-dom';
import ChannelOverview from '../components/ChannelOverview';
import { useEffect, useState } from 'react';
import loadChannelById from '../api/loader/loadChannelById';
import { ChannelResponseType } from './ChannelBase';
import Linkify from '../components/Linkify';
import deleteChannel from '../api/actions/deleteChannel';
import Routes from '../configuration/routes/RouteList';
import queueReindex, { ReindexType, ReindexTypeEnum } from '../api/actions/queueReindex';
import formatDate from '../functions/formatDates';
import PaginationDummy from '../components/PaginationDummy';
import FormattedNumber from '../components/FormattedNumber';
import Button from '../components/Button';
import updateChannelSettings, {
ChannelAboutConfigType,
} from '../api/actions/updateChannelSettings';
const toStringToBool = (str: string) => {
try {
return JSON.parse(str);
} catch {
return null;
}
};
export type ChannelBaseOutletContextType = {
isAdmin: boolean;
currentPage: number;
setCurrentPage: (page: number) => void;
startNotification: boolean;
setStartNotification: (status: boolean) => void;
};
export type OutletContextType = {
isAdmin: boolean;
currentPage: number;
setCurrentPage: (page: number) => void;
};
type ChannelAboutParams = {
channelId: string;
};
const ChannelAbout = () => {
const { channelId } = useParams() as ChannelAboutParams;
const { isAdmin, setStartNotification } = useOutletContext() as ChannelBaseOutletContextType;
const navigate = useNavigate();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [descriptionExpanded, setDescriptionExpanded] = useState(false);
const [reindex, setReindex] = useState(false);
const [refresh, setRefresh] = useState(true);
const [channelResponse, setChannelResponse] = useState<ChannelResponseType>();
const [channelConfig, setChannelConfig] = useState<ChannelAboutConfigType>();
const [downloadFormat, setDownloadFormat] = useState(channelConfig?.download_format);
const [autoDeleteAfter, setAutoDeleteAfter] = useState(channelConfig?.autodelete_days);
const [indexPlaylists, setIndexPlaylists] = useState(
channelConfig?.index_playlists ? 'true' : 'false',
);
const [enableSponsorblock, setEnableSponsorblock] = useState(
channelConfig?.integrate_sponsorblock,
);
const [pageSizeVideo, setPageSizeVideo] = useState(channelConfig?.subscriptions_channel_size);
const [pageSizeStreams, setPageSizeStreams] = useState(
channelConfig?.subscriptions_live_channel_size,
);
const [pageSizeShorts, setPageSizeShorts] = useState(
channelConfig?.subscriptions_shorts_channel_size,
);
const channel = channelResponse?.data;
const channelOverwrites = channel?.channel_overwrites;
const handleSubmit = async (event: { preventDefault: () => void }) => {
event.preventDefault();
await updateChannelSettings(channelId, {
index_playlists: toStringToBool(indexPlaylists),
download_format: downloadFormat,
autodelete_days: autoDeleteAfter,
integrate_sponsorblock: enableSponsorblock,
subscriptions_channel_size: pageSizeVideo,
subscriptions_live_channel_size: pageSizeStreams,
subscriptions_shorts_channel_size: pageSizeShorts,
});
setRefresh(true);
};
useEffect(() => {
(async () => {
if (refresh) {
const channelResponse = await loadChannelById(channelId);
setChannelResponse(channelResponse);
setChannelConfig(channelResponse?.data?.channel_overwrites);
console.log('channel_overwrites', channelResponse?.data);
console.log('channel_overwrites', channelResponse?.data?.channel_overwrites);
console.log('channel_overwrites', '--------');
setRefresh(false);
}
})();
}, [refresh, channelId]);
if (!channel) {
return 'Channel not found!';
}
return (
<>
<title>{`TA | Channel: About ${channel.channel_name}`}</title>
<div className="boxed-content">
<div className="info-box info-box-3">
<ChannelOverview
channelId={channel.channel_id}
channelname={channel.channel_name}
channelSubs={channel.channel_subs}
channelSubscribed={channel.channel_subscribed}
channelThumbUrl={channel.channel_thumb_url}
showSubscribeButton={true}
isUserAdmin={isAdmin}
setRefresh={setRefresh}
/>
<div className="info-box-item">
<div>
<p>Last refreshed: {formatDate(channel.channel_last_refresh)}</p>
{channel.channel_active && (
<p>
Youtube:{' '}
<a href={`https://www.youtube.com/channel/${channel.channel_id}`} target="_blank">
Active
</a>
</p>
)}
{!channel.channel_active && <p>Youtube: Deactivated</p>}
</div>
</div>
<div className="info-box-item">
<div>
{channel.channel_views > 0 && (
<FormattedNumber text="Channel views:" number={channel.channel_views} />
)}
{isAdmin && (
<>
<div className="button-box">
{!showDeleteConfirm && (
<Button
label="Delete Channel"
id="delete-item"
onClick={() => setShowDeleteConfirm(!showDeleteConfirm)}
/>
)}
{showDeleteConfirm && (
<div className="delete-confirm" id="delete-button">
<span>Delete {channel.channel_name} including all videos? </span>
<Button
label="Delete"
className="danger-button"
onClick={async () => {
await deleteChannel(channelId);
navigate(Routes.Channels);
}}
/>{' '}
<Button
label="Cancel"
onClick={() => setShowDeleteConfirm(!showDeleteConfirm)}
/>
</div>
)}
</div>
{reindex && <p>Reindex scheduled</p>}
{!reindex && (
<div id="reindex-button" className="button-box">
<Button
label="Reindex"
title={`Reindex Channel ${channel.channel_name}`}
onClick={async () => {
await queueReindex(channelId, ReindexTypeEnum.channel as ReindexType);
setReindex(true);
setStartNotification(true);
}}
/>{' '}
<Button
label="Reindex Videos"
title={`Reindex Videos of ${channel.channel_name}`}
onClick={async () => {
await queueReindex(
channelId,
ReindexTypeEnum.channel as ReindexType,
true,
);
setReindex(true);
setStartNotification(true);
}}
/>
</div>
)}
</>
)}
</div>
</div>
</div>
{channel.channel_description && (
<div className="description-box">
<p
id={descriptionExpanded ? 'text-expand-expanded' : 'text-expand'}
className="description-text"
>
<Linkify>{channel.channel_description}</Linkify>
</p>
<Button
label="Show more"
id="text-expand-button"
onClick={() => setDescriptionExpanded(!descriptionExpanded)}
/>
</div>
)}
{channel.channel_tags && (
<div className="description-box">
<div className="video-tag-box">
{channel.channel_tags.map(tag => {
return (
<span key={tag} className="video-tag">
{tag}
</span>
);
})}
</div>
</div>
)}
{isAdmin && (
<div id="overwrite-form" className="info-box">
<div className="info-box-item">
<h2>Customize {channel.channel_name}</h2>
<form className="overwrite-form" onSubmit={handleSubmit}>
<div className="overwrite-form-item">
<p>
Download format:{' '}
<span className="settings-current">
{channelOverwrites?.download_format || 'False'}
</span>
</p>
<input
type="text"
name="download_format"
id="id_download_format"
onChange={event => {
const value = event.currentTarget.value;
setDownloadFormat(value);
}}
/>
<Button
label="Reset"
onClick={() => {
setDownloadFormat(false);
}}
/>
<br />
</div>
<div className="overwrite-form-item">
<p>
Auto delete watched videos after x days:{' '}
<span className="settings-current">
{channelOverwrites?.autodelete_days || 'False'}
</span>
</p>
<input
type="number"
name="autodelete_days"
id="id_autodelete_days"
onChange={event => {
const value = Number(event.currentTarget.value);
if (value === 0) {
setAutoDeleteAfter(false);
} else {
setAutoDeleteAfter(Number(value));
}
}}
/>
<br />
</div>
<div className="overwrite-form-item">
<p>
Index playlists:{' '}
<span className="settings-current">
{JSON.stringify(channelOverwrites?.index_playlists)}
</span>
</p>
<select
name="index_playlists"
id="id_index_playlists"
value={indexPlaylists}
onChange={event => {
const value = event.currentTarget.value;
setIndexPlaylists(value);
}}
>
<option value="null">-- change playlist index --</option>
<option value="false">Disable playlist index</option>
<option value="true">Enable playlist index</option>
</select>
<br />
</div>
<div className="overwrite-form-item">
<p>
Enable{' '}
<a href="https://sponsor.ajay.app/" target="_blank">
SponsorBlock
</a>
:{' '}
<span className="settings-current">
{JSON.stringify(channelOverwrites?.integrate_sponsorblock)}
</span>
</p>
<select
name="integrate_sponsorblock"
id="id_integrate_sponsorblock"
value={enableSponsorblock?.toString() || ''}
onChange={event => {
const value = event.currentTarget.value;
if (value !== '') {
setEnableSponsorblock(JSON.parse(value));
} else {
setEnableSponsorblock(undefined);
}
}}
>
<option value="">-- change sponsorblock integrations</option>
<option value="false">disable sponsorblock integration</option>
<option value="true">enable sponsorblock integration</option>
<option value="null">unset sponsorblock integration</option>
</select>
</div>
<h3>Page Size Overrides</h3>
<br />
<p>
Disable standard videos, shorts, or streams for this channel by setting their page
size to 0 (zero).
</p>
<br />
<p>Disable page size overwrite for channel by setting to negative value.</p>
<br />
<div className="overwrite-form-item">
<p>
YouTube page size:{' '}
<span className="settings-current">
{channelOverwrites?.subscriptions_channel_size || 'False'}
</span>
</p>
<i>
Videos to scan to find new items for the <b>Rescan subscriptions</b> task, max
recommended 50.
</i>
<br />
<input
type="number"
name="channel_size"
id="id_channel_size"
onChange={event => {
setPageSizeVideo(Number(event.currentTarget.value));
}}
/>
<br />
</div>
<div className="overwrite-form-item">
<p>
YouTube Live page size:{' '}
<span className="settings-current">
{channelOverwrites?.subscriptions_live_channel_size || 'False'}
</span>
</p>
<i>
Live Videos to scan to find new items for the <b>Rescan subscriptions</b> task,
max recommended 50.
</i>
<br />
<input
type="number"
name="live_channel_size"
id="id_live_channel_size"
onChange={event => {
setPageSizeStreams(Number(event.currentTarget.value));
}}
/>
<br />
</div>
<div className="overwrite-form-item">
<p>
YouTube Shorts page size:{' '}
<span className="settings-current">
{channelOverwrites?.subscriptions_shorts_channel_size || 'False'}
</span>
</p>
<i>
Shorts Videos to scan to find new items for the <b>Rescan subscriptions</b>{' '}
task, max recommended 50.
</i>
<br />
<input
type="number"
name="shorts_channel_size"
id="id_shorts_channel_size"
onChange={event => {
setPageSizeShorts(Number(event.currentTarget.value));
}}
/>
</div>
<br />
<Button type="submit" label="Save Channel Overwrites" />
</form>
</div>
</div>
)}
</div>
<PaginationDummy />
</>
);
};
export default ChannelAbout;

View File

@ -1,99 +1,104 @@
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';
type ChannelParams = {
channelId: string;
};
export type ChannelResponseType = {
data: ChannelType;
config: ConfigType;
};
const ChannelBase = () => {
const { channelId } = useParams() as ChannelParams;
const { isAdmin, currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
const [channelNav, setChannelNav] = useState<ChannelNavResponseType>();
const [startNotification, setStartNotification] = useState(false);
const { has_streams, has_shorts, has_playlists, has_pending } = channelNav || {};
useEffect(() => {
(async () => {
const channelNavResponse = await loadChannelNav(channelId);
setChannelNav(channelNavResponse);
})();
}, [channelId]);
if (!channelId) {
return [];
}
return (
<>
<div className="boxed-content">
<div className="channel-banner">
<Link to={Routes.ChannelVideo(channelId)}>
<ChannelBanner channel_id={channelId} />
</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={{
isAdmin,
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';
type ChannelParams = {
channelId: string;
};
export type ChannelResponseType = {
data: ChannelType;
config: ConfigType;
};
const ChannelBase = () => {
const { channelId } = useParams() as ChannelParams;
const { isAdmin, currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
const [channelResponse, setChannelResponse] = useState<ChannelResponseType>();
const [channelNav, setChannelNav] = useState<ChannelNavResponseType>();
const [startNotification, setStartNotification] = useState(false);
const channel = channelResponse?.data;
const { has_streams, has_shorts, has_playlists, has_pending } = channelNav || {};
useEffect(() => {
(async () => {
const channelNavResponse = await loadChannelNav(channelId);
const channelResponse = await loadChannelById(channelId);
setChannelResponse(channelResponse);
setChannelNav(channelNavResponse);
})();
}, [channelId]);
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={{
isAdmin,
currentPage,
setCurrentPage,
startNotification,
setStartNotification,
}}
/>
</>
);
};
export default ChannelBase;

View File

@ -1,122 +1,119 @@
import { useLoaderData, useOutletContext, useParams } from 'react-router-dom';
import Notifications from '../components/Notifications';
import PlaylistList from '../components/PlaylistList';
import { ViewLayoutType } from './Home';
import { ViewStyles } from '../configuration/constants/ViewStyle';
import { useEffect, useState } from 'react';
import { OutletContextType } from './Base';
import Pagination from '../components/Pagination';
import ScrollToTopOnNavigate from '../components/ScrollToTop';
import { Helmet } from 'react-helmet';
import loadPlaylistList from '../api/loader/loadPlaylistList';
import { PlaylistsResponseType } from './Playlists';
import iconGridView from '/img/icon-gridview.svg';
import iconListView from '/img/icon-listview.svg';
import { UserMeType } from '../api/actions/updateUserConfig';
type ChannelPlaylistLoaderDataType = {
userConfig: UserMeType;
};
const ChannelPlaylist = () => {
const { channelId } = useParams();
const { userConfig } = useLoaderData() as ChannelPlaylistLoaderDataType;
const { currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
const userMeConfig = userConfig.config;
const [showSubedOnly, setShowSubedOnly] = useState(userMeConfig.show_subed_only || false);
const [view, setView] = useState<ViewLayoutType>(userMeConfig.view_style_playlist || 'grid');
const [gridItems, setGridItems] = useState(userMeConfig.grid_items || 3);
const [refreshPlaylists, setRefreshPlaylists] = useState(false);
const [playlistsResponse, setPlaylistsResponse] = useState<PlaylistsResponseType>();
const playlistList = playlistsResponse?.data;
const pagination = playlistsResponse?.paginate;
const isGridView = view === ViewStyles.grid;
const gridView = isGridView ? `boxed-${gridItems}` : '';
const gridViewGrid = isGridView ? `grid-${gridItems}` : '';
useEffect(() => {
(async () => {
const playlists = await loadPlaylistList({
channel: channelId,
subscribed: showSubedOnly,
});
setPlaylistsResponse(playlists);
setRefreshPlaylists(false);
})();
}, [channelId, refreshPlaylists, showSubedOnly, currentPage]);
return (
<>
<Helmet>
<title>TA | Channel: Playlists</title>
</Helmet>
<ScrollToTopOnNavigate />
<div className={`boxed-content ${gridView}`}>
<Notifications pageName="channel" includeReindex={true} />
<div className="view-controls">
<div className="toggle">
<span>Show subscribed only:</span>
<div className="toggleBox">
<input
checked={showSubedOnly}
onChange={() => {
setShowSubedOnly(!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={() => {
setView('grid');
}}
alt="grid view"
/>
<img
src={iconListView}
onClick={() => {
setView('list');
}}
alt="list view"
/>
</div>
</div>
</div>
<div className={`boxed-content ${gridView}`}>
<div className={`playlist-list ${view} ${gridViewGrid}`}>
<PlaylistList
playlistList={playlistList}
viewLayout={view}
setRefresh={setRefreshPlaylists}
/>
</div>
</div>
<div className="boxed-content">
{pagination && <Pagination pagination={pagination} setPage={setCurrentPage} />}
</div>
</>
);
};
export default ChannelPlaylist;
import { useLoaderData, useOutletContext, useParams } from 'react-router-dom';
import Notifications from '../components/Notifications';
import PlaylistList from '../components/PlaylistList';
import { ViewLayoutType } from './Home';
import { ViewStyles } from '../configuration/constants/ViewStyle';
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 { UserMeType } from '../api/actions/updateUserConfig';
type ChannelPlaylistLoaderDataType = {
userConfig: UserMeType;
};
const ChannelPlaylist = () => {
const { channelId } = useParams();
const { userConfig } = useLoaderData() as ChannelPlaylistLoaderDataType;
const { currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
const userMeConfig = userConfig.config;
const [showSubedOnly, setShowSubedOnly] = useState(userMeConfig.show_subed_only || false);
const [view, setView] = useState<ViewLayoutType>(userMeConfig.view_style_playlist || 'grid');
const [gridItems] = useState(userMeConfig.grid_items || 3);
const [refreshPlaylists, setRefreshPlaylists] = useState(false);
const [playlistsResponse, setPlaylistsResponse] = useState<PlaylistsResponseType>();
const playlistList = playlistsResponse?.data;
const pagination = playlistsResponse?.paginate;
const isGridView = view === ViewStyles.grid;
const gridView = isGridView ? `boxed-${gridItems}` : '';
const gridViewGrid = isGridView ? `grid-${gridItems}` : '';
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 ${gridView}`}>
<Notifications pageName="channel" includeReindex={true} />
<div className="view-controls">
<div className="toggle">
<span>Show subscribed only:</span>
<div className="toggleBox">
<input
checked={showSubedOnly}
onChange={() => {
setShowSubedOnly(!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={() => {
setView('grid');
}}
alt="grid view"
/>
<img
src={iconListView}
onClick={() => {
setView('list');
}}
alt="list view"
/>
</div>
</div>
</div>
<div className={`boxed-content ${gridView}`}>
<div className={`playlist-list ${view} ${gridViewGrid}`}>
<PlaylistList
playlistList={playlistList}
viewLayout={view}
setRefresh={setRefreshPlaylists}
/>
</div>
</div>
<div className="boxed-content">
{pagination && <Pagination pagination={pagination} setPage={setCurrentPage} />}
</div>
</>
);
};
export default ChannelPlaylist;

View File

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

View File

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

View File

@ -1,211 +1,216 @@
import { useEffect, useState } from 'react';
import {
Link,
useLoaderData,
useOutletContext,
useParams,
useSearchParams,
} from 'react-router-dom';
import { SortByType, SortOrderType, ViewLayoutType } from './Home';
import { OutletContextType } from './Base';
import { UserMeType } from '../api/actions/updateUserConfig';
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 { Helmet } from 'react-helmet';
import Button from '../components/Button';
import loadVideoListByFilter, {
VideoListByFilterResponseType,
} from '../api/loader/loadVideoListByPage';
type ChannelParams = {
channelId: string;
};
type ChannelVideoLoaderType = {
userConfig: UserMeType;
};
const ChannelVideo = () => {
const { channelId } = useParams() as ChannelParams;
const { userConfig } = useLoaderData() as ChannelVideoLoaderType;
const { isAdmin, currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
const [searchParams] = useSearchParams();
const videoId = searchParams.get('videoId');
const userMeConfig = userConfig.config;
const [hideWatched, setHideWatched] = useState(userMeConfig.hide_watched || false);
const [sortBy, setSortBy] = useState<SortByType>(userMeConfig.sort_by || 'published');
const [sortOrder, setSortOrder] = useState<SortOrderType>(userMeConfig.sort_order || 'asc');
const [view, setView] = useState<ViewLayoutType>(userMeConfig.view_style_home || 'grid');
const [gridItems, setGridItems] = useState(userMeConfig.grid_items || 3);
const [refresh, setRefresh] = useState(false);
const [channelResponse, setChannelResponse] = useState<ChannelResponseType>();
const [videoResponse, setVideoReponse] = useState<VideoListByFilterResponseType>();
const channel = channelResponse?.data;
const videoList = videoResponse?.data;
const pagination = videoResponse?.paginate;
const hasVideos = videoResponse?.data?.length !== 0;
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 channelResponse = await loadChannelById(channelId);
const videos = await loadVideoListByFilter({
channel: channelId,
page: currentPage,
watch: hideWatched ? 'unwatched' : undefined,
sort: sortBy,
order: sortOrder,
});
setChannelResponse(channelResponse);
setVideoReponse(videos);
setRefresh(false);
}
})();
// Do not add sort, order, hideWatched this will not work as expected!
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refresh, currentPage, channelId, pagination?.current_page]);
const aggs = {
total_items: { value: '<debug>' },
total_duration: { value_str: '<debug>' },
total_size: { value: '<debug>' },
};
if (!channel) {
return (
<div className="boxed-content">
<br />
<h2>Channel {channelId} not found!</h2>
</div>
);
}
return (
<>
<Helmet>
<title>TA | Channel: {channel.channel_name}</title>
</Helmet>
<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}
showSubscribeButton={true}
isUserAdmin={isAdmin}
setRefresh={setRefresh}
/>
<div className="info-box-item">
{aggs && (
<>
<p>
{aggs.total_items.value} videos <span className="space-carrot">|</span>{' '}
{aggs.total_duration.value_str} playback <span className="space-carrot">|</span>{' '}
Total size {aggs.total_size.value}
</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:'}
view={view}
isGridView={isGridView}
hideWatched={hideWatched}
gridItems={gridItems}
sortBy={sortBy}
sortOrder={sortOrder}
userMeConfig={userMeConfig}
setSortBy={setSortBy}
setSortOrder={setSortOrder}
setHideWatched={setHideWatched}
setView={setView}
setGridItems={setGridItems}
viewStyleName={ViewStyleNames.channel}
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,
useLoaderData,
useOutletContext,
useParams,
useSearchParams,
} from 'react-router-dom';
import { SortByType, SortOrderType, ViewLayoutType } from './Home';
import { OutletContextType } from './Base';
import { UserMeType } from '../api/actions/updateUserConfig';
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';
type ChannelParams = {
channelId: string;
};
type ChannelVideoLoaderType = {
userConfig: UserMeType;
};
type ChannelVideoProps = {
videoType: VideoTypes;
};
const ChannelVideo = ({ videoType }: ChannelVideoProps) => {
const { channelId } = useParams() as ChannelParams;
const { userConfig } = useLoaderData() as ChannelVideoLoaderType;
const { isAdmin, currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
const [searchParams] = useSearchParams();
const videoId = searchParams.get('videoId');
const userMeConfig = userConfig.config;
const [hideWatched, setHideWatched] = useState(userMeConfig.hide_watched || false);
const [sortBy, setSortBy] = useState<SortByType>(userMeConfig.sort_by || 'published');
const [sortOrder, setSortOrder] = useState<SortOrderType>(userMeConfig.sort_order || 'asc');
const [view, setView] = useState<ViewLayoutType>(userMeConfig.view_style_home || 'grid');
const [gridItems, setGridItems] = useState(userMeConfig.grid_items || 3);
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 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 channelResponse = await loadChannelById(channelId);
const videos = await loadVideoListByFilter({
channel: channelId,
page: currentPage,
watch: hideWatched ? 'unwatched' : undefined,
sort: sortBy,
order: sortOrder,
type: videoType,
});
const channelAggs = await loadChannelAggs(channelId);
setChannelResponse(channelResponse);
setVideoReponse(videos);
setVideoAggsResponse(channelAggs);
setRefresh(false);
}
})();
// Do not add sort, order, hideWatched this will not work as expected!
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refresh, currentPage, channelId, pagination?.current_page]);
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}
isUserAdmin={isAdmin}
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:'}
view={view}
isGridView={isGridView}
hideWatched={hideWatched}
gridItems={gridItems}
sortBy={sortBy}
sortOrder={sortOrder}
userMeConfig={userMeConfig}
setSortBy={setSortBy}
setSortOrder={setSortOrder}
setHideWatched={setHideWatched}
setView={setView}
setGridItems={setGridItems}
viewStyleName={ViewStyleNames.channel}
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,206 +1,203 @@
import { useLoaderData, 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, ViewLayoutType } from './Home';
import updateUserConfig, { UserConfigType, UserMeType } from '../api/actions/updateUserConfig';
import { OutletContextType } from './Base';
import ChannelList from '../components/ChannelList';
import ScrollToTopOnNavigate from '../components/ScrollToTop';
import Notifications from '../components/Notifications';
import { Helmet } from 'react-helmet';
import Button from '../components/Button';
type ChannelOverwritesType = {
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;
};
type ChannelsLoaderDataType = {
userConfig: UserMeType;
};
const Channels = () => {
const { userConfig } = useLoaderData() as ChannelsLoaderDataType;
const { isAdmin, currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
const userMeConfig = userConfig.config;
const [channelListResponse, setChannelListResponse] = useState<ChannelsListResponse>();
const [showSubscribedOnly, setShowSubscribedOnly] = useState(
userMeConfig.show_subed_only || false,
);
const [view, setView] = useState<ViewLayoutType>(userMeConfig.view_style_channel || 'grid');
const [showAddForm, setShowAddForm] = useState(false);
const [refresh, setRefresh] = useState(false);
const channels = channelListResponse?.data;
const pagination = channelListResponse?.paginate;
const channelCount = pagination?.total_hits;
const hasChannels = channels?.length !== 0;
useEffect(() => {
(async () => {
if (
userMeConfig.view_style_channel !== view ||
userMeConfig.show_subed_only !== showSubscribedOnly
) {
const userConfig: UserConfigType = {
show_subed_only: showSubscribedOnly,
view_style_channel: view,
};
await updateUserConfig(userConfig);
}
})();
}, [showSubscribedOnly, userMeConfig.show_subed_only, userMeConfig.view_style_channel, view]);
useEffect(() => {
(async () => {
if (
refresh ||
pagination?.current_page === undefined ||
currentPage !== pagination?.current_page
) {
const channelListResponse = await loadChannelList(currentPage, showSubscribedOnly);
setChannelListResponse(channelListResponse);
setRefresh(false);
}
})();
}, [currentPage, showSubscribedOnly, refresh, pagination?.current_page]);
return (
<>
<Helmet>
<title>TA | Channels</title>
</Helmet>
<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 rows={3} placeholder="Input channel ID, URL or Video of a channel" />
</div>
<Button label="Subscribe" type="submit" />
</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={() => {
setShowSubscribedOnly(!showSubscribedOnly);
}}
type="checkbox"
checked={showSubscribedOnly}
/>
{!showSubscribedOnly && (
<label htmlFor="" className="ofbtn">
Off
</label>
)}
{showSubscribedOnly && (
<label htmlFor="" className="onbtn">
On
</label>
)}
</div>
</div>
<div className="view-icons">
<img
src={iconGridView}
onClick={() => {
setView('grid');
}}
data-origin="channel"
data-value="grid"
alt="grid view"
/>
<img
src={iconListView}
onClick={() => {
setView('list');
}}
data-origin="channel"
data-value="list"
alt="list view"
/>
</div>
</div>
{hasChannels && <h2>Total channels: {channelCount}</h2>}
<div className={`channel-list ${view}`}>
{!hasChannels && <h2>No channels found...</h2>}
{hasChannels && (
<ChannelList channelList={channels} viewLayout={view} refreshChannelList={setRefresh} />
)}
</div>
{pagination && (
<div className="boxed-content">
<Pagination pagination={pagination} setPage={setCurrentPage} />
</div>
)}
</div>
</>
);
};
export default Channels;
import { useLoaderData, 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, ViewLayoutType } from './Home';
import updateUserConfig, { UserConfigType, UserMeType } from '../api/actions/updateUserConfig';
import { OutletContextType } from './Base';
import ChannelList from '../components/ChannelList';
import ScrollToTopOnNavigate from '../components/ScrollToTop';
import Notifications from '../components/Notifications';
import Button from '../components/Button';
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;
};
type ChannelsLoaderDataType = {
userConfig: UserMeType;
};
const Channels = () => {
const { userConfig } = useLoaderData() as ChannelsLoaderDataType;
const { isAdmin, currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
const userMeConfig = userConfig.config;
const [channelListResponse, setChannelListResponse] = useState<ChannelsListResponse>();
const [showSubscribedOnly, setShowSubscribedOnly] = useState(
userMeConfig.show_subed_only || false,
);
const [view, setView] = useState<ViewLayoutType>(userMeConfig.view_style_channel || 'grid');
const [showAddForm, setShowAddForm] = useState(false);
const [refresh, setRefresh] = useState(false);
const channels = channelListResponse?.data;
const pagination = channelListResponse?.paginate;
const channelCount = pagination?.total_hits;
const hasChannels = channels?.length !== 0;
useEffect(() => {
(async () => {
if (
userMeConfig.view_style_channel !== view ||
userMeConfig.show_subed_only !== showSubscribedOnly
) {
const userConfig: UserConfigType = {
show_subed_only: showSubscribedOnly,
view_style_channel: view,
};
await updateUserConfig(userConfig);
}
})();
}, [showSubscribedOnly, userMeConfig.show_subed_only, userMeConfig.view_style_channel, view]);
useEffect(() => {
(async () => {
if (
refresh ||
pagination?.current_page === undefined ||
currentPage !== pagination?.current_page
) {
const channelListResponse = await loadChannelList(currentPage, showSubscribedOnly);
setChannelListResponse(channelListResponse);
setRefresh(false);
}
})();
}, [currentPage, showSubscribedOnly, refresh, 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 rows={3} placeholder="Input channel ID, URL or Video of a channel" />
</div>
<Button label="Subscribe" type="submit" />
</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={() => {
setShowSubscribedOnly(!showSubscribedOnly);
}}
type="checkbox"
checked={showSubscribedOnly}
/>
{!showSubscribedOnly && (
<label htmlFor="" className="ofbtn">
Off
</label>
)}
{showSubscribedOnly && (
<label htmlFor="" className="onbtn">
On
</label>
)}
</div>
</div>
<div className="view-icons">
<img
src={iconGridView}
onClick={() => {
setView('grid');
}}
data-origin="channel"
data-value="grid"
alt="grid view"
/>
<img
src={iconListView}
onClick={() => {
setView('list');
}}
data-origin="channel"
data-value="list"
alt="list view"
/>
</div>
</div>
{hasChannels && <h2>Total channels: {channelCount}</h2>}
<div className={`channel-list ${view}`}>
{!hasChannels && <h2>No channels found...</h2>}
{hasChannels && (
<ChannelList channelList={channels} viewLayout={view} refreshChannelList={setRefresh} />
)}
</div>
{pagination && (
<div className="boxed-content">
<Pagination pagination={pagination} setPage={setCurrentPage} />
</div>
)}
</div>
</>
);
};
export default Channels;

View File

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

View File

@ -1,35 +1,32 @@
import { Helmet } from 'react-helmet';
import { useRouteError } from 'react-router-dom';
import importColours, { ColourConstant, ColourVariants } 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(ColourConstant.Dark as ColourVariants);
console.error('ErrorPage', error);
return (
<>
<Helmet>
<title>TA | Oops!</title>
</Helmet>
<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, { ColourConstant, ColourVariants } 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(ColourConstant.Dark as ColourVariants);
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,251 +1,248 @@
import { useEffect, useState } from 'react';
import { Link, useLoaderData, 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 { UserMeType } from '../api/actions/updateUserConfig';
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 { Helmet } from 'react-helmet';
import { SponsorBlockType } from './Video';
export type PlayerType = {
watched: boolean;
duration: number;
duration_str: string;
progress: 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;
};
type HomeLoaderDataType = {
userConfig: UserMeType;
};
export type SortByType = 'published' | 'downloaded' | 'views' | 'likes' | 'duration' | 'filesize';
export type SortOrderType = 'asc' | 'desc';
export type ViewLayoutType = 'grid' | 'list';
const Home = () => {
const { userConfig } = useLoaderData() as HomeLoaderDataType;
const { currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
const [searchParams] = useSearchParams();
const videoId = searchParams.get('videoId');
const userMeConfig = userConfig.config;
const [hideWatched, setHideWatched] = useState(userMeConfig.hide_watched || false);
const [sortBy, setSortBy] = useState<SortByType>(userMeConfig.sort_by || 'published');
const [sortOrder, setSortOrder] = useState<SortOrderType>(userMeConfig.sort_order || 'asc');
const [view, setView] = useState<ViewLayoutType>(userMeConfig.view_style_home || 'grid');
const [gridItems, setGridItems] = useState(userMeConfig.grid_items || 3);
const [showHidden, setShowHidden] = useState(false);
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 = view === ViewStyles.grid;
const gridView = isGridView ? `boxed-${gridItems}` : '';
const gridViewGrid = isGridView ? `grid-${gridItems}` : '';
useEffect(() => {
(async () => {
if (
refreshVideoList ||
pagination?.current_page === undefined ||
currentPage !== pagination?.current_page
) {
const videos = await loadVideoListByFilter({
page: currentPage,
watch: hideWatched ? 'unwatched' : undefined,
sort: sortBy,
order: sortOrder,
});
try {
const continueVideoResponse = await loadVideoListByFilter({ watch: 'continue' });
setContinueVideoResponse(continueVideoResponse);
} catch (error) {
console.log('Server error on continue vids?');
}
setVideoReponse(videos);
setRefreshVideoList(false);
}
})();
// Do not add sort, order, hideWatched this will not work as expected!
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refreshVideoList, currentPage, pagination?.current_page]);
return (
<>
<Helmet>
<title>TubeArchivist</title>
</Helmet>
<ScrollToTopOnNavigate />
<div className={`boxed-content ${gridView}`}>
{continueVideos && continueVideos.length > 0 && (
<>
<div className="title-bar">
<h1>Continue Watching</h1>
</div>
<div className={`video-list ${view} ${gridViewGrid}`}>
<VideoList
videoList={continueVideos}
viewLayout={view}
refreshVideoList={setRefreshVideoList}
/>
</div>
</>
)}
<div className="title-bar">
<h1>Recent Videos</h1>
</div>
<Filterbar
hideToggleText="Hide watched:"
showHidden={showHidden}
hideWatched={hideWatched}
isGridView={isGridView}
view={view}
gridItems={gridItems}
sortBy={sortBy}
sortOrder={sortOrder}
userMeConfig={userMeConfig}
setShowHidden={setShowHidden}
setHideWatched={setHideWatched}
setView={setView}
setSortBy={setSortBy}
setSortOrder={setSortOrder}
setGridItems={setGridItems}
viewStyleName={ViewStyleNames.home}
setRefresh={setRefreshVideoList}
/>
</div>
{showEmbeddedVideo && <EmbeddableVideoPlayer videoId={videoId} />}
<div className={`boxed-content ${gridView}`}>
<div className={`video-list ${view} ${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={view}
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, useLoaderData, 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 { UserMeType } from '../api/actions/updateUserConfig';
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';
export type PlayerType = {
watched: boolean;
duration: number;
duration_str: string;
progress: 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;
};
type HomeLoaderDataType = {
userConfig: UserMeType;
};
export type SortByType = 'published' | 'downloaded' | 'views' | 'likes' | 'duration' | 'filesize';
export type SortOrderType = 'asc' | 'desc';
export type ViewLayoutType = 'grid' | 'list';
const Home = () => {
const { userConfig } = useLoaderData() as HomeLoaderDataType;
const { currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
const [searchParams] = useSearchParams();
const videoId = searchParams.get('videoId');
const userMeConfig = userConfig.config;
const [hideWatched, setHideWatched] = useState(userMeConfig.hide_watched || false);
const [sortBy, setSortBy] = useState<SortByType>(userMeConfig.sort_by || 'published');
const [sortOrder, setSortOrder] = useState<SortOrderType>(userMeConfig.sort_order || 'asc');
const [view, setView] = useState<ViewLayoutType>(userMeConfig.view_style_home || 'grid');
const [gridItems, setGridItems] = useState(userMeConfig.grid_items || 3);
const [showHidden, setShowHidden] = useState(false);
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 = view === ViewStyles.grid;
const gridView = isGridView ? `boxed-${gridItems}` : '';
const gridViewGrid = isGridView ? `grid-${gridItems}` : '';
useEffect(() => {
(async () => {
if (
refreshVideoList ||
pagination?.current_page === undefined ||
currentPage !== pagination?.current_page
) {
const videos = await loadVideoListByFilter({
page: currentPage,
watch: hideWatched ? 'unwatched' : undefined,
sort: sortBy,
order: sortOrder,
});
try {
const continueVideoResponse = await loadVideoListByFilter({ watch: 'continue' });
setContinueVideoResponse(continueVideoResponse);
} catch (error) {
console.log('Server error on continue vids?');
}
setVideoReponse(videos);
setRefreshVideoList(false);
}
})();
// Do not add sort, order, hideWatched this will not work as expected!
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refreshVideoList, currentPage, pagination?.current_page]);
return (
<>
<title>TubeArchivist</title>
<ScrollToTopOnNavigate />
<div className={`boxed-content ${gridView}`}>
{continueVideos && continueVideos.length > 0 && (
<>
<div className="title-bar">
<h1>Continue Watching</h1>
</div>
<div className={`video-list ${view} ${gridViewGrid}`}>
<VideoList
videoList={continueVideos}
viewLayout={view}
refreshVideoList={setRefreshVideoList}
/>
</div>
</>
)}
<div className="title-bar">
<h1>Recent Videos</h1>
</div>
<Filterbar
hideToggleText="Hide watched:"
showHidden={showHidden}
hideWatched={hideWatched}
isGridView={isGridView}
view={view}
gridItems={gridItems}
sortBy={sortBy}
sortOrder={sortOrder}
userMeConfig={userMeConfig}
setShowHidden={setShowHidden}
setHideWatched={setHideWatched}
setView={setView}
setSortBy={setSortBy}
setSortOrder={setSortOrder}
setGridItems={setGridItems}
viewStyleName={ViewStyleNames.home}
setRefresh={setRefreshVideoList}
/>
</div>
{showEmbeddedVideo && <EmbeddableVideoPlayer videoId={videoId} />}
<div className={`boxed-content ${gridView}`}>
<div className={`video-list ${view} ${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={view}
refreshVideoList={setRefreshVideoList}
/>
)}
</div>
</div>
{pagination && (
<div className="boxed-content">
<Pagination pagination={pagination} setPage={setCurrentPage} />
</div>
)}
</>
);
};
export default Home;

View File

@ -1,106 +1,103 @@
import { useState } from 'react';
import Routes from '../configuration/routes/RouteList';
import { useNavigate } from 'react-router-dom';
import importColours, { ColourConstant, ColourVariants } from '../configuration/colours/getColours';
import { Helmet } from 'react-helmet';
import Button from '../components/Button';
import signIn from '../api/actions/signIn';
const Login = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [saveLogin, setSaveLogin] = useState(false);
const navigate = useNavigate();
importColours(ColourConstant.Dark as ColourVariants);
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 (
<>
<Helmet>
<title>TA | Welcome</title>
</Helmet>
<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, { ColourConstant, ColourVariants } 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(ColourConstant.Dark as ColourVariants);
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,390 +1,388 @@
import { useEffect, useState } from 'react';
import {
Link,
useLoaderData,
useNavigate,
useOutletContext,
useParams,
useSearchParams,
} from 'react-router-dom';
import { UserMeType } from '../api/actions/updateUserConfig';
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 { Helmet } from 'react-helmet';
import Button from '../components/Button';
import loadVideoListByFilter from '../api/loader/loadVideoListByPage';
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;
};
type PlaylistLoaderDataType = {
userConfig: UserMeType;
};
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 } = useLoaderData() as PlaylistLoaderDataType;
const { isAdmin, currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
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 (
<>
<Helmet>
<title>TA | Playlist: {playlist.playlist_name}</title>
</Helmet>
<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}
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,
useLoaderData,
useNavigate,
useOutletContext,
useParams,
useSearchParams,
} from 'react-router-dom';
import { UserMeType } from '../api/actions/updateUserConfig';
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';
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;
};
type PlaylistLoaderDataType = {
userConfig: UserMeType;
};
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 } = useLoaderData() as PlaylistLoaderDataType;
const { isAdmin, currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
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,219 +1,222 @@
import { useEffect, useState } from 'react';
import { useLoaderData, 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 updateUserConfig, { UserConfigType, UserMeType } from '../api/actions/updateUserConfig';
import loadPlaylistList from '../api/loader/loadPlaylistList';
import { ConfigType, ViewLayoutType } 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 { Helmet } from 'react-helmet';
import Button from '../components/Button';
export type PlaylistEntryType = {
youtube_id: string;
title: string;
uploader: string;
idx: number;
downloaded: boolean;
};
export type PlaylistsResponseType = {
data?: PlaylistType[];
config?: ConfigType;
paginate?: PaginationType;
};
type PlaylistLoaderDataType = {
userConfig: UserMeType;
};
const Playlists = () => {
const { userConfig } = useLoaderData() as PlaylistLoaderDataType;
const { isAdmin, currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
const userMeConfig = userConfig.config;
const [showSubedOnly, setShowSubedOnly] = useState(userMeConfig.show_subed_only || false);
const [view, setView] = useState<ViewLayoutType>(userMeConfig.view_style_playlist || 'grid');
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;
useEffect(() => {
(async () => {
if (
userMeConfig.view_style_playlist !== view ||
userMeConfig.show_subed_only !== showSubedOnly
) {
const userConfig: UserConfigType = {
show_subed_only: showSubedOnly,
view_style_playlist: view,
};
await updateUserConfig(userConfig);
}
})();
}, [showSubedOnly, userMeConfig.show_subed_only, userMeConfig.view_style_playlist, view]);
useEffect(() => {
(async () => {
if (
refresh ||
pagination?.current_page === undefined ||
currentPage !== pagination?.current_page
) {
const playlist = await loadPlaylistList({ page: currentPage });
setPlaylistReponse(playlist);
setRefresh(false);
}
})();
}, [refresh, currentPage, showSubedOnly, view, pagination?.current_page]);
return (
<>
<Helmet>
<title>TA | Playlists</title>
</Helmet>
<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);
}}
/>
</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={() => {
setShowSubedOnly(!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={() => {
setView('grid');
}}
alt="grid view"
/>
<img
src={iconListView}
onClick={() => {
setView('list');
}}
alt="list view"
/>
</div>
</div>
<div className={`playlist-list ${view}`}>
{!hasPlaylists && <h2>No playlists found...</h2>}
{hasPlaylists && (
<PlaylistList playlistList={playlistList} viewLayout={view} setRefresh={setRefresh} />
)}
</div>
</div>
<div className="boxed-content">
{pagination && <Pagination pagination={pagination} setPage={setCurrentPage} />}
</div>
</>
);
};
export default Playlists;
import { useEffect, useState } from 'react';
import { useLoaderData, 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 updateUserConfig, { UserConfigType, UserMeType } from '../api/actions/updateUserConfig';
import loadPlaylistList from '../api/loader/loadPlaylistList';
import { ConfigType, ViewLayoutType } 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';
export type PlaylistEntryType = {
youtube_id: string;
title: string;
uploader: string;
idx: number;
downloaded: boolean;
};
export type PlaylistsResponseType = {
data?: PlaylistType[];
config?: ConfigType;
paginate?: PaginationType;
};
type PlaylistLoaderDataType = {
userConfig: UserMeType;
};
const Playlists = () => {
const { userConfig } = useLoaderData() as PlaylistLoaderDataType;
const { isAdmin, currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
const userMeConfig = userConfig.config;
const [showSubedOnly, setShowSubedOnly] = useState(userMeConfig.show_subed_only || false);
const [view, setView] = useState<ViewLayoutType>(userMeConfig.view_style_playlist || 'grid');
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;
useEffect(() => {
(async () => {
if (
userMeConfig.view_style_playlist !== view ||
userMeConfig.show_subed_only !== showSubedOnly
) {
const userConfig: UserConfigType = {
show_subed_only: showSubedOnly,
view_style_playlist: view,
};
await updateUserConfig(userConfig);
setRefresh(true);
}
})();
}, [showSubedOnly, userMeConfig.show_subed_only, userMeConfig.view_style_playlist, view]);
useEffect(() => {
(async () => {
if (
refresh ||
pagination?.current_page === undefined ||
currentPage !== pagination?.current_page
) {
const playlist = await loadPlaylistList({
page: currentPage,
subscribed: showSubedOnly,
});
setPlaylistReponse(playlist);
setRefresh(false);
}
})();
// Do not add showSubedOnly, view this will not work as expected!
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refresh, currentPage, pagination?.current_page]);
return (
<>
<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);
}}
/>
</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={() => {
setShowSubedOnly(!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={() => {
setView('grid');
}}
alt="grid view"
/>
<img
src={iconListView}
onClick={() => {
setView('list');
}}
alt="list view"
/>
</div>
</div>
<div className={`playlist-list ${view}`}>
{!hasPlaylists && <h2>No playlists found...</h2>}
{hasPlaylists && (
<PlaylistList playlistList={playlistList} viewLayout={view} setRefresh={setRefresh} />
)}
</div>
</div>
<div className="boxed-content">
{pagination && <Pagination pagination={pagination} setPage={setCurrentPage} />}
</div>
</>
);
};
export default Playlists;

View File

@ -1,170 +1,167 @@
import { useLoaderData, useSearchParams } from 'react-router-dom';
import { UserMeType } from '../api/actions/updateUserConfig';
import { useEffect, useState } from 'react';
import { VideoType, ViewLayoutType } 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 { Helmet } from 'react-helmet';
import SearchExampleQueries from '../components/SearchExampleQueries';
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;
};
type SearchLoaderDataType = {
userConfig: UserMeType;
};
const Search = () => {
const { userConfig } = useLoaderData() as SearchLoaderDataType;
const [searchParams] = useSearchParams();
const videoId = searchParams.get('videoId');
const userMeConfig = userConfig.config;
const view = (userMeConfig.view_style_home || ViewStyles.grid) as ViewLayoutType;
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 = view === 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 (
<>
<Helmet>
<title>TubeArchivist</title>
</Helmet>
{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"
name="searchInput"
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 ${view} ${gridViewGrid}`}>
<VideoList videoList={videoList} viewLayout={view} refreshVideoList={setRefresh} />
</div>
</div>
)}
{hasSearchQuery && isChannelQuery && (
<div className="multi-search-result">
<h2>Channel Results</h2>
<div id="channel-results" className={`channel-list ${view} ${gridViewGrid}`}>
<ChannelList
channelList={channelList}
viewLayout={view}
refreshChannelList={setRefresh}
/>
</div>
</div>
)}
{hasSearchQuery && isPlaylistQuery && (
<div className="multi-search-result">
<h2>Playlist Results</h2>
<div id="playlist-results" className={`playlist-list ${view} ${gridViewGrid}`}>
<PlaylistList
playlistList={playlistList}
viewLayout={view}
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 { useLoaderData, useSearchParams } from 'react-router-dom';
import { UserMeType } from '../api/actions/updateUserConfig';
import { useEffect, useState } from 'react';
import { VideoType, ViewLayoutType } 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';
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;
};
type SearchLoaderDataType = {
userConfig: UserMeType;
};
const Search = () => {
const { userConfig } = useLoaderData() as SearchLoaderDataType;
const [searchParams] = useSearchParams();
const videoId = searchParams.get('videoId');
const userMeConfig = userConfig.config;
const view = (userMeConfig.view_style_home || ViewStyles.grid) as ViewLayoutType;
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 = view === 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 ${view} ${gridViewGrid}`}>
<VideoList videoList={videoList} viewLayout={view} refreshVideoList={setRefresh} />
</div>
</div>
)}
{hasSearchQuery && isChannelQuery && (
<div className="multi-search-result">
<h2>Channel Results</h2>
<div id="channel-results" className={`channel-list ${view} ${gridViewGrid}`}>
<ChannelList
channelList={channelList}
viewLayout={view}
refreshChannelList={setRefresh}
/>
</div>
</div>
)}
{hasSearchQuery && isPlaylistQuery && (
<div className="multi-search-result">
<h2>Playlist Results</h2>
<div id="playlist-results" className={`playlist-list ${view} ${gridViewGrid}`}>
<PlaylistList
playlistList={playlistList}
viewLayout={view}
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,245 +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 { Helmet } from 'react-helmet';
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 (
<>
<Helmet>
<title>TA | Actions</title>
</Helmet>
<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,263 +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';
import { Helmet } from 'react-helmet';
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 (
<>
<Helmet>
<title>TA | Settings Dashboard</title>
</Helmet>
<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,462 +1,496 @@
import { Helmet } from 'react-helmet';
import Notifications from '../components/Notifications';
import SettingsNavigation from '../components/SettingsNavigation';
import Button from '../components/Button';
type CronTabType = {
minute: number;
hour: number;
day_of_week: number;
};
type SchedulerErrorType = {
errors: string[];
};
type NotificationItemType = {
task: string;
notification: {
title: string;
urls: string[];
};
};
type SettingsSchedulingResponseType = {
update_subscribed: {
crontab: CronTabType;
};
check_reindex: {
crontab: CronTabType;
task_config: {
days: 0;
};
};
thumbnail_check: {
crontab: CronTabType;
};
download_pending: {
crontab: CronTabType;
};
run_backup: {
crontab: CronTabType;
task_config: {
rotate: false;
};
};
notifications: {
items: NotificationItemType[];
};
scheduler_form: {
update_subscribed: SchedulerErrorType;
download_pending: SchedulerErrorType;
check_reindex: SchedulerErrorType;
thumbnail_check: SchedulerErrorType;
run_backup: SchedulerErrorType;
};
};
const SettingsScheduling = () => {
const response: SettingsSchedulingResponseType = {
update_subscribed: {
crontab: {
minute: 0,
hour: 0,
day_of_week: 0,
},
},
check_reindex: {
crontab: {
minute: 0,
hour: 0,
day_of_week: 0,
},
task_config: {
days: 0,
},
},
thumbnail_check: {
crontab: {
minute: 0,
hour: 0,
day_of_week: 0,
},
},
download_pending: {
crontab: {
minute: 0,
hour: 0,
day_of_week: 0,
},
},
run_backup: {
crontab: {
minute: 0,
hour: 0,
day_of_week: 0,
},
task_config: {
rotate: false,
},
},
notifications: {
items: [
{
task: '',
notification: {
title: '',
urls: [''],
},
},
],
},
scheduler_form: {
update_subscribed: {
errors: ['error?'],
},
download_pending: {
errors: ['error?'],
},
check_reindex: {
errors: ['error?'],
},
thumbnail_check: {
errors: ['error?'],
},
run_backup: {
errors: ['error?'],
},
},
};
const {
check_reindex,
download_pending,
notifications,
run_backup,
scheduler_form,
thumbnail_check,
update_subscribed,
} = response;
return (
<>
<Helmet>
<title>TA | Scheduling Settings</title>
</Helmet>
<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>
<form action="{% url 'settings_scheduling' %}" method="POST" name="scheduler-update">
<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">
{update_subscribed && (
<>
{update_subscribed.crontab.minute} {update_subscribed.crontab.hour}{' '}
{update_subscribed.crontab.day_of_week}
<Button
label="Delete"
data-schedule="update_subscribed"
onclick="deleteSchedule(this)"
className="danger-button"
/>
</>
)}
{!update_subscribed && 'False'}
</span>
</p>
<p>Periodically rescan your subscriptions:</p>
{scheduler_form.update_subscribed.errors.map(error => {
return (
<p key={error} className="danger-zone">
{error}
</p>
);
})}
<input type="text" name="update_subscribed" id="id_update_subscribed" />
</div>
</div>
<div className="settings-group">
<h2>Start Download</h2>
<div className="settings-item">
<p>
Current Download schedule:{' '}
<span className="settings-current">
{download_pending && (
<>
{download_pending.crontab.minute} {download_pending.crontab.hour}{' '}
{download_pending.crontab.day_of_week}
<Button
label="Delete"
data-schedule="download_pending"
onclick="deleteSchedule(this)"
className="danger-button"
/>
</>
)}
{!download_pending && 'False'}
</span>
</p>
<p>Automatic video download schedule:</p>
{scheduler_form.download_pending.errors.map(error => {
return (
<p key={error} className="danger-zone">
{error}
</p>
);
})}
<input type="text" name="download_pending" id="id_download_pending" />
</div>
</div>
<div className="settings-group">
<h2>Refresh Metadata</h2>
<div className="settings-item">
<p>
Current Metadata refresh schedule:{' '}
<span className="settings-current">
{check_reindex && (
<>
{check_reindex.crontab.minute} {check_reindex.crontab.hour}{' '}
{check_reindex.crontab.day_of_week}
<Button
label="Delete"
data-schedule="check_reindex"
onclick="deleteSchedule(this)"
className="danger-button"
/>
</>
)}
{!check_reindex && 'False'}
</span>
</p>
<p>Daily schedule to refresh metadata from YouTube:</p>
<input type="text" name="check_reindex" id="id_check_reindex" />
</div>
<div className="settings-item">
<p>
Current refresh for metadata older than x days:{' '}
<span className="settings-current">{check_reindex.task_config.days}</span>
</p>
<p>Refresh older than x days, recommended 90:</p>
{scheduler_form.check_reindex.errors.map(error => {
return (
<p key={error} className="danger-zone">
{error}
</p>
);
})}
<input type="number" name="check_reindex_days" id="id_check_reindex_days" />
</div>
</div>
<div className="settings-group">
<h2>Thumbnail Check</h2>
<div className="settings-item">
<p>
Current thumbnail check schedule:{' '}
<span className="settings-current">
{thumbnail_check && (
<>
{thumbnail_check.crontab.minute} {thumbnail_check.crontab.hour}{' '}
{thumbnail_check.crontab.day_of_week}
<Button
label="Delete"
data-schedule="thumbnail_check"
onclick="deleteSchedule(this)"
className="danger-button"
/>
</>
)}
{!thumbnail_check && 'False'}
</span>
</p>
<p>Periodically check and cleanup thumbnails:</p>
{scheduler_form.thumbnail_check.errors.map(error => {
return (
<p key={error} className="danger-zone">
{error}
</p>
);
})}
<input type="text" name="thumbnail_check" id="id_thumbnail_check" />
</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">
{run_backup && (
<>
{run_backup.crontab.minute} {run_backup.crontab.hour}{' '}
{run_backup.crontab.day_of_week}
<Button
label="Delete"
data-schedule="run_backup"
onclick="deleteSchedule(this)"
className="danger-button"
/>
</>
)}
{!run_backup && 'False'}
</span>
</p>
<p>Automatically backup metadata to a zip file:</p>
{scheduler_form.run_backup.errors.map(error => {
return (
<p key={error} className="danger-zone">
{error}
</p>
);
})}
<input type="text" name="run_backup" id="id_run_backup" />
</div>
<div className="settings-item">
<p>
Current backup files to keep:{' '}
<span className="settings-current">{run_backup.task_config.rotate}</span>
</p>
<p>Max auto backups to keep:</p>
<input type="number" name="run_backup_rotate" id="id_run_backup_rotate" />
</div>
</div>
<div className="settings-group">
<h2>Add Notification URL</h2>
<div className="settings-item">
{notifications && (
<>
<p>
<Button
label="Show"
type="button"
onclick="textReveal(this)"
id="text-reveal-button"
/>{' '}
stored notification links
</p>
<div id="text-reveal" className="description-text">
{notifications.items.map(({ task, notification }) => {
return (
<>
<h3 key={task}>{notification.title}</h3>
{notification.urls.map((url: string) => {
return (
<p>
<Button
type="button"
className="danger-button"
label="Delete"
data-url={url}
data-task={task}
onclick="deleteNotificationUrl(this)"
/>
<span> {url}</span>
</p>
);
})}
</>
);
})}
</div>
</>
)}
{!notifications && <p>No notifications stored</p>}
</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 name="task" id="id_task" defaultValue="">
<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"
name="notification_url"
placeholder="Apprise notification URL"
id="id_notification_url"
/>
</div>
</div>
<Button type="submit" name="scheduler-settings" label="Update Scheduler Settings" />
</form>
</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>(undefined);
const [thumbnailCheck, setThumbnailCheck] = useState<string | undefined>();
const [zipBackup, setZipBackup] = useState<string | undefined>();
const [zipBackupDays, setZipBackupDays] = useState<number | undefined>(undefined);
const [notificationUrl, setNotificationUrl] = useState<string | undefined>(undefined);
const [notificationTask, setNotificationTask] = useState<AppriseTaskNameType | string>('');
useEffect(() => {
(async () => {
if (refresh) {
const scheduleResponse = await loadSchedule();
const appriseNotificationResponse = await loadAppriseNotification();
setScheduleResponse(scheduleResponse);
setAppriseNotification(appriseNotificationResponse);
setRefresh(false);
}
})();
}, [refresh]);
useEffect(() => {
setRefresh(true);
}, []);
const groupedSchedules = Object.groupBy(scheduleResponse, ({ name }) => name);
console.log(groupedSchedules);
const { update_subscribed, download_pending, run_backup, check_reindex, thumbnail_check } =
groupedSchedules;
const updateSubscribedSchedule = update_subscribed?.pop();
const downloadPendingSchedule = download_pending?.pop();
const runBackup = run_backup?.pop();
const checkReindexSchedule = check_reindex?.pop();
const thumbnailCheckSchedule = thumbnail_check?.pop();
return (
<>
<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}
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()}
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
defaultValue=""
value={notificationTask}
onChange={e => {
setNotificationTask(e.currentTarget.value);
}}
>
<option value="">-- select task --</option>
<option value="update_subscribed">Rescan your Subscriptions</option>
<option value="extract_download">Add to download queue</option>
<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,143 +1,140 @@
import { useLoaderData, useNavigate, useOutletContext } from 'react-router-dom';
import updateUserConfig, { UserConfigType, UserMeType } from '../api/actions/updateUserConfig';
import { useEffect, useState } from 'react';
import loadUserMeConfig from '../api/loader/loadUserConfig';
import { ColourConstant, ColourVariants } from '../configuration/colours/getColours';
import SettingsNavigation from '../components/SettingsNavigation';
import Notifications from '../components/Notifications';
import { Helmet } from 'react-helmet';
import Button from '../components/Button';
import { OutletContextType } from './Base';
type SettingsUserLoaderData = {
userConfig: UserMeType;
};
const SettingsUser = () => {
const { isAdmin } = useOutletContext() as OutletContextType;
const { userConfig } = useLoaderData() as SettingsUserLoaderData;
const navigate = useNavigate();
const userMeConfig = userConfig.config;
const { stylesheet, page_size } = userMeConfig;
const [selectedStylesheet, setSelectedStylesheet] = useState(userMeConfig.stylesheet);
const [selectedPageSize, setSelectedPageSize] = useState(userMeConfig.page_size);
const [refresh, setRefresh] = useState(false);
const [userConfigResponse, setUserConfigResponse] = useState<UserConfigType>();
const stylesheetOverwritable =
userConfigResponse?.stylesheet || stylesheet || (ColourConstant.Dark as ColourVariants);
const pageSizeOverwritable = userConfigResponse?.page_size || page_size || 12;
useEffect(() => {
(async () => {
if (refresh) {
const userConfigResponse = await loadUserMeConfig();
setUserConfigResponse(userConfigResponse.config);
setRefresh(false);
navigate(0);
}
})();
}, [navigate, refresh]);
return (
<>
<Helmet>
<title>TA | User Settings</title>
</Helmet>
<div className="boxed-content">
<SettingsNavigation />
<Notifications pageName={'all'} />
<div className="title-bar">
<h1>User Configurations</h1>
</div>
<div>
<div className="settings-group">
<h2>Stylesheet</h2>
<div className="settings-item">
<p>
Current stylesheet:{' '}
<span className="settings-current">{stylesheetOverwritable}</span>
</p>
<i>Select your preferred stylesheet.</i>
<br />
<select
name="stylesheet"
id="id_stylesheet"
value={selectedStylesheet}
onChange={event => {
setSelectedStylesheet(event.target.value as ColourVariants);
}}
>
<option value="">-- change stylesheet --</option>
{Object.entries(ColourConstant).map(([key, value]) => {
return (
<option key={key} value={value}>
{key}
</option>
);
})}
</select>
</div>
</div>
<div className="settings-group">
<h2>Archive View</h2>
<div className="settings-item">
<p>
Current page size: <span className="settings-current">{pageSizeOverwritable}</span>
</p>
<i>Result of videos showing in archive page</i>
<br />
<input
type="number"
name="page_size"
id="id_page_size"
value={selectedPageSize}
onChange={event => {
setSelectedPageSize(Number(event.target.value));
}}
></input>
</div>
</div>
<Button
name="user-settings"
label="Update User Configurations"
onClick={async () => {
await updateUserConfig({
page_size: selectedPageSize,
stylesheet: selectedStylesheet,
});
setRefresh(true);
}}
/>
</div>
{isAdmin && (
<>
<div className="title-bar">
<h1>Users</h1>
</div>
<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 { useLoaderData, useNavigate, useOutletContext } from 'react-router-dom';
import updateUserConfig, { UserConfigType, UserMeType } from '../api/actions/updateUserConfig';
import { useEffect, useState } from 'react';
import loadUserMeConfig from '../api/loader/loadUserConfig';
import { ColourConstant, ColourVariants } from '../configuration/colours/getColours';
import SettingsNavigation from '../components/SettingsNavigation';
import Notifications from '../components/Notifications';
import Button from '../components/Button';
import { OutletContextType } from './Base';
type SettingsUserLoaderData = {
userConfig: UserMeType;
};
const SettingsUser = () => {
const { isAdmin } = useOutletContext() as OutletContextType;
const { userConfig } = useLoaderData() as SettingsUserLoaderData;
const navigate = useNavigate();
const userMeConfig = userConfig.config;
const { stylesheet, page_size } = userMeConfig;
const [selectedStylesheet, setSelectedStylesheet] = useState(userMeConfig.stylesheet);
const [selectedPageSize, setSelectedPageSize] = useState(userMeConfig.page_size);
const [refresh, setRefresh] = useState(false);
const [userConfigResponse, setUserConfigResponse] = useState<UserConfigType>();
const stylesheetOverwritable =
userConfigResponse?.stylesheet || stylesheet || (ColourConstant.Dark as ColourVariants);
const pageSizeOverwritable = userConfigResponse?.page_size || page_size || 12;
useEffect(() => {
(async () => {
if (refresh) {
const userConfigResponse = await loadUserMeConfig();
setUserConfigResponse(userConfigResponse.config);
setRefresh(false);
navigate(0);
}
})();
}, [navigate, refresh]);
return (
<>
<title>TA | User Settings</title>
<div className="boxed-content">
<SettingsNavigation />
<Notifications pageName={'all'} />
<div className="title-bar">
<h1>User Configurations</h1>
</div>
<div>
<div className="settings-group">
<h2>Stylesheet</h2>
<div className="settings-item">
<p>
Current stylesheet:{' '}
<span className="settings-current">{stylesheetOverwritable}</span>
</p>
<i>Select your preferred stylesheet.</i>
<br />
<select
name="stylesheet"
id="id_stylesheet"
value={selectedStylesheet}
onChange={event => {
setSelectedStylesheet(event.target.value as ColourVariants);
}}
>
<option value="">-- change stylesheet --</option>
{Object.entries(ColourConstant).map(([key, value]) => {
return (
<option key={key} value={value}>
{key}
</option>
);
})}
</select>
</div>
</div>
<div className="settings-group">
<h2>Archive View</h2>
<div className="settings-item">
<p>
Current page size: <span className="settings-current">{pageSizeOverwritable}</span>
</p>
<i>Result of videos showing in archive page</i>
<br />
<input
type="number"
name="page_size"
id="id_page_size"
value={selectedPageSize}
onChange={event => {
setSelectedPageSize(Number(event.target.value));
}}
></input>
</div>
</div>
<Button
name="user-settings"
label="Update User Configurations"
onClick={async () => {
await updateUserConfig({
page_size: selectedPageSize,
stylesheet: selectedStylesheet,
});
setRefresh(true);
}}
/>
</div>
{isAdmin && (
<>
<div className="title-bar">
<h1>Users</h1>
</div>
<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

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

View File

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