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

File diff suppressed because it is too large Load Diff

View File

@ -1,35 +1,32 @@
{ {
"name": "tubearchivist-frontend", "name": "tubearchivist-frontend",
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"build:deploy": "vite build", "build:deploy": "vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"dompurify": "^3.1.6", "dompurify": "^3.2.3",
"react": "^18.3.1", "react": "^19.0.0",
"react-dom": "^18.3.1", "react-dom": "^19.0.0",
"react-helmet": "^6.1.0", "react-router-dom": "^7.0.2"
"react-router-dom": "^6.25.1" },
}, "devDependencies": {
"devDependencies": { "@types/react": "^19.0.1",
"@types/dompurify": "^3.0.5", "@types/react-dom": "^19.0.2",
"@types/react": "^18.3.3", "@typescript-eslint/eslint-plugin": "^8.18.0",
"@types/react-dom": "^18.3.0", "@typescript-eslint/parser": "^8.18.0",
"@types/react-helmet": "^6.1.11", "@vitejs/plugin-react-swc": "^3.7.2",
"@typescript-eslint/eslint-plugin": "^7.16.1", "eslint": "^9.16.0",
"@typescript-eslint/parser": "^7.16.1", "eslint-config-prettier": "^9.1.0",
"@vitejs/plugin-react-swc": "^3.7.0", "eslint-plugin-react-hooks": "^5.1.0",
"eslint": "^8.57.0", "eslint-plugin-react-refresh": "^0.4.16",
"eslint-config-prettier": "^9.1.0", "prettier": "3.4.2",
"eslint-plugin-react-hooks": "^4.6.2", "typescript": "^5.7.2",
"eslint-plugin-react-refresh": "^0.4.8", "vite": "^6.0.3"
"prettier": "3.3.3", }
"typescript": "^5.5.3", }
"vite": "^5.3.4"
}
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,85 +1,87 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import Routes from '../configuration/routes/RouteList'; import Routes from '../configuration/routes/RouteList';
import { ViewLayoutType } from '../pages/Home'; import { ViewLayoutType } from '../pages/Home';
import { PlaylistType } from '../pages/Playlist'; import { PlaylistType } from '../pages/Playlist';
import updatePlaylistSubscription from '../api/actions/updatePlaylistSubscription'; import updatePlaylistSubscription from '../api/actions/updatePlaylistSubscription';
import formatDate from '../functions/formatDates'; import formatDate from '../functions/formatDates';
import Button from './Button'; import Button from './Button';
import getApiUrl from '../configuration/getApiUrl'; import PlaylistThumbnail from './PlaylistThumbnail';
type PlaylistListProps = { type PlaylistListProps = {
playlistList: PlaylistType[] | undefined; playlistList: PlaylistType[] | undefined;
viewLayout: ViewLayoutType; viewLayout: ViewLayoutType;
setRefresh: (status: boolean) => void; setRefresh: (status: boolean) => void;
}; };
const PlaylistList = ({ playlistList, viewLayout, setRefresh }: PlaylistListProps) => { const PlaylistList = ({ playlistList, viewLayout, setRefresh }: PlaylistListProps) => {
if (!playlistList || playlistList.length === 0) { if (!playlistList || playlistList.length === 0) {
return <p>No playlists found.</p>; return <p>No playlists found.</p>;
} }
return ( return (
<> <>
{playlistList.map((playlist: PlaylistType) => { {playlistList.map((playlist: PlaylistType) => {
return ( return (
<div key={playlist.playlist_id} className={`playlist-item ${viewLayout}`}> <div key={playlist.playlist_id} className={`playlist-item ${viewLayout}`}>
<div className="playlist-thumbnail"> <div className="playlist-thumbnail">
<Link to={Routes.Playlist(playlist.playlist_id)}> <Link to={Routes.Playlist(playlist.playlist_id)}>
<img <PlaylistThumbnail
src={`${getApiUrl()}/cache/playlists/${playlist.playlist_id}.jpg`} playlistId={playlist.playlist_id}
alt={`${playlist.playlist_id}-thumbnail`} playlistThumbnail={playlist.playlist_thumbnail}
/> />
</Link> </Link>
</div> </div>
<div className={`playlist-desc ${viewLayout}`}> <div className={`playlist-desc ${viewLayout}`}>
{playlist.playlist_type != 'custom' && ( {playlist.playlist_type != 'custom' && (
<Link to={Routes.Channel(playlist.playlist_channel_id)}> <Link to={Routes.Channel(playlist.playlist_channel_id)}>
<h3>{playlist.playlist_channel}</h3> <h3>{playlist.playlist_channel}</h3>
</Link> </Link>
)} )}
<Link to={Routes.Playlist(playlist.playlist_id)}> <Link to={Routes.Playlist(playlist.playlist_id)}>
<h2>{playlist.playlist_name}</h2> <h2>{playlist.playlist_name}</h2>
</Link> </Link>
<p>Last refreshed: {formatDate(playlist.playlist_last_refresh)}</p> <p>Last refreshed: {formatDate(playlist.playlist_last_refresh)}</p>
{playlist.playlist_type != 'custom' && ( {playlist.playlist_type != 'custom' && (
<> <>
{playlist.playlist_subscribed && ( {playlist.playlist_subscribed && (
<Button <Button
label="Unsubscribe" label="Unsubscribe"
className="unsubscribe" className="unsubscribe"
type="button" type="button"
title={`Unsubscribe from ${playlist.playlist_name}`} title={`Unsubscribe from ${playlist.playlist_name}`}
onClick={async () => { onClick={async () => {
await updatePlaylistSubscription(playlist.playlist_id, false); await updatePlaylistSubscription(playlist.playlist_id, false);
setRefresh(true); setRefresh(true);
}} }}
/> />
)} )}
{!playlist.playlist_subscribed && ( {!playlist.playlist_subscribed && (
<Button <Button
label="Subscribe" label="Subscribe"
type="button" type="button"
title={`Subscribe to ${playlist.playlist_name}`} title={`Subscribe to ${playlist.playlist_name}`}
onClick={async () => { onClick={async () => {
await updatePlaylistSubscription(playlist.playlist_id, true); await updatePlaylistSubscription(playlist.playlist_id, true);
setRefresh(true); setTimeout(() => {
}} setRefresh(true);
/> }, 500);
)} }}
</> />
)} )}
</div> </>
</div> )}
); </div>
})} </div>
</> );
); })}
}; </>
);
export default PlaylistList; };
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, id: video.youtube_id,
is_watched: status, is_watched: status,
}); });
}}
onDone={() => {
refreshVideoList(true); refreshVideoList(true);
}} }}
/> />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,462 +1,496 @@
import { Helmet } from 'react-helmet'; import Notifications from '../components/Notifications';
import Notifications from '../components/Notifications'; import SettingsNavigation from '../components/SettingsNavigation';
import SettingsNavigation from '../components/SettingsNavigation'; import Button from '../components/Button';
import Button from '../components/Button'; import PaginationDummy from '../components/PaginationDummy';
import { useEffect, useState } from 'react';
type CronTabType = { import loadSchedule, { ScheduleResponseType } from '../api/loader/loadSchedule';
minute: number; import loadAppriseNotification, {
hour: number; AppriseNotificationType,
day_of_week: number; } from '../api/loader/loadAppriseNotification';
}; import deleteTaskSchedule from '../api/actions/deleteTaskSchedule';
import createTaskSchedule from '../api/actions/createTaskSchedule';
type SchedulerErrorType = { import createAppriseNotificationUrl, {
errors: string[]; AppriseTaskNameType,
}; } from '../api/actions/createAppriseNotificationUrl';
import deleteAppriseNotificationUrl from '../api/actions/deleteAppriseNotificationUrl';
type NotificationItemType = {
task: string; const SettingsScheduling = () => {
notification: { const [refresh, setRefresh] = useState(false);
title: string;
urls: string[]; const [scheduleResponse, setScheduleResponse] = useState<ScheduleResponseType>([]);
}; const [appriseNotification, setAppriseNotification] = useState<AppriseNotificationType>();
};
const [updateSubscribed, setUpdateSubscribed] = useState<string | undefined>();
type SettingsSchedulingResponseType = { const [downloadPending, setDownloadPending] = useState<string | undefined>();
update_subscribed: { const [checkReindex, setCheckReindex] = useState<string | undefined>();
crontab: CronTabType; const [checkReindexDays, setCheckReindexDays] = useState<number | undefined>(undefined);
}; const [thumbnailCheck, setThumbnailCheck] = useState<string | undefined>();
check_reindex: { const [zipBackup, setZipBackup] = useState<string | undefined>();
crontab: CronTabType; const [zipBackupDays, setZipBackupDays] = useState<number | undefined>(undefined);
task_config: { const [notificationUrl, setNotificationUrl] = useState<string | undefined>(undefined);
days: 0; const [notificationTask, setNotificationTask] = useState<AppriseTaskNameType | string>('');
};
}; useEffect(() => {
thumbnail_check: { (async () => {
crontab: CronTabType; if (refresh) {
}; const scheduleResponse = await loadSchedule();
download_pending: { const appriseNotificationResponse = await loadAppriseNotification();
crontab: CronTabType;
}; setScheduleResponse(scheduleResponse);
run_backup: { setAppriseNotification(appriseNotificationResponse);
crontab: CronTabType;
task_config: { setRefresh(false);
rotate: false; }
}; })();
}; }, [refresh]);
notifications: {
items: NotificationItemType[]; useEffect(() => {
}; setRefresh(true);
scheduler_form: { }, []);
update_subscribed: SchedulerErrorType;
download_pending: SchedulerErrorType; const groupedSchedules = Object.groupBy(scheduleResponse, ({ name }) => name);
check_reindex: SchedulerErrorType;
thumbnail_check: SchedulerErrorType; console.log(groupedSchedules);
run_backup: SchedulerErrorType;
}; const { update_subscribed, download_pending, run_backup, check_reindex, thumbnail_check } =
}; groupedSchedules;
const SettingsScheduling = () => { const updateSubscribedSchedule = update_subscribed?.pop();
const response: SettingsSchedulingResponseType = { const downloadPendingSchedule = download_pending?.pop();
update_subscribed: { const runBackup = run_backup?.pop();
crontab: { const checkReindexSchedule = check_reindex?.pop();
minute: 0, const thumbnailCheckSchedule = thumbnail_check?.pop();
hour: 0,
day_of_week: 0, return (
}, <>
}, <title>TA | Scheduling Settings</title>
check_reindex: { <div className="boxed-content">
crontab: { <SettingsNavigation />
minute: 0, <Notifications pageName={'all'} />
hour: 0,
day_of_week: 0, <div className="title-bar">
}, <h1>Scheduler Setup</h1>
task_config: { <div className="settings-group">
days: 0, <p>
}, Schedule settings expect a cron like format, where the first value is minute, second
}, is hour and third is day of the week.
thumbnail_check: { </p>
crontab: { <p>Examples:</p>
minute: 0, <ul>
hour: 0, <li>
day_of_week: 0, <span className="settings-current">0 15 *</span>: Run task every day at 15:00 in the
}, afternoon.
}, </li>
download_pending: { <li>
crontab: { <span className="settings-current">30 8 */2</span>: Run task every second day of the
minute: 0, week (Sun, Tue, Thu, Sat) at 08:30 in the morning.
hour: 0, </li>
day_of_week: 0, <li>
}, <span className="settings-current">auto</span>: Sensible default.
}, </li>
run_backup: { </ul>
crontab: { <p>Note:</p>
minute: 0, <ul>
hour: 0, <li>
day_of_week: 0, 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
task_config: { hour.
rotate: false, </li>
}, </ul>
}, </div>
notifications: { </div>
items: [
{ <div className="settings-group">
task: '', <h2>Rescan Subscriptions</h2>
notification: { <div className="settings-item">
title: '', <p>
urls: [''], 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
scheduler_form: { new videos uploaded by your favorite channels.
update_subscribed: { </p>
errors: ['error?'], <p>
}, Current rescan schedule:{' '}
download_pending: { <span className="settings-current">
errors: ['error?'], {!updateSubscribedSchedule && 'False'}
}, {updateSubscribedSchedule && (
check_reindex: { <>
errors: ['error?'], {updateSubscribedSchedule?.schedule}{' '}
}, <Button
thumbnail_check: { label="Delete"
errors: ['error?'], data-schedule="update_subscribed"
}, onClick={async () => {
run_backup: { await deleteTaskSchedule('update_subscribed');
errors: ['error?'],
}, setRefresh(true);
}, }}
}; className="danger-button"
/>
const { </>
check_reindex, )}
download_pending, </span>
notifications, </p>
run_backup, <p>Periodically rescan your subscriptions:</p>
scheduler_form,
thumbnail_check, <input
update_subscribed, type="text"
} = response; value={updateSubscribed || updateSubscribedSchedule?.schedule}
onChange={e => {
return ( setUpdateSubscribed(e.currentTarget.value);
<> }}
<Helmet> />
<title>TA | Scheduling Settings</title> <Button
</Helmet> label="Save"
<div className="boxed-content"> onClick={async () => {
<SettingsNavigation /> await createTaskSchedule('update_subscribed', {
<Notifications pageName={'all'} /> schedule: updateSubscribed,
});
<div className="title-bar">
<h1>Scheduler Setup</h1> setUpdateSubscribed('');
<div className="settings-group">
<p> setRefresh(true);
Schedule settings expect a cron like format, where the first value is minute, second }}
is hour and third is day of the week. />
</p> </div>
<p>Examples:</p> </div>
<ul> <div className="settings-group">
<li> <h2>Start Download</h2>
<span className="settings-current">0 15 *</span>: Run task every day at 15:00 in the <div className="settings-item">
afternoon. <p>
</li> Current Download schedule:{' '}
<li> <span className="settings-current">
<span className="settings-current">30 8 */2</span>: Run task every second day of the {!download_pending && 'False'}
week (Sun, Tue, Thu, Sat) at 08:30 in the morning. {downloadPendingSchedule && (
</li> <>
<li> {downloadPendingSchedule?.schedule}{' '}
<span className="settings-current">auto</span>: Sensible default. <Button
</li> label="Delete"
</ul> className="danger-button"
<p>Note:</p> onClick={async () => {
<ul> await deleteTaskSchedule('download_pending');
<li>
Avoid an unnecessary frequent schedule to not get blocked by YouTube. For that setRefresh(true);
reason, the scheduler doesn't support schedules that trigger more than once per }}
hour. />
</li> </>
</ul> )}
</div> </span>
</div> </p>
<form action="{% url 'settings_scheduling' %}" method="POST" name="scheduler-update"> <p>Automatic video download schedule:</p>
<div className="settings-group">
<h2>Rescan Subscriptions</h2> <input
<div className="settings-item"> type="text"
<p> value={downloadPending || downloadPendingSchedule?.schedule}
Become a sponsor and join{' '} onChange={e => {
<a href="https://members.tubearchivist.com/" target="_blank"> setDownloadPending(e.currentTarget.value);
members.tubearchivist.com }}
</a>{' '} />
to get access to <span className="settings-current">real time</span> notifications <Button
for new videos uploaded by your favorite channels. label="Save"
</p> onClick={async () => {
<p> await createTaskSchedule('download_pending', {
Current rescan schedule:{' '} schedule: downloadPending,
<span className="settings-current"> });
{update_subscribed && (
<> setDownloadPending('');
{update_subscribed.crontab.minute} {update_subscribed.crontab.hour}{' '}
{update_subscribed.crontab.day_of_week} setRefresh(true);
<Button }}
label="Delete" />
data-schedule="update_subscribed" </div>
onclick="deleteSchedule(this)" </div>
className="danger-button"
/> <div className="settings-group">
</> <h2>Refresh Metadata</h2>
)} <div className="settings-item">
<p>
{!update_subscribed && 'False'} Current Metadata refresh schedule:{' '}
</span> <span className="settings-current">
</p> {!checkReindexSchedule && 'False'}
<p>Periodically rescan your subscriptions:</p> {checkReindexSchedule && (
{scheduler_form.update_subscribed.errors.map(error => { <>
return ( {checkReindexSchedule?.schedule}{' '}
<p key={error} className="danger-zone"> <Button
{error} label="Delete"
</p> className="danger-button"
); onClick={async () => {
})} await deleteTaskSchedule('check_reindex');
<input type="text" name="update_subscribed" id="id_update_subscribed" /> setRefresh(true);
</div> }}
</div> />
<div className="settings-group"> </>
<h2>Start Download</h2> )}
<div className="settings-item"> </span>
<p> </p>
Current Download schedule:{' '} <p>Daily schedule to refresh metadata from YouTube:</p>
<span className="settings-current">
{download_pending && ( <input
<> type="text"
{download_pending.crontab.minute} {download_pending.crontab.hour}{' '} value={checkReindex || checkReindexSchedule?.schedule}
{download_pending.crontab.day_of_week} onChange={e => {
<Button setCheckReindex(e.currentTarget.value);
label="Delete" }}
data-schedule="download_pending" />
onclick="deleteSchedule(this)" <Button
className="danger-button" label="Save"
/> onClick={async () => {
</> await createTaskSchedule('check_reindex', {
)} schedule: checkReindex,
});
{!download_pending && 'False'}
</span> setCheckReindex('');
</p>
<p>Automatic video download schedule:</p> setRefresh(true);
{scheduler_form.download_pending.errors.map(error => { }}
return ( />
<p key={error} className="danger-zone"> </div>
{error} <div className="settings-item">
</p> <p>
); Current refresh for metadata older than x days:{' '}
})} <span className="settings-current">{checkReindexSchedule?.config?.days}</span>
</p>
<input type="text" name="download_pending" id="id_download_pending" /> <p>Refresh older than x days, recommended 90:</p>
</div>
</div> <input
<div className="settings-group"> type="number"
<h2>Refresh Metadata</h2> value={checkReindexDays || checkReindexSchedule?.config?.days}
<div className="settings-item"> onChange={e => {
<p> setCheckReindexDays(Number(e.currentTarget.value));
Current Metadata refresh schedule:{' '} }}
<span className="settings-current"> />
{check_reindex && ( <Button
<> label="Save"
{check_reindex.crontab.minute} {check_reindex.crontab.hour}{' '} onClick={async () => {
{check_reindex.crontab.day_of_week} await createTaskSchedule('check_reindex', {
<Button config: {
label="Delete" days: checkReindexDays,
data-schedule="check_reindex" },
onclick="deleteSchedule(this)" });
className="danger-button"
/> setCheckReindexDays(undefined);
</>
)} setRefresh(true);
}}
{!check_reindex && 'False'} />
</span> </div>
</p> </div>
<p>Daily schedule to refresh metadata from YouTube:</p>
<div className="settings-group">
<input type="text" name="check_reindex" id="id_check_reindex" /> <h2>Thumbnail Check</h2>
</div> <div className="settings-item">
<div className="settings-item"> <p>
<p> Current thumbnail check schedule:{' '}
Current refresh for metadata older than x days:{' '} <span className="settings-current">
<span className="settings-current">{check_reindex.task_config.days}</span> {!thumbnailCheckSchedule && 'False'}
</p> {thumbnailCheckSchedule && (
<p>Refresh older than x days, recommended 90:</p> <>
{scheduler_form.check_reindex.errors.map(error => { {thumbnailCheckSchedule?.schedule}{' '}
return ( <Button
<p key={error} className="danger-zone"> label="Delete"
{error} className="danger-button"
</p> onClick={async () => {
); await deleteTaskSchedule('thumbnail_check');
})}
setRefresh(true);
<input type="number" name="check_reindex_days" id="id_check_reindex_days" /> }}
</div> />
</div> </>
<div className="settings-group"> )}
<h2>Thumbnail Check</h2> </span>
<div className="settings-item"> </p>
<p> <p>Periodically check and cleanup thumbnails:</p>
Current thumbnail check schedule:{' '}
<span className="settings-current"> <input
{thumbnail_check && ( type="text"
<> value={thumbnailCheck || thumbnailCheckSchedule?.schedule}
{thumbnail_check.crontab.minute} {thumbnail_check.crontab.hour}{' '} onChange={e => {
{thumbnail_check.crontab.day_of_week} setThumbnailCheck(e.currentTarget.value);
<Button }}
label="Delete" />
data-schedule="thumbnail_check" <Button
onclick="deleteSchedule(this)" label="Save"
className="danger-button" onClick={async () => {
/> await createTaskSchedule('thumbnail_check', {
</> schedule: thumbnailCheck,
)} });
{!thumbnail_check && 'False'} setThumbnailCheck('');
</span>
</p> setRefresh(true);
<p>Periodically check and cleanup thumbnails:</p> }}
{scheduler_form.thumbnail_check.errors.map(error => { />
return ( </div>
<p key={error} className="danger-zone"> </div>
{error} <div className="settings-group">
</p> <h2>ZIP file index backup</h2>
); <div className="settings-item">
})} <p>
<i>
<input type="text" name="thumbnail_check" id="id_thumbnail_check" /> Zip file backups are very slow for large archives and consistency is not guaranteed,
</div> use snapshots instead. Make sure no other tasks are running when creating a Zip file
</div> backup.
<div className="settings-group"> </i>
<h2>ZIP file index backup</h2> </p>
<div className="settings-item"> <p>
<p> Current index backup schedule:{' '}
<i> <span className="settings-current">
Zip file backups are very slow for large archives and consistency is not {!runBackup && 'False'}
guaranteed, use snapshots instead. Make sure no other tasks are running when {runBackup && (
creating a Zip file backup. <>
</i> {runBackup.schedule}{' '}
</p> <Button
<p> label="Delete"
Current index backup schedule:{' '} className="danger-button"
<span className="settings-current"> onClick={async () => {
{run_backup && ( await deleteTaskSchedule('run_backup');
<>
{run_backup.crontab.minute} {run_backup.crontab.hour}{' '} setRefresh(true);
{run_backup.crontab.day_of_week} }}
<Button />
label="Delete" </>
data-schedule="run_backup" )}
onclick="deleteSchedule(this)" </span>
className="danger-button" </p>
/> <p>Automatically backup metadata to a zip file:</p>
</>
)} <input
type="text"
{!run_backup && 'False'} value={zipBackup || runBackup?.schedule}
</span> onChange={e => {
</p> setZipBackup(e.currentTarget.value);
<p>Automatically backup metadata to a zip file:</p> }}
{scheduler_form.run_backup.errors.map(error => { />
return ( <Button
<p key={error} className="danger-zone"> label="Save"
{error} onClick={async () => {
</p> await createTaskSchedule('run_backup', {
); schedule: zipBackup,
})} });
<input type="text" name="run_backup" id="id_run_backup" /> setZipBackup('');
</div>
<div className="settings-item"> setRefresh(true);
<p> }}
Current backup files to keep:{' '} />
<span className="settings-current">{run_backup.task_config.rotate}</span> </div>
</p> <div className="settings-item">
<p>Max auto backups to keep:</p> <p>
Current backup files to keep:{' '}
<input type="number" name="run_backup_rotate" id="id_run_backup_rotate" /> <span className="settings-current">{runBackup?.config?.rotate}</span>
</div> </p>
</div> <p>Max auto backups to keep:</p>
<div className="settings-group">
<h2>Add Notification URL</h2> <input
<div className="settings-item"> type="number"
{notifications && ( value={(zipBackupDays || runBackup?.config?.rotate)?.toString()}
<> onChange={e => {
<p> setZipBackupDays(Number(e.currentTarget.value));
<Button }}
label="Show" />
type="button" <Button
onclick="textReveal(this)" label="Save"
id="text-reveal-button" onClick={async () => {
/>{' '} await createTaskSchedule('run_backup', {
stored notification links config: {
</p> rotate: zipBackupDays,
<div id="text-reveal" className="description-text"> },
{notifications.items.map(({ task, notification }) => { });
return (
<> setZipBackupDays(undefined);
<h3 key={task}>{notification.title}</h3>
{notification.urls.map((url: string) => { setRefresh(true);
return ( }}
<p> />
<Button </div>
type="button" </div>
className="danger-button" <div className="settings-group">
label="Delete" <h2>Add Notification URL</h2>
data-url={url} <div className="settings-item">
data-task={task} {!appriseNotification && <p>No notifications stored</p>}
onclick="deleteNotificationUrl(this)" {appriseNotification && (
/> <>
<span> {url}</span> <div className="description-text">
</p> {Object.entries(appriseNotification)?.map(([key, { urls, title }]) => {
); return (
})} <>
</> <h3 key={key}>{title}</h3>
); {urls.map((url: string) => {
})} return (
</div> <p>
</> <span>{url} </span>
)} <Button
type="button"
{!notifications && <p>No notifications stored</p>} className="danger-button"
</div> label="Delete"
<div className="settings-item"> onClick={async () => {
<p> await deleteAppriseNotificationUrl(key as AppriseTaskNameType);
<i>
Send notification on completed tasks with the help of the{' '} setRefresh(true);
<a href="https://github.com/caronc/apprise" target="_blank"> }}
Apprise />
</a>{' '} </p>
library. );
</i> })}
</p> </>
<select name="task" id="id_task" defaultValue=""> );
<option value="">-- select task --</option> })}
<option value="update_subscribed">Rescan your Subscriptions</option> </div>
<option value="extract_download">Add to download queue</option> </>
<option value="download_pending">Downloading</option> )}
<option value="check_reindex">Reindex Documents</option> </div>
</select> <div className="settings-item">
<p>
<input <i>
type="text" Send notification on completed tasks with the help of the{' '}
name="notification_url" <a href="https://github.com/caronc/apprise" target="_blank">
placeholder="Apprise notification URL" Apprise
id="id_notification_url" </a>{' '}
/> library.
</div> </i>
</div> </p>
<select
<Button type="submit" name="scheduler-settings" label="Update Scheduler Settings" /> defaultValue=""
</form> value={notificationTask}
</div> onChange={e => {
</> setNotificationTask(e.currentTarget.value);
); }}
}; >
<option value="">-- select task --</option>
export default SettingsScheduling; <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 { useLoaderData, useNavigate, useOutletContext } from 'react-router-dom';
import updateUserConfig, { UserConfigType, UserMeType } from '../api/actions/updateUserConfig'; import updateUserConfig, { UserConfigType, UserMeType } from '../api/actions/updateUserConfig';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import loadUserMeConfig from '../api/loader/loadUserConfig'; import loadUserMeConfig from '../api/loader/loadUserConfig';
import { ColourConstant, ColourVariants } from '../configuration/colours/getColours'; import { ColourConstant, ColourVariants } from '../configuration/colours/getColours';
import SettingsNavigation from '../components/SettingsNavigation'; import SettingsNavigation from '../components/SettingsNavigation';
import Notifications from '../components/Notifications'; import Notifications from '../components/Notifications';
import { Helmet } from 'react-helmet'; import Button from '../components/Button';
import Button from '../components/Button'; import { OutletContextType } from './Base';
import { OutletContextType } from './Base';
type SettingsUserLoaderData = {
type SettingsUserLoaderData = { userConfig: UserMeType;
userConfig: UserMeType; };
};
const SettingsUser = () => {
const SettingsUser = () => { const { isAdmin } = useOutletContext() as OutletContextType;
const { isAdmin } = useOutletContext() as OutletContextType; const { userConfig } = useLoaderData() as SettingsUserLoaderData;
const { userConfig } = useLoaderData() as SettingsUserLoaderData; const navigate = useNavigate();
const navigate = useNavigate();
const userMeConfig = userConfig.config;
const userMeConfig = userConfig.config; const { stylesheet, page_size } = userMeConfig;
const { stylesheet, page_size } = userMeConfig;
const [selectedStylesheet, setSelectedStylesheet] = useState(userMeConfig.stylesheet);
const [selectedStylesheet, setSelectedStylesheet] = useState(userMeConfig.stylesheet); const [selectedPageSize, setSelectedPageSize] = useState(userMeConfig.page_size);
const [selectedPageSize, setSelectedPageSize] = useState(userMeConfig.page_size); const [refresh, setRefresh] = useState(false);
const [refresh, setRefresh] = useState(false);
const [userConfigResponse, setUserConfigResponse] = useState<UserConfigType>();
const [userConfigResponse, setUserConfigResponse] = useState<UserConfigType>();
const stylesheetOverwritable =
const stylesheetOverwritable = userConfigResponse?.stylesheet || stylesheet || (ColourConstant.Dark as ColourVariants);
userConfigResponse?.stylesheet || stylesheet || (ColourConstant.Dark as ColourVariants); const pageSizeOverwritable = userConfigResponse?.page_size || page_size || 12;
const pageSizeOverwritable = userConfigResponse?.page_size || page_size || 12;
useEffect(() => {
useEffect(() => { (async () => {
(async () => { if (refresh) {
if (refresh) { const userConfigResponse = await loadUserMeConfig();
const userConfigResponse = await loadUserMeConfig();
setUserConfigResponse(userConfigResponse.config);
setUserConfigResponse(userConfigResponse.config); setRefresh(false);
setRefresh(false); navigate(0);
navigate(0); }
} })();
})(); }, [navigate, refresh]);
}, [navigate, refresh]);
return (
return ( <>
<> <title>TA | User Settings</title>
<Helmet> <div className="boxed-content">
<title>TA | User Settings</title> <SettingsNavigation />
</Helmet> <Notifications pageName={'all'} />
<div className="boxed-content">
<SettingsNavigation /> <div className="title-bar">
<Notifications pageName={'all'} /> <h1>User Configurations</h1>
</div>
<div className="title-bar"> <div>
<h1>User Configurations</h1> <div className="settings-group">
</div> <h2>Stylesheet</h2>
<div> <div className="settings-item">
<div className="settings-group"> <p>
<h2>Stylesheet</h2> Current stylesheet:{' '}
<div className="settings-item"> <span className="settings-current">{stylesheetOverwritable}</span>
<p> </p>
Current stylesheet:{' '} <i>Select your preferred stylesheet.</i>
<span className="settings-current">{stylesheetOverwritable}</span> <br />
</p> <select
<i>Select your preferred stylesheet.</i> name="stylesheet"
<br /> id="id_stylesheet"
<select value={selectedStylesheet}
name="stylesheet" onChange={event => {
id="id_stylesheet" setSelectedStylesheet(event.target.value as ColourVariants);
value={selectedStylesheet} }}
onChange={event => { >
setSelectedStylesheet(event.target.value as ColourVariants); <option value="">-- change stylesheet --</option>
}} {Object.entries(ColourConstant).map(([key, value]) => {
> return (
<option value="">-- change stylesheet --</option> <option key={key} value={value}>
{Object.entries(ColourConstant).map(([key, value]) => { {key}
return ( </option>
<option key={key} value={value}> );
{key} })}
</option> </select>
); </div>
})} </div>
</select> <div className="settings-group">
</div> <h2>Archive View</h2>
</div> <div className="settings-item">
<div className="settings-group"> <p>
<h2>Archive View</h2> Current page size: <span className="settings-current">{pageSizeOverwritable}</span>
<div className="settings-item"> </p>
<p> <i>Result of videos showing in archive page</i>
Current page size: <span className="settings-current">{pageSizeOverwritable}</span> <br />
</p>
<i>Result of videos showing in archive page</i> <input
<br /> type="number"
name="page_size"
<input id="id_page_size"
type="number" value={selectedPageSize}
name="page_size" onChange={event => {
id="id_page_size" setSelectedPageSize(Number(event.target.value));
value={selectedPageSize} }}
onChange={event => { ></input>
setSelectedPageSize(Number(event.target.value)); </div>
}} </div>
></input> <Button
</div> name="user-settings"
</div> label="Update User Configurations"
<Button onClick={async () => {
name="user-settings" await updateUserConfig({
label="Update User Configurations" page_size: selectedPageSize,
onClick={async () => { stylesheet: selectedStylesheet,
await updateUserConfig({ });
page_size: selectedPageSize,
stylesheet: selectedStylesheet, setRefresh(true);
}); }}
/>
setRefresh(true); </div>
}}
/> {isAdmin && (
</div> <>
<div className="title-bar">
{isAdmin && ( <h1>Users</h1>
<> </div>
<div className="title-bar"> <div className="settings-group">
<h1>Users</h1> <h2>User Management</h2>
</div> <p>
<div className="settings-group"> Access the admin interface for basic user management functionality like adding and
<h2>User Management</h2> deleting users, changing passwords and more.
<p> </p>
Access the admin interface for basic user management functionality like adding and <a href="/admin/">
deleting users, changing passwords and more. <Button label="Admin Interface" />
</p> </a>
<a href="/admin/"> </div>
<Button label="Admin Interface" /> </>
</a> )}
</div> </div>
</> </>
)} );
</div> };
</>
); export default SettingsUser;
};
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; display: flex;
justify-content: space-between; justify-content: space-between;
} }
/** loading indicator */
/** source: https://github.com/loadingio/css-spinner/ */
.lds-ring,
.lds-ring div {
box-sizing: border-box;
}
.lds-ring {
display: inline-block;
position: relative;
width: 20px;
height: 20px;
margin-right: 10px;
}
.lds-ring div {
box-sizing: border-box;
display: block;
position: absolute;
width: 20px;
height: 20px;
border: 4px solid currentColor;
border-radius: 50%;
animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
border-color: currentColor transparent transparent transparent;
}
.lds-ring div:nth-child(1) {
animation-delay: -0.45s;
}
.lds-ring div:nth-child(2) {
animation-delay: -0.3s;
}
.lds-ring div:nth-child(3) {
animation-delay: -0.15s;
}
@keyframes lds-ring {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

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