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:
parent
75339e479e
commit
5a5d47da9b
@ -1,269 +1,261 @@
|
||||
"""
|
||||
Loose collection of helper functions
|
||||
- don't import AppConfig class here to avoid circular imports
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from common.src.env_settings import EnvironmentSettings
|
||||
from common.src.es_connect import IndexPaginate
|
||||
|
||||
|
||||
def ignore_filelist(filelist: list[str]) -> list[str]:
|
||||
"""ignore temp files for os.listdir sanitizer"""
|
||||
to_ignore = [
|
||||
"@eaDir",
|
||||
"Icon\r\r",
|
||||
"Network Trash Folder",
|
||||
"Temporary Items",
|
||||
]
|
||||
cleaned: list[str] = []
|
||||
for file_name in filelist:
|
||||
if file_name.startswith(".") or file_name in to_ignore:
|
||||
continue
|
||||
|
||||
cleaned.append(file_name)
|
||||
|
||||
return cleaned
|
||||
|
||||
|
||||
def randomizor(length: int) -> str:
|
||||
"""generate random alpha numeric string"""
|
||||
pool: str = string.digits + string.ascii_letters
|
||||
return "".join(random.choice(pool) for i in range(length))
|
||||
|
||||
|
||||
def requests_headers() -> dict[str, str]:
|
||||
"""build header with random user agent for requests outside of yt-dlp"""
|
||||
|
||||
chrome_versions = (
|
||||
"90.0.4430.212",
|
||||
"90.0.4430.24",
|
||||
"90.0.4430.70",
|
||||
"90.0.4430.72",
|
||||
"90.0.4430.85",
|
||||
"90.0.4430.93",
|
||||
"91.0.4472.101",
|
||||
"91.0.4472.106",
|
||||
"91.0.4472.114",
|
||||
"91.0.4472.124",
|
||||
"91.0.4472.164",
|
||||
"91.0.4472.19",
|
||||
"91.0.4472.77",
|
||||
"92.0.4515.107",
|
||||
"92.0.4515.115",
|
||||
"92.0.4515.131",
|
||||
"92.0.4515.159",
|
||||
"92.0.4515.43",
|
||||
"93.0.4556.0",
|
||||
"93.0.4577.15",
|
||||
"93.0.4577.63",
|
||||
"93.0.4577.82",
|
||||
"94.0.4606.41",
|
||||
"94.0.4606.54",
|
||||
"94.0.4606.61",
|
||||
"94.0.4606.71",
|
||||
"94.0.4606.81",
|
||||
"94.0.4606.85",
|
||||
"95.0.4638.17",
|
||||
"95.0.4638.50",
|
||||
"95.0.4638.54",
|
||||
"95.0.4638.69",
|
||||
"95.0.4638.74",
|
||||
"96.0.4664.18",
|
||||
"96.0.4664.45",
|
||||
"96.0.4664.55",
|
||||
"96.0.4664.93",
|
||||
"97.0.4692.20",
|
||||
)
|
||||
template = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
+ f"Chrome/{random.choice(chrome_versions)} Safari/537.36"
|
||||
)
|
||||
|
||||
return {"User-Agent": template}
|
||||
|
||||
|
||||
def date_parser(timestamp: int | str) -> str:
|
||||
"""return formatted date string"""
|
||||
if isinstance(timestamp, int):
|
||||
date_obj = datetime.fromtimestamp(timestamp)
|
||||
elif isinstance(timestamp, str):
|
||||
date_obj = datetime.strptime(timestamp, "%Y-%m-%d")
|
||||
else:
|
||||
raise TypeError(f"invalid timestamp: {timestamp}")
|
||||
|
||||
return date_obj.date().isoformat()
|
||||
|
||||
|
||||
def time_parser(timestamp: str) -> float:
|
||||
"""return seconds from timestamp, false on empty"""
|
||||
if not timestamp:
|
||||
return False
|
||||
|
||||
if timestamp.isnumeric():
|
||||
return int(timestamp)
|
||||
|
||||
hours, minutes, seconds = timestamp.split(":", maxsplit=3)
|
||||
return int(hours) * 60 * 60 + int(minutes) * 60 + float(seconds)
|
||||
|
||||
|
||||
def clear_dl_cache(cache_dir: str) -> int:
|
||||
"""clear leftover files from dl cache"""
|
||||
print("clear download cache")
|
||||
download_cache_dir = os.path.join(cache_dir, "download")
|
||||
leftover_files = ignore_filelist(os.listdir(download_cache_dir))
|
||||
for cached in leftover_files:
|
||||
to_delete = os.path.join(download_cache_dir, cached)
|
||||
os.remove(to_delete)
|
||||
|
||||
return len(leftover_files)
|
||||
|
||||
|
||||
def get_mapping() -> dict:
|
||||
"""read index_mapping.json and get expected mapping and settings"""
|
||||
with open("appsettings/index_mapping.json", "r", encoding="utf-8") as f:
|
||||
index_config: dict = json.load(f).get("index_config")
|
||||
|
||||
return index_config
|
||||
|
||||
|
||||
def is_shorts(youtube_id: str) -> bool:
|
||||
"""check if youtube_id is a shorts video, bot not it it's not a shorts"""
|
||||
shorts_url = f"https://www.youtube.com/shorts/{youtube_id}"
|
||||
cookies = {"SOCS": "CAI"}
|
||||
response = requests.head(
|
||||
shorts_url, cookies=cookies, headers=requests_headers(), timeout=10
|
||||
)
|
||||
|
||||
return response.status_code == 200
|
||||
|
||||
|
||||
def get_duration_sec(file_path: str) -> int:
|
||||
"""get duration of media file from file path"""
|
||||
|
||||
duration = subprocess.run(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
file_path,
|
||||
],
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
duration_raw = duration.stdout.decode().strip()
|
||||
if duration_raw == "N/A":
|
||||
return 0
|
||||
|
||||
duration_sec = int(float(duration_raw))
|
||||
return duration_sec
|
||||
|
||||
|
||||
def get_duration_str(seconds: int) -> str:
|
||||
"""Return a human-readable duration string from seconds."""
|
||||
if not seconds:
|
||||
return "NA"
|
||||
|
||||
units = [("y", 31536000), ("d", 86400), ("h", 3600), ("m", 60), ("s", 1)]
|
||||
duration_parts = []
|
||||
|
||||
for unit_label, unit_seconds in units:
|
||||
if seconds >= unit_seconds:
|
||||
unit_count, seconds = divmod(seconds, unit_seconds)
|
||||
duration_parts.append(f"{unit_count:02}{unit_label}")
|
||||
|
||||
duration_parts[0] = duration_parts[0].lstrip("0")
|
||||
|
||||
return " ".join(duration_parts)
|
||||
|
||||
|
||||
def ta_host_parser(ta_host: str) -> tuple[list[str], list[str]]:
|
||||
"""parse ta_host env var for ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS"""
|
||||
allowed_hosts: list[str] = [
|
||||
"localhost",
|
||||
"tubearchivist",
|
||||
]
|
||||
csrf_trusted_origins: list[str] = [
|
||||
"http://localhost",
|
||||
"http://tubearchivist",
|
||||
]
|
||||
for host in ta_host.split():
|
||||
host_clean = host.strip()
|
||||
if not host_clean.startswith("http"):
|
||||
host_clean = f"http://{host_clean}"
|
||||
|
||||
parsed = urlparse(host_clean)
|
||||
allowed_hosts.append(f"{parsed.hostname}")
|
||||
csrf_trusted_origins.append(f"{parsed.scheme}://{parsed.hostname}")
|
||||
|
||||
return allowed_hosts, csrf_trusted_origins
|
||||
|
||||
|
||||
def get_stylesheets() -> list:
|
||||
"""Get all valid stylesheets from /static/css"""
|
||||
app_root = EnvironmentSettings.APP_DIR
|
||||
try:
|
||||
stylesheets = os.listdir(os.path.join(app_root, "static/css"))
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
|
||||
stylesheets.remove("style.css")
|
||||
stylesheets.sort()
|
||||
stylesheets = list(filter(lambda x: x.endswith(".css"), stylesheets))
|
||||
return stylesheets
|
||||
|
||||
|
||||
def check_stylesheet(stylesheet: str):
|
||||
"""Check if a stylesheet exists. Return dark.css as a fallback"""
|
||||
if stylesheet in get_stylesheets():
|
||||
return stylesheet
|
||||
|
||||
return "dark.css"
|
||||
|
||||
|
||||
def is_missing(
|
||||
to_check: str | list[str],
|
||||
index_name: str = "ta_video,ta_download",
|
||||
on_key: str = "youtube_id",
|
||||
) -> list[str]:
|
||||
"""id or list of ids that are missing from index_name"""
|
||||
if isinstance(to_check, str):
|
||||
to_check = [to_check]
|
||||
|
||||
data = {
|
||||
"query": {"terms": {on_key: to_check}},
|
||||
"_source": [on_key],
|
||||
}
|
||||
result = IndexPaginate(index_name, data=data).get_results()
|
||||
existing_ids = [i[on_key] for i in result]
|
||||
dl = [i for i in to_check if i not in existing_ids]
|
||||
|
||||
return dl
|
||||
|
||||
|
||||
def get_channel_overwrites() -> dict[str, dict[str, Any]]:
|
||||
"""get overwrites indexed my channel_id"""
|
||||
data = {
|
||||
"query": {
|
||||
"bool": {"must": [{"exists": {"field": "channel_overwrites"}}]}
|
||||
},
|
||||
"_source": ["channel_id", "channel_overwrites"],
|
||||
}
|
||||
result = IndexPaginate("ta_channel", data).get_results()
|
||||
overwrites = {i["channel_id"]: i["channel_overwrites"] for i in result}
|
||||
|
||||
return overwrites
|
||||
"""
|
||||
Loose collection of helper functions
|
||||
- don't import AppConfig class here to avoid circular imports
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from common.src.es_connect import IndexPaginate
|
||||
|
||||
|
||||
def ignore_filelist(filelist: list[str]) -> list[str]:
|
||||
"""ignore temp files for os.listdir sanitizer"""
|
||||
to_ignore = [
|
||||
"@eaDir",
|
||||
"Icon\r\r",
|
||||
"Network Trash Folder",
|
||||
"Temporary Items",
|
||||
]
|
||||
cleaned: list[str] = []
|
||||
for file_name in filelist:
|
||||
if file_name.startswith(".") or file_name in to_ignore:
|
||||
continue
|
||||
|
||||
cleaned.append(file_name)
|
||||
|
||||
return cleaned
|
||||
|
||||
|
||||
def randomizor(length: int) -> str:
|
||||
"""generate random alpha numeric string"""
|
||||
pool: str = string.digits + string.ascii_letters
|
||||
return "".join(random.choice(pool) for i in range(length))
|
||||
|
||||
|
||||
def requests_headers() -> dict[str, str]:
|
||||
"""build header with random user agent for requests outside of yt-dlp"""
|
||||
|
||||
chrome_versions = (
|
||||
"90.0.4430.212",
|
||||
"90.0.4430.24",
|
||||
"90.0.4430.70",
|
||||
"90.0.4430.72",
|
||||
"90.0.4430.85",
|
||||
"90.0.4430.93",
|
||||
"91.0.4472.101",
|
||||
"91.0.4472.106",
|
||||
"91.0.4472.114",
|
||||
"91.0.4472.124",
|
||||
"91.0.4472.164",
|
||||
"91.0.4472.19",
|
||||
"91.0.4472.77",
|
||||
"92.0.4515.107",
|
||||
"92.0.4515.115",
|
||||
"92.0.4515.131",
|
||||
"92.0.4515.159",
|
||||
"92.0.4515.43",
|
||||
"93.0.4556.0",
|
||||
"93.0.4577.15",
|
||||
"93.0.4577.63",
|
||||
"93.0.4577.82",
|
||||
"94.0.4606.41",
|
||||
"94.0.4606.54",
|
||||
"94.0.4606.61",
|
||||
"94.0.4606.71",
|
||||
"94.0.4606.81",
|
||||
"94.0.4606.85",
|
||||
"95.0.4638.17",
|
||||
"95.0.4638.50",
|
||||
"95.0.4638.54",
|
||||
"95.0.4638.69",
|
||||
"95.0.4638.74",
|
||||
"96.0.4664.18",
|
||||
"96.0.4664.45",
|
||||
"96.0.4664.55",
|
||||
"96.0.4664.93",
|
||||
"97.0.4692.20",
|
||||
)
|
||||
template = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
+ f"Chrome/{random.choice(chrome_versions)} Safari/537.36"
|
||||
)
|
||||
|
||||
return {"User-Agent": template}
|
||||
|
||||
|
||||
def date_parser(timestamp: int | str) -> str:
|
||||
"""return formatted date string"""
|
||||
if isinstance(timestamp, int):
|
||||
date_obj = datetime.fromtimestamp(timestamp)
|
||||
elif isinstance(timestamp, str):
|
||||
date_obj = datetime.strptime(timestamp, "%Y-%m-%d")
|
||||
else:
|
||||
raise TypeError(f"invalid timestamp: {timestamp}")
|
||||
|
||||
return date_obj.date().isoformat()
|
||||
|
||||
|
||||
def time_parser(timestamp: str) -> float:
|
||||
"""return seconds from timestamp, false on empty"""
|
||||
if not timestamp:
|
||||
return False
|
||||
|
||||
if timestamp.isnumeric():
|
||||
return int(timestamp)
|
||||
|
||||
hours, minutes, seconds = timestamp.split(":", maxsplit=3)
|
||||
return int(hours) * 60 * 60 + int(minutes) * 60 + float(seconds)
|
||||
|
||||
|
||||
def clear_dl_cache(cache_dir: str) -> int:
|
||||
"""clear leftover files from dl cache"""
|
||||
print("clear download cache")
|
||||
download_cache_dir = os.path.join(cache_dir, "download")
|
||||
leftover_files = ignore_filelist(os.listdir(download_cache_dir))
|
||||
for cached in leftover_files:
|
||||
to_delete = os.path.join(download_cache_dir, cached)
|
||||
os.remove(to_delete)
|
||||
|
||||
return len(leftover_files)
|
||||
|
||||
|
||||
def get_mapping() -> dict:
|
||||
"""read index_mapping.json and get expected mapping and settings"""
|
||||
with open("appsettings/index_mapping.json", "r", encoding="utf-8") as f:
|
||||
index_config: dict = json.load(f).get("index_config")
|
||||
|
||||
return index_config
|
||||
|
||||
|
||||
def is_shorts(youtube_id: str) -> bool:
|
||||
"""check if youtube_id is a shorts video, bot not it it's not a shorts"""
|
||||
shorts_url = f"https://www.youtube.com/shorts/{youtube_id}"
|
||||
cookies = {"SOCS": "CAI"}
|
||||
response = requests.head(
|
||||
shorts_url, cookies=cookies, headers=requests_headers(), timeout=10
|
||||
)
|
||||
|
||||
return response.status_code == 200
|
||||
|
||||
|
||||
def get_duration_sec(file_path: str) -> int:
|
||||
"""get duration of media file from file path"""
|
||||
|
||||
duration = subprocess.run(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
file_path,
|
||||
],
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
duration_raw = duration.stdout.decode().strip()
|
||||
if duration_raw == "N/A":
|
||||
return 0
|
||||
|
||||
duration_sec = int(float(duration_raw))
|
||||
return duration_sec
|
||||
|
||||
|
||||
def get_duration_str(seconds: int) -> str:
|
||||
"""Return a human-readable duration string from seconds."""
|
||||
if not seconds:
|
||||
return "NA"
|
||||
|
||||
units = [("y", 31536000), ("d", 86400), ("h", 3600), ("m", 60), ("s", 1)]
|
||||
duration_parts = []
|
||||
|
||||
for unit_label, unit_seconds in units:
|
||||
if seconds >= unit_seconds:
|
||||
unit_count, seconds = divmod(seconds, unit_seconds)
|
||||
duration_parts.append(f"{unit_count:02}{unit_label}")
|
||||
|
||||
duration_parts[0] = duration_parts[0].lstrip("0")
|
||||
|
||||
return " ".join(duration_parts)
|
||||
|
||||
|
||||
def ta_host_parser(ta_host: str) -> tuple[list[str], list[str]]:
|
||||
"""parse ta_host env var for ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS"""
|
||||
allowed_hosts: list[str] = [
|
||||
"localhost",
|
||||
"tubearchivist",
|
||||
]
|
||||
csrf_trusted_origins: list[str] = [
|
||||
"http://localhost",
|
||||
"http://tubearchivist",
|
||||
]
|
||||
for host in ta_host.split():
|
||||
host_clean = host.strip()
|
||||
if not host_clean.startswith("http"):
|
||||
host_clean = f"http://{host_clean}"
|
||||
|
||||
parsed = urlparse(host_clean)
|
||||
allowed_hosts.append(f"{parsed.hostname}")
|
||||
csrf_trusted_origins.append(f"{parsed.scheme}://{parsed.hostname}")
|
||||
|
||||
return allowed_hosts, csrf_trusted_origins
|
||||
|
||||
|
||||
def get_stylesheets() -> list:
|
||||
"""Get all valid stylesheets from /static/css"""
|
||||
|
||||
stylesheets = ["dark.css", "light.css", "matrix.css", "midnight.css"]
|
||||
return stylesheets
|
||||
|
||||
|
||||
def check_stylesheet(stylesheet: str):
|
||||
"""Check if a stylesheet exists. Return dark.css as a fallback"""
|
||||
if stylesheet in get_stylesheets():
|
||||
return stylesheet
|
||||
|
||||
return "dark.css"
|
||||
|
||||
|
||||
def is_missing(
|
||||
to_check: str | list[str],
|
||||
index_name: str = "ta_video,ta_download",
|
||||
on_key: str = "youtube_id",
|
||||
) -> list[str]:
|
||||
"""id or list of ids that are missing from index_name"""
|
||||
if isinstance(to_check, str):
|
||||
to_check = [to_check]
|
||||
|
||||
data = {
|
||||
"query": {"terms": {on_key: to_check}},
|
||||
"_source": [on_key],
|
||||
}
|
||||
result = IndexPaginate(index_name, data=data).get_results()
|
||||
existing_ids = [i[on_key] for i in result]
|
||||
dl = [i for i in to_check if i not in existing_ids]
|
||||
|
||||
return dl
|
||||
|
||||
|
||||
def get_channel_overwrites() -> dict[str, dict[str, Any]]:
|
||||
"""get overwrites indexed my channel_id"""
|
||||
data = {
|
||||
"query": {
|
||||
"bool": {"must": [{"exists": {"field": "channel_overwrites"}}]}
|
||||
},
|
||||
"_source": ["channel_id", "channel_overwrites"],
|
||||
}
|
||||
result = IndexPaginate("ta_channel", data).get_results()
|
||||
overwrites = {i["channel_id"]: i["channel_overwrites"] for i in result}
|
||||
|
||||
return overwrites
|
||||
|
@ -1,26 +1,26 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png" />
|
||||
<link rel="manifest" href="/favicon/site.webmanifest" />
|
||||
<link rel="mask-icon" href="/favicon/safari-pinned-tab.svg" color="#01202e" />
|
||||
<link rel="shortcut icon" href="/favicon/favicon.ico" />
|
||||
|
||||
<meta name="apple-mobile-web-app-title" content="TubeArchivist" />
|
||||
<meta name="application-name" content="TubeArchivist" />
|
||||
<meta name="msapplication-TileColor" content="#01202e" />
|
||||
<meta name="msapplication-config" content="/favicon/browserconfig.xml" />
|
||||
<meta name="theme-color" content="#01202e" />
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>TubeArchivist</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png" />
|
||||
<link rel="manifest" href="/favicon/site.webmanifest" />
|
||||
<link rel="mask-icon" href="/favicon/safari-pinned-tab.svg" color="#01202e" />
|
||||
<link rel="shortcut icon" href="/favicon/favicon.ico" />
|
||||
|
||||
<meta name="apple-mobile-web-app-title" content="TubeArchivist" />
|
||||
<meta name="application-name" content="TubeArchivist" />
|
||||
<meta name="msapplication-TileColor" content="#01202e" />
|
||||
<meta name="msapplication-config" content="/favicon/browserconfig.xml" />
|
||||
<meta name="theme-color" content="#01202e" />
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>TubeArchivist</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
5884
frontend/package-lock.json
generated
5884
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,35 +1,32 @@
|
||||
{
|
||||
"name": "tubearchivist-frontend",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"build:deploy": "vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"dompurify": "^3.1.6",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-router-dom": "^6.25.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-helmet": "^6.1.11",
|
||||
"@typescript-eslint/eslint-plugin": "^7.16.1",
|
||||
"@typescript-eslint/parser": "^7.16.1",
|
||||
"@vitejs/plugin-react-swc": "^3.7.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-refresh": "^0.4.8",
|
||||
"prettier": "3.3.3",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.3.4"
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "tubearchivist-frontend",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"build:deploy": "vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"dompurify": "^3.2.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.1",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.0",
|
||||
"@typescript-eslint/parser": "^8.18.0",
|
||||
"@vitejs/plugin-react-swc": "^3.7.2",
|
||||
"eslint": "^9.16.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
"prettier": "3.4.2",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
36
frontend/src/api/actions/createAppriseNotificationUrl.ts
Normal file
36
frontend/src/api/actions/createAppriseNotificationUrl.ts
Normal 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;
|
53
frontend/src/api/actions/createTaskSchedule.ts
Normal file
53
frontend/src/api/actions/createTaskSchedule.ts
Normal 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;
|
36
frontend/src/api/actions/deleteAppriseNotificationUrl.ts
Normal file
36
frontend/src/api/actions/deleteAppriseNotificationUrl.ts
Normal 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;
|
30
frontend/src/api/actions/deleteTaskSchedule.ts
Normal file
30
frontend/src/api/actions/deleteTaskSchedule.ts
Normal 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;
|
39
frontend/src/api/actions/updateChannelSettings.ts
Normal file
39
frontend/src/api/actions/updateChannelSettings.ts
Normal 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;
|
@ -1,32 +1,33 @@
|
||||
import defaultHeaders from '../../configuration/defaultHeaders';
|
||||
import getApiUrl from '../../configuration/getApiUrl';
|
||||
import getFetchCredentials from '../../configuration/getFetchCredentials';
|
||||
import getCookie from '../../functions/getCookie';
|
||||
|
||||
export type ValidatedCookieType = {
|
||||
cookie_enabled: boolean;
|
||||
status: boolean;
|
||||
validated: number;
|
||||
validated_str: string;
|
||||
};
|
||||
|
||||
const updateCookie = async (): Promise<ValidatedCookieType> => {
|
||||
const apiUrl = getApiUrl();
|
||||
const csrfCookie = getCookie('csrftoken');
|
||||
|
||||
const response = await fetch(`${apiUrl}/api/appsettings/cookie/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
'X-CSRFToken': csrfCookie || '',
|
||||
},
|
||||
credentials: getFetchCredentials(),
|
||||
});
|
||||
|
||||
const validatedCookie = await response.json();
|
||||
console.log('updateCookie', validatedCookie);
|
||||
|
||||
return validatedCookie;
|
||||
};
|
||||
|
||||
export default updateCookie;
|
||||
import defaultHeaders from '../../configuration/defaultHeaders';
|
||||
import getApiUrl from '../../configuration/getApiUrl';
|
||||
import getFetchCredentials from '../../configuration/getFetchCredentials';
|
||||
import getCookie from '../../functions/getCookie';
|
||||
|
||||
export type ValidatedCookieType = {
|
||||
cookie_enabled: boolean;
|
||||
status: boolean;
|
||||
validated: number;
|
||||
validated_str: string;
|
||||
cookie_validated?: boolean;
|
||||
};
|
||||
|
||||
const updateCookie = async (): Promise<ValidatedCookieType> => {
|
||||
const apiUrl = getApiUrl();
|
||||
const csrfCookie = getCookie('csrftoken');
|
||||
|
||||
const response = await fetch(`${apiUrl}/api/appsettings/cookie/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
'X-CSRFToken': csrfCookie || '',
|
||||
},
|
||||
credentials: getFetchCredentials(),
|
||||
});
|
||||
|
||||
const validatedCookie = await response.json();
|
||||
console.log('updateCookie', validatedCookie);
|
||||
|
||||
return validatedCookie;
|
||||
};
|
||||
|
||||
export default updateCookie;
|
||||
|
@ -1,32 +1,36 @@
|
||||
import defaultHeaders from '../../configuration/defaultHeaders';
|
||||
import getApiUrl from '../../configuration/getApiUrl';
|
||||
import getFetchCredentials from '../../configuration/getFetchCredentials';
|
||||
import getCookie from '../../functions/getCookie';
|
||||
import isDevEnvironment from '../../functions/isDevEnvironment';
|
||||
|
||||
type ApiTokenResponse = {
|
||||
token: string;
|
||||
};
|
||||
|
||||
const loadApiToken = async (): Promise<ApiTokenResponse> => {
|
||||
const apiUrl = getApiUrl();
|
||||
const csrfCookie = getCookie('csrftoken');
|
||||
|
||||
const response = await fetch(`${apiUrl}/api/appsettings/token/`, {
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
'X-CSRFToken': csrfCookie || '',
|
||||
},
|
||||
credentials: getFetchCredentials(),
|
||||
});
|
||||
|
||||
const apiToken = await response.json();
|
||||
|
||||
if (isDevEnvironment()) {
|
||||
console.log('loadApiToken', apiToken);
|
||||
}
|
||||
|
||||
return apiToken;
|
||||
};
|
||||
|
||||
export default loadApiToken;
|
||||
import defaultHeaders from '../../configuration/defaultHeaders';
|
||||
import getApiUrl from '../../configuration/getApiUrl';
|
||||
import getFetchCredentials from '../../configuration/getFetchCredentials';
|
||||
import getCookie from '../../functions/getCookie';
|
||||
import isDevEnvironment from '../../functions/isDevEnvironment';
|
||||
|
||||
type ApiTokenResponse = {
|
||||
token: string;
|
||||
};
|
||||
|
||||
const loadApiToken = async (): Promise<ApiTokenResponse> => {
|
||||
const apiUrl = getApiUrl();
|
||||
const csrfCookie = getCookie('csrftoken');
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/appsettings/token/`, {
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
'X-CSRFToken': csrfCookie || '',
|
||||
},
|
||||
credentials: getFetchCredentials(),
|
||||
});
|
||||
|
||||
const apiToken = await response.json();
|
||||
|
||||
if (isDevEnvironment()) {
|
||||
console.log('loadApiToken', apiToken);
|
||||
}
|
||||
|
||||
return apiToken;
|
||||
} catch (e) {
|
||||
return { token: '' };
|
||||
}
|
||||
};
|
||||
|
||||
export default loadApiToken;
|
||||
|
42
frontend/src/api/loader/loadAppriseNotification.ts
Normal file
42
frontend/src/api/loader/loadAppriseNotification.ts
Normal 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;
|
@ -1,54 +1,54 @@
|
||||
import defaultHeaders from '../../configuration/defaultHeaders';
|
||||
import getApiUrl from '../../configuration/getApiUrl';
|
||||
import getFetchCredentials from '../../configuration/getFetchCredentials';
|
||||
import isDevEnvironment from '../../functions/isDevEnvironment';
|
||||
|
||||
export type AppSettingsConfigType = {
|
||||
subscriptions: {
|
||||
channel_size: number;
|
||||
live_channel_size: number;
|
||||
shorts_channel_size: number;
|
||||
auto_start: boolean;
|
||||
};
|
||||
downloads: {
|
||||
limit_speed: boolean | number;
|
||||
sleep_interval: number;
|
||||
autodelete_days: boolean | number;
|
||||
format: boolean | string;
|
||||
format_sort: boolean | string;
|
||||
add_metadata: boolean;
|
||||
add_thumbnail: boolean;
|
||||
subtitle: boolean | string;
|
||||
subtitle_source: boolean | string;
|
||||
subtitle_index: boolean;
|
||||
comment_max: boolean | number;
|
||||
comment_sort: string;
|
||||
cookie_import: boolean;
|
||||
throttledratelimit: boolean | number;
|
||||
extractor_lang: boolean | string;
|
||||
integrate_ryd: boolean;
|
||||
integrate_sponsorblock: boolean;
|
||||
};
|
||||
application: {
|
||||
enable_snapshot: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
const loadAppsettingsConfig = async (): Promise<AppSettingsConfigType> => {
|
||||
const apiUrl = getApiUrl();
|
||||
|
||||
const response = await fetch(`${apiUrl}/api/appsettings/config/`, {
|
||||
headers: defaultHeaders,
|
||||
credentials: getFetchCredentials(),
|
||||
});
|
||||
|
||||
const appSettingsConfig = await response.json();
|
||||
|
||||
if (isDevEnvironment()) {
|
||||
console.log('loadApplicationConfig', appSettingsConfig);
|
||||
}
|
||||
|
||||
return appSettingsConfig;
|
||||
};
|
||||
|
||||
export default loadAppsettingsConfig;
|
||||
import defaultHeaders from '../../configuration/defaultHeaders';
|
||||
import getApiUrl from '../../configuration/getApiUrl';
|
||||
import getFetchCredentials from '../../configuration/getFetchCredentials';
|
||||
import isDevEnvironment from '../../functions/isDevEnvironment';
|
||||
|
||||
export type AppSettingsConfigType = {
|
||||
subscriptions: {
|
||||
channel_size: number;
|
||||
live_channel_size: number;
|
||||
shorts_channel_size: number;
|
||||
auto_start: boolean;
|
||||
};
|
||||
downloads: {
|
||||
limit_speed: false | number;
|
||||
sleep_interval: number;
|
||||
autodelete_days: number;
|
||||
format: number | string;
|
||||
format_sort: boolean | string;
|
||||
add_metadata: boolean;
|
||||
add_thumbnail: boolean;
|
||||
subtitle: boolean | string;
|
||||
subtitle_source: boolean | string;
|
||||
subtitle_index: boolean;
|
||||
comment_max: string | number;
|
||||
comment_sort: string;
|
||||
cookie_import: boolean;
|
||||
throttledratelimit: false | number;
|
||||
extractor_lang: boolean | string;
|
||||
integrate_ryd: boolean;
|
||||
integrate_sponsorblock: boolean;
|
||||
};
|
||||
application: {
|
||||
enable_snapshot: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
const loadAppsettingsConfig = async (): Promise<AppSettingsConfigType> => {
|
||||
const apiUrl = getApiUrl();
|
||||
|
||||
const response = await fetch(`${apiUrl}/api/appsettings/config/`, {
|
||||
headers: defaultHeaders,
|
||||
credentials: getFetchCredentials(),
|
||||
});
|
||||
|
||||
const appSettingsConfig = await response.json();
|
||||
|
||||
if (isDevEnvironment()) {
|
||||
console.log('loadApplicationConfig', appSettingsConfig);
|
||||
}
|
||||
|
||||
return appSettingsConfig;
|
||||
};
|
||||
|
||||
export default loadAppsettingsConfig;
|
||||
|
36
frontend/src/api/loader/loadChannelAggs.ts
Normal file
36
frontend/src/api/loader/loadChannelAggs.ts
Normal 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;
|
36
frontend/src/api/loader/loadSchedule.ts
Normal file
36
frontend/src/api/loader/loadSchedule.ts
Normal 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;
|
@ -1,74 +1,74 @@
|
||||
import defaultHeaders from '../../configuration/defaultHeaders';
|
||||
import getApiUrl from '../../configuration/getApiUrl';
|
||||
import getFetchCredentials from '../../configuration/getFetchCredentials';
|
||||
import isDevEnvironment from '../../functions/isDevEnvironment';
|
||||
import { ConfigType, SortByType, SortOrderType, VideoType } from '../../pages/Home';
|
||||
import { PaginationType } from '../../components/Pagination';
|
||||
|
||||
export type VideoListByFilterResponseType = {
|
||||
data?: VideoType[];
|
||||
config?: ConfigType;
|
||||
paginate?: PaginationType;
|
||||
};
|
||||
|
||||
type WatchTypes = 'watched' | 'unwatched' | 'continue';
|
||||
type VideoTypes = 'videos' | 'streams' | 'shorts';
|
||||
|
||||
type FilterType = {
|
||||
page?: number;
|
||||
playlist?: string;
|
||||
channel?: string;
|
||||
watch?: WatchTypes;
|
||||
sort?: SortByType;
|
||||
order?: SortOrderType;
|
||||
type?: VideoTypes;
|
||||
};
|
||||
|
||||
const loadVideoListByFilter = async (
|
||||
filter: FilterType,
|
||||
): Promise<VideoListByFilterResponseType> => {
|
||||
const apiUrl = getApiUrl();
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (filter.page) {
|
||||
searchParams.append('page', filter.page.toString());
|
||||
}
|
||||
|
||||
if (filter.playlist) {
|
||||
searchParams.append('playlist', filter.playlist);
|
||||
} else if (filter.channel) {
|
||||
searchParams.append('channel', filter.channel);
|
||||
}
|
||||
|
||||
if (filter.watch) {
|
||||
searchParams.append('watch', filter.watch);
|
||||
}
|
||||
|
||||
if (filter.sort) {
|
||||
searchParams.append('sort', filter.sort);
|
||||
}
|
||||
|
||||
if (filter.order) {
|
||||
searchParams.append('order', filter.order);
|
||||
}
|
||||
|
||||
if (filter.type) {
|
||||
searchParams.append('type', filter.type);
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiUrl}/api/video/?${searchParams.toString()}`, {
|
||||
headers: defaultHeaders,
|
||||
credentials: getFetchCredentials(),
|
||||
});
|
||||
|
||||
const videos = await response.json();
|
||||
|
||||
if (isDevEnvironment()) {
|
||||
console.log('loadVideoListByFilter', filter, videos);
|
||||
}
|
||||
|
||||
return videos;
|
||||
};
|
||||
|
||||
export default loadVideoListByFilter;
|
||||
import defaultHeaders from '../../configuration/defaultHeaders';
|
||||
import getApiUrl from '../../configuration/getApiUrl';
|
||||
import getFetchCredentials from '../../configuration/getFetchCredentials';
|
||||
import isDevEnvironment from '../../functions/isDevEnvironment';
|
||||
import { ConfigType, SortByType, SortOrderType, VideoType } from '../../pages/Home';
|
||||
import { PaginationType } from '../../components/Pagination';
|
||||
|
||||
export type VideoListByFilterResponseType = {
|
||||
data?: VideoType[];
|
||||
config?: ConfigType;
|
||||
paginate?: PaginationType;
|
||||
};
|
||||
|
||||
type WatchTypes = 'watched' | 'unwatched' | 'continue';
|
||||
export type VideoTypes = 'videos' | 'streams' | 'shorts';
|
||||
|
||||
type FilterType = {
|
||||
page?: number;
|
||||
playlist?: string;
|
||||
channel?: string;
|
||||
watch?: WatchTypes;
|
||||
sort?: SortByType;
|
||||
order?: SortOrderType;
|
||||
type?: VideoTypes;
|
||||
};
|
||||
|
||||
const loadVideoListByFilter = async (
|
||||
filter: FilterType,
|
||||
): Promise<VideoListByFilterResponseType> => {
|
||||
const apiUrl = getApiUrl();
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (filter.page) {
|
||||
searchParams.append('page', filter.page.toString());
|
||||
}
|
||||
|
||||
if (filter.playlist) {
|
||||
searchParams.append('playlist', filter.playlist);
|
||||
} else if (filter.channel) {
|
||||
searchParams.append('channel', filter.channel);
|
||||
}
|
||||
|
||||
if (filter.watch) {
|
||||
searchParams.append('watch', filter.watch);
|
||||
}
|
||||
|
||||
if (filter.sort) {
|
||||
searchParams.append('sort', filter.sort);
|
||||
}
|
||||
|
||||
if (filter.order) {
|
||||
searchParams.append('order', filter.order);
|
||||
}
|
||||
|
||||
if (filter.type) {
|
||||
searchParams.append('type', filter.type);
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiUrl}/api/video/?${searchParams.toString()}`, {
|
||||
headers: defaultHeaders,
|
||||
credentials: getFetchCredentials(),
|
||||
});
|
||||
|
||||
const videos = await response.json();
|
||||
|
||||
if (isDevEnvironment()) {
|
||||
console.log('loadVideoListByFilter', filter, videos);
|
||||
}
|
||||
|
||||
return videos;
|
||||
};
|
||||
|
||||
export default loadVideoListByFilter;
|
||||
|
@ -1,40 +1,42 @@
|
||||
export interface ButtonProps {
|
||||
id?: string;
|
||||
name?: string;
|
||||
className?: string;
|
||||
type?: 'submit' | 'reset' | 'button' | undefined;
|
||||
label?: string | JSX.Element | JSX.Element[];
|
||||
children?: string | JSX.Element | JSX.Element[];
|
||||
value?: string;
|
||||
title?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const Button = ({
|
||||
id,
|
||||
name,
|
||||
className,
|
||||
type,
|
||||
label,
|
||||
children,
|
||||
value,
|
||||
title,
|
||||
onClick,
|
||||
}: ButtonProps) => {
|
||||
return (
|
||||
<button
|
||||
id={id}
|
||||
name={name}
|
||||
className={className}
|
||||
type={type}
|
||||
value={value}
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
>
|
||||
{label}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default Button;
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export interface ButtonProps {
|
||||
id?: string;
|
||||
name?: string;
|
||||
className?: string;
|
||||
type?: 'submit' | 'reset' | 'button' | undefined;
|
||||
label?: string | ReactNode | ReactNode[];
|
||||
children?: string | ReactNode | ReactNode[];
|
||||
value?: string;
|
||||
title?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const Button = ({
|
||||
id,
|
||||
name,
|
||||
className,
|
||||
type,
|
||||
label,
|
||||
children,
|
||||
value,
|
||||
title,
|
||||
onClick,
|
||||
}: ButtonProps) => {
|
||||
return (
|
||||
<button
|
||||
id={id}
|
||||
name={name}
|
||||
className={className}
|
||||
type={type}
|
||||
value={value}
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
>
|
||||
{label}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default Button;
|
||||
|
@ -1,21 +1,22 @@
|
||||
import getApiUrl from '../configuration/getApiUrl';
|
||||
import defaultChannelImage from '/img/default-channel-banner.jpg';
|
||||
|
||||
type ChannelIconProps = {
|
||||
channel_id: string;
|
||||
};
|
||||
|
||||
const ChannelBanner = ({ channel_id }: ChannelIconProps) => {
|
||||
return (
|
||||
<img
|
||||
src={`${getApiUrl()}/cache/channels/${channel_id}_banner.jpg`}
|
||||
alt={`${channel_id}-banner`}
|
||||
onError={({ currentTarget }) => {
|
||||
currentTarget.onerror = null; // prevents looping
|
||||
currentTarget.src = defaultChannelImage;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelBanner;
|
||||
import getApiUrl from '../configuration/getApiUrl';
|
||||
import defaultChannelImage from '/img/default-channel-banner.jpg';
|
||||
|
||||
type ChannelIconProps = {
|
||||
channelId: string;
|
||||
channelBannerUrl: string | undefined;
|
||||
};
|
||||
|
||||
const ChannelBanner = ({ channelId, channelBannerUrl }: ChannelIconProps) => {
|
||||
return (
|
||||
<img
|
||||
src={`${getApiUrl()}${channelBannerUrl}`}
|
||||
alt={`${channelId}-banner`}
|
||||
onError={({ currentTarget }) => {
|
||||
currentTarget.onerror = null; // prevents looping
|
||||
currentTarget.src = defaultChannelImage;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelBanner;
|
||||
|
@ -1,21 +1,22 @@
|
||||
import getApiUrl from '../configuration/getApiUrl';
|
||||
import defaultChannelIcon from '/img/default-channel-icon.jpg';
|
||||
|
||||
type ChannelIconProps = {
|
||||
channel_id: string;
|
||||
};
|
||||
|
||||
const ChannelIcon = ({ channel_id }: ChannelIconProps) => {
|
||||
return (
|
||||
<img
|
||||
src={`${getApiUrl()}/cache/channels/${channel_id}_thumb.jpg`}
|
||||
alt="channel-thumb"
|
||||
onError={({ currentTarget }) => {
|
||||
currentTarget.onerror = null; // prevents looping
|
||||
currentTarget.src = defaultChannelIcon;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelIcon;
|
||||
import getApiUrl from '../configuration/getApiUrl';
|
||||
import defaultChannelIcon from '/img/default-channel-icon.jpg';
|
||||
|
||||
type ChannelIconProps = {
|
||||
channelId: string;
|
||||
channelThumbUrl: string | undefined;
|
||||
};
|
||||
|
||||
const ChannelIcon = ({ channelId, channelThumbUrl }: ChannelIconProps) => {
|
||||
return (
|
||||
<img
|
||||
src={`${getApiUrl()}${channelThumbUrl}`}
|
||||
alt={`${channelId}-thumb`}
|
||||
onError={({ currentTarget }) => {
|
||||
currentTarget.onerror = null; // prevents looping
|
||||
currentTarget.src = defaultChannelIcon;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelIcon;
|
||||
|
@ -1,83 +1,93 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ChannelType } from '../pages/Channels';
|
||||
import { ViewLayoutType } from '../pages/Home';
|
||||
import Routes from '../configuration/routes/RouteList';
|
||||
import updateChannelSubscription from '../api/actions/updateChannelSubscription';
|
||||
import formatDate from '../functions/formatDates';
|
||||
import FormattedNumber from './FormattedNumber';
|
||||
import Button from './Button';
|
||||
import ChannelIcon from './ChannelIcon';
|
||||
import ChannelBanner from './ChannelBanner';
|
||||
|
||||
type ChannelListProps = {
|
||||
channelList: ChannelType[] | undefined;
|
||||
viewLayout: ViewLayoutType;
|
||||
refreshChannelList: (refresh: boolean) => void;
|
||||
};
|
||||
|
||||
const ChannelList = ({ channelList, viewLayout, refreshChannelList }: ChannelListProps) => {
|
||||
if (!channelList || channelList.length === 0) {
|
||||
return <p>No channels found.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{channelList.map(channel => {
|
||||
return (
|
||||
<div key={channel.channel_id} className={`channel-item ${viewLayout}`}>
|
||||
<div className={`channel-banner ${viewLayout}`}>
|
||||
<Link to={Routes.Channel(channel.channel_id)}>
|
||||
<ChannelBanner channel_id={channel.channel_id} />
|
||||
</Link>
|
||||
</div>
|
||||
<div className={`info-box info-box-2 ${viewLayout}`}>
|
||||
<div className="info-box-item">
|
||||
<div className="round-img">
|
||||
<Link to={Routes.Channel(channel.channel_id)}>
|
||||
<ChannelIcon channel_id={channel.channel_id} />
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<h3>
|
||||
<Link to={Routes.Channel(channel.channel_id)}>{channel.channel_name}</Link>
|
||||
</h3>
|
||||
<FormattedNumber text="Subscribers:" number={channel.channel_subs} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="info-box-item">
|
||||
<div>
|
||||
<p>Last refreshed: {formatDate(channel.channel_last_refresh)}</p>
|
||||
{channel.channel_subscribed && (
|
||||
<Button
|
||||
label="Unsubscribe"
|
||||
className="unsubscribe"
|
||||
type="button"
|
||||
title={`Unsubscribe from ${channel.channel_name}`}
|
||||
onClick={async () => {
|
||||
await updateChannelSubscription(channel.channel_id, false);
|
||||
refreshChannelList(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!channel.channel_subscribed && (
|
||||
<Button
|
||||
label="Subscribe"
|
||||
type="button"
|
||||
title={`Subscribe to ${channel.channel_name}`}
|
||||
onClick={async () => {
|
||||
await updateChannelSubscription(channel.channel_id, true);
|
||||
refreshChannelList(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelList;
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ChannelType } from '../pages/Channels';
|
||||
import { ViewLayoutType } from '../pages/Home';
|
||||
import Routes from '../configuration/routes/RouteList';
|
||||
import updateChannelSubscription from '../api/actions/updateChannelSubscription';
|
||||
import formatDate from '../functions/formatDates';
|
||||
import FormattedNumber from './FormattedNumber';
|
||||
import Button from './Button';
|
||||
import ChannelIcon from './ChannelIcon';
|
||||
import ChannelBanner from './ChannelBanner';
|
||||
|
||||
type ChannelListProps = {
|
||||
channelList: ChannelType[] | undefined;
|
||||
viewLayout: ViewLayoutType;
|
||||
refreshChannelList: (refresh: boolean) => void;
|
||||
};
|
||||
|
||||
const ChannelList = ({ channelList, viewLayout, refreshChannelList }: ChannelListProps) => {
|
||||
if (!channelList || channelList.length === 0) {
|
||||
return <p>No channels found.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{channelList.map(channel => {
|
||||
return (
|
||||
<div key={channel.channel_id} className={`channel-item ${viewLayout}`}>
|
||||
<div className={`channel-banner ${viewLayout}`}>
|
||||
<Link to={Routes.Channel(channel.channel_id)}>
|
||||
<ChannelBanner
|
||||
channelId={channel.channel_id}
|
||||
channelBannerUrl={channel.channel_banner_url}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<div className={`info-box info-box-2 ${viewLayout}`}>
|
||||
<div className="info-box-item">
|
||||
<div className="round-img">
|
||||
<Link to={Routes.Channel(channel.channel_id)}>
|
||||
<ChannelIcon
|
||||
channelId={channel.channel_id}
|
||||
channelThumbUrl={channel.channel_thumb_url}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<h3>
|
||||
<Link to={Routes.Channel(channel.channel_id)}>{channel.channel_name}</Link>
|
||||
</h3>
|
||||
<FormattedNumber text="Subscribers:" number={channel.channel_subs} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="info-box-item">
|
||||
<div>
|
||||
<p>Last refreshed: {formatDate(channel.channel_last_refresh)}</p>
|
||||
{channel.channel_subscribed && (
|
||||
<Button
|
||||
label="Unsubscribe"
|
||||
className="unsubscribe"
|
||||
type="button"
|
||||
title={`Unsubscribe from ${channel.channel_name}`}
|
||||
onClick={async () => {
|
||||
await updateChannelSubscription(channel.channel_id, false);
|
||||
refreshChannelList(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!channel.channel_subscribed && (
|
||||
<Button
|
||||
label="Subscribe"
|
||||
type="button"
|
||||
title={`Subscribe to ${channel.channel_name}`}
|
||||
onClick={async () => {
|
||||
await updateChannelSubscription(channel.channel_id, true);
|
||||
|
||||
setTimeout(() => {
|
||||
refreshChannelList(true);
|
||||
}, 500);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelList;
|
||||
|
@ -1,76 +1,78 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import Routes from '../configuration/routes/RouteList';
|
||||
import updateChannelSubscription from '../api/actions/updateChannelSubscription';
|
||||
import FormattedNumber from './FormattedNumber';
|
||||
import Button from './Button';
|
||||
import ChannelIcon from './ChannelIcon';
|
||||
|
||||
type ChannelOverviewProps = {
|
||||
channelId: string;
|
||||
channelname: string;
|
||||
channelSubs: number;
|
||||
channelSubscribed: boolean;
|
||||
showSubscribeButton?: boolean;
|
||||
isUserAdmin?: boolean;
|
||||
setRefresh: (status: boolean) => void;
|
||||
};
|
||||
|
||||
const ChannelOverview = ({
|
||||
channelId,
|
||||
channelSubs,
|
||||
channelSubscribed,
|
||||
channelname,
|
||||
showSubscribeButton = false,
|
||||
isUserAdmin,
|
||||
setRefresh,
|
||||
}: ChannelOverviewProps) => {
|
||||
return (
|
||||
<>
|
||||
<div className="info-box-item">
|
||||
<div className="round-img">
|
||||
<Link to={Routes.Channel(channelId)}>
|
||||
<ChannelIcon channel_id={channelId} />
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<h3>
|
||||
<Link to={Routes.ChannelVideo(channelId)}>{channelname}</Link>
|
||||
</h3>
|
||||
|
||||
<FormattedNumber text="Subscribers:" number={channelSubs} />
|
||||
|
||||
{showSubscribeButton && (
|
||||
<>
|
||||
{channelSubscribed && isUserAdmin && (
|
||||
<Button
|
||||
label="Unsubscribe"
|
||||
className="unsubscribe"
|
||||
type="button"
|
||||
title={`Unsubscribe from ${channelname}`}
|
||||
onClick={async () => {
|
||||
await updateChannelSubscription(channelId, false);
|
||||
setRefresh(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!channelSubscribed && (
|
||||
<Button
|
||||
label="Subscribe"
|
||||
type="button"
|
||||
title={`Subscribe to ${channelname}`}
|
||||
onClick={async () => {
|
||||
await updateChannelSubscription(channelId, true);
|
||||
setRefresh(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelOverview;
|
||||
import { Link } from 'react-router-dom';
|
||||
import Routes from '../configuration/routes/RouteList';
|
||||
import updateChannelSubscription from '../api/actions/updateChannelSubscription';
|
||||
import FormattedNumber from './FormattedNumber';
|
||||
import Button from './Button';
|
||||
import ChannelIcon from './ChannelIcon';
|
||||
|
||||
type ChannelOverviewProps = {
|
||||
channelId: string;
|
||||
channelname: string;
|
||||
channelSubs: number;
|
||||
channelSubscribed: boolean;
|
||||
channelThumbUrl: string;
|
||||
showSubscribeButton?: boolean;
|
||||
isUserAdmin?: boolean;
|
||||
setRefresh: (status: boolean) => void;
|
||||
};
|
||||
|
||||
const ChannelOverview = ({
|
||||
channelId,
|
||||
channelSubs,
|
||||
channelSubscribed,
|
||||
channelname,
|
||||
channelThumbUrl,
|
||||
showSubscribeButton = false,
|
||||
isUserAdmin,
|
||||
setRefresh,
|
||||
}: ChannelOverviewProps) => {
|
||||
return (
|
||||
<>
|
||||
<div className="info-box-item">
|
||||
<div className="round-img">
|
||||
<Link to={Routes.Channel(channelId)}>
|
||||
<ChannelIcon channelId={channelId} channelThumbUrl={channelThumbUrl} />
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<h3>
|
||||
<Link to={Routes.ChannelVideo(channelId)}>{channelname}</Link>
|
||||
</h3>
|
||||
|
||||
<FormattedNumber text="Subscribers:" number={channelSubs} />
|
||||
|
||||
{showSubscribeButton && (
|
||||
<>
|
||||
{channelSubscribed && isUserAdmin && (
|
||||
<Button
|
||||
label="Unsubscribe"
|
||||
className="unsubscribe"
|
||||
type="button"
|
||||
title={`Unsubscribe from ${channelname}`}
|
||||
onClick={async () => {
|
||||
await updateChannelSubscription(channelId, false);
|
||||
setRefresh(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!channelSubscribed && (
|
||||
<Button
|
||||
label="Subscribe"
|
||||
type="button"
|
||||
title={`Subscribe to ${channelname}`}
|
||||
onClick={async () => {
|
||||
await updateChannelSubscription(channelId, true);
|
||||
setRefresh(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelOverview;
|
||||
|
@ -115,7 +115,8 @@ const EmbeddableVideoPlayer = ({ videoId }: EmbeddableVideoPlayerProps) => {
|
||||
id: videoId,
|
||||
is_watched: status,
|
||||
});
|
||||
|
||||
}}
|
||||
onDone={() => {
|
||||
setRefresh(true);
|
||||
}}
|
||||
/>
|
||||
|
@ -48,24 +48,16 @@ const Filterbar = ({
|
||||
}: FilterbarProps) => {
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (
|
||||
userMeConfig.hide_watched !== hideWatched ||
|
||||
userMeConfig[viewStyleName.toString() as keyof typeof userMeConfig] !== view ||
|
||||
userMeConfig.grid_items !== gridItems ||
|
||||
userMeConfig.sort_by !== sortBy ||
|
||||
userMeConfig.sort_order !== sortOrder
|
||||
) {
|
||||
const userConfig: UserConfigType = {
|
||||
hide_watched: hideWatched,
|
||||
[viewStyleName.toString()]: view,
|
||||
grid_items: gridItems,
|
||||
sort_by: sortBy,
|
||||
sort_order: sortOrder,
|
||||
};
|
||||
const userConfig: UserConfigType = {
|
||||
hide_watched: hideWatched,
|
||||
[viewStyleName.toString()]: view,
|
||||
grid_items: gridItems,
|
||||
sort_by: sortBy,
|
||||
sort_order: sortOrder,
|
||||
};
|
||||
|
||||
await updateUserConfig(userConfig);
|
||||
setRefresh?.(true);
|
||||
}
|
||||
await updateUserConfig(userConfig);
|
||||
setRefresh?.(true);
|
||||
})();
|
||||
}, [hideWatched, view, gridItems, sortBy, sortOrder, viewStyleName, setRefresh, userMeConfig]);
|
||||
|
||||
|
@ -1,230 +1,228 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { VideoType } from '../pages/Home';
|
||||
import updateWatchedState from '../api/actions/updateWatchedState';
|
||||
import updateVideoProgressById from '../api/actions/updateVideoProgressById';
|
||||
import watchedThreshold from '../functions/watchedThreshold';
|
||||
import { VideoProgressType } from './VideoPlayer';
|
||||
|
||||
const getURL = () => {
|
||||
return window.location.origin;
|
||||
};
|
||||
|
||||
function shiftCurrentTime(contentCurrentTime: number | undefined) {
|
||||
console.log(contentCurrentTime);
|
||||
if (contentCurrentTime === undefined) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Shift media back 3 seconds to prevent missing some of the content
|
||||
if (contentCurrentTime > 5) {
|
||||
return contentCurrentTime - 3;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function castVideoProgress(
|
||||
player: {
|
||||
mediaInfo: { contentId: string | string[] };
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
},
|
||||
video: VideoType | undefined,
|
||||
) {
|
||||
if (!video) {
|
||||
console.log('castVideoProgress: Video to cast not found...');
|
||||
return;
|
||||
}
|
||||
const videoId = video.youtube_id;
|
||||
|
||||
if (player.mediaInfo.contentId.includes(videoId)) {
|
||||
const currentTime = player.currentTime;
|
||||
const duration = player.duration;
|
||||
|
||||
if (currentTime % 10 <= 1.0 && currentTime !== 0 && duration !== 0) {
|
||||
// Check progress every 10 seconds or else progress is checked a few times a second
|
||||
await updateVideoProgressById({
|
||||
youtubeId: videoId,
|
||||
currentProgress: currentTime,
|
||||
});
|
||||
|
||||
if (!video.player.watched) {
|
||||
// Check if video is already marked as watched
|
||||
if (watchedThreshold(currentTime, duration)) {
|
||||
await updateWatchedState({
|
||||
id: videoId,
|
||||
is_watched: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function castVideoPaused(
|
||||
player: {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
mediaInfo: { contentId: string | string[] } | null;
|
||||
},
|
||||
video: VideoType | undefined,
|
||||
) {
|
||||
if (!video) {
|
||||
console.log('castVideoPaused: Video to cast not found...');
|
||||
return;
|
||||
}
|
||||
|
||||
const videoId = video?.youtube_id;
|
||||
|
||||
const currentTime = player.currentTime;
|
||||
const duration = player.duration;
|
||||
|
||||
if (player.mediaInfo != null) {
|
||||
if (player.mediaInfo.contentId.includes(videoId)) {
|
||||
if (currentTime !== 0 && duration !== 0) {
|
||||
await updateVideoProgressById({
|
||||
youtubeId: videoId,
|
||||
currentProgress: currentTime,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type GoogleCastProps = {
|
||||
video?: VideoType;
|
||||
videoProgress?: VideoProgressType;
|
||||
setRefresh?: () => void;
|
||||
};
|
||||
|
||||
const GoogleCast = ({ video, videoProgress, setRefresh }: GoogleCastProps) => {
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
|
||||
const setup = useCallback(() => {
|
||||
const cast = globalThis.cast;
|
||||
const chrome = globalThis.chrome;
|
||||
|
||||
cast.framework.CastContext.getInstance().setOptions({
|
||||
receiverApplicationId: chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID, // Use built in receiver app on cast device, see https://developers.google.com/cast/docs/styled_receiver if you want to be able to add a theme, splash screen or watermark. Has a $5 one time fee.
|
||||
autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED,
|
||||
});
|
||||
|
||||
const player = new cast.framework.RemotePlayer();
|
||||
|
||||
const playerController = new cast.framework.RemotePlayerController(player);
|
||||
|
||||
// Add event listerner to check if a connection to a cast device is initiated
|
||||
playerController.addEventListener(
|
||||
cast.framework.RemotePlayerEventType.IS_CONNECTED_CHANGED,
|
||||
function () {
|
||||
setIsConnected(player.isConnected);
|
||||
},
|
||||
);
|
||||
playerController.addEventListener(
|
||||
cast.framework.RemotePlayerEventType.CURRENT_TIME_CHANGED,
|
||||
function () {
|
||||
castVideoProgress(player, video);
|
||||
},
|
||||
);
|
||||
playerController.addEventListener(
|
||||
cast.framework.RemotePlayerEventType.IS_PAUSED_CHANGED,
|
||||
function () {
|
||||
castVideoPaused(player, video);
|
||||
setRefresh?.();
|
||||
},
|
||||
);
|
||||
}, [setRefresh, video]);
|
||||
|
||||
const startPlayback = useCallback(() => {
|
||||
const chrome = globalThis.chrome;
|
||||
const cast = globalThis.cast;
|
||||
const castSession = cast.framework.CastContext.getInstance().getCurrentSession();
|
||||
|
||||
const mediaUrl = video?.media_url;
|
||||
const vidThumbUrl = video?.vid_thumb_url;
|
||||
const contentTitle = video?.title;
|
||||
const contentId = `${getURL()}${mediaUrl}`;
|
||||
const contentImage = `${getURL()}${vidThumbUrl}`;
|
||||
const contentType = 'video/mp4'; // Set content type, only videos right now so it is hard coded
|
||||
|
||||
const contentSubtitles = [];
|
||||
const videoSubtitles = video?.subtitles; // Array of subtitles
|
||||
if (typeof videoSubtitles !== 'undefined') {
|
||||
for (let i = 0; i < videoSubtitles.length; i++) {
|
||||
const subtitle = new chrome.cast.media.Track(i, chrome.cast.media.TrackType.TEXT);
|
||||
|
||||
subtitle.trackContentId = videoSubtitles[i].media_url;
|
||||
subtitle.trackContentType = 'text/vtt';
|
||||
subtitle.subtype = chrome.cast.media.TextTrackType.SUBTITLES;
|
||||
subtitle.name = videoSubtitles[i].name;
|
||||
subtitle.language = videoSubtitles[i].lang;
|
||||
subtitle.customData = null;
|
||||
|
||||
contentSubtitles.push(subtitle);
|
||||
}
|
||||
}
|
||||
|
||||
const mediaInfo = new chrome.cast.media.MediaInfo(contentId, contentType); // Create MediaInfo var that contains url and content type
|
||||
// mediaInfo.streamType = chrome.cast.media.StreamType.BUFFERED; // Set type of stream, BUFFERED, LIVE, OTHER
|
||||
mediaInfo.metadata = new chrome.cast.media.GenericMediaMetadata(); // Create metadata var and add it to MediaInfo
|
||||
mediaInfo.metadata.title = contentTitle?.replace('&', '&'); // Set the video title
|
||||
mediaInfo.metadata.images = [new chrome.cast.Image(contentImage)]; // Set the video thumbnail
|
||||
// mediaInfo.textTrackStyle = new chrome.cast.media.TextTrackStyle();
|
||||
mediaInfo.tracks = contentSubtitles;
|
||||
|
||||
const request = new chrome.cast.media.LoadRequest(mediaInfo); // Create request with the previously set MediaInfo.
|
||||
// request.queueData = new chrome.cast.media.QueueData(); // See https://developers.google.com/cast/docs/reference/web_sender/chrome.cast.media.QueueData for playlist support.
|
||||
request.currentTime = shiftCurrentTime(videoProgress?.position); // Set video start position based on the browser video position
|
||||
// request.activeTrackIds = contentActiveSubtitle; // Set active subtitle based on video player
|
||||
|
||||
castSession.loadMedia(request).then(
|
||||
function () {
|
||||
console.log('media loaded');
|
||||
},
|
||||
function (error: { code: string }) {
|
||||
console.log('Error', error, 'Error code: ' + error.code);
|
||||
},
|
||||
); // Send request to cast device
|
||||
|
||||
// Do not add videoProgress?.position, this will cause loops!
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [video?.media_url, video?.subtitles, video?.title, video?.vid_thumb_url]);
|
||||
|
||||
useEffect(() => {
|
||||
// @ts-expect-error __onGCastApiAvailable is the google cast window hook ( source: https://developers.google.com/cast/docs/web_sender/integrate )
|
||||
window['__onGCastApiAvailable'] = function (isAvailable: boolean) {
|
||||
if (isAvailable) {
|
||||
setup();
|
||||
}
|
||||
};
|
||||
}, [setup]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('isConnected', isConnected);
|
||||
if (isConnected) {
|
||||
startPlayback();
|
||||
}
|
||||
}, [isConnected, startPlayback]);
|
||||
|
||||
if (!video) {
|
||||
return <p>Video for cast not found...</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<>
|
||||
<Helmet>
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"
|
||||
></script>
|
||||
</Helmet>
|
||||
{/* @ts-expect-error React does not know what to do with the google-cast-launcher, but it works. */}
|
||||
<google-cast-launcher id="castbutton"></google-cast-launcher>
|
||||
</>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GoogleCast;
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { VideoType } from '../pages/Home';
|
||||
import updateWatchedState from '../api/actions/updateWatchedState';
|
||||
import updateVideoProgressById from '../api/actions/updateVideoProgressById';
|
||||
import watchedThreshold from '../functions/watchedThreshold';
|
||||
import { VideoProgressType } from './VideoPlayer';
|
||||
|
||||
const getURL = () => {
|
||||
return window.location.origin;
|
||||
};
|
||||
|
||||
function shiftCurrentTime(contentCurrentTime: number | undefined) {
|
||||
console.log(contentCurrentTime);
|
||||
if (contentCurrentTime === undefined) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Shift media back 3 seconds to prevent missing some of the content
|
||||
if (contentCurrentTime > 5) {
|
||||
return contentCurrentTime - 3;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function castVideoProgress(
|
||||
player: {
|
||||
mediaInfo: { contentId: string | string[] };
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
},
|
||||
video: VideoType | undefined,
|
||||
) {
|
||||
if (!video) {
|
||||
console.log('castVideoProgress: Video to cast not found...');
|
||||
return;
|
||||
}
|
||||
const videoId = video.youtube_id;
|
||||
|
||||
if (player.mediaInfo.contentId.includes(videoId)) {
|
||||
const currentTime = player.currentTime;
|
||||
const duration = player.duration;
|
||||
|
||||
if (currentTime % 10 <= 1.0 && currentTime !== 0 && duration !== 0) {
|
||||
// Check progress every 10 seconds or else progress is checked a few times a second
|
||||
await updateVideoProgressById({
|
||||
youtubeId: videoId,
|
||||
currentProgress: currentTime,
|
||||
});
|
||||
|
||||
if (!video.player.watched) {
|
||||
// Check if video is already marked as watched
|
||||
if (watchedThreshold(currentTime, duration)) {
|
||||
await updateWatchedState({
|
||||
id: videoId,
|
||||
is_watched: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function castVideoPaused(
|
||||
player: {
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
mediaInfo: { contentId: string | string[] } | null;
|
||||
},
|
||||
video: VideoType | undefined,
|
||||
) {
|
||||
if (!video) {
|
||||
console.log('castVideoPaused: Video to cast not found...');
|
||||
return;
|
||||
}
|
||||
|
||||
const videoId = video?.youtube_id;
|
||||
|
||||
const currentTime = player.currentTime;
|
||||
const duration = player.duration;
|
||||
|
||||
if (player.mediaInfo != null) {
|
||||
if (player.mediaInfo.contentId.includes(videoId)) {
|
||||
if (currentTime !== 0 && duration !== 0) {
|
||||
await updateVideoProgressById({
|
||||
youtubeId: videoId,
|
||||
currentProgress: currentTime,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type GoogleCastProps = {
|
||||
video?: VideoType;
|
||||
videoProgress?: VideoProgressType;
|
||||
setRefresh?: () => void;
|
||||
};
|
||||
|
||||
const GoogleCast = ({ video, videoProgress, setRefresh }: GoogleCastProps) => {
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
|
||||
const setup = useCallback(() => {
|
||||
const cast = globalThis.cast;
|
||||
const chrome = globalThis.chrome;
|
||||
|
||||
cast.framework.CastContext.getInstance().setOptions({
|
||||
receiverApplicationId: chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID, // Use built in receiver app on cast device, see https://developers.google.com/cast/docs/styled_receiver if you want to be able to add a theme, splash screen or watermark. Has a $5 one time fee.
|
||||
autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED,
|
||||
});
|
||||
|
||||
const player = new cast.framework.RemotePlayer();
|
||||
|
||||
const playerController = new cast.framework.RemotePlayerController(player);
|
||||
|
||||
// Add event listerner to check if a connection to a cast device is initiated
|
||||
playerController.addEventListener(
|
||||
cast.framework.RemotePlayerEventType.IS_CONNECTED_CHANGED,
|
||||
function () {
|
||||
setIsConnected(player.isConnected);
|
||||
},
|
||||
);
|
||||
playerController.addEventListener(
|
||||
cast.framework.RemotePlayerEventType.CURRENT_TIME_CHANGED,
|
||||
function () {
|
||||
castVideoProgress(player, video);
|
||||
},
|
||||
);
|
||||
playerController.addEventListener(
|
||||
cast.framework.RemotePlayerEventType.IS_PAUSED_CHANGED,
|
||||
function () {
|
||||
castVideoPaused(player, video);
|
||||
setRefresh?.();
|
||||
},
|
||||
);
|
||||
}, [setRefresh, video]);
|
||||
|
||||
const startPlayback = useCallback(() => {
|
||||
const chrome = globalThis.chrome;
|
||||
const cast = globalThis.cast;
|
||||
const castSession = cast.framework.CastContext.getInstance().getCurrentSession();
|
||||
|
||||
const mediaUrl = video?.media_url;
|
||||
const vidThumbUrl = video?.vid_thumb_url;
|
||||
const contentTitle = video?.title;
|
||||
const contentId = `${getURL()}${mediaUrl}`;
|
||||
const contentImage = `${getURL()}${vidThumbUrl}`;
|
||||
const contentType = 'video/mp4'; // Set content type, only videos right now so it is hard coded
|
||||
|
||||
const contentSubtitles = [];
|
||||
const videoSubtitles = video?.subtitles; // Array of subtitles
|
||||
if (typeof videoSubtitles !== 'undefined') {
|
||||
for (let i = 0; i < videoSubtitles.length; i++) {
|
||||
const subtitle = new chrome.cast.media.Track(i, chrome.cast.media.TrackType.TEXT);
|
||||
|
||||
subtitle.trackContentId = videoSubtitles[i].media_url;
|
||||
subtitle.trackContentType = 'text/vtt';
|
||||
subtitle.subtype = chrome.cast.media.TextTrackType.SUBTITLES;
|
||||
subtitle.name = videoSubtitles[i].name;
|
||||
subtitle.language = videoSubtitles[i].lang;
|
||||
subtitle.customData = null;
|
||||
|
||||
contentSubtitles.push(subtitle);
|
||||
}
|
||||
}
|
||||
|
||||
const mediaInfo = new chrome.cast.media.MediaInfo(contentId, contentType); // Create MediaInfo var that contains url and content type
|
||||
// mediaInfo.streamType = chrome.cast.media.StreamType.BUFFERED; // Set type of stream, BUFFERED, LIVE, OTHER
|
||||
mediaInfo.metadata = new chrome.cast.media.GenericMediaMetadata(); // Create metadata var and add it to MediaInfo
|
||||
mediaInfo.metadata.title = contentTitle?.replace('&', '&'); // Set the video title
|
||||
mediaInfo.metadata.images = [new chrome.cast.Image(contentImage)]; // Set the video thumbnail
|
||||
// mediaInfo.textTrackStyle = new chrome.cast.media.TextTrackStyle();
|
||||
mediaInfo.tracks = contentSubtitles;
|
||||
|
||||
const request = new chrome.cast.media.LoadRequest(mediaInfo); // Create request with the previously set MediaInfo.
|
||||
// request.queueData = new chrome.cast.media.QueueData(); // See https://developers.google.com/cast/docs/reference/web_sender/chrome.cast.media.QueueData for playlist support.
|
||||
request.currentTime = shiftCurrentTime(videoProgress?.position); // Set video start position based on the browser video position
|
||||
// request.activeTrackIds = contentActiveSubtitle; // Set active subtitle based on video player
|
||||
|
||||
castSession.loadMedia(request).then(
|
||||
function () {
|
||||
console.log('media loaded');
|
||||
},
|
||||
function (error: { code: string }) {
|
||||
console.log('Error', error, 'Error code: ' + error.code);
|
||||
},
|
||||
); // Send request to cast device
|
||||
|
||||
// Do not add videoProgress?.position, this will cause loops!
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [video?.media_url, video?.subtitles, video?.title, video?.vid_thumb_url]);
|
||||
|
||||
useEffect(() => {
|
||||
// @ts-expect-error __onGCastApiAvailable is the google cast window hook ( source: https://developers.google.com/cast/docs/web_sender/integrate )
|
||||
window['__onGCastApiAvailable'] = function (isAvailable: boolean) {
|
||||
if (isAvailable) {
|
||||
setup();
|
||||
}
|
||||
};
|
||||
}, [setup]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('isConnected', isConnected);
|
||||
if (isConnected) {
|
||||
startPlayback();
|
||||
}
|
||||
}, [isConnected, startPlayback]);
|
||||
|
||||
if (!video) {
|
||||
return <p>Video for cast not found...</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<>
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"
|
||||
></script>
|
||||
|
||||
{/* @ts-expect-error React does not know what to do with the google-cast-launcher, but it works. */}
|
||||
<google-cast-launcher id="castbutton"></google-cast-launcher>
|
||||
</>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GoogleCast;
|
||||
|
@ -1,9 +1,9 @@
|
||||
const PaginationDummy = () => {
|
||||
return (
|
||||
<div className="boxed-content">
|
||||
<div className="pagination">{/** dummy pagination for padding */}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaginationDummy;
|
||||
const PaginationDummy = () => {
|
||||
return (
|
||||
<div className="boxed-content">
|
||||
<div className="pagination">{/** dummy pagination for consistent padding */}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaginationDummy;
|
||||
|
@ -1,85 +1,87 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import Routes from '../configuration/routes/RouteList';
|
||||
import { ViewLayoutType } from '../pages/Home';
|
||||
import { PlaylistType } from '../pages/Playlist';
|
||||
import updatePlaylistSubscription from '../api/actions/updatePlaylistSubscription';
|
||||
import formatDate from '../functions/formatDates';
|
||||
import Button from './Button';
|
||||
import getApiUrl from '../configuration/getApiUrl';
|
||||
|
||||
type PlaylistListProps = {
|
||||
playlistList: PlaylistType[] | undefined;
|
||||
viewLayout: ViewLayoutType;
|
||||
setRefresh: (status: boolean) => void;
|
||||
};
|
||||
|
||||
const PlaylistList = ({ playlistList, viewLayout, setRefresh }: PlaylistListProps) => {
|
||||
if (!playlistList || playlistList.length === 0) {
|
||||
return <p>No playlists found.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{playlistList.map((playlist: PlaylistType) => {
|
||||
return (
|
||||
<div key={playlist.playlist_id} className={`playlist-item ${viewLayout}`}>
|
||||
<div className="playlist-thumbnail">
|
||||
<Link to={Routes.Playlist(playlist.playlist_id)}>
|
||||
<img
|
||||
src={`${getApiUrl()}/cache/playlists/${playlist.playlist_id}.jpg`}
|
||||
alt={`${playlist.playlist_id}-thumbnail`}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<div className={`playlist-desc ${viewLayout}`}>
|
||||
{playlist.playlist_type != 'custom' && (
|
||||
<Link to={Routes.Channel(playlist.playlist_channel_id)}>
|
||||
<h3>{playlist.playlist_channel}</h3>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Link to={Routes.Playlist(playlist.playlist_id)}>
|
||||
<h2>{playlist.playlist_name}</h2>
|
||||
</Link>
|
||||
|
||||
<p>Last refreshed: {formatDate(playlist.playlist_last_refresh)}</p>
|
||||
|
||||
{playlist.playlist_type != 'custom' && (
|
||||
<>
|
||||
{playlist.playlist_subscribed && (
|
||||
<Button
|
||||
label="Unsubscribe"
|
||||
className="unsubscribe"
|
||||
type="button"
|
||||
title={`Unsubscribe from ${playlist.playlist_name}`}
|
||||
onClick={async () => {
|
||||
await updatePlaylistSubscription(playlist.playlist_id, false);
|
||||
|
||||
setRefresh(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!playlist.playlist_subscribed && (
|
||||
<Button
|
||||
label="Subscribe"
|
||||
type="button"
|
||||
title={`Subscribe to ${playlist.playlist_name}`}
|
||||
onClick={async () => {
|
||||
await updatePlaylistSubscription(playlist.playlist_id, true);
|
||||
|
||||
setRefresh(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlaylistList;
|
||||
import { Link } from 'react-router-dom';
|
||||
import Routes from '../configuration/routes/RouteList';
|
||||
import { ViewLayoutType } from '../pages/Home';
|
||||
import { PlaylistType } from '../pages/Playlist';
|
||||
import updatePlaylistSubscription from '../api/actions/updatePlaylistSubscription';
|
||||
import formatDate from '../functions/formatDates';
|
||||
import Button from './Button';
|
||||
import PlaylistThumbnail from './PlaylistThumbnail';
|
||||
|
||||
type PlaylistListProps = {
|
||||
playlistList: PlaylistType[] | undefined;
|
||||
viewLayout: ViewLayoutType;
|
||||
setRefresh: (status: boolean) => void;
|
||||
};
|
||||
|
||||
const PlaylistList = ({ playlistList, viewLayout, setRefresh }: PlaylistListProps) => {
|
||||
if (!playlistList || playlistList.length === 0) {
|
||||
return <p>No playlists found.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{playlistList.map((playlist: PlaylistType) => {
|
||||
return (
|
||||
<div key={playlist.playlist_id} className={`playlist-item ${viewLayout}`}>
|
||||
<div className="playlist-thumbnail">
|
||||
<Link to={Routes.Playlist(playlist.playlist_id)}>
|
||||
<PlaylistThumbnail
|
||||
playlistId={playlist.playlist_id}
|
||||
playlistThumbnail={playlist.playlist_thumbnail}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<div className={`playlist-desc ${viewLayout}`}>
|
||||
{playlist.playlist_type != 'custom' && (
|
||||
<Link to={Routes.Channel(playlist.playlist_channel_id)}>
|
||||
<h3>{playlist.playlist_channel}</h3>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Link to={Routes.Playlist(playlist.playlist_id)}>
|
||||
<h2>{playlist.playlist_name}</h2>
|
||||
</Link>
|
||||
|
||||
<p>Last refreshed: {formatDate(playlist.playlist_last_refresh)}</p>
|
||||
|
||||
{playlist.playlist_type != 'custom' && (
|
||||
<>
|
||||
{playlist.playlist_subscribed && (
|
||||
<Button
|
||||
label="Unsubscribe"
|
||||
className="unsubscribe"
|
||||
type="button"
|
||||
title={`Unsubscribe from ${playlist.playlist_name}`}
|
||||
onClick={async () => {
|
||||
await updatePlaylistSubscription(playlist.playlist_id, false);
|
||||
|
||||
setRefresh(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!playlist.playlist_subscribed && (
|
||||
<Button
|
||||
label="Subscribe"
|
||||
type="button"
|
||||
title={`Subscribe to ${playlist.playlist_name}`}
|
||||
onClick={async () => {
|
||||
await updatePlaylistSubscription(playlist.playlist_id, true);
|
||||
|
||||
setTimeout(() => {
|
||||
setRefresh(true);
|
||||
}, 500);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlaylistList;
|
||||
|
22
frontend/src/components/PlaylistThumbnail.tsx
Normal file
22
frontend/src/components/PlaylistThumbnail.tsx
Normal 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;
|
@ -79,7 +79,8 @@ const VideoListItem = ({
|
||||
id: video.youtube_id,
|
||||
is_watched: status,
|
||||
});
|
||||
|
||||
}}
|
||||
onDone={() => {
|
||||
refreshVideoList(true);
|
||||
}}
|
||||
/>
|
||||
|
@ -1,254 +1,268 @@
|
||||
import updateVideoProgressById from '../api/actions/updateVideoProgressById';
|
||||
import updateWatchedState from '../api/actions/updateWatchedState';
|
||||
import { SponsorBlockSegmentType, SponsorBlockType, VideoResponseType } from '../pages/Video';
|
||||
import watchedThreshold from '../functions/watchedThreshold';
|
||||
import Notifications from './Notifications';
|
||||
import { Dispatch, SetStateAction, SyntheticEvent, useState } from 'react';
|
||||
import formatTime from '../functions/formatTime';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import getApiUrl from '../configuration/getApiUrl';
|
||||
|
||||
type VideoTag = SyntheticEvent<HTMLVideoElement, Event>;
|
||||
|
||||
export type SkippedSegmentType = {
|
||||
from: number;
|
||||
to: number;
|
||||
};
|
||||
|
||||
export type SponsorSegmentsSkippedType = Record<string, SkippedSegmentType>;
|
||||
|
||||
type Subtitle = {
|
||||
name: string;
|
||||
source: string;
|
||||
lang: string;
|
||||
media_url: string;
|
||||
};
|
||||
|
||||
type SubtitlesProp = {
|
||||
subtitles: Subtitle[];
|
||||
};
|
||||
|
||||
const Subtitles = ({ subtitles }: SubtitlesProp) => {
|
||||
return subtitles.map((subtitle: Subtitle) => {
|
||||
let label = subtitle.name;
|
||||
|
||||
if (subtitle.source === 'auto') {
|
||||
label += ' - auto';
|
||||
}
|
||||
|
||||
return (
|
||||
<track
|
||||
key={subtitle.name}
|
||||
label={label}
|
||||
kind="subtitles"
|
||||
srcLang={subtitle.lang}
|
||||
src={`${getApiUrl()}${subtitle.media_url}`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const handleTimeUpdate =
|
||||
(
|
||||
youtubeId: string,
|
||||
duration: number,
|
||||
watched: boolean,
|
||||
sponsorBlock?: SponsorBlockType,
|
||||
setSponsorSegmentSkipped?: Dispatch<SetStateAction<SponsorSegmentsSkippedType>>,
|
||||
) =>
|
||||
async (videoTag: VideoTag) => {
|
||||
const currentTime = Number(videoTag.currentTarget.currentTime);
|
||||
|
||||
if (sponsorBlock && sponsorBlock.segments) {
|
||||
sponsorBlock.segments.forEach((segment: SponsorBlockSegmentType) => {
|
||||
const [from, to] = segment.segment;
|
||||
|
||||
if (currentTime >= from && currentTime <= from + 0.3) {
|
||||
videoTag.currentTarget.currentTime = to;
|
||||
|
||||
setSponsorSegmentSkipped?.((segments: SponsorSegmentsSkippedType) => {
|
||||
return { ...segments, [segment.UUID]: { from, to } };
|
||||
});
|
||||
}
|
||||
|
||||
if (currentTime > to + 10) {
|
||||
setSponsorSegmentSkipped?.((segments: SponsorSegmentsSkippedType) => {
|
||||
return { ...segments, [segment.UUID]: { from: 0, to: 0 } };
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (currentTime < 10) return;
|
||||
if (Number((currentTime % 10).toFixed(1)) <= 0.2) {
|
||||
// Check progress every 10 seconds or else progress is checked a few times a second
|
||||
await updateVideoProgressById({
|
||||
youtubeId,
|
||||
currentProgress: currentTime,
|
||||
});
|
||||
|
||||
if (!watched) {
|
||||
// Check if video is already marked as watched
|
||||
if (watchedThreshold(currentTime, duration)) {
|
||||
await updateWatchedState({
|
||||
id: youtubeId,
|
||||
is_watched: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleVideoEnd =
|
||||
(
|
||||
youtubeId: string,
|
||||
watched: boolean,
|
||||
setSponsorSegmentSkipped?: Dispatch<SetStateAction<SponsorSegmentsSkippedType>>,
|
||||
) =>
|
||||
async () => {
|
||||
if (!watched) {
|
||||
// Check if video is already marked as watched
|
||||
await updateWatchedState({ id: youtubeId, is_watched: true });
|
||||
}
|
||||
|
||||
setSponsorSegmentSkipped?.((segments: SponsorSegmentsSkippedType) => {
|
||||
const keys = Object.keys(segments);
|
||||
|
||||
keys.forEach(uuid => {
|
||||
segments[uuid] = { from: 0, to: 0 };
|
||||
});
|
||||
|
||||
return segments;
|
||||
});
|
||||
};
|
||||
|
||||
export type VideoProgressType = {
|
||||
youtube_id: string;
|
||||
user_id: number;
|
||||
position: number;
|
||||
};
|
||||
|
||||
type VideoPlayerProps = {
|
||||
video: VideoResponseType;
|
||||
videoProgress?: VideoProgressType;
|
||||
sponsorBlock?: SponsorBlockType;
|
||||
embed?: boolean;
|
||||
};
|
||||
|
||||
const VideoPlayer = ({ video, videoProgress, sponsorBlock, embed }: VideoPlayerProps) => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const searchParamVideoProgress = searchParams.get('t');
|
||||
|
||||
const [skippedSegments, setSkippedSegments] = useState<SponsorSegmentsSkippedType>({});
|
||||
|
||||
const videoId = video.data.youtube_id;
|
||||
const videoUrl = video.data.media_url;
|
||||
const videoThumbUrl = video.data.vid_thumb_url;
|
||||
const watched = video.data.player.watched;
|
||||
const duration = video.data.player.duration;
|
||||
const videoSubtitles = video.data.subtitles;
|
||||
|
||||
let videoSrcProgress = Number(videoProgress?.position) > 0 ? Number(videoProgress?.position) : '';
|
||||
|
||||
if (searchParamVideoProgress !== null) {
|
||||
videoSrcProgress = searchParamVideoProgress;
|
||||
}
|
||||
|
||||
const autoplay = false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id="player" className={embed ? '' : 'player-wrapper'}>
|
||||
<div className={embed ? '' : 'video-main'}>
|
||||
<video
|
||||
poster={`${getApiUrl()}${videoThumbUrl}`}
|
||||
onVolumeChange={(videoTag: VideoTag) => {
|
||||
localStorage.setItem('playerVolume', videoTag.currentTarget.volume.toString());
|
||||
}}
|
||||
onLoadStart={(videoTag: VideoTag) => {
|
||||
videoTag.currentTarget.volume = Number(localStorage.getItem('playerVolume')) ?? 1;
|
||||
}}
|
||||
onTimeUpdate={handleTimeUpdate(
|
||||
videoId,
|
||||
duration,
|
||||
watched,
|
||||
sponsorBlock,
|
||||
setSkippedSegments,
|
||||
)}
|
||||
onPause={async (videoTag: VideoTag) => {
|
||||
const currentTime = Number(videoTag.currentTarget.currentTime);
|
||||
|
||||
if (currentTime < 10) return;
|
||||
|
||||
await updateVideoProgressById({
|
||||
youtubeId: videoId,
|
||||
currentProgress: currentTime,
|
||||
});
|
||||
}}
|
||||
onEnded={handleVideoEnd(videoId, watched)}
|
||||
autoPlay={autoplay}
|
||||
controls
|
||||
width="100%"
|
||||
playsInline
|
||||
id="video-item"
|
||||
>
|
||||
<source
|
||||
src={`${getApiUrl()}${videoUrl}#t=${videoSrcProgress}`}
|
||||
type="video/mp4"
|
||||
id="video-source"
|
||||
/>
|
||||
{videoSubtitles && <Subtitles subtitles={videoSubtitles} />}
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Notifications pageName="all" />
|
||||
<div className="sponsorblock" id="sponsorblock">
|
||||
{sponsorBlock?.is_enabled && (
|
||||
<>
|
||||
{sponsorBlock.segments.length == 0 && (
|
||||
<h4>
|
||||
This video doesn't have any sponsor segments added. To add a segment go to{' '}
|
||||
<u>
|
||||
<a href={`https://www.youtube.com/watch?v=${videoId}`}>this video on YouTube</a>
|
||||
</u>{' '}
|
||||
and add a segment using the{' '}
|
||||
<u>
|
||||
<a href="https://sponsor.ajay.app/">SponsorBlock</a>
|
||||
</u>{' '}
|
||||
extension.
|
||||
</h4>
|
||||
)}
|
||||
{sponsorBlock.has_unlocked && (
|
||||
<h4>
|
||||
This video has unlocked sponsor segments. Go to{' '}
|
||||
<u>
|
||||
<a href={`https://www.youtube.com/watch?v=${videoId}`}>this video on YouTube</a>
|
||||
</u>{' '}
|
||||
and vote on the segments using the{' '}
|
||||
<u>
|
||||
<a href="https://sponsor.ajay.app/">SponsorBlock</a>
|
||||
</u>{' '}
|
||||
extension.
|
||||
</h4>
|
||||
)}
|
||||
|
||||
{Object.values(skippedSegments).map(({ from, to }) => {
|
||||
return (
|
||||
<>
|
||||
{from !== 0 && to !== 0 && (
|
||||
<h3>
|
||||
Skipped sponsor segment from {formatTime(from)} to {formatTime(to)}.
|
||||
</h3>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoPlayer;
|
||||
import updateVideoProgressById from '../api/actions/updateVideoProgressById';
|
||||
import updateWatchedState from '../api/actions/updateWatchedState';
|
||||
import { SponsorBlockSegmentType, SponsorBlockType, VideoResponseType } from '../pages/Video';
|
||||
import watchedThreshold from '../functions/watchedThreshold';
|
||||
import Notifications from './Notifications';
|
||||
import { Dispatch, SetStateAction, SyntheticEvent, useState } from 'react';
|
||||
import formatTime from '../functions/formatTime';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import getApiUrl from '../configuration/getApiUrl';
|
||||
|
||||
type VideoTag = SyntheticEvent<HTMLVideoElement, Event>;
|
||||
|
||||
export type SkippedSegmentType = {
|
||||
from: number;
|
||||
to: number;
|
||||
};
|
||||
|
||||
export type SponsorSegmentsSkippedType = Record<string, SkippedSegmentType>;
|
||||
|
||||
type Subtitle = {
|
||||
name: string;
|
||||
source: string;
|
||||
lang: string;
|
||||
media_url: string;
|
||||
};
|
||||
|
||||
type SubtitlesProp = {
|
||||
subtitles: Subtitle[];
|
||||
};
|
||||
|
||||
const Subtitles = ({ subtitles }: SubtitlesProp) => {
|
||||
return subtitles.map((subtitle: Subtitle) => {
|
||||
let label = subtitle.name;
|
||||
|
||||
if (subtitle.source === 'auto') {
|
||||
label += ' - auto';
|
||||
}
|
||||
|
||||
return (
|
||||
<track
|
||||
key={subtitle.name}
|
||||
label={label}
|
||||
kind="subtitles"
|
||||
srcLang={subtitle.lang}
|
||||
src={`${getApiUrl()}${subtitle.media_url}`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const handleTimeUpdate =
|
||||
(
|
||||
youtubeId: string,
|
||||
duration: number,
|
||||
watched: boolean,
|
||||
sponsorBlock?: SponsorBlockType,
|
||||
setSponsorSegmentSkipped?: Dispatch<SetStateAction<SponsorSegmentsSkippedType>>,
|
||||
) =>
|
||||
async (videoTag: VideoTag) => {
|
||||
const currentTime = Number(videoTag.currentTarget.currentTime);
|
||||
|
||||
if (sponsorBlock && sponsorBlock.segments) {
|
||||
sponsorBlock.segments.forEach((segment: SponsorBlockSegmentType) => {
|
||||
const [from, to] = segment.segment;
|
||||
|
||||
if (currentTime >= from && currentTime <= from + 0.3) {
|
||||
videoTag.currentTarget.currentTime = to;
|
||||
|
||||
setSponsorSegmentSkipped?.((segments: SponsorSegmentsSkippedType) => {
|
||||
return { ...segments, [segment.UUID]: { from, to } };
|
||||
});
|
||||
}
|
||||
|
||||
if (currentTime > to + 10) {
|
||||
setSponsorSegmentSkipped?.((segments: SponsorSegmentsSkippedType) => {
|
||||
return { ...segments, [segment.UUID]: { from: 0, to: 0 } };
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (currentTime < 10) return;
|
||||
if (Number((currentTime % 10).toFixed(1)) <= 0.2) {
|
||||
// Check progress every 10 seconds or else progress is checked a few times a second
|
||||
await updateVideoProgressById({
|
||||
youtubeId,
|
||||
currentProgress: currentTime,
|
||||
});
|
||||
|
||||
if (!watched) {
|
||||
// Check if video is already marked as watched
|
||||
if (watchedThreshold(currentTime, duration)) {
|
||||
await updateWatchedState({
|
||||
id: youtubeId,
|
||||
is_watched: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export type VideoProgressType = {
|
||||
youtube_id: string;
|
||||
user_id: number;
|
||||
position: number;
|
||||
};
|
||||
|
||||
type VideoPlayerProps = {
|
||||
video: VideoResponseType;
|
||||
videoProgress?: VideoProgressType;
|
||||
sponsorBlock?: SponsorBlockType;
|
||||
embed?: boolean;
|
||||
autoplay?: boolean;
|
||||
onVideoEnd?: () => void;
|
||||
};
|
||||
|
||||
const VideoPlayer = ({
|
||||
video,
|
||||
videoProgress,
|
||||
sponsorBlock,
|
||||
embed,
|
||||
autoplay = false,
|
||||
onVideoEnd,
|
||||
}: VideoPlayerProps) => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const searchParamVideoProgress = searchParams.get('t');
|
||||
|
||||
const [skippedSegments, setSkippedSegments] = useState<SponsorSegmentsSkippedType>({});
|
||||
|
||||
const videoId = video.data.youtube_id;
|
||||
const videoUrl = video.data.media_url;
|
||||
const videoThumbUrl = video.data.vid_thumb_url;
|
||||
const watched = video.data.player.watched;
|
||||
const duration = video.data.player.duration;
|
||||
const videoSubtitles = video.data.subtitles;
|
||||
|
||||
let videoSrcProgress = Number(videoProgress?.position) > 0 ? Number(videoProgress?.position) : '';
|
||||
|
||||
if (searchParamVideoProgress !== null) {
|
||||
videoSrcProgress = searchParamVideoProgress;
|
||||
}
|
||||
|
||||
const handleVideoEnd =
|
||||
(
|
||||
youtubeId: string,
|
||||
watched: boolean,
|
||||
setSponsorSegmentSkipped?: Dispatch<SetStateAction<SponsorSegmentsSkippedType>>,
|
||||
) =>
|
||||
async () => {
|
||||
if (!watched) {
|
||||
// Check if video is already marked as watched
|
||||
await updateWatchedState({ id: youtubeId, is_watched: true });
|
||||
}
|
||||
|
||||
setSponsorSegmentSkipped?.((segments: SponsorSegmentsSkippedType) => {
|
||||
const keys = Object.keys(segments);
|
||||
|
||||
keys.forEach(uuid => {
|
||||
segments[uuid] = { from: 0, to: 0 };
|
||||
});
|
||||
|
||||
return segments;
|
||||
});
|
||||
|
||||
onVideoEnd?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id="player" className={embed ? '' : 'player-wrapper'}>
|
||||
<div className={embed ? '' : 'video-main'}>
|
||||
<video
|
||||
poster={`${getApiUrl()}${videoThumbUrl}`}
|
||||
onVolumeChange={(videoTag: VideoTag) => {
|
||||
localStorage.setItem('playerVolume', videoTag.currentTarget.volume.toString());
|
||||
}}
|
||||
onRateChange={(videoTag: VideoTag) => {
|
||||
localStorage.setItem('playerSpeed', videoTag.currentTarget.playbackRate.toString());
|
||||
}}
|
||||
onLoadStart={(videoTag: VideoTag) => {
|
||||
videoTag.currentTarget.volume = Number(localStorage.getItem('playerVolume')) ?? 1;
|
||||
videoTag.currentTarget.playbackRate =
|
||||
Number(localStorage.getItem('playerSpeed')) ?? 1;
|
||||
}}
|
||||
onTimeUpdate={handleTimeUpdate(
|
||||
videoId,
|
||||
duration,
|
||||
watched,
|
||||
sponsorBlock,
|
||||
setSkippedSegments,
|
||||
)}
|
||||
onPause={async (videoTag: VideoTag) => {
|
||||
const currentTime = Number(videoTag.currentTarget.currentTime);
|
||||
|
||||
if (currentTime < 10) return;
|
||||
|
||||
await updateVideoProgressById({
|
||||
youtubeId: videoId,
|
||||
currentProgress: currentTime,
|
||||
});
|
||||
}}
|
||||
onEnded={handleVideoEnd(videoId, watched)}
|
||||
autoPlay={autoplay}
|
||||
controls
|
||||
width="100%"
|
||||
playsInline
|
||||
id="video-item"
|
||||
>
|
||||
<source
|
||||
src={`${getApiUrl()}${videoUrl}#t=${videoSrcProgress}`}
|
||||
type="video/mp4"
|
||||
id="video-source"
|
||||
/>
|
||||
{videoSubtitles && <Subtitles subtitles={videoSubtitles} />}
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Notifications pageName="all" />
|
||||
<div className="sponsorblock" id="sponsorblock">
|
||||
{sponsorBlock?.is_enabled && (
|
||||
<>
|
||||
{sponsorBlock.segments.length == 0 && (
|
||||
<h4>
|
||||
This video doesn't have any sponsor segments added. To add a segment go to{' '}
|
||||
<u>
|
||||
<a href={`https://www.youtube.com/watch?v=${videoId}`}>this video on YouTube</a>
|
||||
</u>{' '}
|
||||
and add a segment using the{' '}
|
||||
<u>
|
||||
<a href="https://sponsor.ajay.app/">SponsorBlock</a>
|
||||
</u>{' '}
|
||||
extension.
|
||||
</h4>
|
||||
)}
|
||||
{sponsorBlock.has_unlocked && (
|
||||
<h4>
|
||||
This video has unlocked sponsor segments. Go to{' '}
|
||||
<u>
|
||||
<a href={`https://www.youtube.com/watch?v=${videoId}`}>this video on YouTube</a>
|
||||
</u>{' '}
|
||||
and vote on the segments using the{' '}
|
||||
<u>
|
||||
<a href="https://sponsor.ajay.app/">SponsorBlock</a>
|
||||
</u>{' '}
|
||||
extension.
|
||||
</h4>
|
||||
)}
|
||||
|
||||
{Object.values(skippedSegments).map(({ from, to }) => {
|
||||
return (
|
||||
<>
|
||||
{from !== 0 && to !== 0 && (
|
||||
<h3>
|
||||
Skipped sponsor segment from {formatTime(from)} to {formatTime(to)}.
|
||||
</h3>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoPlayer;
|
||||
|
@ -1,33 +1,63 @@
|
||||
import iconUnseen from '/img/icon-unseen.svg';
|
||||
import iconSeen from '/img/icon-seen.svg';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type WatchedCheckBoxProps = {
|
||||
watched: boolean;
|
||||
onClick?: (status: boolean) => void;
|
||||
onDone?: (status: boolean) => void;
|
||||
};
|
||||
|
||||
const WatchedCheckBox = ({ watched, onClick }: WatchedCheckBoxProps) => {
|
||||
const WatchedCheckBox = ({ watched, onClick, onDone }: WatchedCheckBoxProps) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [state, setState] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) {
|
||||
onClick?.(state);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
onDone?.(state);
|
||||
setLoading(false);
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [loading]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{watched && (
|
||||
{loading && (
|
||||
<>
|
||||
<div className="lds-ring" style={{ color: 'var(--accent-font-dark)' }}>
|
||||
<div />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!loading && watched && (
|
||||
<img
|
||||
src={iconSeen}
|
||||
alt="seen-icon"
|
||||
className="watch-button"
|
||||
title="Mark as unwatched"
|
||||
onClick={async () => {
|
||||
onClick?.(false);
|
||||
setState(false);
|
||||
setLoading(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!watched && (
|
||||
{!loading && !watched && (
|
||||
<img
|
||||
src={iconUnseen}
|
||||
alt="unseen-icon"
|
||||
className="watch-button"
|
||||
title="Mark as watched"
|
||||
onClick={async () => {
|
||||
onClick?.(true);
|
||||
setState(true);
|
||||
setLoading(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
@ -1,314 +1,316 @@
|
||||
import * as React from 'react';
|
||||
import * as ReactDOM from 'react-dom/client';
|
||||
import { createBrowserRouter, redirect, RouterProvider } from 'react-router-dom';
|
||||
import Routes from './configuration/routes/RouteList';
|
||||
import './style.css';
|
||||
import Base from './pages/Base';
|
||||
import About from './pages/About';
|
||||
import Channels from './pages/Channels';
|
||||
import ErrorPage from './pages/ErrorPage';
|
||||
import Home from './pages/Home';
|
||||
import Logout from './pages/Logout';
|
||||
import Playlist from './pages/Playlist';
|
||||
import Playlists from './pages/Playlists';
|
||||
import Search from './pages/Search';
|
||||
import SettingsDashboard from './pages/SettingsDashboard';
|
||||
import Video from './pages/Video';
|
||||
import Login from './pages/Login';
|
||||
import SettingsActions from './pages/SettingsActions';
|
||||
import SettingsApplication from './pages/SettingsApplication';
|
||||
import SettingsScheduling from './pages/SettingsScheduling';
|
||||
import SettingsUser from './pages/SettingsUser';
|
||||
import loadUserMeConfig from './api/loader/loadUserConfig';
|
||||
import loadAuth from './api/loader/loadAuth';
|
||||
import ChannelBase from './pages/ChannelBase';
|
||||
import ChannelVideo from './pages/ChannelVideo';
|
||||
import ChannelPlaylist from './pages/ChannelPlaylist';
|
||||
import ChannelAbout from './pages/ChannelAbout';
|
||||
import ChannelStream from './pages/ChannelStream';
|
||||
import Download from './pages/Download';
|
||||
import ChannelShorts from './pages/ChannelShorts';
|
||||
|
||||
const router = createBrowserRouter(
|
||||
[
|
||||
{
|
||||
path: Routes.Home,
|
||||
loader: async () => {
|
||||
console.log('------------ after reload');
|
||||
|
||||
const auth = await loadAuth();
|
||||
if (auth.status === 403) {
|
||||
return redirect(Routes.Login);
|
||||
}
|
||||
|
||||
const authData = await auth.json();
|
||||
|
||||
const userConfig = await loadUserMeConfig();
|
||||
|
||||
return { userConfig, auth: authData };
|
||||
},
|
||||
element: <Base />,
|
||||
errorElement: <ErrorPage />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <Home />,
|
||||
loader: async () => {
|
||||
const authResponse = await loadAuth();
|
||||
if (authResponse.status === 403) {
|
||||
return redirect(Routes.Login);
|
||||
}
|
||||
|
||||
const userConfig = await loadUserMeConfig();
|
||||
|
||||
return { userConfig };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: Routes.Video(':videoId'),
|
||||
element: <Video />,
|
||||
loader: async () => {
|
||||
const authResponse = await loadAuth();
|
||||
if (authResponse.status === 403) {
|
||||
return redirect(Routes.Login);
|
||||
}
|
||||
|
||||
return {};
|
||||
},
|
||||
},
|
||||
{
|
||||
path: Routes.Channels,
|
||||
element: <Channels />,
|
||||
loader: async () => {
|
||||
const authResponse = await loadAuth();
|
||||
if (authResponse.status === 403) {
|
||||
return redirect(Routes.Login);
|
||||
}
|
||||
|
||||
const userConfig = await loadUserMeConfig();
|
||||
|
||||
return { userConfig };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: Routes.Channel(':channelId'),
|
||||
element: <ChannelBase />,
|
||||
loader: async () => {
|
||||
const authResponse = await loadAuth();
|
||||
if (authResponse.status === 403) {
|
||||
return redirect(Routes.Login);
|
||||
}
|
||||
|
||||
return {};
|
||||
},
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
path: Routes.ChannelVideo(':channelId'),
|
||||
element: <ChannelVideo />,
|
||||
loader: async () => {
|
||||
const authResponse = await loadAuth();
|
||||
if (authResponse.status === 403) {
|
||||
return redirect(Routes.Login);
|
||||
}
|
||||
|
||||
const userConfig = await loadUserMeConfig();
|
||||
|
||||
return { userConfig };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: Routes.ChannelStream(':channelId'),
|
||||
element: <ChannelStream />,
|
||||
loader: async () => {
|
||||
const authResponse = await loadAuth();
|
||||
if (authResponse.status === 403) {
|
||||
return redirect(Routes.Login);
|
||||
}
|
||||
|
||||
return {};
|
||||
},
|
||||
},
|
||||
{
|
||||
path: Routes.ChannelShorts(':channelId'),
|
||||
element: <ChannelShorts />,
|
||||
loader: async () => {
|
||||
const authResponse = await loadAuth();
|
||||
if (authResponse.status === 403) {
|
||||
return redirect(Routes.Login);
|
||||
}
|
||||
|
||||
return {};
|
||||
},
|
||||
},
|
||||
{
|
||||
path: Routes.ChannelPlaylist(':channelId'),
|
||||
element: <ChannelPlaylist />,
|
||||
loader: async () => {
|
||||
const authResponse = await loadAuth();
|
||||
if (authResponse.status === 403) {
|
||||
return redirect(Routes.Login);
|
||||
}
|
||||
|
||||
const userConfig = await loadUserMeConfig();
|
||||
|
||||
return { userConfig };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: Routes.ChannelAbout(':channelId'),
|
||||
element: <ChannelAbout />,
|
||||
loader: async () => {
|
||||
const authResponse = await loadAuth();
|
||||
if (authResponse.status === 403) {
|
||||
return redirect(Routes.Login);
|
||||
}
|
||||
|
||||
return {};
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: Routes.Playlists,
|
||||
element: <Playlists />,
|
||||
loader: async () => {
|
||||
const authResponse = await loadAuth();
|
||||
if (authResponse.status === 403) {
|
||||
return redirect(Routes.Login);
|
||||
}
|
||||
|
||||
const userConfig = await loadUserMeConfig();
|
||||
|
||||
return { userConfig };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: Routes.Playlist(':playlistId'),
|
||||
element: <Playlist />,
|
||||
loader: async () => {
|
||||
const authResponse = await loadAuth();
|
||||
if (authResponse.status === 403) {
|
||||
return redirect(Routes.Login);
|
||||
}
|
||||
|
||||
const userConfig = await loadUserMeConfig();
|
||||
|
||||
return { userConfig };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: Routes.Downloads,
|
||||
element: <Download />,
|
||||
loader: async () => {
|
||||
const authResponse = await loadAuth();
|
||||
if (authResponse.status === 403) {
|
||||
return redirect(Routes.Login);
|
||||
}
|
||||
|
||||
const userConfig = await loadUserMeConfig();
|
||||
|
||||
return { userConfig };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: Routes.Search,
|
||||
element: <Search />,
|
||||
loader: async () => {
|
||||
const authResponse = await loadAuth();
|
||||
if (authResponse.status === 403) {
|
||||
return redirect(Routes.Login);
|
||||
}
|
||||
|
||||
const userConfig = await loadUserMeConfig();
|
||||
|
||||
return { userConfig };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: Routes.SettingsDashboard,
|
||||
element: <SettingsDashboard />,
|
||||
loader: async () => {
|
||||
const authResponse = await loadAuth();
|
||||
if (authResponse.status === 403) {
|
||||
return redirect(Routes.Login);
|
||||
}
|
||||
|
||||
return {};
|
||||
},
|
||||
},
|
||||
{
|
||||
path: Routes.SettingsActions,
|
||||
element: <SettingsActions />,
|
||||
loader: async () => {
|
||||
const authResponse = await loadAuth();
|
||||
if (authResponse.status === 403) {
|
||||
return redirect(Routes.Login);
|
||||
}
|
||||
|
||||
return {};
|
||||
},
|
||||
},
|
||||
{
|
||||
path: Routes.SettingsApplication,
|
||||
element: <SettingsApplication />,
|
||||
loader: async () => {
|
||||
const authResponse = await loadAuth();
|
||||
if (authResponse.status === 403) {
|
||||
return redirect(Routes.Login);
|
||||
}
|
||||
|
||||
return {};
|
||||
},
|
||||
},
|
||||
{
|
||||
path: Routes.SettingsScheduling,
|
||||
element: <SettingsScheduling />,
|
||||
loader: async () => {
|
||||
const authResponse = await loadAuth();
|
||||
if (authResponse.status === 403) {
|
||||
return redirect(Routes.Login);
|
||||
}
|
||||
|
||||
return {};
|
||||
},
|
||||
},
|
||||
{
|
||||
path: Routes.SettingsUser,
|
||||
element: <SettingsUser />,
|
||||
loader: async () => {
|
||||
const auth = await loadAuth();
|
||||
if (auth.status === 403) {
|
||||
return redirect(Routes.Login);
|
||||
}
|
||||
|
||||
const userConfig = await loadUserMeConfig();
|
||||
|
||||
return { userConfig };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: Routes.About,
|
||||
element: <About />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: Routes.Login,
|
||||
element: <Login />,
|
||||
errorElement: <ErrorPage />,
|
||||
},
|
||||
{
|
||||
path: Routes.Logout,
|
||||
element: <Logout />,
|
||||
errorElement: <ErrorPage />,
|
||||
},
|
||||
],
|
||||
{ basename: import.meta.env.BASE_URL },
|
||||
);
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
import * as React from 'react';
|
||||
import * as ReactDOM from 'react-dom/client';
|
||||
import { createBrowserRouter, redirect, RouterProvider } from 'react-router-dom';
|
||||
import Routes from './configuration/routes/RouteList';
|
||||
import './style.css';
|
||||
import Base from './pages/Base';
|
||||
import About from './pages/About';
|
||||
import Channels from './pages/Channels';
|
||||
import ErrorPage from './pages/ErrorPage';
|
||||
import Home from './pages/Home';
|
||||
import Logout from './pages/Logout';
|
||||
import Playlist from './pages/Playlist';
|
||||
import Playlists from './pages/Playlists';
|
||||
import Search from './pages/Search';
|
||||
import SettingsDashboard from './pages/SettingsDashboard';
|
||||
import Video from './pages/Video';
|
||||
import Login from './pages/Login';
|
||||
import SettingsActions from './pages/SettingsActions';
|
||||
import SettingsApplication from './pages/SettingsApplication';
|
||||
import SettingsScheduling from './pages/SettingsScheduling';
|
||||
import SettingsUser from './pages/SettingsUser';
|
||||
import loadUserMeConfig from './api/loader/loadUserConfig';
|
||||
import loadAuth from './api/loader/loadAuth';
|
||||
import ChannelBase from './pages/ChannelBase';
|
||||
import ChannelVideo from './pages/ChannelVideo';
|
||||
import ChannelPlaylist from './pages/ChannelPlaylist';
|
||||
import ChannelAbout from './pages/ChannelAbout';
|
||||
import Download from './pages/Download';
|
||||
|
||||
const router = createBrowserRouter(
|
||||
[
|
||||
{
|
||||
path: Routes.Home,
|
||||
loader: async () => {
|
||||
console.log('------------ after reload');
|
||||
|
||||
const auth = await loadAuth();
|
||||
if (auth.status === 403) {
|
||||
return redirect(Routes.Login);
|
||||
}
|
||||
|
||||
const authData = await auth.json();
|
||||
|
||||
const userConfig = await loadUserMeConfig();
|
||||
|
||||
return { userConfig, auth: authData };
|
||||
},
|
||||
element: <Base />,
|
||||
errorElement: <ErrorPage />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <Home />,
|
||||
loader: async () => {
|
||||
const authResponse = await loadAuth();
|
||||
if (authResponse.status === 403) {
|
||||
return redirect(Routes.Login);
|
||||
}
|
||||
|
||||
const userConfig = await loadUserMeConfig();
|
||||
|
||||
return { userConfig };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: Routes.Video(':videoId'),
|
||||
element: <Video />,
|
||||
loader: async () => {
|
||||
const authResponse = await loadAuth();
|
||||
if (authResponse.status === 403) {
|
||||
return redirect(Routes.Login);
|
||||
}
|
||||
|
||||
return {};
|
||||
},
|
||||
},
|
||||
{
|
||||
path: Routes.Channels,
|
||||
element: <Channels />,
|
||||
loader: async () => {
|
||||
const authResponse = await loadAuth();
|
||||
if (authResponse.status === 403) {
|
||||
return redirect(Routes.Login);
|
||||
}
|
||||
|
||||
const userConfig = await loadUserMeConfig();
|
||||
|
||||
return { userConfig };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: Routes.Channel(':channelId'),
|
||||
element: <ChannelBase />,
|
||||
loader: async () => {
|
||||
const authResponse = await loadAuth();
|
||||
if (authResponse.status === 403) {
|
||||
return redirect(Routes.Login);
|
||||
}
|
||||
|
||||
return {};
|
||||
},
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
path: Routes.ChannelVideo(':channelId'),
|
||||
element: <ChannelVideo videoType="videos" />,
|
||||
loader: async () => {
|
||||
const authResponse = await loadAuth();
|
||||
if (authResponse.status === 403) {
|
||||
return redirect(Routes.Login);
|
||||
}
|
||||
|
||||
const userConfig = await loadUserMeConfig();
|
||||
|
||||
return { userConfig };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: Routes.ChannelStream(':channelId'),
|
||||
element: <ChannelVideo videoType="streams" />,
|
||||
loader: async () => {
|
||||
const authResponse = await loadAuth();
|
||||
if (authResponse.status === 403) {
|
||||
return redirect(Routes.Login);
|
||||
}
|
||||
|
||||
const userConfig = await loadUserMeConfig();
|
||||
|
||||
return { userConfig };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: Routes.ChannelShorts(':channelId'),
|
||||
element: <ChannelVideo videoType="shorts" />,
|
||||
loader: async () => {
|
||||
const authResponse = await loadAuth();
|
||||
if (authResponse.status === 403) {
|
||||
return redirect(Routes.Login);
|
||||
}
|
||||
|
||||
const userConfig = await loadUserMeConfig();
|
||||
|
||||
return { userConfig };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: Routes.ChannelPlaylist(':channelId'),
|
||||
element: <ChannelPlaylist />,
|
||||
loader: async () => {
|
||||
const authResponse = await loadAuth();
|
||||
if (authResponse.status === 403) {
|
||||
return redirect(Routes.Login);
|
||||
}
|
||||
|
||||
const userConfig = await loadUserMeConfig();
|
||||
|
||||
return { userConfig };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: Routes.ChannelAbout(':channelId'),
|
||||
element: <ChannelAbout />,
|
||||
loader: async () => {
|
||||
const authResponse = await loadAuth();
|
||||
if (authResponse.status === 403) {
|
||||
return redirect(Routes.Login);
|
||||
}
|
||||
|
||||
return {};
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: Routes.Playlists,
|
||||
element: <Playlists />,
|
||||
loader: async () => {
|
||||
const authResponse = await loadAuth();
|
||||
if (authResponse.status === 403) {
|
||||
return redirect(Routes.Login);
|
||||
}
|
||||
|
||||
const userConfig = await loadUserMeConfig();
|
||||
|
||||
return { userConfig };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: Routes.Playlist(':playlistId'),
|
||||
element: <Playlist />,
|
||||
loader: async () => {
|
||||
const authResponse = await loadAuth();
|
||||
if (authResponse.status === 403) {
|
||||
return redirect(Routes.Login);
|
||||
}
|
||||
|
||||
const userConfig = await loadUserMeConfig();
|
||||
|
||||
return { userConfig };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: Routes.Downloads,
|
||||
element: <Download />,
|
||||
loader: async () => {
|
||||
const authResponse = await loadAuth();
|
||||
if (authResponse.status === 403) {
|
||||
return redirect(Routes.Login);
|
||||
}
|
||||
|
||||
const userConfig = await loadUserMeConfig();
|
||||
|
||||
return { userConfig };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: Routes.Search,
|
||||
element: <Search />,
|
||||
loader: async () => {
|
||||
const authResponse = await loadAuth();
|
||||
if (authResponse.status === 403) {
|
||||
return redirect(Routes.Login);
|
||||
}
|
||||
|
||||
const userConfig = await loadUserMeConfig();
|
||||
|
||||
return { userConfig };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: Routes.SettingsDashboard,
|
||||
element: <SettingsDashboard />,
|
||||
loader: async () => {
|
||||
const authResponse = await loadAuth();
|
||||
if (authResponse.status === 403) {
|
||||
return redirect(Routes.Login);
|
||||
}
|
||||
|
||||
return {};
|
||||
},
|
||||
},
|
||||
{
|
||||
path: Routes.SettingsActions,
|
||||
element: <SettingsActions />,
|
||||
loader: async () => {
|
||||
const authResponse = await loadAuth();
|
||||
if (authResponse.status === 403) {
|
||||
return redirect(Routes.Login);
|
||||
}
|
||||
|
||||
return {};
|
||||
},
|
||||
},
|
||||
{
|
||||
path: Routes.SettingsApplication,
|
||||
element: <SettingsApplication />,
|
||||
loader: async () => {
|
||||
const authResponse = await loadAuth();
|
||||
if (authResponse.status === 403) {
|
||||
return redirect(Routes.Login);
|
||||
}
|
||||
|
||||
return {};
|
||||
},
|
||||
},
|
||||
{
|
||||
path: Routes.SettingsScheduling,
|
||||
element: <SettingsScheduling />,
|
||||
loader: async () => {
|
||||
const authResponse = await loadAuth();
|
||||
if (authResponse.status === 403) {
|
||||
return redirect(Routes.Login);
|
||||
}
|
||||
|
||||
return {};
|
||||
},
|
||||
},
|
||||
{
|
||||
path: Routes.SettingsUser,
|
||||
element: <SettingsUser />,
|
||||
loader: async () => {
|
||||
const auth = await loadAuth();
|
||||
if (auth.status === 403) {
|
||||
return redirect(Routes.Login);
|
||||
}
|
||||
|
||||
const userConfig = await loadUserMeConfig();
|
||||
|
||||
return { userConfig };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: Routes.About,
|
||||
element: <About />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: Routes.Login,
|
||||
element: <Login />,
|
||||
errorElement: <ErrorPage />,
|
||||
},
|
||||
{
|
||||
path: Routes.Logout,
|
||||
element: <Logout />,
|
||||
errorElement: <ErrorPage />,
|
||||
},
|
||||
],
|
||||
{ basename: import.meta.env.BASE_URL },
|
||||
);
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
@ -1,64 +1,60 @@
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
const About = () => {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>TA | About</title>
|
||||
</Helmet>
|
||||
|
||||
<div className="boxed-content">
|
||||
<div className="title-bar">
|
||||
<h1>About The Tube Archivist</h1>
|
||||
</div>
|
||||
<div className="about-section">
|
||||
<h2>Useful Links</h2>
|
||||
<p>
|
||||
This project is in active and constant development, take a look at the{' '}
|
||||
<a href="https://github.com/tubearchivist/tubearchivist#roadmap" target="_blank">
|
||||
roadmap
|
||||
</a>{' '}
|
||||
for a overview.
|
||||
</p>
|
||||
<p>
|
||||
All functionality is documented in our up-to-date{' '}
|
||||
<a href="https://docs.tubearchivist.com" target="_blank">
|
||||
user guide
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<p>
|
||||
All contributions are welcome: Open an{' '}
|
||||
<a href="https://github.com/tubearchivist/tubearchivist/issues" target="_blank">
|
||||
issue
|
||||
</a>{' '}
|
||||
for any bugs and errors, join us on{' '}
|
||||
<a href="https://www.tubearchivist.com/discord" target="_blank">
|
||||
Discord
|
||||
</a>{' '}
|
||||
to discuss details. The{' '}
|
||||
<a
|
||||
href="https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md"
|
||||
target="_blank"
|
||||
>
|
||||
contributing
|
||||
</a>{' '}
|
||||
page is a good place to get started.
|
||||
</p>
|
||||
</div>
|
||||
<div className="about-section">
|
||||
<h2>Donate</h2>
|
||||
<p>
|
||||
Here are{' '}
|
||||
<a href="https://github.com/tubearchivist/tubearchivist#donate" target="_blank">
|
||||
some links
|
||||
</a>
|
||||
, if you want to buy the developer a coffee. Thank you for your support!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default About;
|
||||
const About = () => {
|
||||
return (
|
||||
<>
|
||||
<title>TA | About</title>
|
||||
|
||||
<div className="boxed-content">
|
||||
<div className="title-bar">
|
||||
<h1>About The Tube Archivist</h1>
|
||||
</div>
|
||||
<div className="about-section">
|
||||
<h2>Useful Links</h2>
|
||||
<p>
|
||||
This project is in active and constant development, take a look at the{' '}
|
||||
<a href="https://github.com/tubearchivist/tubearchivist#roadmap" target="_blank">
|
||||
roadmap
|
||||
</a>{' '}
|
||||
for a overview.
|
||||
</p>
|
||||
<p>
|
||||
All functionality is documented in our up-to-date{' '}
|
||||
<a href="https://docs.tubearchivist.com" target="_blank">
|
||||
user guide
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<p>
|
||||
All contributions are welcome: Open an{' '}
|
||||
<a href="https://github.com/tubearchivist/tubearchivist/issues" target="_blank">
|
||||
issue
|
||||
</a>{' '}
|
||||
for any bugs and errors, join us on{' '}
|
||||
<a href="https://www.tubearchivist.com/discord" target="_blank">
|
||||
Discord
|
||||
</a>{' '}
|
||||
to discuss details. The{' '}
|
||||
<a
|
||||
href="https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md"
|
||||
target="_blank"
|
||||
>
|
||||
contributing
|
||||
</a>{' '}
|
||||
page is a good place to get started.
|
||||
</p>
|
||||
</div>
|
||||
<div className="about-section">
|
||||
<h2>Donate</h2>
|
||||
<p>
|
||||
Here are{' '}
|
||||
<a href="https://github.com/tubearchivist/tubearchivist#donate" target="_blank">
|
||||
some links
|
||||
</a>
|
||||
, if you want to buy the developer a coffee. Thank you for your support!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default About;
|
||||
|
@ -1,350 +1,444 @@
|
||||
import { useNavigate, useOutletContext, useParams } from 'react-router-dom';
|
||||
import ChannelOverview from '../components/ChannelOverview';
|
||||
import { useEffect, useState } from 'react';
|
||||
import loadChannelById from '../api/loader/loadChannelById';
|
||||
import { ChannelResponseType } from './ChannelBase';
|
||||
import Linkify from '../components/Linkify';
|
||||
import deleteChannel from '../api/actions/deleteChannel';
|
||||
import Routes from '../configuration/routes/RouteList';
|
||||
import queueReindex, { ReindexType, ReindexTypeEnum } from '../api/actions/queueReindex';
|
||||
import formatDate from '../functions/formatDates';
|
||||
import PaginationDummy from '../components/PaginationDummy';
|
||||
import FormattedNumber from '../components/FormattedNumber';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import Button from '../components/Button';
|
||||
|
||||
const handleSponsorBlockIntegrationOverwrite = (integration: boolean | undefined) => {
|
||||
if (integration === undefined) {
|
||||
return 'False';
|
||||
}
|
||||
|
||||
if (integration) {
|
||||
return integration;
|
||||
} else {
|
||||
return 'Disabled';
|
||||
}
|
||||
};
|
||||
|
||||
export type ChannelBaseOutletContextType = {
|
||||
isAdmin: boolean;
|
||||
currentPage: number;
|
||||
setCurrentPage: (page: number) => void;
|
||||
startNotification: boolean;
|
||||
setStartNotification: (status: boolean) => void;
|
||||
};
|
||||
|
||||
export type OutletContextType = {
|
||||
isAdmin: boolean;
|
||||
currentPage: number;
|
||||
setCurrentPage: (page: number) => void;
|
||||
};
|
||||
|
||||
type ChannelAboutParams = {
|
||||
channelId: string;
|
||||
};
|
||||
|
||||
const ChannelAbout = () => {
|
||||
const { channelId } = useParams() as ChannelAboutParams;
|
||||
const { isAdmin, setStartNotification } = useOutletContext() as ChannelBaseOutletContextType;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [descriptionExpanded, setDescriptionExpanded] = useState(false);
|
||||
const [reindex, setReindex] = useState(false);
|
||||
const [refresh, setRefresh] = useState(false);
|
||||
|
||||
const [channelResponse, setChannelResponse] = useState<ChannelResponseType>();
|
||||
|
||||
const channel = channelResponse?.data;
|
||||
|
||||
const channelOverwrites = channel?.channel_overwrites;
|
||||
|
||||
const handleSubmit = async (event: { preventDefault: () => void }) => {
|
||||
event.preventDefault();
|
||||
|
||||
//TODO: implement request to about api endpoint ( when implemented )
|
||||
// `/api/channel/${channel.channel_id}/about/`
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const channelResponse = await loadChannelById(channelId);
|
||||
|
||||
setChannelResponse(channelResponse);
|
||||
setRefresh(false);
|
||||
})();
|
||||
}, [refresh, channelId]);
|
||||
|
||||
if (!channel) {
|
||||
return 'Channel not found!';
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>TA | Channel: About {channel.channel_name}</title>
|
||||
</Helmet>
|
||||
<div className="boxed-content">
|
||||
<div className="info-box info-box-3">
|
||||
<ChannelOverview
|
||||
channelId={channel.channel_id}
|
||||
channelname={channel.channel_name}
|
||||
channelSubs={channel.channel_subs}
|
||||
channelSubscribed={channel.channel_subscribed}
|
||||
showSubscribeButton={true}
|
||||
isUserAdmin={isAdmin}
|
||||
setRefresh={setRefresh}
|
||||
/>
|
||||
|
||||
<div className="info-box-item">
|
||||
<div>
|
||||
<p>Last refreshed: {formatDate(channel.channel_last_refresh)}</p>
|
||||
{channel.channel_active && (
|
||||
<p>
|
||||
Youtube:{' '}
|
||||
<a href={`https://www.youtube.com/channel/${channel.channel_id}`} target="_blank">
|
||||
Active
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
{!channel.channel_active && <p>Youtube: Deactivated</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="info-box-item">
|
||||
<div>
|
||||
{channel.channel_views > 0 && (
|
||||
<FormattedNumber text="Channel views:" number={channel.channel_views} />
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="button-box">
|
||||
{!showDeleteConfirm && (
|
||||
<Button
|
||||
label="Delete Channel"
|
||||
id="delete-item"
|
||||
onClick={() => setShowDeleteConfirm(!showDeleteConfirm)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDeleteConfirm && (
|
||||
<div className="delete-confirm" id="delete-button">
|
||||
<span>Delete {channel.channel_name} including all videos? </span>
|
||||
<Button
|
||||
label="Delete"
|
||||
className="danger-button"
|
||||
onClick={async () => {
|
||||
await deleteChannel(channelId);
|
||||
navigate(Routes.Channels);
|
||||
}}
|
||||
/>{' '}
|
||||
<Button
|
||||
label="Cancel"
|
||||
onClick={() => setShowDeleteConfirm(!showDeleteConfirm)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{reindex && <p>Reindex scheduled</p>}
|
||||
{!reindex && (
|
||||
<div id="reindex-button" className="button-box">
|
||||
<Button
|
||||
label="Reindex"
|
||||
title={`Reindex Channel ${channel.channel_name}`}
|
||||
onClick={async () => {
|
||||
await queueReindex(channelId, ReindexTypeEnum.channel as ReindexType);
|
||||
|
||||
setReindex(true);
|
||||
setStartNotification(true);
|
||||
}}
|
||||
/>{' '}
|
||||
<Button
|
||||
label="Reindex Videos"
|
||||
title={`Reindex Videos of ${channel.channel_name}`}
|
||||
onClick={async () => {
|
||||
await queueReindex(
|
||||
channelId,
|
||||
ReindexTypeEnum.channel as ReindexType,
|
||||
true,
|
||||
);
|
||||
|
||||
setReindex(true);
|
||||
setStartNotification(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{channel.channel_description && (
|
||||
<div className="description-box">
|
||||
<p
|
||||
id={descriptionExpanded ? 'text-expand-expanded' : 'text-expand'}
|
||||
className="description-text"
|
||||
>
|
||||
<Linkify>{channel.channel_description}</Linkify>
|
||||
</p>
|
||||
|
||||
<Button
|
||||
label="Show more"
|
||||
id="text-expand-button"
|
||||
onClick={() => setDescriptionExpanded(!descriptionExpanded)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{channel.channel_tags && (
|
||||
<div className="description-box">
|
||||
<div className="video-tag-box">
|
||||
{channel.channel_tags.map(tag => {
|
||||
return (
|
||||
<span key={tag} className="video-tag">
|
||||
{tag}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<div id="overwrite-form" className="info-box">
|
||||
<div className="info-box-item">
|
||||
<h2>Customize {channel.channel_name}</h2>
|
||||
<form className="overwrite-form" onSubmit={handleSubmit}>
|
||||
<div className="overwrite-form-item">
|
||||
<p>
|
||||
Download format:{' '}
|
||||
<span className="settings-current">
|
||||
{channelOverwrites?.download_format || 'False'}
|
||||
</span>
|
||||
</p>
|
||||
<input type="text" name="download_format" id="id_download_format" />
|
||||
<br />
|
||||
</div>
|
||||
<div className="overwrite-form-item">
|
||||
<p>
|
||||
Auto delete watched videos after x days:{' '}
|
||||
<span className="settings-current">
|
||||
{channelOverwrites?.autodelete_days || 'False'}
|
||||
</span>
|
||||
</p>
|
||||
<input type="number" name="autodelete_days" id="id_autodelete_days" />
|
||||
|
||||
<br />
|
||||
</div>
|
||||
|
||||
<div className="overwrite-form-item">
|
||||
<p>
|
||||
Index playlists:{' '}
|
||||
<span className="settings-current">
|
||||
{channelOverwrites?.index_playlists || 'False'}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<select name="index_playlists" id="id_index_playlists" defaultValue="">
|
||||
<option value="">-- change playlist index --</option>
|
||||
<option value="false">Disable playlist index</option>
|
||||
<option value="true">Enable playlist index</option>
|
||||
</select>
|
||||
|
||||
<br />
|
||||
</div>
|
||||
|
||||
<div className="overwrite-form-item">
|
||||
<p>
|
||||
Enable{' '}
|
||||
<a href="https://sponsor.ajay.app/" target="_blank">
|
||||
SponsorBlock
|
||||
</a>
|
||||
:{' '}
|
||||
<span className="settings-current">
|
||||
{handleSponsorBlockIntegrationOverwrite(
|
||||
channelOverwrites?.integrate_sponsorblock,
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
<select
|
||||
name="integrate_sponsorblock"
|
||||
id="id_integrate_sponsorblock"
|
||||
defaultValue=""
|
||||
>
|
||||
<option value="">-- change sponsorblock integrations</option>
|
||||
<option value="disable">disable sponsorblock integration</option>
|
||||
<option value="true">enable sponsorblock integration</option>
|
||||
<option value="false">unset sponsorblock integration</option>
|
||||
</select>
|
||||
</div>
|
||||
<h3>Page Size Overrides</h3>
|
||||
<br />
|
||||
<p>
|
||||
Disable standard videos, shorts, or streams for this channel by setting their page
|
||||
size to 0 (zero).
|
||||
</p>
|
||||
<br />
|
||||
<p>Disable page size overwrite for channel by setting to negative value.</p>
|
||||
<br />
|
||||
<div className="overwrite-form-item">
|
||||
<p>
|
||||
YouTube page size:{' '}
|
||||
<span className="settings-current">
|
||||
{channelOverwrites?.subscriptions_channel_size || 'False'}
|
||||
</span>
|
||||
</p>
|
||||
<i>
|
||||
Videos to scan to find new items for the <b>Rescan subscriptions</b> task, max
|
||||
recommended 50.
|
||||
</i>
|
||||
<br />
|
||||
<input type="number" name="channel_size" id="id_channel_size" />
|
||||
<br />
|
||||
</div>
|
||||
<div className="overwrite-form-item">
|
||||
<p>
|
||||
YouTube Live page size:{' '}
|
||||
<span className="settings-current">
|
||||
{channelOverwrites?.subscriptions_live_channel_size || 'False'}
|
||||
</span>
|
||||
</p>
|
||||
<i>
|
||||
Live Videos to scan to find new items for the <b>Rescan subscriptions</b> task,
|
||||
max recommended 50.
|
||||
</i>
|
||||
<br />
|
||||
<input type="number" name="live_channel_size" id="id_live_channel_size" />
|
||||
<br />
|
||||
</div>
|
||||
<div className="overwrite-form-item">
|
||||
<p>
|
||||
YouTube Shorts page size:{' '}
|
||||
<span className="settings-current">
|
||||
{channelOverwrites?.subscriptions_shorts_channel_size || 'False'}
|
||||
</span>
|
||||
</p>
|
||||
<i>
|
||||
Shorts Videos to scan to find new items for the <b>Rescan subscriptions</b>{' '}
|
||||
task, max recommended 50.
|
||||
</i>
|
||||
<br />
|
||||
<input type="number" name="shorts_channel_size" id="id_shorts_channel_size" />
|
||||
</div>
|
||||
<br />
|
||||
|
||||
<Button type="submit" label="Save Channel Overwrites" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PaginationDummy />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelAbout;
|
||||
import { useNavigate, useOutletContext, useParams } from 'react-router-dom';
|
||||
import ChannelOverview from '../components/ChannelOverview';
|
||||
import { useEffect, useState } from 'react';
|
||||
import loadChannelById from '../api/loader/loadChannelById';
|
||||
import { ChannelResponseType } from './ChannelBase';
|
||||
import Linkify from '../components/Linkify';
|
||||
import deleteChannel from '../api/actions/deleteChannel';
|
||||
import Routes from '../configuration/routes/RouteList';
|
||||
import queueReindex, { ReindexType, ReindexTypeEnum } from '../api/actions/queueReindex';
|
||||
import formatDate from '../functions/formatDates';
|
||||
import PaginationDummy from '../components/PaginationDummy';
|
||||
import FormattedNumber from '../components/FormattedNumber';
|
||||
import Button from '../components/Button';
|
||||
import updateChannelSettings, {
|
||||
ChannelAboutConfigType,
|
||||
} from '../api/actions/updateChannelSettings';
|
||||
|
||||
const toStringToBool = (str: string) => {
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export type ChannelBaseOutletContextType = {
|
||||
isAdmin: boolean;
|
||||
currentPage: number;
|
||||
setCurrentPage: (page: number) => void;
|
||||
startNotification: boolean;
|
||||
setStartNotification: (status: boolean) => void;
|
||||
};
|
||||
|
||||
export type OutletContextType = {
|
||||
isAdmin: boolean;
|
||||
currentPage: number;
|
||||
setCurrentPage: (page: number) => void;
|
||||
};
|
||||
|
||||
type ChannelAboutParams = {
|
||||
channelId: string;
|
||||
};
|
||||
|
||||
const ChannelAbout = () => {
|
||||
const { channelId } = useParams() as ChannelAboutParams;
|
||||
const { isAdmin, setStartNotification } = useOutletContext() as ChannelBaseOutletContextType;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [descriptionExpanded, setDescriptionExpanded] = useState(false);
|
||||
const [reindex, setReindex] = useState(false);
|
||||
const [refresh, setRefresh] = useState(true);
|
||||
|
||||
const [channelResponse, setChannelResponse] = useState<ChannelResponseType>();
|
||||
const [channelConfig, setChannelConfig] = useState<ChannelAboutConfigType>();
|
||||
|
||||
const [downloadFormat, setDownloadFormat] = useState(channelConfig?.download_format);
|
||||
const [autoDeleteAfter, setAutoDeleteAfter] = useState(channelConfig?.autodelete_days);
|
||||
const [indexPlaylists, setIndexPlaylists] = useState(
|
||||
channelConfig?.index_playlists ? 'true' : 'false',
|
||||
);
|
||||
const [enableSponsorblock, setEnableSponsorblock] = useState(
|
||||
channelConfig?.integrate_sponsorblock,
|
||||
);
|
||||
const [pageSizeVideo, setPageSizeVideo] = useState(channelConfig?.subscriptions_channel_size);
|
||||
const [pageSizeStreams, setPageSizeStreams] = useState(
|
||||
channelConfig?.subscriptions_live_channel_size,
|
||||
);
|
||||
const [pageSizeShorts, setPageSizeShorts] = useState(
|
||||
channelConfig?.subscriptions_shorts_channel_size,
|
||||
);
|
||||
|
||||
const channel = channelResponse?.data;
|
||||
|
||||
const channelOverwrites = channel?.channel_overwrites;
|
||||
|
||||
const handleSubmit = async (event: { preventDefault: () => void }) => {
|
||||
event.preventDefault();
|
||||
|
||||
await updateChannelSettings(channelId, {
|
||||
index_playlists: toStringToBool(indexPlaylists),
|
||||
download_format: downloadFormat,
|
||||
autodelete_days: autoDeleteAfter,
|
||||
integrate_sponsorblock: enableSponsorblock,
|
||||
subscriptions_channel_size: pageSizeVideo,
|
||||
subscriptions_live_channel_size: pageSizeStreams,
|
||||
subscriptions_shorts_channel_size: pageSizeShorts,
|
||||
});
|
||||
|
||||
setRefresh(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (refresh) {
|
||||
const channelResponse = await loadChannelById(channelId);
|
||||
|
||||
setChannelResponse(channelResponse);
|
||||
setChannelConfig(channelResponse?.data?.channel_overwrites);
|
||||
console.log('channel_overwrites', channelResponse?.data);
|
||||
console.log('channel_overwrites', channelResponse?.data?.channel_overwrites);
|
||||
console.log('channel_overwrites', '--------');
|
||||
setRefresh(false);
|
||||
}
|
||||
})();
|
||||
}, [refresh, channelId]);
|
||||
|
||||
if (!channel) {
|
||||
return 'Channel not found!';
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>{`TA | Channel: About ${channel.channel_name}`}</title>
|
||||
<div className="boxed-content">
|
||||
<div className="info-box info-box-3">
|
||||
<ChannelOverview
|
||||
channelId={channel.channel_id}
|
||||
channelname={channel.channel_name}
|
||||
channelSubs={channel.channel_subs}
|
||||
channelSubscribed={channel.channel_subscribed}
|
||||
channelThumbUrl={channel.channel_thumb_url}
|
||||
showSubscribeButton={true}
|
||||
isUserAdmin={isAdmin}
|
||||
setRefresh={setRefresh}
|
||||
/>
|
||||
|
||||
<div className="info-box-item">
|
||||
<div>
|
||||
<p>Last refreshed: {formatDate(channel.channel_last_refresh)}</p>
|
||||
{channel.channel_active && (
|
||||
<p>
|
||||
Youtube:{' '}
|
||||
<a href={`https://www.youtube.com/channel/${channel.channel_id}`} target="_blank">
|
||||
Active
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
{!channel.channel_active && <p>Youtube: Deactivated</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="info-box-item">
|
||||
<div>
|
||||
{channel.channel_views > 0 && (
|
||||
<FormattedNumber text="Channel views:" number={channel.channel_views} />
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="button-box">
|
||||
{!showDeleteConfirm && (
|
||||
<Button
|
||||
label="Delete Channel"
|
||||
id="delete-item"
|
||||
onClick={() => setShowDeleteConfirm(!showDeleteConfirm)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDeleteConfirm && (
|
||||
<div className="delete-confirm" id="delete-button">
|
||||
<span>Delete {channel.channel_name} including all videos? </span>
|
||||
<Button
|
||||
label="Delete"
|
||||
className="danger-button"
|
||||
onClick={async () => {
|
||||
await deleteChannel(channelId);
|
||||
navigate(Routes.Channels);
|
||||
}}
|
||||
/>{' '}
|
||||
<Button
|
||||
label="Cancel"
|
||||
onClick={() => setShowDeleteConfirm(!showDeleteConfirm)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{reindex && <p>Reindex scheduled</p>}
|
||||
{!reindex && (
|
||||
<div id="reindex-button" className="button-box">
|
||||
<Button
|
||||
label="Reindex"
|
||||
title={`Reindex Channel ${channel.channel_name}`}
|
||||
onClick={async () => {
|
||||
await queueReindex(channelId, ReindexTypeEnum.channel as ReindexType);
|
||||
|
||||
setReindex(true);
|
||||
setStartNotification(true);
|
||||
}}
|
||||
/>{' '}
|
||||
<Button
|
||||
label="Reindex Videos"
|
||||
title={`Reindex Videos of ${channel.channel_name}`}
|
||||
onClick={async () => {
|
||||
await queueReindex(
|
||||
channelId,
|
||||
ReindexTypeEnum.channel as ReindexType,
|
||||
true,
|
||||
);
|
||||
|
||||
setReindex(true);
|
||||
setStartNotification(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{channel.channel_description && (
|
||||
<div className="description-box">
|
||||
<p
|
||||
id={descriptionExpanded ? 'text-expand-expanded' : 'text-expand'}
|
||||
className="description-text"
|
||||
>
|
||||
<Linkify>{channel.channel_description}</Linkify>
|
||||
</p>
|
||||
|
||||
<Button
|
||||
label="Show more"
|
||||
id="text-expand-button"
|
||||
onClick={() => setDescriptionExpanded(!descriptionExpanded)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{channel.channel_tags && (
|
||||
<div className="description-box">
|
||||
<div className="video-tag-box">
|
||||
{channel.channel_tags.map(tag => {
|
||||
return (
|
||||
<span key={tag} className="video-tag">
|
||||
{tag}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<div id="overwrite-form" className="info-box">
|
||||
<div className="info-box-item">
|
||||
<h2>Customize {channel.channel_name}</h2>
|
||||
<form className="overwrite-form" onSubmit={handleSubmit}>
|
||||
<div className="overwrite-form-item">
|
||||
<p>
|
||||
Download format:{' '}
|
||||
<span className="settings-current">
|
||||
{channelOverwrites?.download_format || 'False'}
|
||||
</span>
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
name="download_format"
|
||||
id="id_download_format"
|
||||
onChange={event => {
|
||||
const value = event.currentTarget.value;
|
||||
|
||||
setDownloadFormat(value);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
label="Reset"
|
||||
onClick={() => {
|
||||
setDownloadFormat(false);
|
||||
}}
|
||||
/>
|
||||
<br />
|
||||
</div>
|
||||
<div className="overwrite-form-item">
|
||||
<p>
|
||||
Auto delete watched videos after x days:{' '}
|
||||
<span className="settings-current">
|
||||
{channelOverwrites?.autodelete_days || 'False'}
|
||||
</span>
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
name="autodelete_days"
|
||||
id="id_autodelete_days"
|
||||
onChange={event => {
|
||||
const value = Number(event.currentTarget.value);
|
||||
|
||||
if (value === 0) {
|
||||
setAutoDeleteAfter(false);
|
||||
} else {
|
||||
setAutoDeleteAfter(Number(value));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<br />
|
||||
</div>
|
||||
|
||||
<div className="overwrite-form-item">
|
||||
<p>
|
||||
Index playlists:{' '}
|
||||
<span className="settings-current">
|
||||
{JSON.stringify(channelOverwrites?.index_playlists)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<select
|
||||
name="index_playlists"
|
||||
id="id_index_playlists"
|
||||
value={indexPlaylists}
|
||||
onChange={event => {
|
||||
const value = event.currentTarget.value;
|
||||
|
||||
setIndexPlaylists(value);
|
||||
}}
|
||||
>
|
||||
<option value="null">-- change playlist index --</option>
|
||||
<option value="false">Disable playlist index</option>
|
||||
<option value="true">Enable playlist index</option>
|
||||
</select>
|
||||
|
||||
<br />
|
||||
</div>
|
||||
|
||||
<div className="overwrite-form-item">
|
||||
<p>
|
||||
Enable{' '}
|
||||
<a href="https://sponsor.ajay.app/" target="_blank">
|
||||
SponsorBlock
|
||||
</a>
|
||||
:{' '}
|
||||
<span className="settings-current">
|
||||
{JSON.stringify(channelOverwrites?.integrate_sponsorblock)}
|
||||
</span>
|
||||
</p>
|
||||
<select
|
||||
name="integrate_sponsorblock"
|
||||
id="id_integrate_sponsorblock"
|
||||
value={enableSponsorblock?.toString() || ''}
|
||||
onChange={event => {
|
||||
const value = event.currentTarget.value;
|
||||
|
||||
if (value !== '') {
|
||||
setEnableSponsorblock(JSON.parse(value));
|
||||
} else {
|
||||
setEnableSponsorblock(undefined);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">-- change sponsorblock integrations</option>
|
||||
<option value="false">disable sponsorblock integration</option>
|
||||
<option value="true">enable sponsorblock integration</option>
|
||||
<option value="null">unset sponsorblock integration</option>
|
||||
</select>
|
||||
</div>
|
||||
<h3>Page Size Overrides</h3>
|
||||
<br />
|
||||
<p>
|
||||
Disable standard videos, shorts, or streams for this channel by setting their page
|
||||
size to 0 (zero).
|
||||
</p>
|
||||
<br />
|
||||
<p>Disable page size overwrite for channel by setting to negative value.</p>
|
||||
<br />
|
||||
<div className="overwrite-form-item">
|
||||
<p>
|
||||
YouTube page size:{' '}
|
||||
<span className="settings-current">
|
||||
{channelOverwrites?.subscriptions_channel_size || 'False'}
|
||||
</span>
|
||||
</p>
|
||||
<i>
|
||||
Videos to scan to find new items for the <b>Rescan subscriptions</b> task, max
|
||||
recommended 50.
|
||||
</i>
|
||||
<br />
|
||||
<input
|
||||
type="number"
|
||||
name="channel_size"
|
||||
id="id_channel_size"
|
||||
onChange={event => {
|
||||
setPageSizeVideo(Number(event.currentTarget.value));
|
||||
}}
|
||||
/>
|
||||
<br />
|
||||
</div>
|
||||
<div className="overwrite-form-item">
|
||||
<p>
|
||||
YouTube Live page size:{' '}
|
||||
<span className="settings-current">
|
||||
{channelOverwrites?.subscriptions_live_channel_size || 'False'}
|
||||
</span>
|
||||
</p>
|
||||
<i>
|
||||
Live Videos to scan to find new items for the <b>Rescan subscriptions</b> task,
|
||||
max recommended 50.
|
||||
</i>
|
||||
<br />
|
||||
<input
|
||||
type="number"
|
||||
name="live_channel_size"
|
||||
id="id_live_channel_size"
|
||||
onChange={event => {
|
||||
setPageSizeStreams(Number(event.currentTarget.value));
|
||||
}}
|
||||
/>
|
||||
<br />
|
||||
</div>
|
||||
<div className="overwrite-form-item">
|
||||
<p>
|
||||
YouTube Shorts page size:{' '}
|
||||
<span className="settings-current">
|
||||
{channelOverwrites?.subscriptions_shorts_channel_size || 'False'}
|
||||
</span>
|
||||
</p>
|
||||
<i>
|
||||
Shorts Videos to scan to find new items for the <b>Rescan subscriptions</b>{' '}
|
||||
task, max recommended 50.
|
||||
</i>
|
||||
<br />
|
||||
<input
|
||||
type="number"
|
||||
name="shorts_channel_size"
|
||||
id="id_shorts_channel_size"
|
||||
onChange={event => {
|
||||
setPageSizeShorts(Number(event.currentTarget.value));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<br />
|
||||
|
||||
<Button type="submit" label="Save Channel Overwrites" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PaginationDummy />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelAbout;
|
||||
|
@ -1,99 +1,104 @@
|
||||
import { Link, Outlet, useOutletContext, useParams } from 'react-router-dom';
|
||||
import Routes from '../configuration/routes/RouteList';
|
||||
import { ChannelType } from './Channels';
|
||||
import { ConfigType } from './Home';
|
||||
import { OutletContextType } from './Base';
|
||||
import Notifications from '../components/Notifications';
|
||||
import { useEffect, useState } from 'react';
|
||||
import ChannelBanner from '../components/ChannelBanner';
|
||||
import loadChannelNav, { ChannelNavResponseType } from '../api/loader/loadChannelNav';
|
||||
|
||||
type ChannelParams = {
|
||||
channelId: string;
|
||||
};
|
||||
|
||||
export type ChannelResponseType = {
|
||||
data: ChannelType;
|
||||
config: ConfigType;
|
||||
};
|
||||
|
||||
const ChannelBase = () => {
|
||||
const { channelId } = useParams() as ChannelParams;
|
||||
const { isAdmin, currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
|
||||
|
||||
const [channelNav, setChannelNav] = useState<ChannelNavResponseType>();
|
||||
const [startNotification, setStartNotification] = useState(false);
|
||||
|
||||
const { has_streams, has_shorts, has_playlists, has_pending } = channelNav || {};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const channelNavResponse = await loadChannelNav(channelId);
|
||||
|
||||
setChannelNav(channelNavResponse);
|
||||
})();
|
||||
}, [channelId]);
|
||||
|
||||
if (!channelId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="boxed-content">
|
||||
<div className="channel-banner">
|
||||
<Link to={Routes.ChannelVideo(channelId)}>
|
||||
<ChannelBanner channel_id={channelId} />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="info-box-item child-page-nav">
|
||||
<Link to={Routes.ChannelVideo(channelId)}>
|
||||
<h3>Videos</h3>
|
||||
</Link>
|
||||
{has_streams && (
|
||||
<Link to={Routes.ChannelStream(channelId)}>
|
||||
<h3>Streams</h3>
|
||||
</Link>
|
||||
)}
|
||||
{has_shorts && (
|
||||
<Link to={Routes.ChannelShorts(channelId)}>
|
||||
<h3>Shorts</h3>
|
||||
</Link>
|
||||
)}
|
||||
{has_playlists && (
|
||||
<Link to={Routes.ChannelPlaylist(channelId)}>
|
||||
<h3>Playlists</h3>
|
||||
</Link>
|
||||
)}
|
||||
<Link to={Routes.ChannelAbout(channelId)}>
|
||||
<h3>About</h3>
|
||||
</Link>
|
||||
{has_pending && isAdmin && (
|
||||
<Link to={Routes.DownloadsByChannelId(channelId)}>
|
||||
<h3>Downloads</h3>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Notifications
|
||||
pageName="channel"
|
||||
includeReindex={true}
|
||||
update={startNotification}
|
||||
setShouldRefresh={() => setStartNotification(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Outlet
|
||||
context={{
|
||||
isAdmin,
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
startNotification,
|
||||
setStartNotification,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelBase;
|
||||
import { Link, Outlet, useOutletContext, useParams } from 'react-router-dom';
|
||||
import Routes from '../configuration/routes/RouteList';
|
||||
import { ChannelType } from './Channels';
|
||||
import { ConfigType } from './Home';
|
||||
import { OutletContextType } from './Base';
|
||||
import Notifications from '../components/Notifications';
|
||||
import { useEffect, useState } from 'react';
|
||||
import ChannelBanner from '../components/ChannelBanner';
|
||||
import loadChannelNav, { ChannelNavResponseType } from '../api/loader/loadChannelNav';
|
||||
import loadChannelById from '../api/loader/loadChannelById';
|
||||
|
||||
type ChannelParams = {
|
||||
channelId: string;
|
||||
};
|
||||
|
||||
export type ChannelResponseType = {
|
||||
data: ChannelType;
|
||||
config: ConfigType;
|
||||
};
|
||||
|
||||
const ChannelBase = () => {
|
||||
const { channelId } = useParams() as ChannelParams;
|
||||
const { isAdmin, currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
|
||||
|
||||
const [channelResponse, setChannelResponse] = useState<ChannelResponseType>();
|
||||
const [channelNav, setChannelNav] = useState<ChannelNavResponseType>();
|
||||
const [startNotification, setStartNotification] = useState(false);
|
||||
|
||||
const channel = channelResponse?.data;
|
||||
const { has_streams, has_shorts, has_playlists, has_pending } = channelNav || {};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const channelNavResponse = await loadChannelNav(channelId);
|
||||
const channelResponse = await loadChannelById(channelId);
|
||||
|
||||
setChannelResponse(channelResponse);
|
||||
setChannelNav(channelNavResponse);
|
||||
})();
|
||||
}, [channelId]);
|
||||
|
||||
if (!channelId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="boxed-content">
|
||||
<div className="channel-banner">
|
||||
<Link to={Routes.ChannelVideo(channelId)}>
|
||||
<ChannelBanner channelId={channelId} channelBannerUrl={channel?.channel_banner_url} />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="info-box-item child-page-nav">
|
||||
<Link to={Routes.ChannelVideo(channelId)}>
|
||||
<h3>Videos</h3>
|
||||
</Link>
|
||||
{has_streams && (
|
||||
<Link to={Routes.ChannelStream(channelId)}>
|
||||
<h3>Streams</h3>
|
||||
</Link>
|
||||
)}
|
||||
{has_shorts && (
|
||||
<Link to={Routes.ChannelShorts(channelId)}>
|
||||
<h3>Shorts</h3>
|
||||
</Link>
|
||||
)}
|
||||
{has_playlists && (
|
||||
<Link to={Routes.ChannelPlaylist(channelId)}>
|
||||
<h3>Playlists</h3>
|
||||
</Link>
|
||||
)}
|
||||
<Link to={Routes.ChannelAbout(channelId)}>
|
||||
<h3>About</h3>
|
||||
</Link>
|
||||
{has_pending && isAdmin && (
|
||||
<Link to={Routes.DownloadsByChannelId(channelId)}>
|
||||
<h3>Downloads</h3>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Notifications
|
||||
pageName="channel"
|
||||
includeReindex={true}
|
||||
update={startNotification}
|
||||
setShouldRefresh={() => setStartNotification(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Outlet
|
||||
context={{
|
||||
isAdmin,
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
startNotification,
|
||||
setStartNotification,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelBase;
|
||||
|
@ -1,122 +1,119 @@
|
||||
import { useLoaderData, useOutletContext, useParams } from 'react-router-dom';
|
||||
import Notifications from '../components/Notifications';
|
||||
import PlaylistList from '../components/PlaylistList';
|
||||
import { ViewLayoutType } from './Home';
|
||||
import { ViewStyles } from '../configuration/constants/ViewStyle';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { OutletContextType } from './Base';
|
||||
import Pagination from '../components/Pagination';
|
||||
import ScrollToTopOnNavigate from '../components/ScrollToTop';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import loadPlaylistList from '../api/loader/loadPlaylistList';
|
||||
import { PlaylistsResponseType } from './Playlists';
|
||||
import iconGridView from '/img/icon-gridview.svg';
|
||||
import iconListView from '/img/icon-listview.svg';
|
||||
import { UserMeType } from '../api/actions/updateUserConfig';
|
||||
|
||||
type ChannelPlaylistLoaderDataType = {
|
||||
userConfig: UserMeType;
|
||||
};
|
||||
|
||||
const ChannelPlaylist = () => {
|
||||
const { channelId } = useParams();
|
||||
const { userConfig } = useLoaderData() as ChannelPlaylistLoaderDataType;
|
||||
const { currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
|
||||
|
||||
const userMeConfig = userConfig.config;
|
||||
|
||||
const [showSubedOnly, setShowSubedOnly] = useState(userMeConfig.show_subed_only || false);
|
||||
const [view, setView] = useState<ViewLayoutType>(userMeConfig.view_style_playlist || 'grid');
|
||||
const [gridItems, setGridItems] = useState(userMeConfig.grid_items || 3);
|
||||
const [refreshPlaylists, setRefreshPlaylists] = useState(false);
|
||||
|
||||
const [playlistsResponse, setPlaylistsResponse] = useState<PlaylistsResponseType>();
|
||||
|
||||
const playlistList = playlistsResponse?.data;
|
||||
const pagination = playlistsResponse?.paginate;
|
||||
|
||||
const isGridView = view === ViewStyles.grid;
|
||||
const gridView = isGridView ? `boxed-${gridItems}` : '';
|
||||
const gridViewGrid = isGridView ? `grid-${gridItems}` : '';
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const playlists = await loadPlaylistList({
|
||||
channel: channelId,
|
||||
subscribed: showSubedOnly,
|
||||
});
|
||||
|
||||
setPlaylistsResponse(playlists);
|
||||
setRefreshPlaylists(false);
|
||||
})();
|
||||
}, [channelId, refreshPlaylists, showSubedOnly, currentPage]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>TA | Channel: Playlists</title>
|
||||
</Helmet>
|
||||
<ScrollToTopOnNavigate />
|
||||
<div className={`boxed-content ${gridView}`}>
|
||||
<Notifications pageName="channel" includeReindex={true} />
|
||||
|
||||
<div className="view-controls">
|
||||
<div className="toggle">
|
||||
<span>Show subscribed only:</span>
|
||||
<div className="toggleBox">
|
||||
<input
|
||||
checked={showSubedOnly}
|
||||
onChange={() => {
|
||||
setShowSubedOnly(!showSubedOnly);
|
||||
}}
|
||||
type="checkbox"
|
||||
/>
|
||||
{!showSubedOnly && (
|
||||
<label htmlFor="" className="ofbtn">
|
||||
Off
|
||||
</label>
|
||||
)}
|
||||
{showSubedOnly && (
|
||||
<label htmlFor="" className="onbtn">
|
||||
On
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="view-icons">
|
||||
<img
|
||||
src={iconGridView}
|
||||
onClick={() => {
|
||||
setView('grid');
|
||||
}}
|
||||
alt="grid view"
|
||||
/>
|
||||
<img
|
||||
src={iconListView}
|
||||
onClick={() => {
|
||||
setView('list');
|
||||
}}
|
||||
alt="list view"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`boxed-content ${gridView}`}>
|
||||
<div className={`playlist-list ${view} ${gridViewGrid}`}>
|
||||
<PlaylistList
|
||||
playlistList={playlistList}
|
||||
viewLayout={view}
|
||||
setRefresh={setRefreshPlaylists}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="boxed-content">
|
||||
{pagination && <Pagination pagination={pagination} setPage={setCurrentPage} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelPlaylist;
|
||||
import { useLoaderData, useOutletContext, useParams } from 'react-router-dom';
|
||||
import Notifications from '../components/Notifications';
|
||||
import PlaylistList from '../components/PlaylistList';
|
||||
import { ViewLayoutType } from './Home';
|
||||
import { ViewStyles } from '../configuration/constants/ViewStyle';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { OutletContextType } from './Base';
|
||||
import Pagination from '../components/Pagination';
|
||||
import ScrollToTopOnNavigate from '../components/ScrollToTop';
|
||||
import loadPlaylistList from '../api/loader/loadPlaylistList';
|
||||
import { PlaylistsResponseType } from './Playlists';
|
||||
import iconGridView from '/img/icon-gridview.svg';
|
||||
import iconListView from '/img/icon-listview.svg';
|
||||
import { UserMeType } from '../api/actions/updateUserConfig';
|
||||
|
||||
type ChannelPlaylistLoaderDataType = {
|
||||
userConfig: UserMeType;
|
||||
};
|
||||
|
||||
const ChannelPlaylist = () => {
|
||||
const { channelId } = useParams();
|
||||
const { userConfig } = useLoaderData() as ChannelPlaylistLoaderDataType;
|
||||
const { currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
|
||||
|
||||
const userMeConfig = userConfig.config;
|
||||
|
||||
const [showSubedOnly, setShowSubedOnly] = useState(userMeConfig.show_subed_only || false);
|
||||
const [view, setView] = useState<ViewLayoutType>(userMeConfig.view_style_playlist || 'grid');
|
||||
const [gridItems] = useState(userMeConfig.grid_items || 3);
|
||||
const [refreshPlaylists, setRefreshPlaylists] = useState(false);
|
||||
|
||||
const [playlistsResponse, setPlaylistsResponse] = useState<PlaylistsResponseType>();
|
||||
|
||||
const playlistList = playlistsResponse?.data;
|
||||
const pagination = playlistsResponse?.paginate;
|
||||
|
||||
const isGridView = view === ViewStyles.grid;
|
||||
const gridView = isGridView ? `boxed-${gridItems}` : '';
|
||||
const gridViewGrid = isGridView ? `grid-${gridItems}` : '';
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const playlists = await loadPlaylistList({
|
||||
channel: channelId,
|
||||
subscribed: showSubedOnly,
|
||||
});
|
||||
|
||||
setPlaylistsResponse(playlists);
|
||||
setRefreshPlaylists(false);
|
||||
})();
|
||||
}, [channelId, refreshPlaylists, showSubedOnly, currentPage]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>TA | Channel: Playlists</title>
|
||||
<ScrollToTopOnNavigate />
|
||||
<div className={`boxed-content ${gridView}`}>
|
||||
<Notifications pageName="channel" includeReindex={true} />
|
||||
|
||||
<div className="view-controls">
|
||||
<div className="toggle">
|
||||
<span>Show subscribed only:</span>
|
||||
<div className="toggleBox">
|
||||
<input
|
||||
checked={showSubedOnly}
|
||||
onChange={() => {
|
||||
setShowSubedOnly(!showSubedOnly);
|
||||
}}
|
||||
type="checkbox"
|
||||
/>
|
||||
{!showSubedOnly && (
|
||||
<label htmlFor="" className="ofbtn">
|
||||
Off
|
||||
</label>
|
||||
)}
|
||||
{showSubedOnly && (
|
||||
<label htmlFor="" className="onbtn">
|
||||
On
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="view-icons">
|
||||
<img
|
||||
src={iconGridView}
|
||||
onClick={() => {
|
||||
setView('grid');
|
||||
}}
|
||||
alt="grid view"
|
||||
/>
|
||||
<img
|
||||
src={iconListView}
|
||||
onClick={() => {
|
||||
setView('list');
|
||||
}}
|
||||
alt="list view"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`boxed-content ${gridView}`}>
|
||||
<div className={`playlist-list ${view} ${gridViewGrid}`}>
|
||||
<PlaylistList
|
||||
playlistList={playlistList}
|
||||
viewLayout={view}
|
||||
setRefresh={setRefreshPlaylists}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="boxed-content">
|
||||
{pagination && <Pagination pagination={pagination} setPage={setCurrentPage} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelPlaylist;
|
||||
|
@ -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;
|
@ -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;
|
@ -1,211 +1,216 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Link,
|
||||
useLoaderData,
|
||||
useOutletContext,
|
||||
useParams,
|
||||
useSearchParams,
|
||||
} from 'react-router-dom';
|
||||
import { SortByType, SortOrderType, ViewLayoutType } from './Home';
|
||||
import { OutletContextType } from './Base';
|
||||
import { UserMeType } from '../api/actions/updateUserConfig';
|
||||
import VideoList from '../components/VideoList';
|
||||
import Routes from '../configuration/routes/RouteList';
|
||||
import Pagination from '../components/Pagination';
|
||||
import Filterbar from '../components/Filterbar';
|
||||
import { ViewStyleNames, ViewStyles } from '../configuration/constants/ViewStyle';
|
||||
import ChannelOverview from '../components/ChannelOverview';
|
||||
import loadChannelById from '../api/loader/loadChannelById';
|
||||
import { ChannelResponseType } from './ChannelBase';
|
||||
import ScrollToTopOnNavigate from '../components/ScrollToTop';
|
||||
import EmbeddableVideoPlayer from '../components/EmbeddableVideoPlayer';
|
||||
import updateWatchedState from '../api/actions/updateWatchedState';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import Button from '../components/Button';
|
||||
import loadVideoListByFilter, {
|
||||
VideoListByFilterResponseType,
|
||||
} from '../api/loader/loadVideoListByPage';
|
||||
|
||||
type ChannelParams = {
|
||||
channelId: string;
|
||||
};
|
||||
|
||||
type ChannelVideoLoaderType = {
|
||||
userConfig: UserMeType;
|
||||
};
|
||||
|
||||
const ChannelVideo = () => {
|
||||
const { channelId } = useParams() as ChannelParams;
|
||||
const { userConfig } = useLoaderData() as ChannelVideoLoaderType;
|
||||
const { isAdmin, currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
|
||||
const [searchParams] = useSearchParams();
|
||||
const videoId = searchParams.get('videoId');
|
||||
|
||||
const userMeConfig = userConfig.config;
|
||||
|
||||
const [hideWatched, setHideWatched] = useState(userMeConfig.hide_watched || false);
|
||||
const [sortBy, setSortBy] = useState<SortByType>(userMeConfig.sort_by || 'published');
|
||||
const [sortOrder, setSortOrder] = useState<SortOrderType>(userMeConfig.sort_order || 'asc');
|
||||
const [view, setView] = useState<ViewLayoutType>(userMeConfig.view_style_home || 'grid');
|
||||
const [gridItems, setGridItems] = useState(userMeConfig.grid_items || 3);
|
||||
const [refresh, setRefresh] = useState(false);
|
||||
|
||||
const [channelResponse, setChannelResponse] = useState<ChannelResponseType>();
|
||||
const [videoResponse, setVideoReponse] = useState<VideoListByFilterResponseType>();
|
||||
|
||||
const channel = channelResponse?.data;
|
||||
const videoList = videoResponse?.data;
|
||||
const pagination = videoResponse?.paginate;
|
||||
|
||||
const hasVideos = videoResponse?.data?.length !== 0;
|
||||
const showEmbeddedVideo = videoId !== null;
|
||||
|
||||
const isGridView = view === ViewStyles.grid;
|
||||
const gridView = isGridView ? `boxed-${gridItems}` : '';
|
||||
const gridViewGrid = isGridView ? `grid-${gridItems}` : '';
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (
|
||||
refresh ||
|
||||
pagination?.current_page === undefined ||
|
||||
currentPage !== pagination?.current_page
|
||||
) {
|
||||
const channelResponse = await loadChannelById(channelId);
|
||||
const videos = await loadVideoListByFilter({
|
||||
channel: channelId,
|
||||
page: currentPage,
|
||||
watch: hideWatched ? 'unwatched' : undefined,
|
||||
sort: sortBy,
|
||||
order: sortOrder,
|
||||
});
|
||||
|
||||
setChannelResponse(channelResponse);
|
||||
setVideoReponse(videos);
|
||||
setRefresh(false);
|
||||
}
|
||||
})();
|
||||
// Do not add sort, order, hideWatched this will not work as expected!
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [refresh, currentPage, channelId, pagination?.current_page]);
|
||||
|
||||
const aggs = {
|
||||
total_items: { value: '<debug>' },
|
||||
total_duration: { value_str: '<debug>' },
|
||||
total_size: { value: '<debug>' },
|
||||
};
|
||||
|
||||
if (!channel) {
|
||||
return (
|
||||
<div className="boxed-content">
|
||||
<br />
|
||||
<h2>Channel {channelId} not found!</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>TA | Channel: {channel.channel_name}</title>
|
||||
</Helmet>
|
||||
<ScrollToTopOnNavigate />
|
||||
<div className="boxed-content">
|
||||
<div className="info-box info-box-2">
|
||||
<ChannelOverview
|
||||
channelId={channel.channel_id}
|
||||
channelname={channel.channel_name}
|
||||
channelSubs={channel.channel_subs}
|
||||
channelSubscribed={channel.channel_subscribed}
|
||||
showSubscribeButton={true}
|
||||
isUserAdmin={isAdmin}
|
||||
setRefresh={setRefresh}
|
||||
/>
|
||||
<div className="info-box-item">
|
||||
{aggs && (
|
||||
<>
|
||||
<p>
|
||||
{aggs.total_items.value} videos <span className="space-carrot">|</span>{' '}
|
||||
{aggs.total_duration.value_str} playback <span className="space-carrot">|</span>{' '}
|
||||
Total size {aggs.total_size.value}
|
||||
</p>
|
||||
<div className="button-box">
|
||||
<Button
|
||||
label="Mark as watched"
|
||||
id="watched-button"
|
||||
type="button"
|
||||
title={`Mark all videos from ${channel.channel_name} as watched`}
|
||||
onClick={async () => {
|
||||
await updateWatchedState({
|
||||
id: channel.channel_id,
|
||||
is_watched: true,
|
||||
});
|
||||
|
||||
setRefresh(true);
|
||||
}}
|
||||
/>{' '}
|
||||
<Button
|
||||
label="Mark as unwatched"
|
||||
id="unwatched-button"
|
||||
type="button"
|
||||
title={`Mark all videos from ${channel.channel_name} as unwatched`}
|
||||
onClick={async () => {
|
||||
await updateWatchedState({
|
||||
id: channel.channel_id,
|
||||
is_watched: false,
|
||||
});
|
||||
|
||||
setRefresh(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`boxed-content ${gridView}`}>
|
||||
<Filterbar
|
||||
hideToggleText={'Hide watched videos:'}
|
||||
view={view}
|
||||
isGridView={isGridView}
|
||||
hideWatched={hideWatched}
|
||||
gridItems={gridItems}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
userMeConfig={userMeConfig}
|
||||
setSortBy={setSortBy}
|
||||
setSortOrder={setSortOrder}
|
||||
setHideWatched={setHideWatched}
|
||||
setView={setView}
|
||||
setGridItems={setGridItems}
|
||||
viewStyleName={ViewStyleNames.channel}
|
||||
setRefresh={setRefresh}
|
||||
/>
|
||||
</div>
|
||||
{showEmbeddedVideo && <EmbeddableVideoPlayer videoId={videoId} />}
|
||||
<div className={`boxed-content ${gridView}`}>
|
||||
<div className={`video-list ${view} ${gridViewGrid}`}>
|
||||
{!hasVideos && (
|
||||
<>
|
||||
<h2>No videos found...</h2>
|
||||
<p>
|
||||
Try going to the <Link to={Routes.Downloads}>downloads page</Link> to start the scan
|
||||
and download tasks.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<VideoList videoList={videoList} viewLayout={view} refreshVideoList={setRefresh} />
|
||||
</div>
|
||||
</div>
|
||||
{pagination && (
|
||||
<div className="boxed-content">
|
||||
<Pagination pagination={pagination} setPage={setCurrentPage} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelVideo;
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Link,
|
||||
useLoaderData,
|
||||
useOutletContext,
|
||||
useParams,
|
||||
useSearchParams,
|
||||
} from 'react-router-dom';
|
||||
import { SortByType, SortOrderType, ViewLayoutType } from './Home';
|
||||
import { OutletContextType } from './Base';
|
||||
import { UserMeType } from '../api/actions/updateUserConfig';
|
||||
import VideoList from '../components/VideoList';
|
||||
import Routes from '../configuration/routes/RouteList';
|
||||
import Pagination from '../components/Pagination';
|
||||
import Filterbar from '../components/Filterbar';
|
||||
import { ViewStyleNames, ViewStyles } from '../configuration/constants/ViewStyle';
|
||||
import ChannelOverview from '../components/ChannelOverview';
|
||||
import loadChannelById from '../api/loader/loadChannelById';
|
||||
import { ChannelResponseType } from './ChannelBase';
|
||||
import ScrollToTopOnNavigate from '../components/ScrollToTop';
|
||||
import EmbeddableVideoPlayer from '../components/EmbeddableVideoPlayer';
|
||||
import updateWatchedState from '../api/actions/updateWatchedState';
|
||||
import Button from '../components/Button';
|
||||
import loadVideoListByFilter, {
|
||||
VideoListByFilterResponseType,
|
||||
VideoTypes,
|
||||
} from '../api/loader/loadVideoListByPage';
|
||||
import loadChannelAggs, { ChannelAggsType } from '../api/loader/loadChannelAggs';
|
||||
import humanFileSize from '../functions/humanFileSize';
|
||||
|
||||
type ChannelParams = {
|
||||
channelId: string;
|
||||
};
|
||||
|
||||
type ChannelVideoLoaderType = {
|
||||
userConfig: UserMeType;
|
||||
};
|
||||
|
||||
type ChannelVideoProps = {
|
||||
videoType: VideoTypes;
|
||||
};
|
||||
|
||||
const ChannelVideo = ({ videoType }: ChannelVideoProps) => {
|
||||
const { channelId } = useParams() as ChannelParams;
|
||||
const { userConfig } = useLoaderData() as ChannelVideoLoaderType;
|
||||
const { isAdmin, currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
|
||||
const [searchParams] = useSearchParams();
|
||||
const videoId = searchParams.get('videoId');
|
||||
|
||||
const userMeConfig = userConfig.config;
|
||||
|
||||
const [hideWatched, setHideWatched] = useState(userMeConfig.hide_watched || false);
|
||||
const [sortBy, setSortBy] = useState<SortByType>(userMeConfig.sort_by || 'published');
|
||||
const [sortOrder, setSortOrder] = useState<SortOrderType>(userMeConfig.sort_order || 'asc');
|
||||
const [view, setView] = useState<ViewLayoutType>(userMeConfig.view_style_home || 'grid');
|
||||
const [gridItems, setGridItems] = useState(userMeConfig.grid_items || 3);
|
||||
const [refresh, setRefresh] = useState(false);
|
||||
|
||||
const [channelResponse, setChannelResponse] = useState<ChannelResponseType>();
|
||||
const [videoResponse, setVideoReponse] = useState<VideoListByFilterResponseType>();
|
||||
const [videoAggsResponse, setVideoAggsResponse] = useState<ChannelAggsType>();
|
||||
|
||||
const channel = channelResponse?.data;
|
||||
const videoList = videoResponse?.data;
|
||||
const pagination = videoResponse?.paginate;
|
||||
|
||||
const hasVideos = videoResponse?.data?.length !== 0;
|
||||
const showEmbeddedVideo = videoId !== null;
|
||||
|
||||
const isGridView = view === ViewStyles.grid;
|
||||
const gridView = isGridView ? `boxed-${gridItems}` : '';
|
||||
const gridViewGrid = isGridView ? `grid-${gridItems}` : '';
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (
|
||||
refresh ||
|
||||
pagination?.current_page === undefined ||
|
||||
currentPage !== pagination?.current_page
|
||||
) {
|
||||
const channelResponse = await loadChannelById(channelId);
|
||||
const videos = await loadVideoListByFilter({
|
||||
channel: channelId,
|
||||
page: currentPage,
|
||||
watch: hideWatched ? 'unwatched' : undefined,
|
||||
sort: sortBy,
|
||||
order: sortOrder,
|
||||
type: videoType,
|
||||
});
|
||||
const channelAggs = await loadChannelAggs(channelId);
|
||||
|
||||
setChannelResponse(channelResponse);
|
||||
setVideoReponse(videos);
|
||||
setVideoAggsResponse(channelAggs);
|
||||
setRefresh(false);
|
||||
}
|
||||
})();
|
||||
// Do not add sort, order, hideWatched this will not work as expected!
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [refresh, currentPage, channelId, pagination?.current_page]);
|
||||
|
||||
if (!channel) {
|
||||
return (
|
||||
<div className="boxed-content">
|
||||
<br />
|
||||
<h2>Channel {channelId} not found!</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>{`TA | Channel: ${channel.channel_name}`}</title>
|
||||
<ScrollToTopOnNavigate />
|
||||
<div className="boxed-content">
|
||||
<div className="info-box info-box-2">
|
||||
<ChannelOverview
|
||||
channelId={channel.channel_id}
|
||||
channelname={channel.channel_name}
|
||||
channelSubs={channel.channel_subs}
|
||||
channelSubscribed={channel.channel_subscribed}
|
||||
channelThumbUrl={channel.channel_thumb_url}
|
||||
showSubscribeButton={true}
|
||||
isUserAdmin={isAdmin}
|
||||
setRefresh={setRefresh}
|
||||
/>
|
||||
<div className="info-box-item">
|
||||
{videoAggsResponse && (
|
||||
<>
|
||||
<p>
|
||||
{videoAggsResponse.total_items.value} videos{' '}
|
||||
<span className="space-carrot">|</span>{' '}
|
||||
{videoAggsResponse.total_duration.value_str} playback{' '}
|
||||
<span className="space-carrot">|</span> Total size{' '}
|
||||
{humanFileSize(videoAggsResponse.total_size.value, true)}
|
||||
</p>
|
||||
<div className="button-box">
|
||||
<Button
|
||||
label="Mark as watched"
|
||||
id="watched-button"
|
||||
type="button"
|
||||
title={`Mark all videos from ${channel.channel_name} as watched`}
|
||||
onClick={async () => {
|
||||
await updateWatchedState({
|
||||
id: channel.channel_id,
|
||||
is_watched: true,
|
||||
});
|
||||
|
||||
setRefresh(true);
|
||||
}}
|
||||
/>{' '}
|
||||
<Button
|
||||
label="Mark as unwatched"
|
||||
id="unwatched-button"
|
||||
type="button"
|
||||
title={`Mark all videos from ${channel.channel_name} as unwatched`}
|
||||
onClick={async () => {
|
||||
await updateWatchedState({
|
||||
id: channel.channel_id,
|
||||
is_watched: false,
|
||||
});
|
||||
|
||||
setRefresh(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`boxed-content ${gridView}`}>
|
||||
<Filterbar
|
||||
hideToggleText={'Hide watched videos:'}
|
||||
view={view}
|
||||
isGridView={isGridView}
|
||||
hideWatched={hideWatched}
|
||||
gridItems={gridItems}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
userMeConfig={userMeConfig}
|
||||
setSortBy={setSortBy}
|
||||
setSortOrder={setSortOrder}
|
||||
setHideWatched={setHideWatched}
|
||||
setView={setView}
|
||||
setGridItems={setGridItems}
|
||||
viewStyleName={ViewStyleNames.channel}
|
||||
setRefresh={setRefresh}
|
||||
/>
|
||||
</div>
|
||||
{showEmbeddedVideo && <EmbeddableVideoPlayer videoId={videoId} />}
|
||||
<div className={`boxed-content ${gridView}`}>
|
||||
<div className={`video-list ${view} ${gridViewGrid}`}>
|
||||
{!hasVideos && (
|
||||
<>
|
||||
<h2>No videos found...</h2>
|
||||
<p>
|
||||
Try going to the <Link to={Routes.Downloads}>downloads page</Link> to start the scan
|
||||
and download tasks.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<VideoList videoList={videoList} viewLayout={view} refreshVideoList={setRefresh} />
|
||||
</div>
|
||||
</div>
|
||||
{pagination && (
|
||||
<div className="boxed-content">
|
||||
<Pagination pagination={pagination} setPage={setCurrentPage} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelVideo;
|
||||
|
@ -1,206 +1,203 @@
|
||||
import { useLoaderData, useOutletContext } from 'react-router-dom';
|
||||
import loadChannelList from '../api/loader/loadChannelList';
|
||||
import iconGridView from '/img/icon-gridview.svg';
|
||||
import iconListView from '/img/icon-listview.svg';
|
||||
import iconAdd from '/img/icon-add.svg';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Pagination, { PaginationType } from '../components/Pagination';
|
||||
import { ConfigType, ViewLayoutType } from './Home';
|
||||
import updateUserConfig, { UserConfigType, UserMeType } from '../api/actions/updateUserConfig';
|
||||
import { OutletContextType } from './Base';
|
||||
import ChannelList from '../components/ChannelList';
|
||||
import ScrollToTopOnNavigate from '../components/ScrollToTop';
|
||||
import Notifications from '../components/Notifications';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import Button from '../components/Button';
|
||||
|
||||
type ChannelOverwritesType = {
|
||||
download_format?: string;
|
||||
autodelete_days?: number;
|
||||
index_playlists?: boolean;
|
||||
integrate_sponsorblock?: boolean;
|
||||
subscriptions_channel_size?: number;
|
||||
subscriptions_live_channel_size?: number;
|
||||
subscriptions_shorts_channel_size?: number;
|
||||
};
|
||||
|
||||
export type ChannelType = {
|
||||
channel_active: boolean;
|
||||
channel_banner_url: string;
|
||||
channel_description: string;
|
||||
channel_id: string;
|
||||
channel_last_refresh: string;
|
||||
channel_name: string;
|
||||
channel_overwrites?: ChannelOverwritesType;
|
||||
channel_subs: number;
|
||||
channel_subscribed: boolean;
|
||||
channel_tags: string[];
|
||||
channel_thumb_url: string;
|
||||
channel_tvart_url: string;
|
||||
channel_views: number;
|
||||
};
|
||||
|
||||
type ChannelsListResponse = {
|
||||
data: ChannelType[];
|
||||
paginate: PaginationType;
|
||||
config?: ConfigType;
|
||||
};
|
||||
|
||||
type ChannelsLoaderDataType = {
|
||||
userConfig: UserMeType;
|
||||
};
|
||||
|
||||
const Channels = () => {
|
||||
const { userConfig } = useLoaderData() as ChannelsLoaderDataType;
|
||||
const { isAdmin, currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
|
||||
|
||||
const userMeConfig = userConfig.config;
|
||||
|
||||
const [channelListResponse, setChannelListResponse] = useState<ChannelsListResponse>();
|
||||
const [showSubscribedOnly, setShowSubscribedOnly] = useState(
|
||||
userMeConfig.show_subed_only || false,
|
||||
);
|
||||
const [view, setView] = useState<ViewLayoutType>(userMeConfig.view_style_channel || 'grid');
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [refresh, setRefresh] = useState(false);
|
||||
|
||||
const channels = channelListResponse?.data;
|
||||
const pagination = channelListResponse?.paginate;
|
||||
const channelCount = pagination?.total_hits;
|
||||
const hasChannels = channels?.length !== 0;
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (
|
||||
userMeConfig.view_style_channel !== view ||
|
||||
userMeConfig.show_subed_only !== showSubscribedOnly
|
||||
) {
|
||||
const userConfig: UserConfigType = {
|
||||
show_subed_only: showSubscribedOnly,
|
||||
view_style_channel: view,
|
||||
};
|
||||
|
||||
await updateUserConfig(userConfig);
|
||||
}
|
||||
})();
|
||||
}, [showSubscribedOnly, userMeConfig.show_subed_only, userMeConfig.view_style_channel, view]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (
|
||||
refresh ||
|
||||
pagination?.current_page === undefined ||
|
||||
currentPage !== pagination?.current_page
|
||||
) {
|
||||
const channelListResponse = await loadChannelList(currentPage, showSubscribedOnly);
|
||||
|
||||
setChannelListResponse(channelListResponse);
|
||||
setRefresh(false);
|
||||
}
|
||||
})();
|
||||
}, [currentPage, showSubscribedOnly, refresh, pagination?.current_page]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>TA | Channels</title>
|
||||
</Helmet>
|
||||
<ScrollToTopOnNavigate />
|
||||
<div className="boxed-content">
|
||||
<div className="title-split">
|
||||
<div className="title-bar">
|
||||
<h1>Channels</h1>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="title-split-form">
|
||||
<img
|
||||
id="animate-icon"
|
||||
onClick={() => {
|
||||
setShowAddForm(!showAddForm);
|
||||
}}
|
||||
src={iconAdd}
|
||||
alt="add-icon"
|
||||
title="Subscribe to Channels"
|
||||
/>
|
||||
{showAddForm && (
|
||||
<div className="show-form">
|
||||
<div>
|
||||
<label>Subscribe to channels:</label>
|
||||
<textarea rows={3} placeholder="Input channel ID, URL or Video of a channel" />
|
||||
</div>
|
||||
|
||||
<Button label="Subscribe" type="submit" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Notifications pageName="all" />
|
||||
|
||||
<div className="view-controls">
|
||||
<div className="toggle">
|
||||
<span>Show subscribed only:</span>
|
||||
<div className="toggleBox">
|
||||
<input
|
||||
id="show_subed_only"
|
||||
onChange={() => {
|
||||
setShowSubscribedOnly(!showSubscribedOnly);
|
||||
}}
|
||||
type="checkbox"
|
||||
checked={showSubscribedOnly}
|
||||
/>
|
||||
{!showSubscribedOnly && (
|
||||
<label htmlFor="" className="ofbtn">
|
||||
Off
|
||||
</label>
|
||||
)}
|
||||
{showSubscribedOnly && (
|
||||
<label htmlFor="" className="onbtn">
|
||||
On
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="view-icons">
|
||||
<img
|
||||
src={iconGridView}
|
||||
onClick={() => {
|
||||
setView('grid');
|
||||
}}
|
||||
data-origin="channel"
|
||||
data-value="grid"
|
||||
alt="grid view"
|
||||
/>
|
||||
<img
|
||||
src={iconListView}
|
||||
onClick={() => {
|
||||
setView('list');
|
||||
}}
|
||||
data-origin="channel"
|
||||
data-value="list"
|
||||
alt="list view"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{hasChannels && <h2>Total channels: {channelCount}</h2>}
|
||||
|
||||
<div className={`channel-list ${view}`}>
|
||||
{!hasChannels && <h2>No channels found...</h2>}
|
||||
|
||||
{hasChannels && (
|
||||
<ChannelList channelList={channels} viewLayout={view} refreshChannelList={setRefresh} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pagination && (
|
||||
<div className="boxed-content">
|
||||
<Pagination pagination={pagination} setPage={setCurrentPage} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Channels;
|
||||
import { useLoaderData, useOutletContext } from 'react-router-dom';
|
||||
import loadChannelList from '../api/loader/loadChannelList';
|
||||
import iconGridView from '/img/icon-gridview.svg';
|
||||
import iconListView from '/img/icon-listview.svg';
|
||||
import iconAdd from '/img/icon-add.svg';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Pagination, { PaginationType } from '../components/Pagination';
|
||||
import { ConfigType, ViewLayoutType } from './Home';
|
||||
import updateUserConfig, { UserConfigType, UserMeType } from '../api/actions/updateUserConfig';
|
||||
import { OutletContextType } from './Base';
|
||||
import ChannelList from '../components/ChannelList';
|
||||
import ScrollToTopOnNavigate from '../components/ScrollToTop';
|
||||
import Notifications from '../components/Notifications';
|
||||
import Button from '../components/Button';
|
||||
|
||||
type ChannelOverwritesType = {
|
||||
download_format?: string;
|
||||
autodelete_days?: number;
|
||||
index_playlists?: boolean;
|
||||
integrate_sponsorblock?: boolean;
|
||||
subscriptions_channel_size?: number;
|
||||
subscriptions_live_channel_size?: number;
|
||||
subscriptions_shorts_channel_size?: number;
|
||||
};
|
||||
|
||||
export type ChannelType = {
|
||||
channel_active: boolean;
|
||||
channel_banner_url: string;
|
||||
channel_description: string;
|
||||
channel_id: string;
|
||||
channel_last_refresh: string;
|
||||
channel_name: string;
|
||||
channel_overwrites?: ChannelOverwritesType;
|
||||
channel_subs: number;
|
||||
channel_subscribed: boolean;
|
||||
channel_tags: string[];
|
||||
channel_thumb_url: string;
|
||||
channel_tvart_url: string;
|
||||
channel_views: number;
|
||||
};
|
||||
|
||||
type ChannelsListResponse = {
|
||||
data: ChannelType[];
|
||||
paginate: PaginationType;
|
||||
config?: ConfigType;
|
||||
};
|
||||
|
||||
type ChannelsLoaderDataType = {
|
||||
userConfig: UserMeType;
|
||||
};
|
||||
|
||||
const Channels = () => {
|
||||
const { userConfig } = useLoaderData() as ChannelsLoaderDataType;
|
||||
const { isAdmin, currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
|
||||
|
||||
const userMeConfig = userConfig.config;
|
||||
|
||||
const [channelListResponse, setChannelListResponse] = useState<ChannelsListResponse>();
|
||||
const [showSubscribedOnly, setShowSubscribedOnly] = useState(
|
||||
userMeConfig.show_subed_only || false,
|
||||
);
|
||||
const [view, setView] = useState<ViewLayoutType>(userMeConfig.view_style_channel || 'grid');
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [refresh, setRefresh] = useState(false);
|
||||
|
||||
const channels = channelListResponse?.data;
|
||||
const pagination = channelListResponse?.paginate;
|
||||
const channelCount = pagination?.total_hits;
|
||||
const hasChannels = channels?.length !== 0;
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (
|
||||
userMeConfig.view_style_channel !== view ||
|
||||
userMeConfig.show_subed_only !== showSubscribedOnly
|
||||
) {
|
||||
const userConfig: UserConfigType = {
|
||||
show_subed_only: showSubscribedOnly,
|
||||
view_style_channel: view,
|
||||
};
|
||||
|
||||
await updateUserConfig(userConfig);
|
||||
}
|
||||
})();
|
||||
}, [showSubscribedOnly, userMeConfig.show_subed_only, userMeConfig.view_style_channel, view]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (
|
||||
refresh ||
|
||||
pagination?.current_page === undefined ||
|
||||
currentPage !== pagination?.current_page
|
||||
) {
|
||||
const channelListResponse = await loadChannelList(currentPage, showSubscribedOnly);
|
||||
|
||||
setChannelListResponse(channelListResponse);
|
||||
setRefresh(false);
|
||||
}
|
||||
})();
|
||||
}, [currentPage, showSubscribedOnly, refresh, pagination?.current_page]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>TA | Channels</title>
|
||||
<ScrollToTopOnNavigate />
|
||||
<div className="boxed-content">
|
||||
<div className="title-split">
|
||||
<div className="title-bar">
|
||||
<h1>Channels</h1>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="title-split-form">
|
||||
<img
|
||||
id="animate-icon"
|
||||
onClick={() => {
|
||||
setShowAddForm(!showAddForm);
|
||||
}}
|
||||
src={iconAdd}
|
||||
alt="add-icon"
|
||||
title="Subscribe to Channels"
|
||||
/>
|
||||
{showAddForm && (
|
||||
<div className="show-form">
|
||||
<div>
|
||||
<label>Subscribe to channels:</label>
|
||||
<textarea rows={3} placeholder="Input channel ID, URL or Video of a channel" />
|
||||
</div>
|
||||
|
||||
<Button label="Subscribe" type="submit" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Notifications pageName="all" />
|
||||
|
||||
<div className="view-controls">
|
||||
<div className="toggle">
|
||||
<span>Show subscribed only:</span>
|
||||
<div className="toggleBox">
|
||||
<input
|
||||
id="show_subed_only"
|
||||
onChange={() => {
|
||||
setShowSubscribedOnly(!showSubscribedOnly);
|
||||
}}
|
||||
type="checkbox"
|
||||
checked={showSubscribedOnly}
|
||||
/>
|
||||
{!showSubscribedOnly && (
|
||||
<label htmlFor="" className="ofbtn">
|
||||
Off
|
||||
</label>
|
||||
)}
|
||||
{showSubscribedOnly && (
|
||||
<label htmlFor="" className="onbtn">
|
||||
On
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="view-icons">
|
||||
<img
|
||||
src={iconGridView}
|
||||
onClick={() => {
|
||||
setView('grid');
|
||||
}}
|
||||
data-origin="channel"
|
||||
data-value="grid"
|
||||
alt="grid view"
|
||||
/>
|
||||
<img
|
||||
src={iconListView}
|
||||
onClick={() => {
|
||||
setView('list');
|
||||
}}
|
||||
data-origin="channel"
|
||||
data-value="list"
|
||||
alt="list view"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{hasChannels && <h2>Total channels: {channelCount}</h2>}
|
||||
|
||||
<div className={`channel-list ${view}`}>
|
||||
{!hasChannels && <h2>No channels found...</h2>}
|
||||
|
||||
{hasChannels && (
|
||||
<ChannelList channelList={channels} viewLayout={view} refreshChannelList={setRefresh} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pagination && (
|
||||
<div className="boxed-content">
|
||||
<Pagination pagination={pagination} setPage={setCurrentPage} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Channels;
|
||||
|
@ -16,7 +16,6 @@ import updateDownloadQueue from '../api/actions/updateDownloadQueue';
|
||||
import updateTaskByName from '../api/actions/updateTaskByName';
|
||||
import Notifications from '../components/Notifications';
|
||||
import ScrollToTopOnNavigate from '../components/ScrollToTop';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import Button from '../components/Button';
|
||||
import DownloadListItem from '../components/DownloadListItem';
|
||||
import loadDownloadAggs, { DownloadAggsType } from '../api/loader/loadDownloadAggs';
|
||||
@ -90,20 +89,14 @@ const Download = () => {
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (
|
||||
userMeConfig.show_ignored_only !== showIgnored ||
|
||||
userMeConfig.view_style_downloads !== view ||
|
||||
userMeConfig.grid_items !== gridItems
|
||||
) {
|
||||
const userConfig: UserConfigType = {
|
||||
show_ignored_only: showIgnored,
|
||||
[ViewStyleNames.downloads]: view,
|
||||
grid_items: gridItems,
|
||||
};
|
||||
const userConfig: UserConfigType = {
|
||||
show_ignored_only: showIgnored,
|
||||
[ViewStyleNames.downloads]: view,
|
||||
grid_items: gridItems,
|
||||
};
|
||||
|
||||
await updateUserConfig(userConfig);
|
||||
setRefresh(true);
|
||||
}
|
||||
await updateUserConfig(userConfig);
|
||||
setRefresh(true);
|
||||
})();
|
||||
}, [
|
||||
view,
|
||||
@ -152,9 +145,7 @@ const Download = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>TA | Downloads</title>
|
||||
</Helmet>
|
||||
<title>TA | Downloads</title>
|
||||
<ScrollToTopOnNavigate />
|
||||
<div className="boxed-content">
|
||||
<div className="title-bar">
|
||||
|
@ -1,35 +1,32 @@
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { useRouteError } from 'react-router-dom';
|
||||
import importColours, { ColourConstant, ColourVariants } from '../configuration/colours/getColours';
|
||||
|
||||
// This is not always the correct response
|
||||
type ErrorType = {
|
||||
statusText: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
const ErrorPage = () => {
|
||||
const error = useRouteError() as ErrorType;
|
||||
importColours(ColourConstant.Dark as ColourVariants);
|
||||
|
||||
console.error('ErrorPage', error);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>TA | Oops!</title>
|
||||
</Helmet>
|
||||
|
||||
<div id="error-page" style={{ margin: '10%' }}>
|
||||
<h1>Oops!</h1>
|
||||
<p>Sorry, an unexpected error has occurred.</p>
|
||||
<p>
|
||||
<i>{error?.statusText}</i>
|
||||
<i>{error?.message}</i>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorPage;
|
||||
import { useRouteError } from 'react-router-dom';
|
||||
import importColours, { ColourConstant, ColourVariants } from '../configuration/colours/getColours';
|
||||
|
||||
// This is not always the correct response
|
||||
type ErrorType = {
|
||||
statusText: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
const ErrorPage = () => {
|
||||
const error = useRouteError() as ErrorType;
|
||||
importColours(ColourConstant.Dark as ColourVariants);
|
||||
|
||||
console.error('ErrorPage', error);
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>TA | Oops!</title>
|
||||
|
||||
<div id="error-page" style={{ margin: '10%' }}>
|
||||
<h1>Oops!</h1>
|
||||
<p>Sorry, an unexpected error has occurred.</p>
|
||||
<p>
|
||||
<i>{error?.statusText}</i>
|
||||
<i>{error?.message}</i>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorPage;
|
||||
|
@ -1,251 +1,248 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useLoaderData, useOutletContext, useSearchParams } from 'react-router-dom';
|
||||
import Routes from '../configuration/routes/RouteList';
|
||||
import Pagination from '../components/Pagination';
|
||||
import loadVideoListByFilter, {
|
||||
VideoListByFilterResponseType,
|
||||
} from '../api/loader/loadVideoListByPage';
|
||||
import { UserMeType } from '../api/actions/updateUserConfig';
|
||||
import VideoList from '../components/VideoList';
|
||||
import { ChannelType } from './Channels';
|
||||
import { OutletContextType } from './Base';
|
||||
import Filterbar from '../components/Filterbar';
|
||||
import { ViewStyleNames, ViewStyles } from '../configuration/constants/ViewStyle';
|
||||
import ScrollToTopOnNavigate from '../components/ScrollToTop';
|
||||
import EmbeddableVideoPlayer from '../components/EmbeddableVideoPlayer';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { SponsorBlockType } from './Video';
|
||||
|
||||
export type PlayerType = {
|
||||
watched: boolean;
|
||||
duration: number;
|
||||
duration_str: string;
|
||||
progress: number;
|
||||
};
|
||||
|
||||
export type StatsType = {
|
||||
view_count: number;
|
||||
like_count: number;
|
||||
dislike_count: number;
|
||||
average_rating: number;
|
||||
};
|
||||
|
||||
export type StreamType = {
|
||||
type: string;
|
||||
index: number;
|
||||
codec: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
bitrate: number;
|
||||
};
|
||||
|
||||
export type Subtitles = {
|
||||
ext: string;
|
||||
url: string;
|
||||
name: string;
|
||||
lang: string;
|
||||
source: string;
|
||||
media_url: string;
|
||||
};
|
||||
|
||||
export type VideoType = {
|
||||
active: boolean;
|
||||
category: string[];
|
||||
channel: ChannelType;
|
||||
date_downloaded: number;
|
||||
description: string;
|
||||
comment_count?: number;
|
||||
media_size: number;
|
||||
media_url: string;
|
||||
player: PlayerType;
|
||||
published: string;
|
||||
sponsorblock?: SponsorBlockType;
|
||||
playlist?: string[];
|
||||
stats: StatsType;
|
||||
streams: StreamType[];
|
||||
subtitles: Subtitles[];
|
||||
tags: string[];
|
||||
title: string;
|
||||
vid_last_refresh: string;
|
||||
vid_thumb_base64: boolean;
|
||||
vid_thumb_url: string;
|
||||
vid_type: string;
|
||||
youtube_id: string;
|
||||
};
|
||||
|
||||
export type DownloadsType = {
|
||||
limit_speed: boolean;
|
||||
sleep_interval: number;
|
||||
autodelete_days: boolean;
|
||||
format: boolean;
|
||||
format_sort: boolean;
|
||||
add_metadata: boolean;
|
||||
add_thumbnail: boolean;
|
||||
subtitle: boolean;
|
||||
subtitle_source: boolean;
|
||||
subtitle_index: boolean;
|
||||
comment_max: boolean;
|
||||
comment_sort: string;
|
||||
cookie_import: boolean;
|
||||
throttledratelimit: boolean;
|
||||
extractor_lang: boolean;
|
||||
integrate_ryd: boolean;
|
||||
integrate_sponsorblock: boolean;
|
||||
};
|
||||
|
||||
export type ConfigType = {
|
||||
enable_cast: boolean;
|
||||
downloads: DownloadsType;
|
||||
};
|
||||
|
||||
type HomeLoaderDataType = {
|
||||
userConfig: UserMeType;
|
||||
};
|
||||
|
||||
export type SortByType = 'published' | 'downloaded' | 'views' | 'likes' | 'duration' | 'filesize';
|
||||
export type SortOrderType = 'asc' | 'desc';
|
||||
export type ViewLayoutType = 'grid' | 'list';
|
||||
|
||||
const Home = () => {
|
||||
const { userConfig } = useLoaderData() as HomeLoaderDataType;
|
||||
const { currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
|
||||
const [searchParams] = useSearchParams();
|
||||
const videoId = searchParams.get('videoId');
|
||||
|
||||
const userMeConfig = userConfig.config;
|
||||
|
||||
const [hideWatched, setHideWatched] = useState(userMeConfig.hide_watched || false);
|
||||
const [sortBy, setSortBy] = useState<SortByType>(userMeConfig.sort_by || 'published');
|
||||
const [sortOrder, setSortOrder] = useState<SortOrderType>(userMeConfig.sort_order || 'asc');
|
||||
const [view, setView] = useState<ViewLayoutType>(userMeConfig.view_style_home || 'grid');
|
||||
const [gridItems, setGridItems] = useState(userMeConfig.grid_items || 3);
|
||||
const [showHidden, setShowHidden] = useState(false);
|
||||
const [refreshVideoList, setRefreshVideoList] = useState(false);
|
||||
|
||||
const [videoResponse, setVideoReponse] = useState<VideoListByFilterResponseType>();
|
||||
const [continueVideoResponse, setContinueVideoResponse] =
|
||||
useState<VideoListByFilterResponseType>();
|
||||
|
||||
const videoList = videoResponse?.data;
|
||||
const pagination = videoResponse?.paginate;
|
||||
const continueVideos = continueVideoResponse?.data;
|
||||
|
||||
const hasVideos = videoResponse?.data?.length !== 0;
|
||||
const showEmbeddedVideo = videoId !== null;
|
||||
|
||||
const isGridView = view === ViewStyles.grid;
|
||||
const gridView = isGridView ? `boxed-${gridItems}` : '';
|
||||
const gridViewGrid = isGridView ? `grid-${gridItems}` : '';
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (
|
||||
refreshVideoList ||
|
||||
pagination?.current_page === undefined ||
|
||||
currentPage !== pagination?.current_page
|
||||
) {
|
||||
const videos = await loadVideoListByFilter({
|
||||
page: currentPage,
|
||||
watch: hideWatched ? 'unwatched' : undefined,
|
||||
sort: sortBy,
|
||||
order: sortOrder,
|
||||
});
|
||||
|
||||
try {
|
||||
const continueVideoResponse = await loadVideoListByFilter({ watch: 'continue' });
|
||||
setContinueVideoResponse(continueVideoResponse);
|
||||
} catch (error) {
|
||||
console.log('Server error on continue vids?');
|
||||
}
|
||||
|
||||
setVideoReponse(videos);
|
||||
|
||||
setRefreshVideoList(false);
|
||||
}
|
||||
})();
|
||||
// Do not add sort, order, hideWatched this will not work as expected!
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [refreshVideoList, currentPage, pagination?.current_page]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>TubeArchivist</title>
|
||||
</Helmet>
|
||||
<ScrollToTopOnNavigate />
|
||||
<div className={`boxed-content ${gridView}`}>
|
||||
{continueVideos && continueVideos.length > 0 && (
|
||||
<>
|
||||
<div className="title-bar">
|
||||
<h1>Continue Watching</h1>
|
||||
</div>
|
||||
<div className={`video-list ${view} ${gridViewGrid}`}>
|
||||
<VideoList
|
||||
videoList={continueVideos}
|
||||
viewLayout={view}
|
||||
refreshVideoList={setRefreshVideoList}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="title-bar">
|
||||
<h1>Recent Videos</h1>
|
||||
</div>
|
||||
|
||||
<Filterbar
|
||||
hideToggleText="Hide watched:"
|
||||
showHidden={showHidden}
|
||||
hideWatched={hideWatched}
|
||||
isGridView={isGridView}
|
||||
view={view}
|
||||
gridItems={gridItems}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
userMeConfig={userMeConfig}
|
||||
setShowHidden={setShowHidden}
|
||||
setHideWatched={setHideWatched}
|
||||
setView={setView}
|
||||
setSortBy={setSortBy}
|
||||
setSortOrder={setSortOrder}
|
||||
setGridItems={setGridItems}
|
||||
viewStyleName={ViewStyleNames.home}
|
||||
setRefresh={setRefreshVideoList}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showEmbeddedVideo && <EmbeddableVideoPlayer videoId={videoId} />}
|
||||
|
||||
<div className={`boxed-content ${gridView}`}>
|
||||
<div className={`video-list ${view} ${gridViewGrid}`}>
|
||||
{!hasVideos && (
|
||||
<>
|
||||
<h2>No videos found...</h2>
|
||||
<p>
|
||||
If you've already added a channel or playlist, try going to the{' '}
|
||||
<Link to={Routes.Downloads}>downloads page</Link> to start the scan and download
|
||||
tasks.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasVideos && (
|
||||
<VideoList
|
||||
videoList={videoList}
|
||||
viewLayout={view}
|
||||
refreshVideoList={setRefreshVideoList}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pagination && (
|
||||
<div className="boxed-content">
|
||||
<Pagination pagination={pagination} setPage={setCurrentPage} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useLoaderData, useOutletContext, useSearchParams } from 'react-router-dom';
|
||||
import Routes from '../configuration/routes/RouteList';
|
||||
import Pagination from '../components/Pagination';
|
||||
import loadVideoListByFilter, {
|
||||
VideoListByFilterResponseType,
|
||||
} from '../api/loader/loadVideoListByPage';
|
||||
import { UserMeType } from '../api/actions/updateUserConfig';
|
||||
import VideoList from '../components/VideoList';
|
||||
import { ChannelType } from './Channels';
|
||||
import { OutletContextType } from './Base';
|
||||
import Filterbar from '../components/Filterbar';
|
||||
import { ViewStyleNames, ViewStyles } from '../configuration/constants/ViewStyle';
|
||||
import ScrollToTopOnNavigate from '../components/ScrollToTop';
|
||||
import EmbeddableVideoPlayer from '../components/EmbeddableVideoPlayer';
|
||||
import { SponsorBlockType } from './Video';
|
||||
|
||||
export type PlayerType = {
|
||||
watched: boolean;
|
||||
duration: number;
|
||||
duration_str: string;
|
||||
progress: number;
|
||||
};
|
||||
|
||||
export type StatsType = {
|
||||
view_count: number;
|
||||
like_count: number;
|
||||
dislike_count: number;
|
||||
average_rating: number;
|
||||
};
|
||||
|
||||
export type StreamType = {
|
||||
type: string;
|
||||
index: number;
|
||||
codec: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
bitrate: number;
|
||||
};
|
||||
|
||||
export type Subtitles = {
|
||||
ext: string;
|
||||
url: string;
|
||||
name: string;
|
||||
lang: string;
|
||||
source: string;
|
||||
media_url: string;
|
||||
};
|
||||
|
||||
export type VideoType = {
|
||||
active: boolean;
|
||||
category: string[];
|
||||
channel: ChannelType;
|
||||
date_downloaded: number;
|
||||
description: string;
|
||||
comment_count?: number;
|
||||
media_size: number;
|
||||
media_url: string;
|
||||
player: PlayerType;
|
||||
published: string;
|
||||
sponsorblock?: SponsorBlockType;
|
||||
playlist?: string[];
|
||||
stats: StatsType;
|
||||
streams: StreamType[];
|
||||
subtitles: Subtitles[];
|
||||
tags: string[];
|
||||
title: string;
|
||||
vid_last_refresh: string;
|
||||
vid_thumb_base64: boolean;
|
||||
vid_thumb_url: string;
|
||||
vid_type: string;
|
||||
youtube_id: string;
|
||||
};
|
||||
|
||||
export type DownloadsType = {
|
||||
limit_speed: boolean;
|
||||
sleep_interval: number;
|
||||
autodelete_days: boolean;
|
||||
format: boolean;
|
||||
format_sort: boolean;
|
||||
add_metadata: boolean;
|
||||
add_thumbnail: boolean;
|
||||
subtitle: boolean;
|
||||
subtitle_source: boolean;
|
||||
subtitle_index: boolean;
|
||||
comment_max: boolean;
|
||||
comment_sort: string;
|
||||
cookie_import: boolean;
|
||||
throttledratelimit: boolean;
|
||||
extractor_lang: boolean;
|
||||
integrate_ryd: boolean;
|
||||
integrate_sponsorblock: boolean;
|
||||
};
|
||||
|
||||
export type ConfigType = {
|
||||
enable_cast: boolean;
|
||||
downloads: DownloadsType;
|
||||
};
|
||||
|
||||
type HomeLoaderDataType = {
|
||||
userConfig: UserMeType;
|
||||
};
|
||||
|
||||
export type SortByType = 'published' | 'downloaded' | 'views' | 'likes' | 'duration' | 'filesize';
|
||||
export type SortOrderType = 'asc' | 'desc';
|
||||
export type ViewLayoutType = 'grid' | 'list';
|
||||
|
||||
const Home = () => {
|
||||
const { userConfig } = useLoaderData() as HomeLoaderDataType;
|
||||
const { currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
|
||||
const [searchParams] = useSearchParams();
|
||||
const videoId = searchParams.get('videoId');
|
||||
|
||||
const userMeConfig = userConfig.config;
|
||||
|
||||
const [hideWatched, setHideWatched] = useState(userMeConfig.hide_watched || false);
|
||||
const [sortBy, setSortBy] = useState<SortByType>(userMeConfig.sort_by || 'published');
|
||||
const [sortOrder, setSortOrder] = useState<SortOrderType>(userMeConfig.sort_order || 'asc');
|
||||
const [view, setView] = useState<ViewLayoutType>(userMeConfig.view_style_home || 'grid');
|
||||
const [gridItems, setGridItems] = useState(userMeConfig.grid_items || 3);
|
||||
const [showHidden, setShowHidden] = useState(false);
|
||||
const [refreshVideoList, setRefreshVideoList] = useState(false);
|
||||
|
||||
const [videoResponse, setVideoReponse] = useState<VideoListByFilterResponseType>();
|
||||
const [continueVideoResponse, setContinueVideoResponse] =
|
||||
useState<VideoListByFilterResponseType>();
|
||||
|
||||
const videoList = videoResponse?.data;
|
||||
const pagination = videoResponse?.paginate;
|
||||
const continueVideos = continueVideoResponse?.data;
|
||||
|
||||
const hasVideos = videoResponse?.data?.length !== 0;
|
||||
const showEmbeddedVideo = videoId !== null;
|
||||
|
||||
const isGridView = view === ViewStyles.grid;
|
||||
const gridView = isGridView ? `boxed-${gridItems}` : '';
|
||||
const gridViewGrid = isGridView ? `grid-${gridItems}` : '';
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (
|
||||
refreshVideoList ||
|
||||
pagination?.current_page === undefined ||
|
||||
currentPage !== pagination?.current_page
|
||||
) {
|
||||
const videos = await loadVideoListByFilter({
|
||||
page: currentPage,
|
||||
watch: hideWatched ? 'unwatched' : undefined,
|
||||
sort: sortBy,
|
||||
order: sortOrder,
|
||||
});
|
||||
|
||||
try {
|
||||
const continueVideoResponse = await loadVideoListByFilter({ watch: 'continue' });
|
||||
setContinueVideoResponse(continueVideoResponse);
|
||||
} catch (error) {
|
||||
console.log('Server error on continue vids?');
|
||||
}
|
||||
|
||||
setVideoReponse(videos);
|
||||
|
||||
setRefreshVideoList(false);
|
||||
}
|
||||
})();
|
||||
// Do not add sort, order, hideWatched this will not work as expected!
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [refreshVideoList, currentPage, pagination?.current_page]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>TubeArchivist</title>
|
||||
<ScrollToTopOnNavigate />
|
||||
<div className={`boxed-content ${gridView}`}>
|
||||
{continueVideos && continueVideos.length > 0 && (
|
||||
<>
|
||||
<div className="title-bar">
|
||||
<h1>Continue Watching</h1>
|
||||
</div>
|
||||
<div className={`video-list ${view} ${gridViewGrid}`}>
|
||||
<VideoList
|
||||
videoList={continueVideos}
|
||||
viewLayout={view}
|
||||
refreshVideoList={setRefreshVideoList}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="title-bar">
|
||||
<h1>Recent Videos</h1>
|
||||
</div>
|
||||
|
||||
<Filterbar
|
||||
hideToggleText="Hide watched:"
|
||||
showHidden={showHidden}
|
||||
hideWatched={hideWatched}
|
||||
isGridView={isGridView}
|
||||
view={view}
|
||||
gridItems={gridItems}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
userMeConfig={userMeConfig}
|
||||
setShowHidden={setShowHidden}
|
||||
setHideWatched={setHideWatched}
|
||||
setView={setView}
|
||||
setSortBy={setSortBy}
|
||||
setSortOrder={setSortOrder}
|
||||
setGridItems={setGridItems}
|
||||
viewStyleName={ViewStyleNames.home}
|
||||
setRefresh={setRefreshVideoList}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showEmbeddedVideo && <EmbeddableVideoPlayer videoId={videoId} />}
|
||||
|
||||
<div className={`boxed-content ${gridView}`}>
|
||||
<div className={`video-list ${view} ${gridViewGrid}`}>
|
||||
{!hasVideos && (
|
||||
<>
|
||||
<h2>No videos found...</h2>
|
||||
<p>
|
||||
If you've already added a channel or playlist, try going to the{' '}
|
||||
<Link to={Routes.Downloads}>downloads page</Link> to start the scan and download
|
||||
tasks.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasVideos && (
|
||||
<VideoList
|
||||
videoList={videoList}
|
||||
viewLayout={view}
|
||||
refreshVideoList={setRefreshVideoList}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pagination && (
|
||||
<div className="boxed-content">
|
||||
<Pagination pagination={pagination} setPage={setCurrentPage} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
|
@ -1,106 +1,103 @@
|
||||
import { useState } from 'react';
|
||||
import Routes from '../configuration/routes/RouteList';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import importColours, { ColourConstant, ColourVariants } from '../configuration/colours/getColours';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import Button from '../components/Button';
|
||||
import signIn from '../api/actions/signIn';
|
||||
|
||||
const Login = () => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [saveLogin, setSaveLogin] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
importColours(ColourConstant.Dark as ColourVariants);
|
||||
|
||||
const form_error = false;
|
||||
|
||||
const handleSubmit = async (event: { preventDefault: () => void }) => {
|
||||
event.preventDefault();
|
||||
|
||||
const loginResponse = await signIn(username, password, saveLogin);
|
||||
|
||||
const signedIn = loginResponse.status === 200;
|
||||
|
||||
if (signedIn) {
|
||||
navigate(Routes.Home);
|
||||
} else {
|
||||
navigate(Routes.Login);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>TA | Welcome</title>
|
||||
</Helmet>
|
||||
<div className="boxed-content login-page">
|
||||
<img alt="tube-archivist-logo" />
|
||||
<h1>Tube Archivist</h1>
|
||||
<h2>Your Self Hosted YouTube Media Server</h2>
|
||||
|
||||
{form_error && <p className="danger-zone">Failed to login.</p>}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
id="id_username"
|
||||
placeholder="Username"
|
||||
autoComplete="username"
|
||||
maxLength={150}
|
||||
required={true}
|
||||
value={username}
|
||||
onChange={event => setUsername(event.target.value)}
|
||||
/>
|
||||
<br />
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
id="id_password"
|
||||
placeholder="Password"
|
||||
autoComplete="current-password"
|
||||
required={true}
|
||||
value={password}
|
||||
onChange={event => setPassword(event.target.value)}
|
||||
/>
|
||||
<br />
|
||||
<p>
|
||||
Remember me:{' '}
|
||||
<input
|
||||
type="checkbox"
|
||||
name="remember_me"
|
||||
id="id_remember_me"
|
||||
checked={saveLogin}
|
||||
onChange={() => {
|
||||
setSaveLogin(!saveLogin);
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<input type="hidden" name="next" value={Routes.Home} />
|
||||
<Button label="Login" type="submit" />
|
||||
</form>
|
||||
<p className="login-links">
|
||||
<span>
|
||||
<a href="https://github.com/tubearchivist/tubearchivist" target="_blank">
|
||||
Github
|
||||
</a>
|
||||
</span>{' '}
|
||||
<span>
|
||||
<a href="https://github.com/tubearchivist/tubearchivist#donate" target="_blank">
|
||||
Donate
|
||||
</a>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="footer-colors">
|
||||
<div className="col-1"></div>
|
||||
<div className="col-2"></div>
|
||||
<div className="col-3"></div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
import { useState } from 'react';
|
||||
import Routes from '../configuration/routes/RouteList';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import importColours, { ColourConstant, ColourVariants } from '../configuration/colours/getColours';
|
||||
import Button from '../components/Button';
|
||||
import signIn from '../api/actions/signIn';
|
||||
|
||||
const Login = () => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [saveLogin, setSaveLogin] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
importColours(ColourConstant.Dark as ColourVariants);
|
||||
|
||||
const form_error = false;
|
||||
|
||||
const handleSubmit = async (event: { preventDefault: () => void }) => {
|
||||
event.preventDefault();
|
||||
|
||||
const loginResponse = await signIn(username, password, saveLogin);
|
||||
|
||||
const signedIn = loginResponse.status === 200;
|
||||
|
||||
if (signedIn) {
|
||||
navigate(Routes.Home);
|
||||
} else {
|
||||
navigate(Routes.Login);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>TA | Welcome</title>
|
||||
<div className="boxed-content login-page">
|
||||
<img alt="tube-archivist-logo" />
|
||||
<h1>Tube Archivist</h1>
|
||||
<h2>Your Self Hosted YouTube Media Server</h2>
|
||||
|
||||
{form_error && <p className="danger-zone">Failed to login.</p>}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
id="id_username"
|
||||
placeholder="Username"
|
||||
autoComplete="username"
|
||||
maxLength={150}
|
||||
required={true}
|
||||
value={username}
|
||||
onChange={event => setUsername(event.target.value)}
|
||||
/>
|
||||
<br />
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
id="id_password"
|
||||
placeholder="Password"
|
||||
autoComplete="current-password"
|
||||
required={true}
|
||||
value={password}
|
||||
onChange={event => setPassword(event.target.value)}
|
||||
/>
|
||||
<br />
|
||||
<p>
|
||||
Remember me:{' '}
|
||||
<input
|
||||
type="checkbox"
|
||||
name="remember_me"
|
||||
id="id_remember_me"
|
||||
checked={saveLogin}
|
||||
onChange={() => {
|
||||
setSaveLogin(!saveLogin);
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<input type="hidden" name="next" value={Routes.Home} />
|
||||
<Button label="Login" type="submit" />
|
||||
</form>
|
||||
<p className="login-links">
|
||||
<span>
|
||||
<a href="https://github.com/tubearchivist/tubearchivist" target="_blank">
|
||||
Github
|
||||
</a>
|
||||
</span>{' '}
|
||||
<span>
|
||||
<a href="https://github.com/tubearchivist/tubearchivist#donate" target="_blank">
|
||||
Donate
|
||||
</a>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="footer-colors">
|
||||
<div className="col-1"></div>
|
||||
<div className="col-2"></div>
|
||||
<div className="col-3"></div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
|
@ -1,390 +1,388 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Link,
|
||||
useLoaderData,
|
||||
useNavigate,
|
||||
useOutletContext,
|
||||
useParams,
|
||||
useSearchParams,
|
||||
} from 'react-router-dom';
|
||||
import { UserMeType } from '../api/actions/updateUserConfig';
|
||||
import loadPlaylistById from '../api/loader/loadPlaylistById';
|
||||
import { OutletContextType } from './Base';
|
||||
import { ConfigType, VideoType, ViewLayoutType } from './Home';
|
||||
import Filterbar from '../components/Filterbar';
|
||||
import { PlaylistEntryType } from './Playlists';
|
||||
import loadChannelById from '../api/loader/loadChannelById';
|
||||
import VideoList from '../components/VideoList';
|
||||
import Pagination, { PaginationType } from '../components/Pagination';
|
||||
import ChannelOverview from '../components/ChannelOverview';
|
||||
import Linkify from '../components/Linkify';
|
||||
import { ViewStyleNames, ViewStyles } from '../configuration/constants/ViewStyle';
|
||||
import updatePlaylistSubscription from '../api/actions/updatePlaylistSubscription';
|
||||
import deletePlaylist from '../api/actions/deletePlaylist';
|
||||
import Routes from '../configuration/routes/RouteList';
|
||||
import { ChannelResponseType } from './ChannelBase';
|
||||
import formatDate from '../functions/formatDates';
|
||||
import queueReindex from '../api/actions/queueReindex';
|
||||
import updateWatchedState from '../api/actions/updateWatchedState';
|
||||
import ScrollToTopOnNavigate from '../components/ScrollToTop';
|
||||
import EmbeddableVideoPlayer from '../components/EmbeddableVideoPlayer';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import Button from '../components/Button';
|
||||
import loadVideoListByFilter from '../api/loader/loadVideoListByPage';
|
||||
|
||||
export type PlaylistType = {
|
||||
playlist_active: boolean;
|
||||
playlist_channel: string;
|
||||
playlist_channel_id: string;
|
||||
playlist_description: string;
|
||||
playlist_entries: PlaylistEntryType[];
|
||||
playlist_id: string;
|
||||
playlist_last_refresh: string;
|
||||
playlist_name: string;
|
||||
playlist_subscribed: boolean;
|
||||
playlist_thumbnail: string;
|
||||
playlist_type: string;
|
||||
_index: string;
|
||||
_score: number;
|
||||
};
|
||||
|
||||
type PlaylistLoaderDataType = {
|
||||
userConfig: UserMeType;
|
||||
};
|
||||
|
||||
export type PlaylistResponseType = {
|
||||
data?: PlaylistType;
|
||||
config?: ConfigType;
|
||||
};
|
||||
|
||||
export type VideoResponseType = {
|
||||
data?: VideoType[];
|
||||
config?: ConfigType;
|
||||
paginate?: PaginationType;
|
||||
};
|
||||
|
||||
const Playlist = () => {
|
||||
const { playlistId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const videoId = searchParams.get('videoId');
|
||||
|
||||
const { userConfig } = useLoaderData() as PlaylistLoaderDataType;
|
||||
const { isAdmin, currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
|
||||
|
||||
const userMeConfig = userConfig.config;
|
||||
|
||||
const [hideWatched, setHideWatched] = useState(userMeConfig.hide_watched || false);
|
||||
const [view, setView] = useState<ViewLayoutType>(userMeConfig.view_style_home || 'grid');
|
||||
const [gridItems, setGridItems] = useState(userMeConfig.grid_items || 3);
|
||||
const [descriptionExpanded, setDescriptionExpanded] = useState(false);
|
||||
const [refresh, setRefresh] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [reindex, setReindex] = useState(false);
|
||||
|
||||
const [playlistResponse, setPlaylistResponse] = useState<PlaylistResponseType>();
|
||||
const [channelResponse, setChannelResponse] = useState<ChannelResponseType>();
|
||||
const [videoResponse, setVideoResponse] = useState<VideoResponseType>();
|
||||
|
||||
const playlist = playlistResponse?.data;
|
||||
const channel = channelResponse?.data;
|
||||
const videos = videoResponse?.data;
|
||||
const pagination = videoResponse?.paginate;
|
||||
|
||||
const palylistEntries = playlistResponse?.data?.playlist_entries;
|
||||
const videoArchivedCount = Number(palylistEntries?.filter(video => video.downloaded).length);
|
||||
const videoInPlaylistCount = pagination?.total_hits;
|
||||
const showEmbeddedVideo = videoId !== null;
|
||||
|
||||
const isGridView = view === ViewStyles.grid;
|
||||
const gridView = isGridView ? `boxed-${gridItems}` : '';
|
||||
const gridViewGrid = isGridView ? `grid-${gridItems}` : '';
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (
|
||||
refresh ||
|
||||
pagination?.current_page === undefined ||
|
||||
currentPage !== pagination?.current_page
|
||||
) {
|
||||
const playlist = await loadPlaylistById(playlistId);
|
||||
const video = await loadVideoListByFilter({
|
||||
playlist: playlistId,
|
||||
page: currentPage,
|
||||
watch: hideWatched ? 'unwatched' : undefined,
|
||||
sort: 'downloaded', // downloaded or published? or playlist sort order?
|
||||
});
|
||||
|
||||
const isCustomPlaylist = playlist?.data?.playlist_type === 'custom';
|
||||
if (!isCustomPlaylist) {
|
||||
const channel = await loadChannelById(playlist.data.playlist_channel_id);
|
||||
|
||||
setChannelResponse(channel);
|
||||
}
|
||||
|
||||
setPlaylistResponse(playlist);
|
||||
setVideoResponse(video);
|
||||
setRefresh(false);
|
||||
}
|
||||
})();
|
||||
// Do not add hideWatched this will not work as expected!
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [playlistId, refresh, currentPage, pagination?.current_page]);
|
||||
|
||||
if (!playlistId || !playlist) {
|
||||
return `Playlist ${playlistId} not found!`;
|
||||
}
|
||||
|
||||
const isCustomPlaylist = playlist.playlist_type === 'custom';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>TA | Playlist: {playlist.playlist_name}</title>
|
||||
</Helmet>
|
||||
<ScrollToTopOnNavigate />
|
||||
<div className="boxed-content">
|
||||
<div className="title-bar">
|
||||
<h1>{playlist.playlist_name}</h1>
|
||||
</div>
|
||||
<div className="info-box info-box-3">
|
||||
{!isCustomPlaylist && channel && (
|
||||
<ChannelOverview
|
||||
channelId={channel?.channel_id}
|
||||
channelname={channel?.channel_name}
|
||||
channelSubs={channel?.channel_subs}
|
||||
channelSubscribed={channel?.channel_subscribed}
|
||||
setRefresh={setRefresh}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="info-box-item">
|
||||
<div>
|
||||
<p>Last refreshed: {formatDate(playlist.playlist_last_refresh)}</p>
|
||||
{!isCustomPlaylist && (
|
||||
<>
|
||||
<p>
|
||||
Playlist:
|
||||
{playlist.playlist_subscribed && (
|
||||
<>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
label="Unsubscribe"
|
||||
className="unsubscribe"
|
||||
type="button"
|
||||
title={`Unsubscribe from ${playlist.playlist_name}`}
|
||||
onClick={async () => {
|
||||
await updatePlaylistSubscription(playlistId, false);
|
||||
|
||||
setRefresh(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}{' '}
|
||||
{!playlist.playlist_subscribed && (
|
||||
<Button
|
||||
label="Subscribe"
|
||||
type="button"
|
||||
title={`Subscribe to ${playlist.playlist_name}`}
|
||||
onClick={async () => {
|
||||
await updatePlaylistSubscription(playlistId, true);
|
||||
|
||||
setRefresh(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
{playlist.playlist_active && (
|
||||
<p>
|
||||
Youtube:{' '}
|
||||
<a
|
||||
href={`https://www.youtube.com/playlist?list=${playlist.playlist_id}`}
|
||||
target="_blank"
|
||||
>
|
||||
Active
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
{!playlist.playlist_active && <p>Youtube: Deactivated</p>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!showDeleteConfirm && (
|
||||
<Button
|
||||
label="Delete Playlist"
|
||||
id="delete-item"
|
||||
onClick={() => setShowDeleteConfirm(!showDeleteConfirm)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDeleteConfirm && (
|
||||
<div className="delete-confirm" id="delete-button">
|
||||
<span>Delete {playlist.playlist_name}?</span>
|
||||
|
||||
<Button
|
||||
label="Delete metadata"
|
||||
onClick={async () => {
|
||||
await deletePlaylist(playlistId, false);
|
||||
navigate(Routes.Playlists);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
label="Delete all"
|
||||
className="danger-button"
|
||||
onClick={async () => {
|
||||
await deletePlaylist(playlistId, true);
|
||||
navigate(Routes.Playlists);
|
||||
}}
|
||||
/>
|
||||
|
||||
<br />
|
||||
<Button label="Cancel" onClick={() => setShowDeleteConfirm(!showDeleteConfirm)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="info-box-item">
|
||||
<div>
|
||||
{videoArchivedCount > 0 && (
|
||||
<>
|
||||
<p>
|
||||
Total Videos archived: {videoArchivedCount}/{videoInPlaylistCount}
|
||||
</p>
|
||||
<div id="watched-button" className="button-box">
|
||||
<Button
|
||||
label="Mark as watched"
|
||||
title={`Mark all videos from ${playlist.playlist_name} as watched`}
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
await updateWatchedState({
|
||||
id: playlistId,
|
||||
is_watched: true,
|
||||
});
|
||||
|
||||
setRefresh(true);
|
||||
}}
|
||||
/>{' '}
|
||||
<Button
|
||||
label="Mark as unwatched"
|
||||
title={`Mark all videos from ${playlist.playlist_name} as unwatched`}
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
await updateWatchedState({
|
||||
id: playlistId,
|
||||
is_watched: false,
|
||||
});
|
||||
|
||||
setRefresh(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{reindex && <p>Reindex scheduled</p>}
|
||||
{!reindex && (
|
||||
<div id="reindex-button" className="button-box">
|
||||
{!isCustomPlaylist && (
|
||||
<Button
|
||||
label="Reindex"
|
||||
title={`Reindex Playlist ${playlist.playlist_name}`}
|
||||
onClick={async () => {
|
||||
setReindex(true);
|
||||
|
||||
await queueReindex(playlist.playlist_id, 'playlist');
|
||||
}}
|
||||
/>
|
||||
)}{' '}
|
||||
<Button
|
||||
label="Reindex Videos"
|
||||
title={`Reindex Videos of ${playlist.playlist_name}`}
|
||||
onClick={async () => {
|
||||
setReindex(true);
|
||||
|
||||
await queueReindex(playlist.playlist_id, 'playlist', true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{playlist.playlist_description && (
|
||||
<div className="description-box">
|
||||
<p
|
||||
id={descriptionExpanded ? 'text-expand-expanded' : 'text-expand'}
|
||||
className="description-text"
|
||||
>
|
||||
<Linkify>{playlist.playlist_description}</Linkify>
|
||||
</p>
|
||||
|
||||
<Button
|
||||
label="Show more"
|
||||
id="text-expand-button"
|
||||
onClick={() => setDescriptionExpanded(!descriptionExpanded)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`boxed-content ${gridView}`}>
|
||||
<Filterbar
|
||||
hideToggleText="Hide watched videos:"
|
||||
hideWatched={hideWatched}
|
||||
isGridView={isGridView}
|
||||
view={view}
|
||||
gridItems={gridItems}
|
||||
userMeConfig={userMeConfig}
|
||||
setHideWatched={setHideWatched}
|
||||
setView={setView}
|
||||
setGridItems={setGridItems}
|
||||
viewStyleName={ViewStyleNames.playlist}
|
||||
setRefresh={setRefresh}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showEmbeddedVideo && <EmbeddableVideoPlayer videoId={videoId} />}
|
||||
|
||||
<div className={`boxed-content ${gridView}`}>
|
||||
<div className={`video-list ${view} ${gridViewGrid}`}>
|
||||
{videoInPlaylistCount === 0 && (
|
||||
<>
|
||||
<h2>No videos found...</h2>
|
||||
{isCustomPlaylist && (
|
||||
<p>
|
||||
Try going to the <a href="{% url 'home' %}">home page</a> to add videos to this
|
||||
playlist.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isCustomPlaylist && (
|
||||
<p>
|
||||
Try going to the <Link to={Routes.Downloads}>downloads page</Link> to start the
|
||||
scan and download tasks.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{videoInPlaylistCount !== 0 && (
|
||||
<VideoList
|
||||
videoList={videos}
|
||||
viewLayout={view}
|
||||
playlistId={playlistId}
|
||||
showReorderButton={isCustomPlaylist}
|
||||
refreshVideoList={setRefresh}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="boxed-content">
|
||||
{pagination && <Pagination pagination={pagination} setPage={setCurrentPage} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Playlist;
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Link,
|
||||
useLoaderData,
|
||||
useNavigate,
|
||||
useOutletContext,
|
||||
useParams,
|
||||
useSearchParams,
|
||||
} from 'react-router-dom';
|
||||
import { UserMeType } from '../api/actions/updateUserConfig';
|
||||
import loadPlaylistById from '../api/loader/loadPlaylistById';
|
||||
import { OutletContextType } from './Base';
|
||||
import { ConfigType, VideoType, ViewLayoutType } from './Home';
|
||||
import Filterbar from '../components/Filterbar';
|
||||
import { PlaylistEntryType } from './Playlists';
|
||||
import loadChannelById from '../api/loader/loadChannelById';
|
||||
import VideoList from '../components/VideoList';
|
||||
import Pagination, { PaginationType } from '../components/Pagination';
|
||||
import ChannelOverview from '../components/ChannelOverview';
|
||||
import Linkify from '../components/Linkify';
|
||||
import { ViewStyleNames, ViewStyles } from '../configuration/constants/ViewStyle';
|
||||
import updatePlaylistSubscription from '../api/actions/updatePlaylistSubscription';
|
||||
import deletePlaylist from '../api/actions/deletePlaylist';
|
||||
import Routes from '../configuration/routes/RouteList';
|
||||
import { ChannelResponseType } from './ChannelBase';
|
||||
import formatDate from '../functions/formatDates';
|
||||
import queueReindex from '../api/actions/queueReindex';
|
||||
import updateWatchedState from '../api/actions/updateWatchedState';
|
||||
import ScrollToTopOnNavigate from '../components/ScrollToTop';
|
||||
import EmbeddableVideoPlayer from '../components/EmbeddableVideoPlayer';
|
||||
import Button from '../components/Button';
|
||||
import loadVideoListByFilter from '../api/loader/loadVideoListByPage';
|
||||
|
||||
export type PlaylistType = {
|
||||
playlist_active: boolean;
|
||||
playlist_channel: string;
|
||||
playlist_channel_id: string;
|
||||
playlist_description: string;
|
||||
playlist_entries: PlaylistEntryType[];
|
||||
playlist_id: string;
|
||||
playlist_last_refresh: string;
|
||||
playlist_name: string;
|
||||
playlist_subscribed: boolean;
|
||||
playlist_thumbnail: string;
|
||||
playlist_type: string;
|
||||
_index: string;
|
||||
_score: number;
|
||||
};
|
||||
|
||||
type PlaylistLoaderDataType = {
|
||||
userConfig: UserMeType;
|
||||
};
|
||||
|
||||
export type PlaylistResponseType = {
|
||||
data?: PlaylistType;
|
||||
config?: ConfigType;
|
||||
};
|
||||
|
||||
export type VideoResponseType = {
|
||||
data?: VideoType[];
|
||||
config?: ConfigType;
|
||||
paginate?: PaginationType;
|
||||
};
|
||||
|
||||
const Playlist = () => {
|
||||
const { playlistId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const videoId = searchParams.get('videoId');
|
||||
|
||||
const { userConfig } = useLoaderData() as PlaylistLoaderDataType;
|
||||
const { isAdmin, currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
|
||||
|
||||
const userMeConfig = userConfig.config;
|
||||
|
||||
const [hideWatched, setHideWatched] = useState(userMeConfig.hide_watched || false);
|
||||
const [view, setView] = useState<ViewLayoutType>(userMeConfig.view_style_home || 'grid');
|
||||
const [gridItems, setGridItems] = useState(userMeConfig.grid_items || 3);
|
||||
const [descriptionExpanded, setDescriptionExpanded] = useState(false);
|
||||
const [refresh, setRefresh] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [reindex, setReindex] = useState(false);
|
||||
|
||||
const [playlistResponse, setPlaylistResponse] = useState<PlaylistResponseType>();
|
||||
const [channelResponse, setChannelResponse] = useState<ChannelResponseType>();
|
||||
const [videoResponse, setVideoResponse] = useState<VideoResponseType>();
|
||||
|
||||
const playlist = playlistResponse?.data;
|
||||
const channel = channelResponse?.data;
|
||||
const videos = videoResponse?.data;
|
||||
const pagination = videoResponse?.paginate;
|
||||
|
||||
const palylistEntries = playlistResponse?.data?.playlist_entries;
|
||||
const videoArchivedCount = Number(palylistEntries?.filter(video => video.downloaded).length);
|
||||
const videoInPlaylistCount = pagination?.total_hits;
|
||||
const showEmbeddedVideo = videoId !== null;
|
||||
|
||||
const isGridView = view === ViewStyles.grid;
|
||||
const gridView = isGridView ? `boxed-${gridItems}` : '';
|
||||
const gridViewGrid = isGridView ? `grid-${gridItems}` : '';
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (
|
||||
refresh ||
|
||||
pagination?.current_page === undefined ||
|
||||
currentPage !== pagination?.current_page
|
||||
) {
|
||||
const playlist = await loadPlaylistById(playlistId);
|
||||
const video = await loadVideoListByFilter({
|
||||
playlist: playlistId,
|
||||
page: currentPage,
|
||||
watch: hideWatched ? 'unwatched' : undefined,
|
||||
sort: 'downloaded', // downloaded or published? or playlist sort order?
|
||||
});
|
||||
|
||||
const isCustomPlaylist = playlist?.data?.playlist_type === 'custom';
|
||||
if (!isCustomPlaylist) {
|
||||
const channel = await loadChannelById(playlist.data.playlist_channel_id);
|
||||
|
||||
setChannelResponse(channel);
|
||||
}
|
||||
|
||||
setPlaylistResponse(playlist);
|
||||
setVideoResponse(video);
|
||||
setRefresh(false);
|
||||
}
|
||||
})();
|
||||
// Do not add hideWatched this will not work as expected!
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [playlistId, refresh, currentPage, pagination?.current_page]);
|
||||
|
||||
if (!playlistId || !playlist) {
|
||||
return `Playlist ${playlistId} not found!`;
|
||||
}
|
||||
|
||||
const isCustomPlaylist = playlist.playlist_type === 'custom';
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>{`TA | Playlist: ${playlist.playlist_name}`}</title>
|
||||
<ScrollToTopOnNavigate />
|
||||
<div className="boxed-content">
|
||||
<div className="title-bar">
|
||||
<h1>{playlist.playlist_name}</h1>
|
||||
</div>
|
||||
<div className="info-box info-box-3">
|
||||
{!isCustomPlaylist && channel && (
|
||||
<ChannelOverview
|
||||
channelId={channel?.channel_id}
|
||||
channelname={channel?.channel_name}
|
||||
channelSubs={channel?.channel_subs}
|
||||
channelSubscribed={channel?.channel_subscribed}
|
||||
channelThumbUrl={channel.channel_thumb_url}
|
||||
setRefresh={setRefresh}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="info-box-item">
|
||||
<div>
|
||||
<p>Last refreshed: {formatDate(playlist.playlist_last_refresh)}</p>
|
||||
{!isCustomPlaylist && (
|
||||
<>
|
||||
<p>
|
||||
Playlist:
|
||||
{playlist.playlist_subscribed && (
|
||||
<>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
label="Unsubscribe"
|
||||
className="unsubscribe"
|
||||
type="button"
|
||||
title={`Unsubscribe from ${playlist.playlist_name}`}
|
||||
onClick={async () => {
|
||||
await updatePlaylistSubscription(playlistId, false);
|
||||
|
||||
setRefresh(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}{' '}
|
||||
{!playlist.playlist_subscribed && (
|
||||
<Button
|
||||
label="Subscribe"
|
||||
type="button"
|
||||
title={`Subscribe to ${playlist.playlist_name}`}
|
||||
onClick={async () => {
|
||||
await updatePlaylistSubscription(playlistId, true);
|
||||
|
||||
setRefresh(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
{playlist.playlist_active && (
|
||||
<p>
|
||||
Youtube:{' '}
|
||||
<a
|
||||
href={`https://www.youtube.com/playlist?list=${playlist.playlist_id}`}
|
||||
target="_blank"
|
||||
>
|
||||
Active
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
{!playlist.playlist_active && <p>Youtube: Deactivated</p>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!showDeleteConfirm && (
|
||||
<Button
|
||||
label="Delete Playlist"
|
||||
id="delete-item"
|
||||
onClick={() => setShowDeleteConfirm(!showDeleteConfirm)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDeleteConfirm && (
|
||||
<div className="delete-confirm" id="delete-button">
|
||||
<span>Delete {playlist.playlist_name}?</span>
|
||||
|
||||
<Button
|
||||
label="Delete metadata"
|
||||
onClick={async () => {
|
||||
await deletePlaylist(playlistId, false);
|
||||
navigate(Routes.Playlists);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
label="Delete all"
|
||||
className="danger-button"
|
||||
onClick={async () => {
|
||||
await deletePlaylist(playlistId, true);
|
||||
navigate(Routes.Playlists);
|
||||
}}
|
||||
/>
|
||||
|
||||
<br />
|
||||
<Button label="Cancel" onClick={() => setShowDeleteConfirm(!showDeleteConfirm)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="info-box-item">
|
||||
<div>
|
||||
{videoArchivedCount > 0 && (
|
||||
<>
|
||||
<p>
|
||||
Total Videos archived: {videoArchivedCount}/{videoInPlaylistCount}
|
||||
</p>
|
||||
<div id="watched-button" className="button-box">
|
||||
<Button
|
||||
label="Mark as watched"
|
||||
title={`Mark all videos from ${playlist.playlist_name} as watched`}
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
await updateWatchedState({
|
||||
id: playlistId,
|
||||
is_watched: true,
|
||||
});
|
||||
|
||||
setRefresh(true);
|
||||
}}
|
||||
/>{' '}
|
||||
<Button
|
||||
label="Mark as unwatched"
|
||||
title={`Mark all videos from ${playlist.playlist_name} as unwatched`}
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
await updateWatchedState({
|
||||
id: playlistId,
|
||||
is_watched: false,
|
||||
});
|
||||
|
||||
setRefresh(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{reindex && <p>Reindex scheduled</p>}
|
||||
{!reindex && (
|
||||
<div id="reindex-button" className="button-box">
|
||||
{!isCustomPlaylist && (
|
||||
<Button
|
||||
label="Reindex"
|
||||
title={`Reindex Playlist ${playlist.playlist_name}`}
|
||||
onClick={async () => {
|
||||
setReindex(true);
|
||||
|
||||
await queueReindex(playlist.playlist_id, 'playlist');
|
||||
}}
|
||||
/>
|
||||
)}{' '}
|
||||
<Button
|
||||
label="Reindex Videos"
|
||||
title={`Reindex Videos of ${playlist.playlist_name}`}
|
||||
onClick={async () => {
|
||||
setReindex(true);
|
||||
|
||||
await queueReindex(playlist.playlist_id, 'playlist', true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{playlist.playlist_description && (
|
||||
<div className="description-box">
|
||||
<p
|
||||
id={descriptionExpanded ? 'text-expand-expanded' : 'text-expand'}
|
||||
className="description-text"
|
||||
>
|
||||
<Linkify>{playlist.playlist_description}</Linkify>
|
||||
</p>
|
||||
|
||||
<Button
|
||||
label="Show more"
|
||||
id="text-expand-button"
|
||||
onClick={() => setDescriptionExpanded(!descriptionExpanded)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`boxed-content ${gridView}`}>
|
||||
<Filterbar
|
||||
hideToggleText="Hide watched videos:"
|
||||
hideWatched={hideWatched}
|
||||
isGridView={isGridView}
|
||||
view={view}
|
||||
gridItems={gridItems}
|
||||
userMeConfig={userMeConfig}
|
||||
setHideWatched={setHideWatched}
|
||||
setView={setView}
|
||||
setGridItems={setGridItems}
|
||||
viewStyleName={ViewStyleNames.playlist}
|
||||
setRefresh={setRefresh}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showEmbeddedVideo && <EmbeddableVideoPlayer videoId={videoId} />}
|
||||
|
||||
<div className={`boxed-content ${gridView}`}>
|
||||
<div className={`video-list ${view} ${gridViewGrid}`}>
|
||||
{videoInPlaylistCount === 0 && (
|
||||
<>
|
||||
<h2>No videos found...</h2>
|
||||
{isCustomPlaylist && (
|
||||
<p>
|
||||
Try going to the <a href="{% url 'home' %}">home page</a> to add videos to this
|
||||
playlist.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isCustomPlaylist && (
|
||||
<p>
|
||||
Try going to the <Link to={Routes.Downloads}>downloads page</Link> to start the
|
||||
scan and download tasks.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{videoInPlaylistCount !== 0 && (
|
||||
<VideoList
|
||||
videoList={videos}
|
||||
viewLayout={view}
|
||||
playlistId={playlistId}
|
||||
showReorderButton={isCustomPlaylist}
|
||||
refreshVideoList={setRefresh}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="boxed-content">
|
||||
{pagination && <Pagination pagination={pagination} setPage={setCurrentPage} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Playlist;
|
||||
|
@ -1,219 +1,222 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLoaderData, useOutletContext } from 'react-router-dom';
|
||||
|
||||
import iconAdd from '/img/icon-add.svg';
|
||||
import iconGridView from '/img/icon-gridview.svg';
|
||||
import iconListView from '/img/icon-listview.svg';
|
||||
|
||||
import { OutletContextType } from './Base';
|
||||
import updateUserConfig, { UserConfigType, UserMeType } from '../api/actions/updateUserConfig';
|
||||
import loadPlaylistList from '../api/loader/loadPlaylistList';
|
||||
import { ConfigType, ViewLayoutType } from './Home';
|
||||
import Pagination, { PaginationType } from '../components/Pagination';
|
||||
import PlaylistList from '../components/PlaylistList';
|
||||
import { PlaylistType } from './Playlist';
|
||||
import updatePlaylistSubscription from '../api/actions/updatePlaylistSubscription';
|
||||
import createCustomPlaylist from '../api/actions/createCustomPlaylist';
|
||||
import ScrollToTopOnNavigate from '../components/ScrollToTop';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import Button from '../components/Button';
|
||||
|
||||
export type PlaylistEntryType = {
|
||||
youtube_id: string;
|
||||
title: string;
|
||||
uploader: string;
|
||||
idx: number;
|
||||
downloaded: boolean;
|
||||
};
|
||||
|
||||
export type PlaylistsResponseType = {
|
||||
data?: PlaylistType[];
|
||||
config?: ConfigType;
|
||||
paginate?: PaginationType;
|
||||
};
|
||||
|
||||
type PlaylistLoaderDataType = {
|
||||
userConfig: UserMeType;
|
||||
};
|
||||
|
||||
const Playlists = () => {
|
||||
const { userConfig } = useLoaderData() as PlaylistLoaderDataType;
|
||||
const { isAdmin, currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
|
||||
|
||||
const userMeConfig = userConfig.config;
|
||||
|
||||
const [showSubedOnly, setShowSubedOnly] = useState(userMeConfig.show_subed_only || false);
|
||||
const [view, setView] = useState<ViewLayoutType>(userMeConfig.view_style_playlist || 'grid');
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [refresh, setRefresh] = useState(false);
|
||||
const [playlistsToAddText, setPlaylistsToAddText] = useState('');
|
||||
const [customPlaylistsToAddText, setCustomPlaylistsToAddText] = useState('');
|
||||
|
||||
const [playlistResponse, setPlaylistReponse] = useState<PlaylistsResponseType>();
|
||||
|
||||
const playlistList = playlistResponse?.data;
|
||||
const pagination = playlistResponse?.paginate;
|
||||
|
||||
const hasPlaylists = playlistResponse?.data?.length !== 0;
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (
|
||||
userMeConfig.view_style_playlist !== view ||
|
||||
userMeConfig.show_subed_only !== showSubedOnly
|
||||
) {
|
||||
const userConfig: UserConfigType = {
|
||||
show_subed_only: showSubedOnly,
|
||||
view_style_playlist: view,
|
||||
};
|
||||
|
||||
await updateUserConfig(userConfig);
|
||||
}
|
||||
})();
|
||||
}, [showSubedOnly, userMeConfig.show_subed_only, userMeConfig.view_style_playlist, view]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (
|
||||
refresh ||
|
||||
pagination?.current_page === undefined ||
|
||||
currentPage !== pagination?.current_page
|
||||
) {
|
||||
const playlist = await loadPlaylistList({ page: currentPage });
|
||||
|
||||
setPlaylistReponse(playlist);
|
||||
setRefresh(false);
|
||||
}
|
||||
})();
|
||||
}, [refresh, currentPage, showSubedOnly, view, pagination?.current_page]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>TA | Playlists</title>
|
||||
</Helmet>
|
||||
<ScrollToTopOnNavigate />
|
||||
<div className="boxed-content">
|
||||
<div className="title-split">
|
||||
<div className="title-bar">
|
||||
<h1>Playlists</h1>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="title-split-form">
|
||||
<img
|
||||
onClick={() => {
|
||||
setShowAddForm(!showAddForm);
|
||||
}}
|
||||
src={iconAdd}
|
||||
alt="add-icon"
|
||||
title="Subscribe to Playlists"
|
||||
/>
|
||||
{showAddForm && (
|
||||
<div className="show-form">
|
||||
<div>
|
||||
<label>Subscribe to playlists:</label>
|
||||
<textarea
|
||||
value={playlistsToAddText}
|
||||
onChange={event => {
|
||||
setPlaylistsToAddText(event.target.value);
|
||||
}}
|
||||
rows={3}
|
||||
cols={40}
|
||||
placeholder="Input playlist IDs or URLs"
|
||||
/>
|
||||
|
||||
<Button
|
||||
label="Subscribe"
|
||||
type="submit"
|
||||
onClick={async () => {
|
||||
await updatePlaylistSubscription(playlistsToAddText, true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<br />
|
||||
<div>
|
||||
<label>Or create custom playlist:</label>
|
||||
<textarea
|
||||
rows={1}
|
||||
cols={40}
|
||||
placeholder="Input playlist name"
|
||||
value={customPlaylistsToAddText}
|
||||
onChange={event => {
|
||||
setCustomPlaylistsToAddText(event.target.value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
label="Create"
|
||||
type="submit"
|
||||
onClick={async () => {
|
||||
await createCustomPlaylist(customPlaylistsToAddText);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div id="notifications"></div>
|
||||
|
||||
<div className="view-controls">
|
||||
<div className="toggle">
|
||||
<span>Show subscribed only:</span>
|
||||
<div className="toggleBox">
|
||||
<input
|
||||
checked={showSubedOnly}
|
||||
onChange={() => {
|
||||
setShowSubedOnly(!showSubedOnly);
|
||||
}}
|
||||
type="checkbox"
|
||||
/>
|
||||
{!showSubedOnly && (
|
||||
<label htmlFor="" className="ofbtn">
|
||||
Off
|
||||
</label>
|
||||
)}
|
||||
{showSubedOnly && (
|
||||
<label htmlFor="" className="onbtn">
|
||||
On
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="view-icons">
|
||||
<img
|
||||
src={iconGridView}
|
||||
onClick={() => {
|
||||
setView('grid');
|
||||
}}
|
||||
alt="grid view"
|
||||
/>
|
||||
<img
|
||||
src={iconListView}
|
||||
onClick={() => {
|
||||
setView('list');
|
||||
}}
|
||||
alt="list view"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`playlist-list ${view}`}>
|
||||
{!hasPlaylists && <h2>No playlists found...</h2>}
|
||||
|
||||
{hasPlaylists && (
|
||||
<PlaylistList playlistList={playlistList} viewLayout={view} setRefresh={setRefresh} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="boxed-content">
|
||||
{pagination && <Pagination pagination={pagination} setPage={setCurrentPage} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Playlists;
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLoaderData, useOutletContext } from 'react-router-dom';
|
||||
|
||||
import iconAdd from '/img/icon-add.svg';
|
||||
import iconGridView from '/img/icon-gridview.svg';
|
||||
import iconListView from '/img/icon-listview.svg';
|
||||
|
||||
import { OutletContextType } from './Base';
|
||||
import updateUserConfig, { UserConfigType, UserMeType } from '../api/actions/updateUserConfig';
|
||||
import loadPlaylistList from '../api/loader/loadPlaylistList';
|
||||
import { ConfigType, ViewLayoutType } from './Home';
|
||||
import Pagination, { PaginationType } from '../components/Pagination';
|
||||
import PlaylistList from '../components/PlaylistList';
|
||||
import { PlaylistType } from './Playlist';
|
||||
import updatePlaylistSubscription from '../api/actions/updatePlaylistSubscription';
|
||||
import createCustomPlaylist from '../api/actions/createCustomPlaylist';
|
||||
import ScrollToTopOnNavigate from '../components/ScrollToTop';
|
||||
import Button from '../components/Button';
|
||||
|
||||
export type PlaylistEntryType = {
|
||||
youtube_id: string;
|
||||
title: string;
|
||||
uploader: string;
|
||||
idx: number;
|
||||
downloaded: boolean;
|
||||
};
|
||||
|
||||
export type PlaylistsResponseType = {
|
||||
data?: PlaylistType[];
|
||||
config?: ConfigType;
|
||||
paginate?: PaginationType;
|
||||
};
|
||||
|
||||
type PlaylistLoaderDataType = {
|
||||
userConfig: UserMeType;
|
||||
};
|
||||
|
||||
const Playlists = () => {
|
||||
const { userConfig } = useLoaderData() as PlaylistLoaderDataType;
|
||||
const { isAdmin, currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
|
||||
|
||||
const userMeConfig = userConfig.config;
|
||||
|
||||
const [showSubedOnly, setShowSubedOnly] = useState(userMeConfig.show_subed_only || false);
|
||||
const [view, setView] = useState<ViewLayoutType>(userMeConfig.view_style_playlist || 'grid');
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [refresh, setRefresh] = useState(false);
|
||||
const [playlistsToAddText, setPlaylistsToAddText] = useState('');
|
||||
const [customPlaylistsToAddText, setCustomPlaylistsToAddText] = useState('');
|
||||
|
||||
const [playlistResponse, setPlaylistReponse] = useState<PlaylistsResponseType>();
|
||||
|
||||
const playlistList = playlistResponse?.data;
|
||||
const pagination = playlistResponse?.paginate;
|
||||
|
||||
const hasPlaylists = playlistResponse?.data?.length !== 0;
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (
|
||||
userMeConfig.view_style_playlist !== view ||
|
||||
userMeConfig.show_subed_only !== showSubedOnly
|
||||
) {
|
||||
const userConfig: UserConfigType = {
|
||||
show_subed_only: showSubedOnly,
|
||||
view_style_playlist: view,
|
||||
};
|
||||
|
||||
await updateUserConfig(userConfig);
|
||||
setRefresh(true);
|
||||
}
|
||||
})();
|
||||
}, [showSubedOnly, userMeConfig.show_subed_only, userMeConfig.view_style_playlist, view]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (
|
||||
refresh ||
|
||||
pagination?.current_page === undefined ||
|
||||
currentPage !== pagination?.current_page
|
||||
) {
|
||||
const playlist = await loadPlaylistList({
|
||||
page: currentPage,
|
||||
subscribed: showSubedOnly,
|
||||
});
|
||||
|
||||
setPlaylistReponse(playlist);
|
||||
setRefresh(false);
|
||||
}
|
||||
})();
|
||||
// Do not add showSubedOnly, view this will not work as expected!
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [refresh, currentPage, pagination?.current_page]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>TA | Playlists</title>
|
||||
<ScrollToTopOnNavigate />
|
||||
<div className="boxed-content">
|
||||
<div className="title-split">
|
||||
<div className="title-bar">
|
||||
<h1>Playlists</h1>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="title-split-form">
|
||||
<img
|
||||
onClick={() => {
|
||||
setShowAddForm(!showAddForm);
|
||||
}}
|
||||
src={iconAdd}
|
||||
alt="add-icon"
|
||||
title="Subscribe to Playlists"
|
||||
/>
|
||||
{showAddForm && (
|
||||
<div className="show-form">
|
||||
<div>
|
||||
<label>Subscribe to playlists:</label>
|
||||
<textarea
|
||||
value={playlistsToAddText}
|
||||
onChange={event => {
|
||||
setPlaylistsToAddText(event.target.value);
|
||||
}}
|
||||
rows={3}
|
||||
cols={40}
|
||||
placeholder="Input playlist IDs or URLs"
|
||||
/>
|
||||
|
||||
<Button
|
||||
label="Subscribe"
|
||||
type="submit"
|
||||
onClick={async () => {
|
||||
await updatePlaylistSubscription(playlistsToAddText, true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<br />
|
||||
<div>
|
||||
<label>Or create custom playlist:</label>
|
||||
<textarea
|
||||
rows={1}
|
||||
cols={40}
|
||||
placeholder="Input playlist name"
|
||||
value={customPlaylistsToAddText}
|
||||
onChange={event => {
|
||||
setCustomPlaylistsToAddText(event.target.value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
label="Create"
|
||||
type="submit"
|
||||
onClick={async () => {
|
||||
await createCustomPlaylist(customPlaylistsToAddText);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div id="notifications"></div>
|
||||
|
||||
<div className="view-controls">
|
||||
<div className="toggle">
|
||||
<span>Show subscribed only:</span>
|
||||
<div className="toggleBox">
|
||||
<input
|
||||
checked={showSubedOnly}
|
||||
onChange={() => {
|
||||
setShowSubedOnly(!showSubedOnly);
|
||||
}}
|
||||
type="checkbox"
|
||||
/>
|
||||
{!showSubedOnly && (
|
||||
<label htmlFor="" className="ofbtn">
|
||||
Off
|
||||
</label>
|
||||
)}
|
||||
{showSubedOnly && (
|
||||
<label htmlFor="" className="onbtn">
|
||||
On
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="view-icons">
|
||||
<img
|
||||
src={iconGridView}
|
||||
onClick={() => {
|
||||
setView('grid');
|
||||
}}
|
||||
alt="grid view"
|
||||
/>
|
||||
<img
|
||||
src={iconListView}
|
||||
onClick={() => {
|
||||
setView('list');
|
||||
}}
|
||||
alt="list view"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`playlist-list ${view}`}>
|
||||
{!hasPlaylists && <h2>No playlists found...</h2>}
|
||||
|
||||
{hasPlaylists && (
|
||||
<PlaylistList playlistList={playlistList} viewLayout={view} setRefresh={setRefresh} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="boxed-content">
|
||||
{pagination && <Pagination pagination={pagination} setPage={setCurrentPage} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Playlists;
|
||||
|
@ -1,170 +1,167 @@
|
||||
import { useLoaderData, useSearchParams } from 'react-router-dom';
|
||||
import { UserMeType } from '../api/actions/updateUserConfig';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { VideoType, ViewLayoutType } from './Home';
|
||||
import loadSearch from '../api/loader/loadSearch';
|
||||
import { PlaylistType } from './Playlist';
|
||||
import { ChannelType } from './Channels';
|
||||
import VideoList from '../components/VideoList';
|
||||
import ChannelList from '../components/ChannelList';
|
||||
import PlaylistList from '../components/PlaylistList';
|
||||
import SubtitleList from '../components/SubtitleList';
|
||||
import { ViewStyles } from '../configuration/constants/ViewStyle';
|
||||
import EmbeddableVideoPlayer from '../components/EmbeddableVideoPlayer';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import SearchExampleQueries from '../components/SearchExampleQueries';
|
||||
|
||||
const EmptySearchResponse: SearchResultsType = {
|
||||
results: {
|
||||
video_results: [],
|
||||
channel_results: [],
|
||||
playlist_results: [],
|
||||
fulltext_results: [],
|
||||
},
|
||||
queryType: 'simple',
|
||||
};
|
||||
|
||||
type SearchResultType = {
|
||||
video_results: VideoType[];
|
||||
channel_results: ChannelType[];
|
||||
playlist_results: PlaylistType[];
|
||||
fulltext_results: [];
|
||||
};
|
||||
|
||||
type SearchResultsType = {
|
||||
results: SearchResultType;
|
||||
queryType: string;
|
||||
};
|
||||
|
||||
type SearchLoaderDataType = {
|
||||
userConfig: UserMeType;
|
||||
};
|
||||
|
||||
const Search = () => {
|
||||
const { userConfig } = useLoaderData() as SearchLoaderDataType;
|
||||
const [searchParams] = useSearchParams();
|
||||
const videoId = searchParams.get('videoId');
|
||||
const userMeConfig = userConfig.config;
|
||||
|
||||
const view = (userMeConfig.view_style_home || ViewStyles.grid) as ViewLayoutType;
|
||||
const gridItems = userMeConfig.grid_items || 3;
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<SearchResultsType>();
|
||||
|
||||
const [refresh, setRefresh] = useState(false);
|
||||
|
||||
const videoList = searchResults?.results.video_results;
|
||||
const channelList = searchResults?.results.channel_results;
|
||||
const playlistList = searchResults?.results.playlist_results;
|
||||
const fulltextList = searchResults?.results.fulltext_results;
|
||||
const queryType = searchResults?.queryType;
|
||||
const showEmbeddedVideo = videoId !== null;
|
||||
|
||||
const hasSearchQuery = searchQuery.length > 0;
|
||||
const hasVideos = Number(videoList?.length) > 0;
|
||||
const hasChannels = Number(channelList?.length) > 0;
|
||||
const hasPlaylist = Number(playlistList?.length) > 0;
|
||||
const hasFulltext = Number(fulltextList?.length) > 0;
|
||||
|
||||
const isSimpleQuery = queryType === 'simple';
|
||||
const isVideoQuery = queryType === 'video' || isSimpleQuery;
|
||||
const isChannelQuery = queryType === 'channel' || isSimpleQuery;
|
||||
const isPlaylistQuery = queryType === 'playlist' || isSimpleQuery;
|
||||
const isFullTextQuery = queryType === 'full' || isSimpleQuery;
|
||||
|
||||
const isGridView = view === ViewStyles.grid;
|
||||
const gridView = isGridView ? `boxed-${gridItems}` : '';
|
||||
const gridViewGrid = isGridView ? `grid-${gridItems}` : '';
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!hasSearchQuery) {
|
||||
setSearchResults(EmptySearchResponse);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const searchResults = await loadSearch(searchQuery);
|
||||
|
||||
setSearchResults(searchResults);
|
||||
setRefresh(false);
|
||||
})();
|
||||
}, [searchQuery, refresh, hasSearchQuery]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>TubeArchivist</title>
|
||||
</Helmet>
|
||||
{showEmbeddedVideo && <EmbeddableVideoPlayer videoId={videoId} />}
|
||||
<div className={`boxed-content ${gridView}`}>
|
||||
<div className="title-bar">
|
||||
<h1>Search your Archive</h1>
|
||||
</div>
|
||||
<div className="multi-search-box">
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
name="searchInput"
|
||||
autoComplete="off"
|
||||
value={searchQuery}
|
||||
onChange={event => {
|
||||
setSearchQuery(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div id="multi-search-results">
|
||||
{hasSearchQuery && isVideoQuery && (
|
||||
<div className="multi-search-result">
|
||||
<h2>Video Results</h2>
|
||||
<div id="video-results" className={`video-list ${view} ${gridViewGrid}`}>
|
||||
<VideoList videoList={videoList} viewLayout={view} refreshVideoList={setRefresh} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasSearchQuery && isChannelQuery && (
|
||||
<div className="multi-search-result">
|
||||
<h2>Channel Results</h2>
|
||||
<div id="channel-results" className={`channel-list ${view} ${gridViewGrid}`}>
|
||||
<ChannelList
|
||||
channelList={channelList}
|
||||
viewLayout={view}
|
||||
refreshChannelList={setRefresh}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasSearchQuery && isPlaylistQuery && (
|
||||
<div className="multi-search-result">
|
||||
<h2>Playlist Results</h2>
|
||||
<div id="playlist-results" className={`playlist-list ${view} ${gridViewGrid}`}>
|
||||
<PlaylistList
|
||||
playlistList={playlistList}
|
||||
viewLayout={view}
|
||||
setRefresh={setRefresh}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasSearchQuery && isFullTextQuery && (
|
||||
<div className="multi-search-result">
|
||||
<h2>Fulltext Results</h2>
|
||||
<div id="fulltext-results" className="video-list list">
|
||||
<SubtitleList subtitleList={fulltextList} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!hasVideos && !hasChannels && !hasPlaylist && !hasFulltext && <SearchExampleQueries />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Search;
|
||||
import { useLoaderData, useSearchParams } from 'react-router-dom';
|
||||
import { UserMeType } from '../api/actions/updateUserConfig';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { VideoType, ViewLayoutType } from './Home';
|
||||
import loadSearch from '../api/loader/loadSearch';
|
||||
import { PlaylistType } from './Playlist';
|
||||
import { ChannelType } from './Channels';
|
||||
import VideoList from '../components/VideoList';
|
||||
import ChannelList from '../components/ChannelList';
|
||||
import PlaylistList from '../components/PlaylistList';
|
||||
import SubtitleList from '../components/SubtitleList';
|
||||
import { ViewStyles } from '../configuration/constants/ViewStyle';
|
||||
import EmbeddableVideoPlayer from '../components/EmbeddableVideoPlayer';
|
||||
import SearchExampleQueries from '../components/SearchExampleQueries';
|
||||
|
||||
const EmptySearchResponse: SearchResultsType = {
|
||||
results: {
|
||||
video_results: [],
|
||||
channel_results: [],
|
||||
playlist_results: [],
|
||||
fulltext_results: [],
|
||||
},
|
||||
queryType: 'simple',
|
||||
};
|
||||
|
||||
type SearchResultType = {
|
||||
video_results: VideoType[];
|
||||
channel_results: ChannelType[];
|
||||
playlist_results: PlaylistType[];
|
||||
fulltext_results: [];
|
||||
};
|
||||
|
||||
type SearchResultsType = {
|
||||
results: SearchResultType;
|
||||
queryType: string;
|
||||
};
|
||||
|
||||
type SearchLoaderDataType = {
|
||||
userConfig: UserMeType;
|
||||
};
|
||||
|
||||
const Search = () => {
|
||||
const { userConfig } = useLoaderData() as SearchLoaderDataType;
|
||||
const [searchParams] = useSearchParams();
|
||||
const videoId = searchParams.get('videoId');
|
||||
const userMeConfig = userConfig.config;
|
||||
|
||||
const view = (userMeConfig.view_style_home || ViewStyles.grid) as ViewLayoutType;
|
||||
const gridItems = userMeConfig.grid_items || 3;
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<SearchResultsType>();
|
||||
|
||||
const [refresh, setRefresh] = useState(false);
|
||||
|
||||
const videoList = searchResults?.results.video_results;
|
||||
const channelList = searchResults?.results.channel_results;
|
||||
const playlistList = searchResults?.results.playlist_results;
|
||||
const fulltextList = searchResults?.results.fulltext_results;
|
||||
const queryType = searchResults?.queryType;
|
||||
const showEmbeddedVideo = videoId !== null;
|
||||
|
||||
const hasSearchQuery = searchQuery.length > 0;
|
||||
const hasVideos = Number(videoList?.length) > 0;
|
||||
const hasChannels = Number(channelList?.length) > 0;
|
||||
const hasPlaylist = Number(playlistList?.length) > 0;
|
||||
const hasFulltext = Number(fulltextList?.length) > 0;
|
||||
|
||||
const isSimpleQuery = queryType === 'simple';
|
||||
const isVideoQuery = queryType === 'video' || isSimpleQuery;
|
||||
const isChannelQuery = queryType === 'channel' || isSimpleQuery;
|
||||
const isPlaylistQuery = queryType === 'playlist' || isSimpleQuery;
|
||||
const isFullTextQuery = queryType === 'full' || isSimpleQuery;
|
||||
|
||||
const isGridView = view === ViewStyles.grid;
|
||||
const gridView = isGridView ? `boxed-${gridItems}` : '';
|
||||
const gridViewGrid = isGridView ? `grid-${gridItems}` : '';
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!hasSearchQuery) {
|
||||
setSearchResults(EmptySearchResponse);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const searchResults = await loadSearch(searchQuery);
|
||||
|
||||
setSearchResults(searchResults);
|
||||
setRefresh(false);
|
||||
})();
|
||||
}, [searchQuery, refresh, hasSearchQuery]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>TubeArchivist</title>
|
||||
{showEmbeddedVideo && <EmbeddableVideoPlayer videoId={videoId} />}
|
||||
<div className={`boxed-content ${gridView}`}>
|
||||
<div className="title-bar">
|
||||
<h1>Search your Archive</h1>
|
||||
</div>
|
||||
<div className="multi-search-box">
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
value={searchQuery}
|
||||
onChange={event => {
|
||||
setSearchQuery(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div id="multi-search-results">
|
||||
{hasSearchQuery && isVideoQuery && (
|
||||
<div className="multi-search-result">
|
||||
<h2>Video Results</h2>
|
||||
<div id="video-results" className={`video-list ${view} ${gridViewGrid}`}>
|
||||
<VideoList videoList={videoList} viewLayout={view} refreshVideoList={setRefresh} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasSearchQuery && isChannelQuery && (
|
||||
<div className="multi-search-result">
|
||||
<h2>Channel Results</h2>
|
||||
<div id="channel-results" className={`channel-list ${view} ${gridViewGrid}`}>
|
||||
<ChannelList
|
||||
channelList={channelList}
|
||||
viewLayout={view}
|
||||
refreshChannelList={setRefresh}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasSearchQuery && isPlaylistQuery && (
|
||||
<div className="multi-search-result">
|
||||
<h2>Playlist Results</h2>
|
||||
<div id="playlist-results" className={`playlist-list ${view} ${gridViewGrid}`}>
|
||||
<PlaylistList
|
||||
playlistList={playlistList}
|
||||
viewLayout={view}
|
||||
setRefresh={setRefresh}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasSearchQuery && isFullTextQuery && (
|
||||
<div className="multi-search-result">
|
||||
<h2>Fulltext Results</h2>
|
||||
<div id="fulltext-results" className="video-list list">
|
||||
<SubtitleList subtitleList={fulltextList} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!hasVideos && !hasChannels && !hasPlaylist && !hasFulltext && <SearchExampleQueries />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Search;
|
||||
|
@ -1,245 +1,242 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import loadBackupList from '../api/loader/loadBackupList';
|
||||
import SettingsNavigation from '../components/SettingsNavigation';
|
||||
import deleteDownloadQueueByFilter from '../api/actions/deleteDownloadQueueByFilter';
|
||||
import updateTaskByName from '../api/actions/updateTaskByName';
|
||||
import queueBackup from '../api/actions/queueBackup';
|
||||
import restoreBackup from '../api/actions/restoreBackup';
|
||||
import Notifications from '../components/Notifications';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import Button from '../components/Button';
|
||||
|
||||
type Backup = {
|
||||
filename: string;
|
||||
file_path: string;
|
||||
file_size: number;
|
||||
timestamp: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
type BackupListType = Backup[];
|
||||
|
||||
const SettingsActions = () => {
|
||||
const [deleteIgnored, setDeleteIgnored] = useState(false);
|
||||
const [deletePending, setDeletePending] = useState(false);
|
||||
const [processingImports, setProcessingImports] = useState(false);
|
||||
const [reEmbed, setReEmbed] = useState(false);
|
||||
const [backupStarted, setBackupStarted] = useState(false);
|
||||
const [isRestoringBackup, setIsRestoringBackup] = useState(false);
|
||||
const [reScanningFileSystem, setReScanningFileSystem] = useState(false);
|
||||
|
||||
const [backupListResponse, setBackupListResponse] = useState<BackupListType>();
|
||||
|
||||
const backups = backupListResponse;
|
||||
const hasBackups = !!backups && backups?.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const backupListResponse = await loadBackupList();
|
||||
|
||||
setBackupListResponse(backupListResponse);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>TA | Actions</title>
|
||||
</Helmet>
|
||||
<div className="boxed-content">
|
||||
<SettingsNavigation />
|
||||
<Notifications
|
||||
pageName={'all'}
|
||||
update={
|
||||
deleteIgnored ||
|
||||
deletePending ||
|
||||
processingImports ||
|
||||
reEmbed ||
|
||||
backupStarted ||
|
||||
isRestoringBackup ||
|
||||
reScanningFileSystem
|
||||
}
|
||||
setShouldRefresh={() => {
|
||||
setDeleteIgnored(false);
|
||||
setDeletePending(false);
|
||||
setProcessingImports(false);
|
||||
setReEmbed(false);
|
||||
setBackupStarted(false);
|
||||
setIsRestoringBackup(false);
|
||||
setReScanningFileSystem(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="title-bar">
|
||||
<h1>Actions</h1>
|
||||
</div>
|
||||
<div className="settings-group">
|
||||
<h2>Delete download queue</h2>
|
||||
<p>Delete your pending or previously ignored videos from your download queue.</p>
|
||||
{deleteIgnored && <p>Deleting download queue: ignored</p>}
|
||||
{!deleteIgnored && (
|
||||
<Button
|
||||
label="Delete all ignored"
|
||||
title="Delete all previously ignored videos from the queue"
|
||||
onClick={async () => {
|
||||
await deleteDownloadQueueByFilter('ignore');
|
||||
setDeleteIgnored(true);
|
||||
}}
|
||||
/>
|
||||
)}{' '}
|
||||
{deletePending && <p>Deleting download queue: pending</p>}
|
||||
{!deletePending && (
|
||||
<Button
|
||||
label="Delete all queued"
|
||||
title="Delete all pending videos from the queue"
|
||||
onClick={async () => {
|
||||
await deleteDownloadQueueByFilter('pending');
|
||||
setDeletePending(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="settings-group">
|
||||
<h2>Manual media files import.</h2>
|
||||
<p>
|
||||
Add files to the <span className="settings-current">cache/import</span> folder. Make
|
||||
sure to follow the instructions in the Github{' '}
|
||||
<a
|
||||
href="https://docs.tubearchivist.com/settings/actions/#manual-media-files-import"
|
||||
target="_blank"
|
||||
>
|
||||
Wiki
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<div id="manual-import">
|
||||
{processingImports && <p>Processing import</p>}
|
||||
{!processingImports && (
|
||||
<Button
|
||||
label="Start import"
|
||||
onClick={async () => {
|
||||
await updateTaskByName('manual_import');
|
||||
setProcessingImports(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-group">
|
||||
<h2>Embed thumbnails into media file.</h2>
|
||||
<p>Set extracted youtube thumbnail as cover art of the media file.</p>
|
||||
<div id="re-embed">
|
||||
{reEmbed && <p>Processing thumbnails</p>}
|
||||
{!reEmbed && (
|
||||
<Button
|
||||
label="Start process"
|
||||
onClick={async () => {
|
||||
await updateTaskByName('resync_thumbs');
|
||||
setReEmbed(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-group">
|
||||
<h2>ZIP file index backup</h2>
|
||||
<p>
|
||||
Export your database to a zip file stored at{' '}
|
||||
<span className="settings-current">cache/backup</span>.
|
||||
</p>
|
||||
<p>
|
||||
<i>
|
||||
Zip file backups are very slow for large archives and consistency is not guaranteed,
|
||||
use snapshots instead. Make sure no other tasks are running when creating a Zip file
|
||||
backup.
|
||||
</i>
|
||||
</p>
|
||||
<div id="db-backup">
|
||||
{backupStarted && <p>Backing up archive</p>}
|
||||
{!backupStarted && (
|
||||
<Button
|
||||
label="Start backup"
|
||||
onClick={async () => {
|
||||
await queueBackup();
|
||||
setBackupStarted(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-group">
|
||||
<h2>Restore from backup</h2>
|
||||
<p>
|
||||
<span className="danger-zone">Danger Zone</span>: This will replace your existing index
|
||||
with the backup.
|
||||
</p>
|
||||
<p>
|
||||
Restore from available backup files from{' '}
|
||||
<span className="settings-current">cache/backup</span>.
|
||||
</p>
|
||||
{!hasBackups && <p>No backups found.</p>}
|
||||
{hasBackups && (
|
||||
<>
|
||||
<div className="backup-grid-row">
|
||||
<span></span>
|
||||
<span>Timestamp</span>
|
||||
<span>Source</span>
|
||||
<span>Filename</span>
|
||||
</div>
|
||||
{isRestoringBackup && <p>Restoring from backup</p>}
|
||||
{!isRestoringBackup &&
|
||||
backups.map(backup => {
|
||||
return (
|
||||
<div key={backup.filename} id={backup.filename} className="backup-grid-row">
|
||||
<Button
|
||||
label="Restore"
|
||||
onClick={async () => {
|
||||
await restoreBackup(backup.filename);
|
||||
setIsRestoringBackup(true);
|
||||
}}
|
||||
/>
|
||||
<span>{backup.timestamp}</span>
|
||||
<span>{backup.reason}</span>
|
||||
<span>{backup.filename}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="settings-group">
|
||||
<h2>Rescan filesystem</h2>
|
||||
<p>
|
||||
<span className="danger-zone">Danger Zone</span>: This will delete the metadata of
|
||||
deleted videos from the filesystem.
|
||||
</p>
|
||||
<p>
|
||||
Rescan your media folder looking for missing videos and clean up index. More infos on
|
||||
the Github{' '}
|
||||
<a
|
||||
href="https://docs.tubearchivist.com/settings/actions/#rescan-filesystem"
|
||||
target="_blank"
|
||||
>
|
||||
Wiki
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<div id="fs-rescan">
|
||||
{reScanningFileSystem && <p>File system scan in progress</p>}
|
||||
{!reScanningFileSystem && (
|
||||
<Button
|
||||
label="Rescan filesystem"
|
||||
onClick={async () => {
|
||||
await updateTaskByName('rescan_filesystem');
|
||||
setReScanningFileSystem(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsActions;
|
||||
import { useEffect, useState } from 'react';
|
||||
import loadBackupList from '../api/loader/loadBackupList';
|
||||
import SettingsNavigation from '../components/SettingsNavigation';
|
||||
import deleteDownloadQueueByFilter from '../api/actions/deleteDownloadQueueByFilter';
|
||||
import updateTaskByName from '../api/actions/updateTaskByName';
|
||||
import queueBackup from '../api/actions/queueBackup';
|
||||
import restoreBackup from '../api/actions/restoreBackup';
|
||||
import Notifications from '../components/Notifications';
|
||||
import Button from '../components/Button';
|
||||
|
||||
type Backup = {
|
||||
filename: string;
|
||||
file_path: string;
|
||||
file_size: number;
|
||||
timestamp: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
type BackupListType = Backup[];
|
||||
|
||||
const SettingsActions = () => {
|
||||
const [deleteIgnored, setDeleteIgnored] = useState(false);
|
||||
const [deletePending, setDeletePending] = useState(false);
|
||||
const [processingImports, setProcessingImports] = useState(false);
|
||||
const [reEmbed, setReEmbed] = useState(false);
|
||||
const [backupStarted, setBackupStarted] = useState(false);
|
||||
const [isRestoringBackup, setIsRestoringBackup] = useState(false);
|
||||
const [reScanningFileSystem, setReScanningFileSystem] = useState(false);
|
||||
|
||||
const [backupListResponse, setBackupListResponse] = useState<BackupListType>();
|
||||
|
||||
const backups = backupListResponse;
|
||||
const hasBackups = !!backups && backups?.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const backupListResponse = await loadBackupList();
|
||||
|
||||
setBackupListResponse(backupListResponse);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>TA | Actions</title>
|
||||
<div className="boxed-content">
|
||||
<SettingsNavigation />
|
||||
<Notifications
|
||||
pageName={'all'}
|
||||
update={
|
||||
deleteIgnored ||
|
||||
deletePending ||
|
||||
processingImports ||
|
||||
reEmbed ||
|
||||
backupStarted ||
|
||||
isRestoringBackup ||
|
||||
reScanningFileSystem
|
||||
}
|
||||
setShouldRefresh={() => {
|
||||
setDeleteIgnored(false);
|
||||
setDeletePending(false);
|
||||
setProcessingImports(false);
|
||||
setReEmbed(false);
|
||||
setBackupStarted(false);
|
||||
setIsRestoringBackup(false);
|
||||
setReScanningFileSystem(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="title-bar">
|
||||
<h1>Actions</h1>
|
||||
</div>
|
||||
<div className="settings-group">
|
||||
<h2>Delete download queue</h2>
|
||||
<p>Delete your pending or previously ignored videos from your download queue.</p>
|
||||
{deleteIgnored && <p>Deleting download queue: ignored</p>}
|
||||
{!deleteIgnored && (
|
||||
<Button
|
||||
label="Delete all ignored"
|
||||
title="Delete all previously ignored videos from the queue"
|
||||
onClick={async () => {
|
||||
await deleteDownloadQueueByFilter('ignore');
|
||||
setDeleteIgnored(true);
|
||||
}}
|
||||
/>
|
||||
)}{' '}
|
||||
{deletePending && <p>Deleting download queue: pending</p>}
|
||||
{!deletePending && (
|
||||
<Button
|
||||
label="Delete all queued"
|
||||
title="Delete all pending videos from the queue"
|
||||
onClick={async () => {
|
||||
await deleteDownloadQueueByFilter('pending');
|
||||
setDeletePending(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="settings-group">
|
||||
<h2>Manual media files import.</h2>
|
||||
<p>
|
||||
Add files to the <span className="settings-current">cache/import</span> folder. Make
|
||||
sure to follow the instructions in the Github{' '}
|
||||
<a
|
||||
href="https://docs.tubearchivist.com/settings/actions/#manual-media-files-import"
|
||||
target="_blank"
|
||||
>
|
||||
Wiki
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<div id="manual-import">
|
||||
{processingImports && <p>Processing import</p>}
|
||||
{!processingImports && (
|
||||
<Button
|
||||
label="Start import"
|
||||
onClick={async () => {
|
||||
await updateTaskByName('manual_import');
|
||||
setProcessingImports(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-group">
|
||||
<h2>Embed thumbnails into media file.</h2>
|
||||
<p>Set extracted youtube thumbnail as cover art of the media file.</p>
|
||||
<div id="re-embed">
|
||||
{reEmbed && <p>Processing thumbnails</p>}
|
||||
{!reEmbed && (
|
||||
<Button
|
||||
label="Start process"
|
||||
onClick={async () => {
|
||||
await updateTaskByName('resync_thumbs');
|
||||
setReEmbed(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-group">
|
||||
<h2>ZIP file index backup</h2>
|
||||
<p>
|
||||
Export your database to a zip file stored at{' '}
|
||||
<span className="settings-current">cache/backup</span>.
|
||||
</p>
|
||||
<p>
|
||||
<i>
|
||||
Zip file backups are very slow for large archives and consistency is not guaranteed,
|
||||
use snapshots instead. Make sure no other tasks are running when creating a Zip file
|
||||
backup.
|
||||
</i>
|
||||
</p>
|
||||
<div id="db-backup">
|
||||
{backupStarted && <p>Backing up archive</p>}
|
||||
{!backupStarted && (
|
||||
<Button
|
||||
label="Start backup"
|
||||
onClick={async () => {
|
||||
await queueBackup();
|
||||
setBackupStarted(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-group">
|
||||
<h2>Restore from backup</h2>
|
||||
<p>
|
||||
<span className="danger-zone">Danger Zone</span>: This will replace your existing index
|
||||
with the backup.
|
||||
</p>
|
||||
<p>
|
||||
Restore from available backup files from{' '}
|
||||
<span className="settings-current">cache/backup</span>.
|
||||
</p>
|
||||
{!hasBackups && <p>No backups found.</p>}
|
||||
{hasBackups && (
|
||||
<>
|
||||
<div className="backup-grid-row">
|
||||
<span></span>
|
||||
<span>Timestamp</span>
|
||||
<span>Source</span>
|
||||
<span>Filename</span>
|
||||
</div>
|
||||
{isRestoringBackup && <p>Restoring from backup</p>}
|
||||
{!isRestoringBackup &&
|
||||
backups.map(backup => {
|
||||
return (
|
||||
<div key={backup.filename} id={backup.filename} className="backup-grid-row">
|
||||
<Button
|
||||
label="Restore"
|
||||
onClick={async () => {
|
||||
await restoreBackup(backup.filename);
|
||||
setIsRestoringBackup(true);
|
||||
}}
|
||||
/>
|
||||
<span>{backup.timestamp}</span>
|
||||
<span>{backup.reason}</span>
|
||||
<span>{backup.filename}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="settings-group">
|
||||
<h2>Rescan filesystem</h2>
|
||||
<p>
|
||||
<span className="danger-zone">Danger Zone</span>: This will delete the metadata of
|
||||
deleted videos from the filesystem.
|
||||
</p>
|
||||
<p>
|
||||
Rescan your media folder looking for missing videos and clean up index. More infos on
|
||||
the Github{' '}
|
||||
<a
|
||||
href="https://docs.tubearchivist.com/settings/actions/#rescan-filesystem"
|
||||
target="_blank"
|
||||
>
|
||||
Wiki
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<div id="fs-rescan">
|
||||
{reScanningFileSystem && <p>File system scan in progress</p>}
|
||||
{!reScanningFileSystem && (
|
||||
<Button
|
||||
label="Rescan filesystem"
|
||||
onClick={async () => {
|
||||
await updateTaskByName('rescan_filesystem');
|
||||
setReScanningFileSystem(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsActions;
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,263 +1,260 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import SettingsNavigation from '../components/SettingsNavigation';
|
||||
import loadStatsVideo from '../api/loader/loadStatsVideo';
|
||||
import loadStatsChannel from '../api/loader/loadStatsChannel';
|
||||
import loadStatsPlaylist from '../api/loader/loadStatsPlaylist';
|
||||
import loadStatsDownload from '../api/loader/loadStatsDownload';
|
||||
import loadStatsWatchProgress from '../api/loader/loadStatsWatchProgress';
|
||||
import loadStatsDownloadHistory from '../api/loader/loadStatsDownloadHistory';
|
||||
import loadStatsBiggestChannels from '../api/loader/loadStatsBiggestChannels';
|
||||
import OverviewStats from '../components/OverviewStats';
|
||||
import VideoTypeStats from '../components/VideoTypeStats';
|
||||
import ApplicationStats from '../components/ApplicationStats';
|
||||
import WatchProgressStats from '../components/WatchProgressStats';
|
||||
import DownloadHistoryStats from '../components/DownloadHistoryStats';
|
||||
import BiggestChannelsStats from '../components/BiggestChannelsStats';
|
||||
import Notifications from '../components/Notifications';
|
||||
import PaginationDummy from '../components/PaginationDummy';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
export type VideoStatsType = {
|
||||
doc_count: number;
|
||||
media_size: number;
|
||||
duration: number;
|
||||
duration_str: string;
|
||||
type_videos: {
|
||||
doc_count: number;
|
||||
media_size: number;
|
||||
duration: number;
|
||||
duration_str: string;
|
||||
};
|
||||
type_shorts: {
|
||||
doc_count: number;
|
||||
media_size: number;
|
||||
duration: number;
|
||||
duration_str: string;
|
||||
};
|
||||
active_true: {
|
||||
doc_count: number;
|
||||
media_size: number;
|
||||
duration: number;
|
||||
duration_str: string;
|
||||
};
|
||||
active_false: {
|
||||
doc_count: number;
|
||||
media_size: number;
|
||||
duration: number;
|
||||
duration_str: string;
|
||||
};
|
||||
type_streams: {
|
||||
doc_count: number;
|
||||
media_size: number;
|
||||
duration: number;
|
||||
duration_str: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ChannelStatsType = {
|
||||
doc_count: number;
|
||||
active_true: number;
|
||||
subscribed_true: number;
|
||||
};
|
||||
|
||||
export type PlaylistStatsType = {
|
||||
doc_count: number;
|
||||
active_false: number;
|
||||
active_true: number;
|
||||
subscribed_true: number;
|
||||
};
|
||||
|
||||
export type DownloadStatsType = {
|
||||
pending: number;
|
||||
pending_videos: number;
|
||||
pending_shorts: number;
|
||||
pending_streams: number;
|
||||
};
|
||||
|
||||
export type WatchProgressStatsType = {
|
||||
total: {
|
||||
duration: number;
|
||||
duration_str: string;
|
||||
items: number;
|
||||
};
|
||||
unwatched: {
|
||||
duration: number;
|
||||
duration_str: string;
|
||||
progress: number;
|
||||
items: number;
|
||||
};
|
||||
watched: {
|
||||
duration: number;
|
||||
duration_str: string;
|
||||
progress: number;
|
||||
items: number;
|
||||
};
|
||||
};
|
||||
|
||||
type DownloadHistoryType = {
|
||||
date: string;
|
||||
count: number;
|
||||
media_size: number;
|
||||
};
|
||||
|
||||
export type DownloadHistoryStatsType = DownloadHistoryType[];
|
||||
|
||||
type BiggestChannelsType = {
|
||||
id: string;
|
||||
name: string;
|
||||
doc_count: number;
|
||||
duration: number;
|
||||
duration_str: string;
|
||||
media_size: number;
|
||||
};
|
||||
|
||||
export type BiggestChannelsStatsType = BiggestChannelsType[];
|
||||
|
||||
type DashboardStatsReponses = {
|
||||
videoStats?: VideoStatsType;
|
||||
channelStats?: ChannelStatsType;
|
||||
playlistStats?: PlaylistStatsType;
|
||||
downloadStats?: DownloadStatsType;
|
||||
watchProgressStats?: WatchProgressStatsType;
|
||||
downloadHistoryStats?: DownloadHistoryStatsType;
|
||||
biggestChannelsStatsByCount?: BiggestChannelsStatsType;
|
||||
biggestChannelsStatsByDuration?: BiggestChannelsStatsType;
|
||||
biggestChannelsStatsByMediaSize?: BiggestChannelsStatsType;
|
||||
};
|
||||
|
||||
const SettingsDashboard = () => {
|
||||
const [useSi, setUseSi] = useState(false);
|
||||
|
||||
const [response, setResponse] = useState<DashboardStatsReponses>({
|
||||
videoStats: undefined,
|
||||
});
|
||||
|
||||
const videoStats = response?.videoStats;
|
||||
const channelStats = response?.channelStats;
|
||||
const playlistStats = response?.playlistStats;
|
||||
const downloadStats = response?.downloadStats;
|
||||
const watchProgressStats = response?.watchProgressStats;
|
||||
const downloadHistoryStats = response?.downloadHistoryStats;
|
||||
const biggestChannelsStatsByCount = response?.biggestChannelsStatsByCount;
|
||||
const biggestChannelsStatsByDuration = response?.biggestChannelsStatsByDuration;
|
||||
const biggestChannelsStatsByMediaSize = response?.biggestChannelsStatsByMediaSize;
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const all = await Promise.all([
|
||||
await loadStatsVideo(),
|
||||
await loadStatsChannel(),
|
||||
await loadStatsPlaylist(),
|
||||
await loadStatsDownload(),
|
||||
await loadStatsWatchProgress(),
|
||||
await loadStatsDownloadHistory(),
|
||||
await loadStatsBiggestChannels('doc_count'),
|
||||
await loadStatsBiggestChannels('duration'),
|
||||
await loadStatsBiggestChannels('media_size'),
|
||||
]);
|
||||
|
||||
const [
|
||||
videoStats,
|
||||
channelStats,
|
||||
playlistStats,
|
||||
downloadStats,
|
||||
watchProgressStats,
|
||||
downloadHistoryStats,
|
||||
biggestChannelsStatsByCount,
|
||||
biggestChannelsStatsByDuration,
|
||||
biggestChannelsStatsByMediaSize,
|
||||
] = all;
|
||||
|
||||
setResponse({
|
||||
videoStats,
|
||||
channelStats,
|
||||
playlistStats,
|
||||
downloadStats,
|
||||
watchProgressStats,
|
||||
downloadHistoryStats,
|
||||
biggestChannelsStatsByCount,
|
||||
biggestChannelsStatsByDuration,
|
||||
biggestChannelsStatsByMediaSize,
|
||||
});
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>TA | Settings Dashboard</title>
|
||||
</Helmet>
|
||||
<div className="boxed-content">
|
||||
<SettingsNavigation />
|
||||
<Notifications pageName={'all'} />
|
||||
<div className="title-bar">
|
||||
<h1>Your Archive</h1>
|
||||
</div>
|
||||
<p>
|
||||
File Sizes in:
|
||||
<select
|
||||
value={useSi ? 'true' : 'false'}
|
||||
onChange={event => {
|
||||
const value = event.target.value;
|
||||
console.log(value);
|
||||
setUseSi(value === 'true');
|
||||
}}
|
||||
>
|
||||
<option value="true">SI units</option>
|
||||
<option value="false">Binary units</option>
|
||||
</select>
|
||||
</p>
|
||||
|
||||
<div className="settings-item">
|
||||
<h2>Overview</h2>
|
||||
<div className="info-box info-box-3">
|
||||
<OverviewStats videoStats={videoStats} useSI={useSi} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-item">
|
||||
<h2>Video Type</h2>
|
||||
<div className="info-box info-box-3">
|
||||
<VideoTypeStats videoStats={videoStats} useSI={useSi} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-item">
|
||||
<h2>Application</h2>
|
||||
<div className="info-box info-box-3">
|
||||
<ApplicationStats
|
||||
channelStats={channelStats}
|
||||
playlistStats={playlistStats}
|
||||
downloadStats={downloadStats}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-item">
|
||||
<h2>Watch Progress</h2>
|
||||
<div className="info-box info-box-2">
|
||||
<WatchProgressStats watchProgressStats={watchProgressStats} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-item">
|
||||
<h2>Download History</h2>
|
||||
<div className="info-box info-box-4">
|
||||
<DownloadHistoryStats downloadHistoryStats={downloadHistoryStats} useSI={false} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-item">
|
||||
<h2>Biggest Channels</h2>
|
||||
<div className="info-box info-box-3">
|
||||
<BiggestChannelsStats
|
||||
biggestChannelsStatsByCount={biggestChannelsStatsByCount}
|
||||
biggestChannelsStatsByDuration={biggestChannelsStatsByDuration}
|
||||
biggestChannelsStatsByMediaSize={biggestChannelsStatsByMediaSize}
|
||||
useSI={useSi}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PaginationDummy />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsDashboard;
|
||||
import { useEffect, useState } from 'react';
|
||||
import SettingsNavigation from '../components/SettingsNavigation';
|
||||
import loadStatsVideo from '../api/loader/loadStatsVideo';
|
||||
import loadStatsChannel from '../api/loader/loadStatsChannel';
|
||||
import loadStatsPlaylist from '../api/loader/loadStatsPlaylist';
|
||||
import loadStatsDownload from '../api/loader/loadStatsDownload';
|
||||
import loadStatsWatchProgress from '../api/loader/loadStatsWatchProgress';
|
||||
import loadStatsDownloadHistory from '../api/loader/loadStatsDownloadHistory';
|
||||
import loadStatsBiggestChannels from '../api/loader/loadStatsBiggestChannels';
|
||||
import OverviewStats from '../components/OverviewStats';
|
||||
import VideoTypeStats from '../components/VideoTypeStats';
|
||||
import ApplicationStats from '../components/ApplicationStats';
|
||||
import WatchProgressStats from '../components/WatchProgressStats';
|
||||
import DownloadHistoryStats from '../components/DownloadHistoryStats';
|
||||
import BiggestChannelsStats from '../components/BiggestChannelsStats';
|
||||
import Notifications from '../components/Notifications';
|
||||
import PaginationDummy from '../components/PaginationDummy';
|
||||
|
||||
export type VideoStatsType = {
|
||||
doc_count: number;
|
||||
media_size: number;
|
||||
duration: number;
|
||||
duration_str: string;
|
||||
type_videos: {
|
||||
doc_count: number;
|
||||
media_size: number;
|
||||
duration: number;
|
||||
duration_str: string;
|
||||
};
|
||||
type_shorts: {
|
||||
doc_count: number;
|
||||
media_size: number;
|
||||
duration: number;
|
||||
duration_str: string;
|
||||
};
|
||||
active_true: {
|
||||
doc_count: number;
|
||||
media_size: number;
|
||||
duration: number;
|
||||
duration_str: string;
|
||||
};
|
||||
active_false: {
|
||||
doc_count: number;
|
||||
media_size: number;
|
||||
duration: number;
|
||||
duration_str: string;
|
||||
};
|
||||
type_streams: {
|
||||
doc_count: number;
|
||||
media_size: number;
|
||||
duration: number;
|
||||
duration_str: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ChannelStatsType = {
|
||||
doc_count: number;
|
||||
active_true: number;
|
||||
subscribed_true: number;
|
||||
};
|
||||
|
||||
export type PlaylistStatsType = {
|
||||
doc_count: number;
|
||||
active_false: number;
|
||||
active_true: number;
|
||||
subscribed_true: number;
|
||||
};
|
||||
|
||||
export type DownloadStatsType = {
|
||||
pending: number;
|
||||
pending_videos: number;
|
||||
pending_shorts: number;
|
||||
pending_streams: number;
|
||||
};
|
||||
|
||||
export type WatchProgressStatsType = {
|
||||
total: {
|
||||
duration: number;
|
||||
duration_str: string;
|
||||
items: number;
|
||||
};
|
||||
unwatched: {
|
||||
duration: number;
|
||||
duration_str: string;
|
||||
progress: number;
|
||||
items: number;
|
||||
};
|
||||
watched: {
|
||||
duration: number;
|
||||
duration_str: string;
|
||||
progress: number;
|
||||
items: number;
|
||||
};
|
||||
};
|
||||
|
||||
type DownloadHistoryType = {
|
||||
date: string;
|
||||
count: number;
|
||||
media_size: number;
|
||||
};
|
||||
|
||||
export type DownloadHistoryStatsType = DownloadHistoryType[];
|
||||
|
||||
type BiggestChannelsType = {
|
||||
id: string;
|
||||
name: string;
|
||||
doc_count: number;
|
||||
duration: number;
|
||||
duration_str: string;
|
||||
media_size: number;
|
||||
};
|
||||
|
||||
export type BiggestChannelsStatsType = BiggestChannelsType[];
|
||||
|
||||
type DashboardStatsReponses = {
|
||||
videoStats?: VideoStatsType;
|
||||
channelStats?: ChannelStatsType;
|
||||
playlistStats?: PlaylistStatsType;
|
||||
downloadStats?: DownloadStatsType;
|
||||
watchProgressStats?: WatchProgressStatsType;
|
||||
downloadHistoryStats?: DownloadHistoryStatsType;
|
||||
biggestChannelsStatsByCount?: BiggestChannelsStatsType;
|
||||
biggestChannelsStatsByDuration?: BiggestChannelsStatsType;
|
||||
biggestChannelsStatsByMediaSize?: BiggestChannelsStatsType;
|
||||
};
|
||||
|
||||
const SettingsDashboard = () => {
|
||||
const [useSi, setUseSi] = useState(false);
|
||||
|
||||
const [response, setResponse] = useState<DashboardStatsReponses>({
|
||||
videoStats: undefined,
|
||||
});
|
||||
|
||||
const videoStats = response?.videoStats;
|
||||
const channelStats = response?.channelStats;
|
||||
const playlistStats = response?.playlistStats;
|
||||
const downloadStats = response?.downloadStats;
|
||||
const watchProgressStats = response?.watchProgressStats;
|
||||
const downloadHistoryStats = response?.downloadHistoryStats;
|
||||
const biggestChannelsStatsByCount = response?.biggestChannelsStatsByCount;
|
||||
const biggestChannelsStatsByDuration = response?.biggestChannelsStatsByDuration;
|
||||
const biggestChannelsStatsByMediaSize = response?.biggestChannelsStatsByMediaSize;
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const all = await Promise.all([
|
||||
await loadStatsVideo(),
|
||||
await loadStatsChannel(),
|
||||
await loadStatsPlaylist(),
|
||||
await loadStatsDownload(),
|
||||
await loadStatsWatchProgress(),
|
||||
await loadStatsDownloadHistory(),
|
||||
await loadStatsBiggestChannels('doc_count'),
|
||||
await loadStatsBiggestChannels('duration'),
|
||||
await loadStatsBiggestChannels('media_size'),
|
||||
]);
|
||||
|
||||
const [
|
||||
videoStats,
|
||||
channelStats,
|
||||
playlistStats,
|
||||
downloadStats,
|
||||
watchProgressStats,
|
||||
downloadHistoryStats,
|
||||
biggestChannelsStatsByCount,
|
||||
biggestChannelsStatsByDuration,
|
||||
biggestChannelsStatsByMediaSize,
|
||||
] = all;
|
||||
|
||||
setResponse({
|
||||
videoStats,
|
||||
channelStats,
|
||||
playlistStats,
|
||||
downloadStats,
|
||||
watchProgressStats,
|
||||
downloadHistoryStats,
|
||||
biggestChannelsStatsByCount,
|
||||
biggestChannelsStatsByDuration,
|
||||
biggestChannelsStatsByMediaSize,
|
||||
});
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>TA | Settings Dashboard</title>
|
||||
<div className="boxed-content">
|
||||
<SettingsNavigation />
|
||||
<Notifications pageName={'all'} />
|
||||
<div className="title-bar">
|
||||
<h1>Your Archive</h1>
|
||||
</div>
|
||||
<p>
|
||||
File Sizes in:
|
||||
<select
|
||||
value={useSi ? 'true' : 'false'}
|
||||
onChange={event => {
|
||||
const value = event.target.value;
|
||||
console.log(value);
|
||||
setUseSi(value === 'true');
|
||||
}}
|
||||
>
|
||||
<option value="true">SI units</option>
|
||||
<option value="false">Binary units</option>
|
||||
</select>
|
||||
</p>
|
||||
|
||||
<div className="settings-item">
|
||||
<h2>Overview</h2>
|
||||
<div className="info-box info-box-3">
|
||||
<OverviewStats videoStats={videoStats} useSI={useSi} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-item">
|
||||
<h2>Video Type</h2>
|
||||
<div className="info-box info-box-3">
|
||||
<VideoTypeStats videoStats={videoStats} useSI={useSi} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-item">
|
||||
<h2>Application</h2>
|
||||
<div className="info-box info-box-3">
|
||||
<ApplicationStats
|
||||
channelStats={channelStats}
|
||||
playlistStats={playlistStats}
|
||||
downloadStats={downloadStats}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-item">
|
||||
<h2>Watch Progress</h2>
|
||||
<div className="info-box info-box-2">
|
||||
<WatchProgressStats watchProgressStats={watchProgressStats} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-item">
|
||||
<h2>Download History</h2>
|
||||
<div className="info-box info-box-4">
|
||||
<DownloadHistoryStats downloadHistoryStats={downloadHistoryStats} useSI={false} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-item">
|
||||
<h2>Biggest Channels</h2>
|
||||
<div className="info-box info-box-3">
|
||||
<BiggestChannelsStats
|
||||
biggestChannelsStatsByCount={biggestChannelsStatsByCount}
|
||||
biggestChannelsStatsByDuration={biggestChannelsStatsByDuration}
|
||||
biggestChannelsStatsByMediaSize={biggestChannelsStatsByMediaSize}
|
||||
useSI={useSi}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PaginationDummy />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsDashboard;
|
||||
|
@ -1,462 +1,496 @@
|
||||
import { Helmet } from 'react-helmet';
|
||||
import Notifications from '../components/Notifications';
|
||||
import SettingsNavigation from '../components/SettingsNavigation';
|
||||
import Button from '../components/Button';
|
||||
|
||||
type CronTabType = {
|
||||
minute: number;
|
||||
hour: number;
|
||||
day_of_week: number;
|
||||
};
|
||||
|
||||
type SchedulerErrorType = {
|
||||
errors: string[];
|
||||
};
|
||||
|
||||
type NotificationItemType = {
|
||||
task: string;
|
||||
notification: {
|
||||
title: string;
|
||||
urls: string[];
|
||||
};
|
||||
};
|
||||
|
||||
type SettingsSchedulingResponseType = {
|
||||
update_subscribed: {
|
||||
crontab: CronTabType;
|
||||
};
|
||||
check_reindex: {
|
||||
crontab: CronTabType;
|
||||
task_config: {
|
||||
days: 0;
|
||||
};
|
||||
};
|
||||
thumbnail_check: {
|
||||
crontab: CronTabType;
|
||||
};
|
||||
download_pending: {
|
||||
crontab: CronTabType;
|
||||
};
|
||||
run_backup: {
|
||||
crontab: CronTabType;
|
||||
task_config: {
|
||||
rotate: false;
|
||||
};
|
||||
};
|
||||
notifications: {
|
||||
items: NotificationItemType[];
|
||||
};
|
||||
scheduler_form: {
|
||||
update_subscribed: SchedulerErrorType;
|
||||
download_pending: SchedulerErrorType;
|
||||
check_reindex: SchedulerErrorType;
|
||||
thumbnail_check: SchedulerErrorType;
|
||||
run_backup: SchedulerErrorType;
|
||||
};
|
||||
};
|
||||
|
||||
const SettingsScheduling = () => {
|
||||
const response: SettingsSchedulingResponseType = {
|
||||
update_subscribed: {
|
||||
crontab: {
|
||||
minute: 0,
|
||||
hour: 0,
|
||||
day_of_week: 0,
|
||||
},
|
||||
},
|
||||
check_reindex: {
|
||||
crontab: {
|
||||
minute: 0,
|
||||
hour: 0,
|
||||
day_of_week: 0,
|
||||
},
|
||||
task_config: {
|
||||
days: 0,
|
||||
},
|
||||
},
|
||||
thumbnail_check: {
|
||||
crontab: {
|
||||
minute: 0,
|
||||
hour: 0,
|
||||
day_of_week: 0,
|
||||
},
|
||||
},
|
||||
download_pending: {
|
||||
crontab: {
|
||||
minute: 0,
|
||||
hour: 0,
|
||||
day_of_week: 0,
|
||||
},
|
||||
},
|
||||
run_backup: {
|
||||
crontab: {
|
||||
minute: 0,
|
||||
hour: 0,
|
||||
day_of_week: 0,
|
||||
},
|
||||
task_config: {
|
||||
rotate: false,
|
||||
},
|
||||
},
|
||||
notifications: {
|
||||
items: [
|
||||
{
|
||||
task: '',
|
||||
notification: {
|
||||
title: '',
|
||||
urls: [''],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
scheduler_form: {
|
||||
update_subscribed: {
|
||||
errors: ['error?'],
|
||||
},
|
||||
download_pending: {
|
||||
errors: ['error?'],
|
||||
},
|
||||
check_reindex: {
|
||||
errors: ['error?'],
|
||||
},
|
||||
thumbnail_check: {
|
||||
errors: ['error?'],
|
||||
},
|
||||
run_backup: {
|
||||
errors: ['error?'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const {
|
||||
check_reindex,
|
||||
download_pending,
|
||||
notifications,
|
||||
run_backup,
|
||||
scheduler_form,
|
||||
thumbnail_check,
|
||||
update_subscribed,
|
||||
} = response;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>TA | Scheduling Settings</title>
|
||||
</Helmet>
|
||||
<div className="boxed-content">
|
||||
<SettingsNavigation />
|
||||
<Notifications pageName={'all'} />
|
||||
|
||||
<div className="title-bar">
|
||||
<h1>Scheduler Setup</h1>
|
||||
<div className="settings-group">
|
||||
<p>
|
||||
Schedule settings expect a cron like format, where the first value is minute, second
|
||||
is hour and third is day of the week.
|
||||
</p>
|
||||
<p>Examples:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<span className="settings-current">0 15 *</span>: Run task every day at 15:00 in the
|
||||
afternoon.
|
||||
</li>
|
||||
<li>
|
||||
<span className="settings-current">30 8 */2</span>: Run task every second day of the
|
||||
week (Sun, Tue, Thu, Sat) at 08:30 in the morning.
|
||||
</li>
|
||||
<li>
|
||||
<span className="settings-current">auto</span>: Sensible default.
|
||||
</li>
|
||||
</ul>
|
||||
<p>Note:</p>
|
||||
<ul>
|
||||
<li>
|
||||
Avoid an unnecessary frequent schedule to not get blocked by YouTube. For that
|
||||
reason, the scheduler doesn't support schedules that trigger more than once per
|
||||
hour.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<form action="{% url 'settings_scheduling' %}" method="POST" name="scheduler-update">
|
||||
<div className="settings-group">
|
||||
<h2>Rescan Subscriptions</h2>
|
||||
<div className="settings-item">
|
||||
<p>
|
||||
Become a sponsor and join{' '}
|
||||
<a href="https://members.tubearchivist.com/" target="_blank">
|
||||
members.tubearchivist.com
|
||||
</a>{' '}
|
||||
to get access to <span className="settings-current">real time</span> notifications
|
||||
for new videos uploaded by your favorite channels.
|
||||
</p>
|
||||
<p>
|
||||
Current rescan schedule:{' '}
|
||||
<span className="settings-current">
|
||||
{update_subscribed && (
|
||||
<>
|
||||
{update_subscribed.crontab.minute} {update_subscribed.crontab.hour}{' '}
|
||||
{update_subscribed.crontab.day_of_week}
|
||||
<Button
|
||||
label="Delete"
|
||||
data-schedule="update_subscribed"
|
||||
onclick="deleteSchedule(this)"
|
||||
className="danger-button"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!update_subscribed && 'False'}
|
||||
</span>
|
||||
</p>
|
||||
<p>Periodically rescan your subscriptions:</p>
|
||||
{scheduler_form.update_subscribed.errors.map(error => {
|
||||
return (
|
||||
<p key={error} className="danger-zone">
|
||||
{error}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
|
||||
<input type="text" name="update_subscribed" id="id_update_subscribed" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-group">
|
||||
<h2>Start Download</h2>
|
||||
<div className="settings-item">
|
||||
<p>
|
||||
Current Download schedule:{' '}
|
||||
<span className="settings-current">
|
||||
{download_pending && (
|
||||
<>
|
||||
{download_pending.crontab.minute} {download_pending.crontab.hour}{' '}
|
||||
{download_pending.crontab.day_of_week}
|
||||
<Button
|
||||
label="Delete"
|
||||
data-schedule="download_pending"
|
||||
onclick="deleteSchedule(this)"
|
||||
className="danger-button"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!download_pending && 'False'}
|
||||
</span>
|
||||
</p>
|
||||
<p>Automatic video download schedule:</p>
|
||||
{scheduler_form.download_pending.errors.map(error => {
|
||||
return (
|
||||
<p key={error} className="danger-zone">
|
||||
{error}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
|
||||
<input type="text" name="download_pending" id="id_download_pending" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-group">
|
||||
<h2>Refresh Metadata</h2>
|
||||
<div className="settings-item">
|
||||
<p>
|
||||
Current Metadata refresh schedule:{' '}
|
||||
<span className="settings-current">
|
||||
{check_reindex && (
|
||||
<>
|
||||
{check_reindex.crontab.minute} {check_reindex.crontab.hour}{' '}
|
||||
{check_reindex.crontab.day_of_week}
|
||||
<Button
|
||||
label="Delete"
|
||||
data-schedule="check_reindex"
|
||||
onclick="deleteSchedule(this)"
|
||||
className="danger-button"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!check_reindex && 'False'}
|
||||
</span>
|
||||
</p>
|
||||
<p>Daily schedule to refresh metadata from YouTube:</p>
|
||||
|
||||
<input type="text" name="check_reindex" id="id_check_reindex" />
|
||||
</div>
|
||||
<div className="settings-item">
|
||||
<p>
|
||||
Current refresh for metadata older than x days:{' '}
|
||||
<span className="settings-current">{check_reindex.task_config.days}</span>
|
||||
</p>
|
||||
<p>Refresh older than x days, recommended 90:</p>
|
||||
{scheduler_form.check_reindex.errors.map(error => {
|
||||
return (
|
||||
<p key={error} className="danger-zone">
|
||||
{error}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
|
||||
<input type="number" name="check_reindex_days" id="id_check_reindex_days" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-group">
|
||||
<h2>Thumbnail Check</h2>
|
||||
<div className="settings-item">
|
||||
<p>
|
||||
Current thumbnail check schedule:{' '}
|
||||
<span className="settings-current">
|
||||
{thumbnail_check && (
|
||||
<>
|
||||
{thumbnail_check.crontab.minute} {thumbnail_check.crontab.hour}{' '}
|
||||
{thumbnail_check.crontab.day_of_week}
|
||||
<Button
|
||||
label="Delete"
|
||||
data-schedule="thumbnail_check"
|
||||
onclick="deleteSchedule(this)"
|
||||
className="danger-button"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!thumbnail_check && 'False'}
|
||||
</span>
|
||||
</p>
|
||||
<p>Periodically check and cleanup thumbnails:</p>
|
||||
{scheduler_form.thumbnail_check.errors.map(error => {
|
||||
return (
|
||||
<p key={error} className="danger-zone">
|
||||
{error}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
|
||||
<input type="text" name="thumbnail_check" id="id_thumbnail_check" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-group">
|
||||
<h2>ZIP file index backup</h2>
|
||||
<div className="settings-item">
|
||||
<p>
|
||||
<i>
|
||||
Zip file backups are very slow for large archives and consistency is not
|
||||
guaranteed, use snapshots instead. Make sure no other tasks are running when
|
||||
creating a Zip file backup.
|
||||
</i>
|
||||
</p>
|
||||
<p>
|
||||
Current index backup schedule:{' '}
|
||||
<span className="settings-current">
|
||||
{run_backup && (
|
||||
<>
|
||||
{run_backup.crontab.minute} {run_backup.crontab.hour}{' '}
|
||||
{run_backup.crontab.day_of_week}
|
||||
<Button
|
||||
label="Delete"
|
||||
data-schedule="run_backup"
|
||||
onclick="deleteSchedule(this)"
|
||||
className="danger-button"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!run_backup && 'False'}
|
||||
</span>
|
||||
</p>
|
||||
<p>Automatically backup metadata to a zip file:</p>
|
||||
{scheduler_form.run_backup.errors.map(error => {
|
||||
return (
|
||||
<p key={error} className="danger-zone">
|
||||
{error}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
|
||||
<input type="text" name="run_backup" id="id_run_backup" />
|
||||
</div>
|
||||
<div className="settings-item">
|
||||
<p>
|
||||
Current backup files to keep:{' '}
|
||||
<span className="settings-current">{run_backup.task_config.rotate}</span>
|
||||
</p>
|
||||
<p>Max auto backups to keep:</p>
|
||||
|
||||
<input type="number" name="run_backup_rotate" id="id_run_backup_rotate" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-group">
|
||||
<h2>Add Notification URL</h2>
|
||||
<div className="settings-item">
|
||||
{notifications && (
|
||||
<>
|
||||
<p>
|
||||
<Button
|
||||
label="Show"
|
||||
type="button"
|
||||
onclick="textReveal(this)"
|
||||
id="text-reveal-button"
|
||||
/>{' '}
|
||||
stored notification links
|
||||
</p>
|
||||
<div id="text-reveal" className="description-text">
|
||||
{notifications.items.map(({ task, notification }) => {
|
||||
return (
|
||||
<>
|
||||
<h3 key={task}>{notification.title}</h3>
|
||||
{notification.urls.map((url: string) => {
|
||||
return (
|
||||
<p>
|
||||
<Button
|
||||
type="button"
|
||||
className="danger-button"
|
||||
label="Delete"
|
||||
data-url={url}
|
||||
data-task={task}
|
||||
onclick="deleteNotificationUrl(this)"
|
||||
/>
|
||||
<span> {url}</span>
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!notifications && <p>No notifications stored</p>}
|
||||
</div>
|
||||
<div className="settings-item">
|
||||
<p>
|
||||
<i>
|
||||
Send notification on completed tasks with the help of the{' '}
|
||||
<a href="https://github.com/caronc/apprise" target="_blank">
|
||||
Apprise
|
||||
</a>{' '}
|
||||
library.
|
||||
</i>
|
||||
</p>
|
||||
<select name="task" id="id_task" defaultValue="">
|
||||
<option value="">-- select task --</option>
|
||||
<option value="update_subscribed">Rescan your Subscriptions</option>
|
||||
<option value="extract_download">Add to download queue</option>
|
||||
<option value="download_pending">Downloading</option>
|
||||
<option value="check_reindex">Reindex Documents</option>
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
name="notification_url"
|
||||
placeholder="Apprise notification URL"
|
||||
id="id_notification_url"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" name="scheduler-settings" label="Update Scheduler Settings" />
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsScheduling;
|
||||
import Notifications from '../components/Notifications';
|
||||
import SettingsNavigation from '../components/SettingsNavigation';
|
||||
import Button from '../components/Button';
|
||||
import PaginationDummy from '../components/PaginationDummy';
|
||||
import { useEffect, useState } from 'react';
|
||||
import loadSchedule, { ScheduleResponseType } from '../api/loader/loadSchedule';
|
||||
import loadAppriseNotification, {
|
||||
AppriseNotificationType,
|
||||
} from '../api/loader/loadAppriseNotification';
|
||||
import deleteTaskSchedule from '../api/actions/deleteTaskSchedule';
|
||||
import createTaskSchedule from '../api/actions/createTaskSchedule';
|
||||
import createAppriseNotificationUrl, {
|
||||
AppriseTaskNameType,
|
||||
} from '../api/actions/createAppriseNotificationUrl';
|
||||
import deleteAppriseNotificationUrl from '../api/actions/deleteAppriseNotificationUrl';
|
||||
|
||||
const SettingsScheduling = () => {
|
||||
const [refresh, setRefresh] = useState(false);
|
||||
|
||||
const [scheduleResponse, setScheduleResponse] = useState<ScheduleResponseType>([]);
|
||||
const [appriseNotification, setAppriseNotification] = useState<AppriseNotificationType>();
|
||||
|
||||
const [updateSubscribed, setUpdateSubscribed] = useState<string | undefined>();
|
||||
const [downloadPending, setDownloadPending] = useState<string | undefined>();
|
||||
const [checkReindex, setCheckReindex] = useState<string | undefined>();
|
||||
const [checkReindexDays, setCheckReindexDays] = useState<number | undefined>(undefined);
|
||||
const [thumbnailCheck, setThumbnailCheck] = useState<string | undefined>();
|
||||
const [zipBackup, setZipBackup] = useState<string | undefined>();
|
||||
const [zipBackupDays, setZipBackupDays] = useState<number | undefined>(undefined);
|
||||
const [notificationUrl, setNotificationUrl] = useState<string | undefined>(undefined);
|
||||
const [notificationTask, setNotificationTask] = useState<AppriseTaskNameType | string>('');
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (refresh) {
|
||||
const scheduleResponse = await loadSchedule();
|
||||
const appriseNotificationResponse = await loadAppriseNotification();
|
||||
|
||||
setScheduleResponse(scheduleResponse);
|
||||
setAppriseNotification(appriseNotificationResponse);
|
||||
|
||||
setRefresh(false);
|
||||
}
|
||||
})();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
setRefresh(true);
|
||||
}, []);
|
||||
|
||||
const groupedSchedules = Object.groupBy(scheduleResponse, ({ name }) => name);
|
||||
|
||||
console.log(groupedSchedules);
|
||||
|
||||
const { update_subscribed, download_pending, run_backup, check_reindex, thumbnail_check } =
|
||||
groupedSchedules;
|
||||
|
||||
const updateSubscribedSchedule = update_subscribed?.pop();
|
||||
const downloadPendingSchedule = download_pending?.pop();
|
||||
const runBackup = run_backup?.pop();
|
||||
const checkReindexSchedule = check_reindex?.pop();
|
||||
const thumbnailCheckSchedule = thumbnail_check?.pop();
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>TA | Scheduling Settings</title>
|
||||
<div className="boxed-content">
|
||||
<SettingsNavigation />
|
||||
<Notifications pageName={'all'} />
|
||||
|
||||
<div className="title-bar">
|
||||
<h1>Scheduler Setup</h1>
|
||||
<div className="settings-group">
|
||||
<p>
|
||||
Schedule settings expect a cron like format, where the first value is minute, second
|
||||
is hour and third is day of the week.
|
||||
</p>
|
||||
<p>Examples:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<span className="settings-current">0 15 *</span>: Run task every day at 15:00 in the
|
||||
afternoon.
|
||||
</li>
|
||||
<li>
|
||||
<span className="settings-current">30 8 */2</span>: Run task every second day of the
|
||||
week (Sun, Tue, Thu, Sat) at 08:30 in the morning.
|
||||
</li>
|
||||
<li>
|
||||
<span className="settings-current">auto</span>: Sensible default.
|
||||
</li>
|
||||
</ul>
|
||||
<p>Note:</p>
|
||||
<ul>
|
||||
<li>
|
||||
Avoid an unnecessary frequent schedule to not get blocked by YouTube. For that
|
||||
reason, the scheduler doesn't support schedules that trigger more than once per
|
||||
hour.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-group">
|
||||
<h2>Rescan Subscriptions</h2>
|
||||
<div className="settings-item">
|
||||
<p>
|
||||
Become a sponsor and join{' '}
|
||||
<a href="https://members.tubearchivist.com/" target="_blank">
|
||||
members.tubearchivist.com
|
||||
</a>{' '}
|
||||
to get access to <span className="settings-current">real time</span> notifications for
|
||||
new videos uploaded by your favorite channels.
|
||||
</p>
|
||||
<p>
|
||||
Current rescan schedule:{' '}
|
||||
<span className="settings-current">
|
||||
{!updateSubscribedSchedule && 'False'}
|
||||
{updateSubscribedSchedule && (
|
||||
<>
|
||||
{updateSubscribedSchedule?.schedule}{' '}
|
||||
<Button
|
||||
label="Delete"
|
||||
data-schedule="update_subscribed"
|
||||
onClick={async () => {
|
||||
await deleteTaskSchedule('update_subscribed');
|
||||
|
||||
setRefresh(true);
|
||||
}}
|
||||
className="danger-button"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
<p>Periodically rescan your subscriptions:</p>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={updateSubscribed || updateSubscribedSchedule?.schedule}
|
||||
onChange={e => {
|
||||
setUpdateSubscribed(e.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
label="Save"
|
||||
onClick={async () => {
|
||||
await createTaskSchedule('update_subscribed', {
|
||||
schedule: updateSubscribed,
|
||||
});
|
||||
|
||||
setUpdateSubscribed('');
|
||||
|
||||
setRefresh(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-group">
|
||||
<h2>Start Download</h2>
|
||||
<div className="settings-item">
|
||||
<p>
|
||||
Current Download schedule:{' '}
|
||||
<span className="settings-current">
|
||||
{!download_pending && 'False'}
|
||||
{downloadPendingSchedule && (
|
||||
<>
|
||||
{downloadPendingSchedule?.schedule}{' '}
|
||||
<Button
|
||||
label="Delete"
|
||||
className="danger-button"
|
||||
onClick={async () => {
|
||||
await deleteTaskSchedule('download_pending');
|
||||
|
||||
setRefresh(true);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
<p>Automatic video download schedule:</p>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={downloadPending || downloadPendingSchedule?.schedule}
|
||||
onChange={e => {
|
||||
setDownloadPending(e.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
label="Save"
|
||||
onClick={async () => {
|
||||
await createTaskSchedule('download_pending', {
|
||||
schedule: downloadPending,
|
||||
});
|
||||
|
||||
setDownloadPending('');
|
||||
|
||||
setRefresh(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-group">
|
||||
<h2>Refresh Metadata</h2>
|
||||
<div className="settings-item">
|
||||
<p>
|
||||
Current Metadata refresh schedule:{' '}
|
||||
<span className="settings-current">
|
||||
{!checkReindexSchedule && 'False'}
|
||||
{checkReindexSchedule && (
|
||||
<>
|
||||
{checkReindexSchedule?.schedule}{' '}
|
||||
<Button
|
||||
label="Delete"
|
||||
className="danger-button"
|
||||
onClick={async () => {
|
||||
await deleteTaskSchedule('check_reindex');
|
||||
|
||||
setRefresh(true);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
<p>Daily schedule to refresh metadata from YouTube:</p>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={checkReindex || checkReindexSchedule?.schedule}
|
||||
onChange={e => {
|
||||
setCheckReindex(e.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
label="Save"
|
||||
onClick={async () => {
|
||||
await createTaskSchedule('check_reindex', {
|
||||
schedule: checkReindex,
|
||||
});
|
||||
|
||||
setCheckReindex('');
|
||||
|
||||
setRefresh(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-item">
|
||||
<p>
|
||||
Current refresh for metadata older than x days:{' '}
|
||||
<span className="settings-current">{checkReindexSchedule?.config?.days}</span>
|
||||
</p>
|
||||
<p>Refresh older than x days, recommended 90:</p>
|
||||
|
||||
<input
|
||||
type="number"
|
||||
value={checkReindexDays || checkReindexSchedule?.config?.days}
|
||||
onChange={e => {
|
||||
setCheckReindexDays(Number(e.currentTarget.value));
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
label="Save"
|
||||
onClick={async () => {
|
||||
await createTaskSchedule('check_reindex', {
|
||||
config: {
|
||||
days: checkReindexDays,
|
||||
},
|
||||
});
|
||||
|
||||
setCheckReindexDays(undefined);
|
||||
|
||||
setRefresh(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-group">
|
||||
<h2>Thumbnail Check</h2>
|
||||
<div className="settings-item">
|
||||
<p>
|
||||
Current thumbnail check schedule:{' '}
|
||||
<span className="settings-current">
|
||||
{!thumbnailCheckSchedule && 'False'}
|
||||
{thumbnailCheckSchedule && (
|
||||
<>
|
||||
{thumbnailCheckSchedule?.schedule}{' '}
|
||||
<Button
|
||||
label="Delete"
|
||||
className="danger-button"
|
||||
onClick={async () => {
|
||||
await deleteTaskSchedule('thumbnail_check');
|
||||
|
||||
setRefresh(true);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
<p>Periodically check and cleanup thumbnails:</p>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={thumbnailCheck || thumbnailCheckSchedule?.schedule}
|
||||
onChange={e => {
|
||||
setThumbnailCheck(e.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
label="Save"
|
||||
onClick={async () => {
|
||||
await createTaskSchedule('thumbnail_check', {
|
||||
schedule: thumbnailCheck,
|
||||
});
|
||||
|
||||
setThumbnailCheck('');
|
||||
|
||||
setRefresh(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-group">
|
||||
<h2>ZIP file index backup</h2>
|
||||
<div className="settings-item">
|
||||
<p>
|
||||
<i>
|
||||
Zip file backups are very slow for large archives and consistency is not guaranteed,
|
||||
use snapshots instead. Make sure no other tasks are running when creating a Zip file
|
||||
backup.
|
||||
</i>
|
||||
</p>
|
||||
<p>
|
||||
Current index backup schedule:{' '}
|
||||
<span className="settings-current">
|
||||
{!runBackup && 'False'}
|
||||
{runBackup && (
|
||||
<>
|
||||
{runBackup.schedule}{' '}
|
||||
<Button
|
||||
label="Delete"
|
||||
className="danger-button"
|
||||
onClick={async () => {
|
||||
await deleteTaskSchedule('run_backup');
|
||||
|
||||
setRefresh(true);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
<p>Automatically backup metadata to a zip file:</p>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={zipBackup || runBackup?.schedule}
|
||||
onChange={e => {
|
||||
setZipBackup(e.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
label="Save"
|
||||
onClick={async () => {
|
||||
await createTaskSchedule('run_backup', {
|
||||
schedule: zipBackup,
|
||||
});
|
||||
|
||||
setZipBackup('');
|
||||
|
||||
setRefresh(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-item">
|
||||
<p>
|
||||
Current backup files to keep:{' '}
|
||||
<span className="settings-current">{runBackup?.config?.rotate}</span>
|
||||
</p>
|
||||
<p>Max auto backups to keep:</p>
|
||||
|
||||
<input
|
||||
type="number"
|
||||
value={(zipBackupDays || runBackup?.config?.rotate)?.toString()}
|
||||
onChange={e => {
|
||||
setZipBackupDays(Number(e.currentTarget.value));
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
label="Save"
|
||||
onClick={async () => {
|
||||
await createTaskSchedule('run_backup', {
|
||||
config: {
|
||||
rotate: zipBackupDays,
|
||||
},
|
||||
});
|
||||
|
||||
setZipBackupDays(undefined);
|
||||
|
||||
setRefresh(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-group">
|
||||
<h2>Add Notification URL</h2>
|
||||
<div className="settings-item">
|
||||
{!appriseNotification && <p>No notifications stored</p>}
|
||||
{appriseNotification && (
|
||||
<>
|
||||
<div className="description-text">
|
||||
{Object.entries(appriseNotification)?.map(([key, { urls, title }]) => {
|
||||
return (
|
||||
<>
|
||||
<h3 key={key}>{title}</h3>
|
||||
{urls.map((url: string) => {
|
||||
return (
|
||||
<p>
|
||||
<span>{url} </span>
|
||||
<Button
|
||||
type="button"
|
||||
className="danger-button"
|
||||
label="Delete"
|
||||
onClick={async () => {
|
||||
await deleteAppriseNotificationUrl(key as AppriseTaskNameType);
|
||||
|
||||
setRefresh(true);
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="settings-item">
|
||||
<p>
|
||||
<i>
|
||||
Send notification on completed tasks with the help of the{' '}
|
||||
<a href="https://github.com/caronc/apprise" target="_blank">
|
||||
Apprise
|
||||
</a>{' '}
|
||||
library.
|
||||
</i>
|
||||
</p>
|
||||
<select
|
||||
defaultValue=""
|
||||
value={notificationTask}
|
||||
onChange={e => {
|
||||
setNotificationTask(e.currentTarget.value);
|
||||
}}
|
||||
>
|
||||
<option value="">-- select task --</option>
|
||||
<option value="update_subscribed">Rescan your Subscriptions</option>
|
||||
<option value="extract_download">Add to download queue</option>
|
||||
<option value="download_pending">Downloading</option>
|
||||
<option value="check_reindex">Reindex Documents</option>
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Apprise notification URL"
|
||||
value={notificationUrl}
|
||||
onChange={e => {
|
||||
setNotificationUrl(e.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
label="Save"
|
||||
onClick={async () => {
|
||||
await createAppriseNotificationUrl(
|
||||
notificationTask as AppriseTaskNameType,
|
||||
notificationUrl || '',
|
||||
);
|
||||
|
||||
setRefresh(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PaginationDummy />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsScheduling;
|
||||
|
@ -1,143 +1,140 @@
|
||||
import { useLoaderData, useNavigate, useOutletContext } from 'react-router-dom';
|
||||
import updateUserConfig, { UserConfigType, UserMeType } from '../api/actions/updateUserConfig';
|
||||
import { useEffect, useState } from 'react';
|
||||
import loadUserMeConfig from '../api/loader/loadUserConfig';
|
||||
import { ColourConstant, ColourVariants } from '../configuration/colours/getColours';
|
||||
import SettingsNavigation from '../components/SettingsNavigation';
|
||||
import Notifications from '../components/Notifications';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import Button from '../components/Button';
|
||||
import { OutletContextType } from './Base';
|
||||
|
||||
type SettingsUserLoaderData = {
|
||||
userConfig: UserMeType;
|
||||
};
|
||||
|
||||
const SettingsUser = () => {
|
||||
const { isAdmin } = useOutletContext() as OutletContextType;
|
||||
const { userConfig } = useLoaderData() as SettingsUserLoaderData;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const userMeConfig = userConfig.config;
|
||||
const { stylesheet, page_size } = userMeConfig;
|
||||
|
||||
const [selectedStylesheet, setSelectedStylesheet] = useState(userMeConfig.stylesheet);
|
||||
const [selectedPageSize, setSelectedPageSize] = useState(userMeConfig.page_size);
|
||||
const [refresh, setRefresh] = useState(false);
|
||||
|
||||
const [userConfigResponse, setUserConfigResponse] = useState<UserConfigType>();
|
||||
|
||||
const stylesheetOverwritable =
|
||||
userConfigResponse?.stylesheet || stylesheet || (ColourConstant.Dark as ColourVariants);
|
||||
const pageSizeOverwritable = userConfigResponse?.page_size || page_size || 12;
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (refresh) {
|
||||
const userConfigResponse = await loadUserMeConfig();
|
||||
|
||||
setUserConfigResponse(userConfigResponse.config);
|
||||
setRefresh(false);
|
||||
navigate(0);
|
||||
}
|
||||
})();
|
||||
}, [navigate, refresh]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>TA | User Settings</title>
|
||||
</Helmet>
|
||||
<div className="boxed-content">
|
||||
<SettingsNavigation />
|
||||
<Notifications pageName={'all'} />
|
||||
|
||||
<div className="title-bar">
|
||||
<h1>User Configurations</h1>
|
||||
</div>
|
||||
<div>
|
||||
<div className="settings-group">
|
||||
<h2>Stylesheet</h2>
|
||||
<div className="settings-item">
|
||||
<p>
|
||||
Current stylesheet:{' '}
|
||||
<span className="settings-current">{stylesheetOverwritable}</span>
|
||||
</p>
|
||||
<i>Select your preferred stylesheet.</i>
|
||||
<br />
|
||||
<select
|
||||
name="stylesheet"
|
||||
id="id_stylesheet"
|
||||
value={selectedStylesheet}
|
||||
onChange={event => {
|
||||
setSelectedStylesheet(event.target.value as ColourVariants);
|
||||
}}
|
||||
>
|
||||
<option value="">-- change stylesheet --</option>
|
||||
{Object.entries(ColourConstant).map(([key, value]) => {
|
||||
return (
|
||||
<option key={key} value={value}>
|
||||
{key}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-group">
|
||||
<h2>Archive View</h2>
|
||||
<div className="settings-item">
|
||||
<p>
|
||||
Current page size: <span className="settings-current">{pageSizeOverwritable}</span>
|
||||
</p>
|
||||
<i>Result of videos showing in archive page</i>
|
||||
<br />
|
||||
|
||||
<input
|
||||
type="number"
|
||||
name="page_size"
|
||||
id="id_page_size"
|
||||
value={selectedPageSize}
|
||||
onChange={event => {
|
||||
setSelectedPageSize(Number(event.target.value));
|
||||
}}
|
||||
></input>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
name="user-settings"
|
||||
label="Update User Configurations"
|
||||
onClick={async () => {
|
||||
await updateUserConfig({
|
||||
page_size: selectedPageSize,
|
||||
stylesheet: selectedStylesheet,
|
||||
});
|
||||
|
||||
setRefresh(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="title-bar">
|
||||
<h1>Users</h1>
|
||||
</div>
|
||||
<div className="settings-group">
|
||||
<h2>User Management</h2>
|
||||
<p>
|
||||
Access the admin interface for basic user management functionality like adding and
|
||||
deleting users, changing passwords and more.
|
||||
</p>
|
||||
<a href="/admin/">
|
||||
<Button label="Admin Interface" />
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsUser;
|
||||
import { useLoaderData, useNavigate, useOutletContext } from 'react-router-dom';
|
||||
import updateUserConfig, { UserConfigType, UserMeType } from '../api/actions/updateUserConfig';
|
||||
import { useEffect, useState } from 'react';
|
||||
import loadUserMeConfig from '../api/loader/loadUserConfig';
|
||||
import { ColourConstant, ColourVariants } from '../configuration/colours/getColours';
|
||||
import SettingsNavigation from '../components/SettingsNavigation';
|
||||
import Notifications from '../components/Notifications';
|
||||
import Button from '../components/Button';
|
||||
import { OutletContextType } from './Base';
|
||||
|
||||
type SettingsUserLoaderData = {
|
||||
userConfig: UserMeType;
|
||||
};
|
||||
|
||||
const SettingsUser = () => {
|
||||
const { isAdmin } = useOutletContext() as OutletContextType;
|
||||
const { userConfig } = useLoaderData() as SettingsUserLoaderData;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const userMeConfig = userConfig.config;
|
||||
const { stylesheet, page_size } = userMeConfig;
|
||||
|
||||
const [selectedStylesheet, setSelectedStylesheet] = useState(userMeConfig.stylesheet);
|
||||
const [selectedPageSize, setSelectedPageSize] = useState(userMeConfig.page_size);
|
||||
const [refresh, setRefresh] = useState(false);
|
||||
|
||||
const [userConfigResponse, setUserConfigResponse] = useState<UserConfigType>();
|
||||
|
||||
const stylesheetOverwritable =
|
||||
userConfigResponse?.stylesheet || stylesheet || (ColourConstant.Dark as ColourVariants);
|
||||
const pageSizeOverwritable = userConfigResponse?.page_size || page_size || 12;
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (refresh) {
|
||||
const userConfigResponse = await loadUserMeConfig();
|
||||
|
||||
setUserConfigResponse(userConfigResponse.config);
|
||||
setRefresh(false);
|
||||
navigate(0);
|
||||
}
|
||||
})();
|
||||
}, [navigate, refresh]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>TA | User Settings</title>
|
||||
<div className="boxed-content">
|
||||
<SettingsNavigation />
|
||||
<Notifications pageName={'all'} />
|
||||
|
||||
<div className="title-bar">
|
||||
<h1>User Configurations</h1>
|
||||
</div>
|
||||
<div>
|
||||
<div className="settings-group">
|
||||
<h2>Stylesheet</h2>
|
||||
<div className="settings-item">
|
||||
<p>
|
||||
Current stylesheet:{' '}
|
||||
<span className="settings-current">{stylesheetOverwritable}</span>
|
||||
</p>
|
||||
<i>Select your preferred stylesheet.</i>
|
||||
<br />
|
||||
<select
|
||||
name="stylesheet"
|
||||
id="id_stylesheet"
|
||||
value={selectedStylesheet}
|
||||
onChange={event => {
|
||||
setSelectedStylesheet(event.target.value as ColourVariants);
|
||||
}}
|
||||
>
|
||||
<option value="">-- change stylesheet --</option>
|
||||
{Object.entries(ColourConstant).map(([key, value]) => {
|
||||
return (
|
||||
<option key={key} value={value}>
|
||||
{key}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-group">
|
||||
<h2>Archive View</h2>
|
||||
<div className="settings-item">
|
||||
<p>
|
||||
Current page size: <span className="settings-current">{pageSizeOverwritable}</span>
|
||||
</p>
|
||||
<i>Result of videos showing in archive page</i>
|
||||
<br />
|
||||
|
||||
<input
|
||||
type="number"
|
||||
name="page_size"
|
||||
id="id_page_size"
|
||||
value={selectedPageSize}
|
||||
onChange={event => {
|
||||
setSelectedPageSize(Number(event.target.value));
|
||||
}}
|
||||
></input>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
name="user-settings"
|
||||
label="Update User Configurations"
|
||||
onClick={async () => {
|
||||
await updateUserConfig({
|
||||
page_size: selectedPageSize,
|
||||
stylesheet: selectedStylesheet,
|
||||
});
|
||||
|
||||
setRefresh(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="title-bar">
|
||||
<h1>Users</h1>
|
||||
</div>
|
||||
<div className="settings-group">
|
||||
<h2>User Management</h2>
|
||||
<p>
|
||||
Access the admin interface for basic user management functionality like adding and
|
||||
deleting users, changing passwords and more.
|
||||
</p>
|
||||
<a href="/admin/">
|
||||
<Button label="Admin Interface" />
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsUser;
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1361,3 +1361,45 @@ video:-webkit-full-screen {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/** loading indicator */
|
||||
/** source: https://github.com/loadingio/css-spinner/ */
|
||||
.lds-ring,
|
||||
.lds-ring div {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.lds-ring {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.lds-ring div {
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 4px solid currentColor;
|
||||
border-radius: 50%;
|
||||
animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
|
||||
border-color: currentColor transparent transparent transparent;
|
||||
}
|
||||
.lds-ring div:nth-child(1) {
|
||||
animation-delay: -0.45s;
|
||||
}
|
||||
.lds-ring div:nth-child(2) {
|
||||
animation-delay: -0.3s;
|
||||
}
|
||||
.lds-ring div:nth-child(3) {
|
||||
animation-delay: -0.15s;
|
||||
}
|
||||
@keyframes lds-ring {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user