mirror of
https://git.vectorsigma.ru/public/tubearchivist.git
synced 2026-08-09 07:29:26 +00:00
Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e5fac847f | ||
|
|
0a5ce3f786 | ||
|
|
0db4ae3c19 | ||
|
|
56f5c6bc4b | ||
|
|
008dc79ebc | ||
|
|
e34da1687e | ||
|
|
c5f212f520 | ||
|
|
6eb774be69 | ||
|
|
f53988b826 | ||
|
|
70b67e5f77 | ||
|
|
25b6ca2a1a | ||
|
|
776481c513 | ||
|
|
1d727a1170 | ||
|
|
270e6e8320 | ||
|
|
897250da6b | ||
|
|
af33dbb22d | ||
|
|
32cc2c8a13 | ||
|
|
c00368d1f2 | ||
|
|
e28a6be3c5 | ||
|
|
2b71dbca9a | ||
|
|
b7a29897dd | ||
|
|
68a69185fe | ||
|
|
253571b5ac | ||
|
|
3cd6bad9d3 | ||
|
|
5ca295ccff | ||
|
|
8fd217e6f0 | ||
|
|
85be8f1114 | ||
|
|
e254a848e0 | ||
|
|
947ec803b3 | ||
|
|
0d1bdb8904 | ||
|
|
a2cb2d283c | ||
|
|
11afb29384 | ||
|
|
cbfb66b5c8 | ||
|
|
7b26a01063 | ||
|
|
96b55bf9d5 | ||
|
|
50a3442ca6 | ||
|
|
c407f27ecf | ||
|
|
148f9e801b | ||
|
|
5fe0afaeb7 | ||
|
|
c73a350095 | ||
|
|
2be190aa65 | ||
|
|
0aa5ef5b31 | ||
|
|
40bae8d73d | ||
|
|
8f34626a27 | ||
|
|
8978e6510a | ||
|
|
04ea6254b7 | ||
|
|
f18d812eb8 | ||
|
|
d69c579839 | ||
|
|
9fe11c3d22 | ||
|
|
f8846836e7 | ||
|
|
3ab218d744 | ||
|
|
c6329571ae | ||
|
|
4ae59848b4 | ||
|
|
17aa693fdf | ||
|
|
164f3d2a22 | ||
|
|
512f661823 | ||
|
|
7fdb93f183 | ||
|
|
edcede5de6 | ||
|
|
52083e6fb7 | ||
|
|
a4824497ef | ||
|
|
c5f549967b | ||
|
|
87d4f16456 | ||
|
|
f0bac927fe | ||
|
|
4b19294899 | ||
|
|
9f351b5907 | ||
|
|
677f85b993 | ||
|
|
158115d319 | ||
|
|
7abcfcc579 | ||
|
|
862a854e64 | ||
|
|
e06e1be433 |
@@ -5,6 +5,13 @@
|
||||
# Docker
|
||||
.docker
|
||||
|
||||
# Backend development
|
||||
backend/static
|
||||
backend/staticfiles
|
||||
|
||||
# Frontend development
|
||||
frontend/node_modules
|
||||
|
||||
# Python
|
||||
tubearchivist/__pycache__/
|
||||
tubearchivist/*/__pycache__/
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,6 +4,7 @@ __pycache__
|
||||
|
||||
# django testing
|
||||
backend/static
|
||||
backend/staticfiles
|
||||
backend/.env
|
||||
|
||||
# vscode custom conf
|
||||
|
||||
@@ -52,7 +52,7 @@ All environment variables are explained in detail in the docs [here](https://doc
|
||||
**TubeArchivist**:
|
||||
| Environment Var | Value | |
|
||||
| ----------- | ----------- | ----------- |
|
||||
| TA_HOST | Server IP or hostname | Required |
|
||||
| TA_HOST | Server IP or hostname `http://tubearchivist.local:8000` | Required |
|
||||
| TA_USERNAME | Initial username when logging into TA | Required |
|
||||
| TA_PASSWORD | Initial password when logging into TA | Required |
|
||||
| ELASTIC_PASSWORD | Password for ElasticSearch | Required |
|
||||
@@ -203,6 +203,7 @@ This is your time to shine, [read this](https://github.com/tubearchivist/tubearc
|
||||
- [lamusmaser/ta_fix_for_video_redirection](https://github.com/lamusmaser/ta_fix_for_video_redirection): Script to fix videos that were incorrectly indexed by YouTube's "Video is Unavailable" response.
|
||||
- [RoninTech/ta-helper](https://github.com/RoninTech/ta-helper): Helper script to provide a symlink association to reference TubeArchivist videos with their original titles.
|
||||
- [tangyjoust/Tautulli-Notify-TubeArchivist-of-Plex-Watched-State](https://github.com/tangyjoust/Tautulli-Notify-TubeArchivist-of-Plex-Watched-State) Mark videos watched in Plex (through streaming not manually) through Tautulli back to TubeArchivist
|
||||
- [Dhs92/delete_shorts](https://github.com/Dhs92/delete_shorts): A script to delete ALL YouTube Shorts from TubeArchivist
|
||||
|
||||
## Donate
|
||||
The best donation to **Tube Archivist** is your time, take a look at the [contribution page](CONTRIBUTING.md) to get started.
|
||||
|
||||
@@ -20,7 +20,7 @@ from common.serializers import (
|
||||
ErrorResponseSerializer,
|
||||
)
|
||||
from common.src.ta_redis import RedisArchivist
|
||||
from common.views_base import AdminOnly, ApiBaseView
|
||||
from common.views_base import AdminOnly, AdminWriteOnly, ApiBaseView
|
||||
from django.conf import settings
|
||||
from download.src.yt_dlp_base import CookieHandler, POTokenHandler
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||
@@ -152,7 +152,7 @@ class AppConfigApiView(ApiBaseView):
|
||||
POST: update app settings
|
||||
"""
|
||||
|
||||
permission_classes = [AdminOnly]
|
||||
permission_classes = [AdminWriteOnly]
|
||||
|
||||
@staticmethod
|
||||
@extend_schema(
|
||||
|
||||
@@ -10,6 +10,7 @@ from datetime import datetime
|
||||
|
||||
from common.src.env_settings import EnvironmentSettings
|
||||
from common.src.es_connect import ElasticWrap, IndexPaginate
|
||||
from common.src.helper import rand_sleep
|
||||
from common.src.index_generic import YouTubeItem
|
||||
from download.src.thumbnails import ThumbManager
|
||||
from download.src.yt_dlp_base import YtWrap
|
||||
@@ -55,12 +56,13 @@ class YoutubeChannel(YouTubeItem):
|
||||
def process_youtube_meta(self):
|
||||
"""extract relevant fields"""
|
||||
self.youtube_meta["thumbnails"].reverse()
|
||||
channel_name = self.youtube_meta["uploader"] or self.youtube_meta["id"]
|
||||
self.json_data = {
|
||||
"channel_active": True,
|
||||
"channel_description": self.youtube_meta.get("description", ""),
|
||||
"channel_id": self.youtube_id,
|
||||
"channel_last_refresh": int(datetime.now().timestamp()),
|
||||
"channel_name": self.youtube_meta["uploader"],
|
||||
"channel_name": channel_name,
|
||||
"channel_subs": self.youtube_meta.get("channel_follower_count", 0),
|
||||
"channel_subscribed": False,
|
||||
"channel_tags": self.youtube_meta.get("tags", []),
|
||||
@@ -156,8 +158,18 @@ class YoutubeChannel(YouTubeItem):
|
||||
# add ingest pipeline
|
||||
processors = []
|
||||
for field, value in self.json_data.items():
|
||||
line = {"set": {"field": "channel." + field, "value": value}}
|
||||
if value is None:
|
||||
line = {
|
||||
"script": {
|
||||
"lang": "painless",
|
||||
"source": f"ctx['{field}'] = null;",
|
||||
}
|
||||
}
|
||||
else:
|
||||
line = {"set": {"field": "channel." + field, "value": value}}
|
||||
|
||||
processors.append(line)
|
||||
|
||||
data = {"description": self.youtube_id, "processors": processors}
|
||||
ingest_path = f"_ingest/pipeline/{self.youtube_id}"
|
||||
_, _ = ElasticWrap(ingest_path).put(data)
|
||||
@@ -251,6 +263,7 @@ class YoutubeChannel(YouTubeItem):
|
||||
|
||||
self._index_single_playlist(playlist)
|
||||
print("add playlist: " + playlist[1])
|
||||
rand_sleep(self.config)
|
||||
|
||||
def _notify_single_playlist(self, idx, total):
|
||||
"""send notification"""
|
||||
@@ -287,6 +300,7 @@ class YoutubeChannel(YouTubeItem):
|
||||
obs = {"skip_download": True, "extract_flat": True}
|
||||
playlists = YtWrap(obs, self.config).extract(url)
|
||||
if not playlists:
|
||||
self.all_playlists = []
|
||||
return
|
||||
|
||||
all_entries = [(i["id"], i["title"]) for i in playlists["entries"]]
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
from django.http import HttpResponse
|
||||
|
||||
|
||||
class HealthCheckMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
if request.path == "/health":
|
||||
return HttpResponse("ok")
|
||||
return self.get_response(request)
|
||||
@@ -231,7 +231,13 @@ def ta_host_parser(ta_host: str) -> tuple[list[str], list[str]]:
|
||||
def get_stylesheets() -> list:
|
||||
"""Get all valid stylesheets from /static/css"""
|
||||
|
||||
stylesheets = ["dark.css", "light.css", "matrix.css", "midnight.css"]
|
||||
stylesheets = [
|
||||
"dark.css",
|
||||
"light.css",
|
||||
"matrix.css",
|
||||
"midnight.css",
|
||||
"custom.css",
|
||||
]
|
||||
return stylesheets
|
||||
|
||||
|
||||
|
||||
@@ -43,14 +43,9 @@ class WatchState:
|
||||
def change_vid_state(self):
|
||||
"""change watched state of video"""
|
||||
path = f"ta_video/_update/{self.youtube_id}"
|
||||
data = {
|
||||
"doc": {
|
||||
"player": {
|
||||
"watched": self.is_watched,
|
||||
"watched_date": self.stamp,
|
||||
}
|
||||
}
|
||||
}
|
||||
data = {"doc": {"player": {"watched": self.is_watched}}}
|
||||
if self.is_watched:
|
||||
data["doc"]["player"]["watched_date"] = self.stamp
|
||||
response, status_code = ElasticWrap(path).post(data=data)
|
||||
key = f"{self.user_id}:progress:{self.youtube_id}"
|
||||
RedisArchivist().del_message(key)
|
||||
|
||||
@@ -25,4 +25,9 @@ urlpatterns = [
|
||||
views.NotificationView.as_view(),
|
||||
name="api-notification",
|
||||
),
|
||||
path(
|
||||
"health/",
|
||||
views.HealthCheck.as_view(),
|
||||
name="api-health",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -20,6 +20,7 @@ from common.src.watched import WatchState
|
||||
from common.views_base import AdminOnly, ApiBaseView
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from task.tasks import check_reindex
|
||||
|
||||
|
||||
@@ -199,3 +200,11 @@ class NotificationView(ApiBaseView):
|
||||
response_serializer = NotificationSerializer(notifications, many=True)
|
||||
|
||||
return Response(response_serializer.data)
|
||||
|
||||
|
||||
class HealthCheck(APIView):
|
||||
"""health check view, no auth needed"""
|
||||
|
||||
def get(self, request):
|
||||
"""health check, no auth needed"""
|
||||
return Response("OK", status=200)
|
||||
|
||||
@@ -58,7 +58,12 @@ class Command(BaseCommand):
|
||||
|
||||
message = " 🗙 Redis connection failed"
|
||||
self.stdout.write(self.style.ERROR(f"{message}"))
|
||||
RedisArchivist().exec("PING")
|
||||
try:
|
||||
redis_conn.execute_command("PING")
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
message = f" 🗙 {type(err).__name__}: {err}"
|
||||
self.stdout.write(self.style.ERROR(f"{message}"))
|
||||
|
||||
sleep(60)
|
||||
raise CommandError(message)
|
||||
|
||||
@@ -89,6 +94,10 @@ class Command(BaseCommand):
|
||||
sleep(5)
|
||||
continue
|
||||
|
||||
if status_code and status_code == 401:
|
||||
sleep(5)
|
||||
continue
|
||||
|
||||
if status_code and status_code == 200:
|
||||
path = "_cluster/health?wait_for_status=yellow&timeout=60s"
|
||||
_, _ = ElasticWrap(path).get(timeout=60)
|
||||
|
||||
@@ -86,6 +86,7 @@ class Command(BaseCommand):
|
||||
self._elastic_user_overwrite()
|
||||
self._ta_port_overwrite()
|
||||
self._ta_backend_port_overwrite()
|
||||
self._disable_static_auth()
|
||||
self._create_superuser()
|
||||
|
||||
def _expected_vars(self):
|
||||
|
||||
@@ -82,7 +82,6 @@ MIDDLEWARE = [
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"common.src.health.HealthCheckMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "config.urls"
|
||||
@@ -295,7 +294,7 @@ CORS_ALLOW_HEADERS = list(default_headers) + [
|
||||
|
||||
# TA application settings
|
||||
TA_UPSTREAM = "https://github.com/tubearchivist/tubearchivist"
|
||||
TA_VERSION = "v0.5.0"
|
||||
TA_VERSION = "v0.5.2"
|
||||
|
||||
# API
|
||||
REST_FRAMEWORK = {
|
||||
|
||||
@@ -56,7 +56,7 @@ class AddDownloadItemSerializer(serializers.Serializer):
|
||||
"""serialize single item to add"""
|
||||
|
||||
youtube_id = serializers.CharField()
|
||||
status = serializers.ChoiceField(choices=["pending"])
|
||||
status = serializers.ChoiceField(choices=["pending", "ignore-force"])
|
||||
|
||||
|
||||
class AddToDownloadListSerializer(serializers.Serializer):
|
||||
|
||||
@@ -311,8 +311,19 @@ class DownloadPostProcess(DownloaderBase):
|
||||
|
||||
print(f"auto delete older than {autodelete_days} days")
|
||||
now_lte = str(self.now - autodelete_days * 24 * 60 * 60)
|
||||
channel_overwrite = "channel.channel_overwrites.autodelete_days"
|
||||
data = {
|
||||
"query": {"range": {"player.watched_date": {"lte": now_lte}}},
|
||||
"query": {
|
||||
"bool": {
|
||||
"must": [
|
||||
{"range": {"player.watched_date": {"lte": now_lte}}},
|
||||
{"term": {"player.watched": True}},
|
||||
],
|
||||
"must_not": [
|
||||
{"exists": {"field": channel_overwrite}},
|
||||
],
|
||||
}
|
||||
},
|
||||
"sort": [{"player.watched_date": {"order": "asc"}}],
|
||||
}
|
||||
self._auto_delete_watched(data)
|
||||
@@ -327,6 +338,7 @@ class DownloadPostProcess(DownloaderBase):
|
||||
must_list = [
|
||||
{"range": {"player.watched_date": {"lte": now_lte}}},
|
||||
{"term": {"channel.channel_id": {"value": channel_id}}},
|
||||
{"term": {"player.watched": True}},
|
||||
]
|
||||
data = {
|
||||
"query": {"bool": {"must": must_list}},
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
-r requirements.txt
|
||||
ipython==9.0.1
|
||||
pre-commit==4.1.0
|
||||
ipython==9.2.0
|
||||
pre-commit==4.2.0
|
||||
pylint-django==2.6.1
|
||||
pylint==3.3.4
|
||||
pytest-django==4.10.0
|
||||
pylint==3.3.7
|
||||
pytest-django==4.11.1
|
||||
pytest==8.3.5
|
||||
python-dotenv==1.0.1
|
||||
python-dotenv==1.1.0
|
||||
requirementscheck==0.0.6
|
||||
types-requests==2.32.0.20250306
|
||||
types-requests==2.32.0.20250328
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
apprise==1.9.2
|
||||
celery==5.4.0
|
||||
django-auth-ldap==5.1.0
|
||||
django-celery-beat==2.7.0
|
||||
apprise==1.9.3
|
||||
celery==5.5.2
|
||||
django-auth-ldap==5.2.0
|
||||
django-celery-beat==2.8.0
|
||||
django-cors-headers==4.7.0
|
||||
Django==5.1.7
|
||||
djangorestframework==3.15.2
|
||||
Django==5.2.1
|
||||
djangorestframework==3.16.0
|
||||
drf-spectacular==0.28.0
|
||||
Pillow==11.1.0
|
||||
redis==5.2.1
|
||||
Pillow==11.2.1
|
||||
redis==6.0.0
|
||||
requests==2.32.3
|
||||
ryd-client==0.0.6
|
||||
uvicorn==0.34.0
|
||||
uvicorn==0.34.2
|
||||
whitenoise==6.9.0
|
||||
yt-dlp[default]==2025.2.19
|
||||
yt-dlp[default]==2025.4.30
|
||||
|
||||
@@ -96,7 +96,7 @@ def update_subscribed(self):
|
||||
manager = TaskManager()
|
||||
if manager.is_pending(self):
|
||||
print(f"[task][{self.name}] rescan already running")
|
||||
self.send_progress("Rescan already in progress.")
|
||||
self.send_progress(["Rescan already in progress."])
|
||||
return None
|
||||
|
||||
manager.init(self)
|
||||
@@ -124,7 +124,7 @@ def download_pending(self, auto_only=False):
|
||||
manager = TaskManager()
|
||||
if manager.is_pending(self):
|
||||
print(f"[task][{self.name}] download queue already running")
|
||||
self.send_progress("Download Queue is already running.")
|
||||
self.send_progress(["Download Queue is already running."])
|
||||
return None
|
||||
|
||||
manager.init(self)
|
||||
@@ -134,7 +134,7 @@ def download_pending(self, auto_only=False):
|
||||
|
||||
if failed:
|
||||
print(f"[task][{self.name}] Videos failed, retry.")
|
||||
self.send_progress("Videos failed, retry.")
|
||||
self.send_progress(["Videos failed, retry."])
|
||||
raise self.retry()
|
||||
|
||||
except Retry as exc:
|
||||
@@ -176,13 +176,13 @@ def check_reindex(self, data=False, extract_videos=False):
|
||||
if data:
|
||||
# started from frontend through API
|
||||
print(f"[task][{self.name}] reindex {data}")
|
||||
self.send_progress("Add items to the reindex Queue.")
|
||||
self.send_progress(["Add items to the reindex Queue."])
|
||||
ReindexManual(extract_videos=extract_videos).extract_data(data)
|
||||
|
||||
manager = TaskManager()
|
||||
if manager.is_pending(self):
|
||||
print(f"[task][{self.name}] reindex queue is already running")
|
||||
self.send_progress("Reindex Queue is already running.")
|
||||
self.send_progress(["Reindex Queue is already running."])
|
||||
return
|
||||
|
||||
manager.init(self)
|
||||
@@ -190,10 +190,10 @@ def check_reindex(self, data=False, extract_videos=False):
|
||||
# started from scheduler
|
||||
populate = ReindexPopulate()
|
||||
print(f"[task][{self.name}] reindex outdated documents")
|
||||
self.send_progress("Add recent documents to the reindex Queue.")
|
||||
self.send_progress(["Add recent documents to the reindex Queue."])
|
||||
populate.get_interval()
|
||||
populate.add_recent()
|
||||
self.send_progress("Add outdated documents to the reindex Queue.")
|
||||
self.send_progress(["Add outdated documents to the reindex Queue."])
|
||||
populate.add_outdated()
|
||||
|
||||
handler = Reindex(task=self)
|
||||
@@ -208,7 +208,7 @@ def run_manual_import(self):
|
||||
manager = TaskManager()
|
||||
if manager.is_pending(self):
|
||||
print(f"[task][{self.name}] manual import is already running")
|
||||
self.send_progress("Manual import is already running.")
|
||||
self.send_progress(["Manual import is already running."])
|
||||
return
|
||||
|
||||
manager.init(self)
|
||||
@@ -221,7 +221,7 @@ def run_backup(self, reason="auto"):
|
||||
manager = TaskManager()
|
||||
if manager.is_pending(self):
|
||||
print(f"[task][{self.name}] backup is already running")
|
||||
self.send_progress("Backup is already running.")
|
||||
self.send_progress(["Backup is already running."])
|
||||
return
|
||||
|
||||
manager.init(self)
|
||||
@@ -234,7 +234,7 @@ def run_restore_backup(self, filename):
|
||||
manager = TaskManager()
|
||||
if manager.is_pending(self):
|
||||
print(f"[task][{self.name}] restore is already running")
|
||||
self.send_progress("Restore is already running.")
|
||||
self.send_progress(["Restore is already running."])
|
||||
return None
|
||||
|
||||
manager.init(self)
|
||||
@@ -252,7 +252,7 @@ def rescan_filesystem(self):
|
||||
manager = TaskManager()
|
||||
if manager.is_pending(self):
|
||||
print(f"[task][{self.name}] filesystem rescan already running")
|
||||
self.send_progress("Filesystem Rescan is already running.")
|
||||
self.send_progress(["Filesystem Rescan is already running."])
|
||||
return
|
||||
|
||||
manager.init(self)
|
||||
@@ -268,7 +268,7 @@ def thumbnail_check(self):
|
||||
manager = TaskManager()
|
||||
if manager.is_pending(self):
|
||||
print(f"[task][{self.name}] thumbnail check is already running")
|
||||
self.send_progress("Thumbnail check is already running.")
|
||||
self.send_progress(["Thumbnail check is already running."])
|
||||
return
|
||||
|
||||
manager.init(self)
|
||||
@@ -283,7 +283,7 @@ def re_sync_thumbs(self):
|
||||
manager = TaskManager()
|
||||
if manager.is_pending(self):
|
||||
print(f"[task][{self.name}] thumb re-embed is already running")
|
||||
self.send_progress("Thumbnail re-embed is already running.")
|
||||
self.send_progress(["Thumbnail re-embed is already running."])
|
||||
return
|
||||
|
||||
manager.init(self)
|
||||
|
||||
@@ -37,6 +37,7 @@ class UserMeConfigSerializer(serializers.Serializer):
|
||||
view_style_playlist = serializers.ChoiceField(choices=["grid", "list"])
|
||||
grid_items = serializers.IntegerField(max_value=7, min_value=3)
|
||||
hide_watched = serializers.BooleanField()
|
||||
file_size_unit = serializers.ChoiceField(choices=["binary", "metric"])
|
||||
show_ignored_only = serializers.BooleanField()
|
||||
show_subed_only = serializers.BooleanField()
|
||||
show_help_text = serializers.BooleanField()
|
||||
|
||||
@@ -22,6 +22,7 @@ class UserConfigType(TypedDict, total=False):
|
||||
view_style_playlist: str
|
||||
grid_items: int
|
||||
hide_watched: bool
|
||||
file_size_unit: str
|
||||
show_ignored_only: bool
|
||||
show_subed_only: bool
|
||||
show_help_text: bool
|
||||
@@ -44,6 +45,7 @@ class UserConfig:
|
||||
view_style_playlist="grid",
|
||||
grid_items=3,
|
||||
hide_watched=False,
|
||||
file_size_unit="binary",
|
||||
show_ignored_only=False,
|
||||
show_subed_only=False,
|
||||
show_help_text=True,
|
||||
|
||||
@@ -15,13 +15,13 @@ services:
|
||||
- REDIS_CON=redis://archivist-redis:6379
|
||||
- HOST_UID=1000
|
||||
- HOST_GID=1000
|
||||
- TA_HOST=http://tubearchivist.local # set your host name with protocol and port
|
||||
- TA_HOST=http://tubearchivist.local:8000 # set your host name with protocol and port
|
||||
- TA_USERNAME=tubearchivist # your initial TA credentials
|
||||
- TA_PASSWORD=verysecret # your initial TA credentials
|
||||
- ELASTIC_PASSWORD=verysecret # set password for Elasticsearch
|
||||
- TZ=America/New_York # set your time zone
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"]
|
||||
interval: 2m
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -40,7 +40,7 @@ services:
|
||||
depends_on:
|
||||
- archivist-es
|
||||
archivist-es:
|
||||
image: bbilly1/tubearchivist-es # only for amd64, or use official es 8.17.2
|
||||
image: bbilly1/tubearchivist-es # only for amd64, or use official es 8.18.0
|
||||
container_name: archivist-es
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
|
||||
2186
frontend/package-lock.json
generated
2186
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tubearchivist-frontend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.5.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -11,27 +11,27 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"dompurify": "^3.2.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.5",
|
||||
"zustand": "^5.0.3"
|
||||
"dompurify": "^3.2.5",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^7.6.0",
|
||||
"zustand": "^5.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.24.0",
|
||||
"@typescript-eslint/parser": "^8.24.0",
|
||||
"@vitejs/plugin-react-swc": "^3.8.0",
|
||||
"eslint": "^9.20.1",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^15.15.0",
|
||||
"prettier": "3.5.1",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.24.0",
|
||||
"vite": "^6.1.0",
|
||||
"vite-plugin-checker": "^0.8.0"
|
||||
"@types/react": "^19.1.3",
|
||||
"@types/react-dom": "^19.1.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.32.0",
|
||||
"@typescript-eslint/parser": "^8.32.0",
|
||||
"@vitejs/plugin-react-swc": "^3.9.0",
|
||||
"eslint": "^9.26.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.1.0",
|
||||
"prettier": "3.5.3",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.32.0",
|
||||
"vite": ">=6.3.5",
|
||||
"vite-plugin-checker": "^0.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
16
frontend/public/css/dark.css
Normal file
16
frontend/public/css/dark.css
Normal file
@@ -0,0 +1,16 @@
|
||||
:root {
|
||||
--main-bg: #00202f;
|
||||
--highlight-bg: #00293b;
|
||||
--highlight-error: #990202;
|
||||
--highlight-error-light: #c44343;
|
||||
--highlight-bg-transparent: #00293baf;
|
||||
--main-font: #eeeeee;
|
||||
--accent-font-dark: #259485;
|
||||
--accent-font-light: #97d4c8;
|
||||
--img-filter: invert(50%) sepia(9%) saturate(2940%) hue-rotate(122deg) brightness(94%)
|
||||
contrast(90%);
|
||||
--img-filter-error: invert(16%) sepia(60%) saturate(3717%) hue-rotate(349deg) brightness(86%)
|
||||
contrast(120%);
|
||||
--banner: url('/img/banner-tube-archivist-dark.png');
|
||||
--logo: url('/img/logo-tube-archivist-dark.png');
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import APIClient from '../../functions/APIClient';
|
||||
const createCustomPlaylist = async (playlistId: string) => {
|
||||
return APIClient('/api/playlist/custom/', {
|
||||
method: 'POST',
|
||||
body: { playlist_name: playlistId },
|
||||
body: { playlist_name: playlistId.trim() },
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import APIClient from '../../functions/APIClient';
|
||||
import { CookieStateType } from '../loader/loadCookie';
|
||||
|
||||
const deleteCookie = async (): Promise<CookieStateType> => {
|
||||
return APIClient('/api/appsettings/cookie/', {
|
||||
const deleteCookie = async () => {
|
||||
return APIClient<CookieStateType>('/api/appsettings/cookie/', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
};
|
||||
|
||||
@@ -8,7 +8,9 @@ const updateBulkChannelSubscriptions = async (channelIds: string, status: boolea
|
||||
const youtubeChannelIds = channelIds.split('\n');
|
||||
|
||||
youtubeChannelIds.forEach(channelId => {
|
||||
channels.push({ channel_id: channelId, channel_subscribed: status });
|
||||
if (channelId.trim()) {
|
||||
channels.push({ channel_id: channelId, channel_subscribed: status });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
channels.push({ channel_id: channelIds, channel_subscribed: status });
|
||||
|
||||
@@ -8,7 +8,9 @@ const updateBulkPlaylistSubscriptions = async (playlistIds: string, status: bool
|
||||
const youtubePlaylistIds = playlistIds.split('\n');
|
||||
|
||||
youtubePlaylistIds.forEach(playlistId => {
|
||||
playlists.push({ playlist_id: playlistId, playlist_subscribed: status });
|
||||
if (playlistId.trim()) {
|
||||
playlists.push({ playlist_id: playlistId, playlist_subscribed: status });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
playlists.push({ playlist_id: playlistIds, playlist_subscribed: status });
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import APIClient from '../../functions/APIClient';
|
||||
import { CookieStateType } from '../loader/loadCookie';
|
||||
|
||||
const updateCookie = async (cookie: string): Promise<CookieStateType> => {
|
||||
return APIClient('/api/appsettings/cookie/', {
|
||||
const updateCookie = async (cookie: string) => {
|
||||
return APIClient<CookieStateType>('/api/appsettings/cookie/', {
|
||||
method: 'PUT',
|
||||
body: { cookie },
|
||||
});
|
||||
|
||||
@@ -8,7 +8,9 @@ const updateDownloadQueue = async (youtubeIdStrings: string, autostart: boolean)
|
||||
const youtubeIds = youtubeIdStrings.split('\n');
|
||||
|
||||
youtubeIds.forEach(youtubeId => {
|
||||
urls.push({ youtube_id: youtubeId, status: 'pending' });
|
||||
if (youtubeId.trim()) {
|
||||
urls.push({ youtube_id: youtubeId, status: 'pending' });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
urls.push({ youtube_id: youtubeIdStrings, status: 'pending' });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import APIClient from '../../functions/APIClient';
|
||||
|
||||
export type DownloadQueueStatus = 'ignore' | 'pending' | 'priority';
|
||||
export type DownloadQueueStatus = 'ignore' | 'ignore-force' | 'pending' | 'priority';
|
||||
|
||||
const updateDownloadQueueStatusById = async (youtubeId: string, status: DownloadQueueStatus) => {
|
||||
return APIClient(`/api/download/${youtubeId}/`, {
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { SortByType, SortOrderType, ViewLayoutType } from '../../pages/Home';
|
||||
import APIClient from '../../functions/APIClient';
|
||||
|
||||
export type ColourVariants = 'dark.css' | 'light.css' | 'matrix.css' | 'midnight.css';
|
||||
export type ColourVariants =
|
||||
| 'dark.css'
|
||||
| 'light.css'
|
||||
| 'matrix.css'
|
||||
| 'midnight.css'
|
||||
| 'custom.css';
|
||||
|
||||
export const FileSizeUnits = {
|
||||
Binary: 'binary',
|
||||
Metric: 'metric',
|
||||
};
|
||||
|
||||
export type UserConfigType = {
|
||||
stylesheet: ColourVariants;
|
||||
@@ -14,13 +24,14 @@ export type UserConfigType = {
|
||||
view_style_playlist: ViewLayoutType;
|
||||
grid_items: number;
|
||||
hide_watched: boolean;
|
||||
file_size_unit: 'binary' | 'metric';
|
||||
show_ignored_only: boolean;
|
||||
show_subed_only: boolean;
|
||||
show_help_text: boolean;
|
||||
};
|
||||
|
||||
const updateUserConfig = async (config: Partial<UserConfigType>): Promise<UserConfigType> => {
|
||||
return APIClient('/api/user/me/', {
|
||||
const updateUserConfig = async (config: Partial<UserConfigType>) => {
|
||||
return APIClient<UserConfigType>('/api/user/me/', {
|
||||
method: 'POST',
|
||||
body: config,
|
||||
});
|
||||
|
||||
@@ -14,11 +14,8 @@ type VideoProgressProp = {
|
||||
currentProgress: number;
|
||||
};
|
||||
|
||||
const updateVideoProgressById = async ({
|
||||
youtubeId,
|
||||
currentProgress,
|
||||
}: VideoProgressProp): Promise<VideoProgressResponseType> => {
|
||||
return APIClient(`/api/video/${youtubeId}/progress/`, {
|
||||
const updateVideoProgressById = async ({ youtubeId, currentProgress }: VideoProgressProp) => {
|
||||
return APIClient<VideoProgressResponseType>(`/api/video/${youtubeId}/progress/`, {
|
||||
method: 'POST',
|
||||
body: { position: currentProgress },
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import APIClient from '../../functions/APIClient';
|
||||
import { CookieStateType } from '../loader/loadCookie';
|
||||
|
||||
const validateCookie = async (): Promise<CookieStateType> => {
|
||||
return APIClient('/api/appsettings/cookie/', {
|
||||
const validateCookie = async () => {
|
||||
return APIClient<CookieStateType>('/api/appsettings/cookie/', {
|
||||
method: 'POST',
|
||||
});
|
||||
};
|
||||
|
||||
@@ -4,8 +4,8 @@ type ApiTokenResponse = {
|
||||
token: string;
|
||||
};
|
||||
|
||||
const loadApiToken = async (): Promise<ApiTokenResponse> => {
|
||||
return APIClient('/api/appsettings/token/');
|
||||
const loadApiToken = async () => {
|
||||
return APIClient<ApiTokenResponse>('/api/appsettings/token/');
|
||||
};
|
||||
|
||||
export default loadApiToken;
|
||||
|
||||
@@ -19,8 +19,8 @@ export type AppriseNotificationType = {
|
||||
};
|
||||
};
|
||||
|
||||
const loadAppriseNotification = async (): Promise<AppriseNotificationType> => {
|
||||
return APIClient('/api/task/notification/');
|
||||
const loadAppriseNotification = async () => {
|
||||
return APIClient<AppriseNotificationType>('/api/task/notification/');
|
||||
};
|
||||
|
||||
export default loadAppriseNotification;
|
||||
|
||||
@@ -33,8 +33,8 @@ export type AppSettingsConfigType = {
|
||||
};
|
||||
};
|
||||
|
||||
const loadAppsettingsConfig = async (): Promise<AppSettingsConfigType> => {
|
||||
return APIClient('/api/appsettings/config/');
|
||||
const loadAppsettingsConfig = async () => {
|
||||
return APIClient<AppSettingsConfigType>('/api/appsettings/config/');
|
||||
};
|
||||
|
||||
export default loadAppsettingsConfig;
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import APIClient from '../../functions/APIClient';
|
||||
|
||||
type Backup = {
|
||||
filename: string;
|
||||
file_path: string;
|
||||
file_size: number;
|
||||
timestamp: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type BackupListType = Backup[];
|
||||
|
||||
const loadBackupList = async () => {
|
||||
return APIClient('/api/appsettings/backup/');
|
||||
return APIClient<BackupListType>('/api/appsettings/backup/');
|
||||
};
|
||||
|
||||
export default loadBackupList;
|
||||
|
||||
@@ -13,8 +13,8 @@ export type ChannelAggsType = {
|
||||
};
|
||||
};
|
||||
|
||||
const loadChannelAggs = async (channelId: string): Promise<ChannelAggsType> => {
|
||||
return APIClient(`/api/channel/${channelId}/aggs/`);
|
||||
const loadChannelAggs = async (channelId: string) => {
|
||||
return APIClient<ChannelAggsType>(`/api/channel/${channelId}/aggs/`);
|
||||
};
|
||||
|
||||
export default loadChannelAggs;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import APIClient from '../../functions/APIClient';
|
||||
import { ChannelResponseType } from '../../pages/ChannelBase';
|
||||
import { ChannelType } from '../../pages/Channels';
|
||||
|
||||
const loadChannelById = async (youtubeChannelId: string): Promise<ChannelResponseType> => {
|
||||
return APIClient(`/api/channel/${youtubeChannelId}/`);
|
||||
export type ChannelResponseType = ChannelType;
|
||||
|
||||
const loadChannelById = async (youtubeChannelId: string) => {
|
||||
return APIClient<ChannelResponseType>(`/api/channel/${youtubeChannelId}/`);
|
||||
};
|
||||
|
||||
export default loadChannelById;
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { PaginationType } from '../../components/Pagination';
|
||||
import APIClient from '../../functions/APIClient';
|
||||
import { ChannelType } from '../../pages/Channels';
|
||||
import { ConfigType } from '../../pages/Home';
|
||||
|
||||
export type ChannelsListResponse = {
|
||||
data: ChannelType[];
|
||||
paginate: PaginationType;
|
||||
config?: ConfigType;
|
||||
};
|
||||
|
||||
const loadChannelList = async (page: number, showSubscribed: boolean) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
@@ -8,7 +17,7 @@ const loadChannelList = async (page: number, showSubscribed: boolean) => {
|
||||
|
||||
const endpoint = `/api/channel/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;
|
||||
|
||||
return APIClient(endpoint);
|
||||
return APIClient<ChannelsListResponse>(endpoint);
|
||||
};
|
||||
|
||||
export default loadChannelList;
|
||||
|
||||
@@ -7,8 +7,8 @@ export type ChannelNavResponseType = {
|
||||
has_pending: boolean;
|
||||
};
|
||||
|
||||
const loadChannelNav = async (youtubeChannelId: string): Promise<ChannelNavResponseType> => {
|
||||
return APIClient(`/api/channel/${youtubeChannelId}/nav/`);
|
||||
const loadChannelNav = async (youtubeChannelId: string) => {
|
||||
return APIClient<ChannelNavResponseType>(`/api/channel/${youtubeChannelId}/nav/`);
|
||||
};
|
||||
|
||||
export default loadChannelNav;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { CommentsType } from '../../components/CommentBox';
|
||||
import APIClient from '../../functions/APIClient';
|
||||
|
||||
export type CommentsResponseType = CommentsType[];
|
||||
|
||||
const loadCommentsbyVideoId = async (youtubeId: string) => {
|
||||
return APIClient(`/api/video/${youtubeId}/comment/`);
|
||||
return APIClient<CommentsResponseType>(`/api/video/${youtubeId}/comment/`);
|
||||
};
|
||||
|
||||
export default loadCommentsbyVideoId;
|
||||
|
||||
@@ -7,8 +7,8 @@ export type CookieStateType = {
|
||||
validated_str?: string;
|
||||
};
|
||||
|
||||
const loadCookie = async (): Promise<CookieStateType> => {
|
||||
return APIClient('/api/appsettings/cookie/');
|
||||
const loadCookie = async () => {
|
||||
return APIClient<CookieStateType>('/api/appsettings/cookie/');
|
||||
};
|
||||
|
||||
export default loadCookie;
|
||||
|
||||
@@ -12,10 +12,10 @@ export type DownloadAggsType = {
|
||||
buckets: DownloadAggsBucket[];
|
||||
};
|
||||
|
||||
const loadDownloadAggs = async (showIgnored: boolean): Promise<DownloadAggsType> => {
|
||||
const loadDownloadAggs = async (showIgnored: boolean) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.append('filter', showIgnored ? 'ignore' : 'pending');
|
||||
return APIClient(
|
||||
return APIClient<DownloadAggsType>(
|
||||
`/api/download/aggs/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import APIClient from '../../functions/APIClient';
|
||||
import { DownloadResponseType } from '../../pages/Download';
|
||||
|
||||
const loadDownloadQueue = async (
|
||||
page: number,
|
||||
channelId: string | null,
|
||||
showIgnored: boolean,
|
||||
): Promise<DownloadResponseType> => {
|
||||
const loadDownloadQueue = async (page: number, channelId: string | null, showIgnored: boolean) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (page) searchParams.append('page', page.toString());
|
||||
@@ -14,7 +10,7 @@ const loadDownloadQueue = async (
|
||||
|
||||
const endpoint = `/api/download/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;
|
||||
|
||||
return APIClient(endpoint);
|
||||
return APIClient<DownloadResponseType>(endpoint);
|
||||
};
|
||||
|
||||
export default loadDownloadQueue;
|
||||
|
||||
@@ -2,6 +2,19 @@ import APIClient from '../../functions/APIClient';
|
||||
|
||||
export type NotificationPages = 'download' | 'settings' | 'channel' | 'all';
|
||||
|
||||
type NotificationType = {
|
||||
title: string;
|
||||
group: string;
|
||||
api_stop: boolean;
|
||||
level: string;
|
||||
id: string;
|
||||
command: boolean | string;
|
||||
messages: string[];
|
||||
progress: number;
|
||||
};
|
||||
|
||||
export type NotificationResponseType = NotificationType[];
|
||||
|
||||
const loadNotifications = async (pageName: NotificationPages, includeReindex = false) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
@@ -10,7 +23,7 @@ const loadNotifications = async (pageName: NotificationPages, includeReindex = f
|
||||
}
|
||||
|
||||
const endpoint = `/api/notification/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;
|
||||
return APIClient(endpoint);
|
||||
return APIClient<NotificationResponseType>(endpoint);
|
||||
};
|
||||
|
||||
export default loadNotifications;
|
||||
|
||||
@@ -26,8 +26,8 @@ export type PlaylistType = {
|
||||
|
||||
export type PlaylistResponseType = PlaylistType;
|
||||
|
||||
const loadPlaylistById = async (playlistId: string | undefined): Promise<PlaylistResponseType> => {
|
||||
return APIClient(`/api/playlist/${playlistId}/`);
|
||||
const loadPlaylistById = async (playlistId: string | undefined) => {
|
||||
return APIClient<PlaylistResponseType>(`/api/playlist/${playlistId}/`);
|
||||
};
|
||||
|
||||
export default loadPlaylistById;
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { PaginationType } from '../../components/Pagination';
|
||||
import APIClient from '../../functions/APIClient';
|
||||
import { PlaylistType } from './loadPlaylistById';
|
||||
|
||||
type PlaylistType = 'regular' | 'custom';
|
||||
export type PlaylistsResponseType = {
|
||||
data?: PlaylistType[];
|
||||
paginate?: PaginationType;
|
||||
};
|
||||
|
||||
type PlaylistCategoryType = 'regular' | 'custom';
|
||||
|
||||
type LoadPlaylistListProps = {
|
||||
channel?: string;
|
||||
page?: number | undefined;
|
||||
subscribed?: boolean;
|
||||
type?: PlaylistType;
|
||||
type?: PlaylistCategoryType;
|
||||
};
|
||||
|
||||
const loadPlaylistList = async ({ channel, page, subscribed, type }: LoadPlaylistListProps) => {
|
||||
@@ -18,7 +25,7 @@ const loadPlaylistList = async ({ channel, page, subscribed, type }: LoadPlaylis
|
||||
if (type) searchParams.append('type', type);
|
||||
|
||||
const endpoint = `/api/playlist/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;
|
||||
return APIClient(endpoint);
|
||||
return APIClient<PlaylistsResponseType>(endpoint);
|
||||
};
|
||||
|
||||
export default loadPlaylistList;
|
||||
|
||||
@@ -13,8 +13,8 @@ type ScheduleType = {
|
||||
|
||||
export type ScheduleResponseType = ScheduleType[];
|
||||
|
||||
const loadSchedule = async (): Promise<ScheduleResponseType> => {
|
||||
return APIClient('/api/task/schedule/');
|
||||
const loadSchedule = async () => {
|
||||
return APIClient<ScheduleResponseType>('/api/task/schedule/');
|
||||
};
|
||||
|
||||
export default loadSchedule;
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
import APIClient from '../../functions/APIClient';
|
||||
import { ChannelType } from '../../pages/Channels';
|
||||
import { VideoType } from '../../pages/Home';
|
||||
import { PlaylistType } from './loadPlaylistById';
|
||||
|
||||
type SearchResultType = {
|
||||
video_results: VideoType[];
|
||||
channel_results: ChannelType[];
|
||||
playlist_results: PlaylistType[];
|
||||
fulltext_results: [];
|
||||
};
|
||||
|
||||
export type SearchResultsType = {
|
||||
results: SearchResultType;
|
||||
queryType: string;
|
||||
};
|
||||
|
||||
const loadSearch = async (query: string) => {
|
||||
return APIClient(`/api/search/?query=${query}`);
|
||||
return APIClient<SearchResultsType>(`/api/search/?query=${query}`);
|
||||
};
|
||||
|
||||
export default loadSearch;
|
||||
|
||||
8
frontend/src/api/loader/loadSimilarVideosById.ts
Normal file
8
frontend/src/api/loader/loadSimilarVideosById.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import APIClient from '../../functions/APIClient';
|
||||
import { VideoResponseType } from './loadVideoById';
|
||||
|
||||
const loadSimilarVideosById = async (youtubeId: string) => {
|
||||
return APIClient<VideoResponseType[]>(`/api/video/${youtubeId}/similar/`);
|
||||
};
|
||||
|
||||
export default loadSimilarVideosById;
|
||||
@@ -1,7 +0,0 @@
|
||||
import APIClient from '../../functions/APIClient';
|
||||
|
||||
const loadSimmilarVideosById = async (youtubeId: string) => {
|
||||
return APIClient(`/api/video/${youtubeId}/similar/`);
|
||||
};
|
||||
|
||||
export default loadSimmilarVideosById;
|
||||
@@ -1,7 +1,24 @@
|
||||
import APIClient from '../../functions/APIClient';
|
||||
|
||||
export type SnapshotType = {
|
||||
id: string;
|
||||
state: string;
|
||||
es_version: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
end_stamp: number;
|
||||
duration_s: number;
|
||||
};
|
||||
|
||||
export type SnapshotListType = {
|
||||
next_exec: number;
|
||||
next_exec_str: string;
|
||||
expire_after: string;
|
||||
snapshots?: SnapshotType[];
|
||||
};
|
||||
|
||||
const loadSnapshots = async () => {
|
||||
return APIClient('/api/appsettings/snapshot/');
|
||||
return APIClient<SnapshotListType>('/api/appsettings/snapshot/');
|
||||
};
|
||||
|
||||
export default loadSnapshots;
|
||||
|
||||
@@ -2,11 +2,24 @@ import APIClient from '../../functions/APIClient';
|
||||
|
||||
type BiggestChannelsOrderType = 'doc_count' | 'duration' | 'media_size';
|
||||
|
||||
type BiggestChannelsType = {
|
||||
id: string;
|
||||
name: string;
|
||||
doc_count: number;
|
||||
duration: number;
|
||||
duration_str: string;
|
||||
media_size: number;
|
||||
};
|
||||
|
||||
export type BiggestChannelsStatsType = BiggestChannelsType[];
|
||||
|
||||
const loadStatsBiggestChannels = async (order: BiggestChannelsOrderType) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.append('order', order);
|
||||
|
||||
return APIClient(`/api/stats/biggestchannels/?${searchParams.toString()}`);
|
||||
return APIClient<BiggestChannelsStatsType>(
|
||||
`/api/stats/biggestchannels/?${searchParams.toString()}`,
|
||||
);
|
||||
};
|
||||
|
||||
export default loadStatsBiggestChannels;
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import APIClient from '../../functions/APIClient';
|
||||
|
||||
export type ChannelStatsType = {
|
||||
doc_count: number;
|
||||
active_true: number;
|
||||
subscribed_true: number;
|
||||
};
|
||||
|
||||
const loadStatsChannel = async () => {
|
||||
return APIClient('/api/stats/channel/');
|
||||
return APIClient<ChannelStatsType>('/api/stats/channel/');
|
||||
};
|
||||
|
||||
export default loadStatsChannel;
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import APIClient from '../../functions/APIClient';
|
||||
|
||||
export type DownloadStatsType = {
|
||||
pending: number;
|
||||
pending_videos: number;
|
||||
pending_shorts: number;
|
||||
pending_streams: number;
|
||||
};
|
||||
|
||||
const loadStatsDownload = async () => {
|
||||
return APIClient('/api/stats/download/');
|
||||
return APIClient<DownloadStatsType>('/api/stats/download/');
|
||||
};
|
||||
|
||||
export default loadStatsDownload;
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import APIClient from '../../functions/APIClient';
|
||||
|
||||
type DownloadHistoryType = {
|
||||
date: string;
|
||||
count: number;
|
||||
media_size: number;
|
||||
};
|
||||
|
||||
export type DownloadHistoryStatsType = DownloadHistoryType[];
|
||||
|
||||
const loadStatsDownloadHistory = async () => {
|
||||
return APIClient('/api/stats/downloadhist/');
|
||||
return APIClient<DownloadHistoryStatsType>('/api/stats/downloadhist/');
|
||||
};
|
||||
|
||||
export default loadStatsDownloadHistory;
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import APIClient from '../../functions/APIClient';
|
||||
|
||||
export type PlaylistStatsType = {
|
||||
doc_count: number;
|
||||
active_false: number;
|
||||
active_true: number;
|
||||
subscribed_true: number;
|
||||
};
|
||||
|
||||
const loadStatsPlaylist = async () => {
|
||||
return APIClient('/api/stats/playlist/');
|
||||
return APIClient<PlaylistStatsType>('/api/stats/playlist/');
|
||||
};
|
||||
|
||||
export default loadStatsPlaylist;
|
||||
|
||||
@@ -1,7 +1,44 @@
|
||||
import APIClient from '../../functions/APIClient';
|
||||
|
||||
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;
|
||||
};
|
||||
};
|
||||
|
||||
const loadStatsVideo = async () => {
|
||||
return APIClient('/api/stats/video/');
|
||||
return APIClient<VideoStatsType>('/api/stats/video/');
|
||||
};
|
||||
|
||||
export default loadStatsVideo;
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
import APIClient from '../../functions/APIClient';
|
||||
|
||||
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;
|
||||
};
|
||||
};
|
||||
|
||||
const loadStatsWatchProgress = async () => {
|
||||
return APIClient('/api/stats/watch/');
|
||||
return APIClient<WatchProgressStatsType>('/api/stats/watch/');
|
||||
};
|
||||
|
||||
export default loadStatsWatchProgress;
|
||||
|
||||
@@ -10,8 +10,8 @@ export type UserAccountType = {
|
||||
last_login: string;
|
||||
};
|
||||
|
||||
const loadUserAccount = async (): Promise<UserAccountType> => {
|
||||
return APIClient('/api/user/account/');
|
||||
const loadUserAccount = async () => {
|
||||
return APIClient<UserAccountType>('/api/user/account/');
|
||||
};
|
||||
|
||||
export default loadUserAccount;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { UserConfigType } from '../actions/updateUserConfig';
|
||||
import APIClient from '../../functions/APIClient';
|
||||
|
||||
const loadUserMeConfig = async (): Promise<UserConfigType> => {
|
||||
return APIClient('/api/user/me/');
|
||||
const loadUserMeConfig = async () => {
|
||||
return APIClient<UserConfigType>('/api/user/me/');
|
||||
};
|
||||
|
||||
export default loadUserMeConfig;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import APIClient from '../../functions/APIClient';
|
||||
import { VideoResponseType } from '../../pages/Video';
|
||||
import { VideoType } from '../../pages/Home';
|
||||
|
||||
const loadVideoById = async (youtubeId: string): Promise<VideoResponseType> => {
|
||||
return APIClient(`/api/video/${youtubeId}/`);
|
||||
export type VideoResponseType = VideoType;
|
||||
|
||||
const loadVideoById = async (youtubeId: string) => {
|
||||
return APIClient<VideoResponseType>(`/api/video/${youtubeId}/`);
|
||||
};
|
||||
|
||||
export default loadVideoById;
|
||||
|
||||
@@ -21,9 +21,7 @@ type FilterType = {
|
||||
type?: VideoTypes;
|
||||
};
|
||||
|
||||
const loadVideoListByFilter = async (
|
||||
filter: FilterType,
|
||||
): Promise<VideoListByFilterResponseType> => {
|
||||
const loadVideoListByFilter = async (filter: FilterType) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (filter.playlist) {
|
||||
@@ -39,7 +37,7 @@ const loadVideoListByFilter = async (
|
||||
if (filter.type) searchParams.append('type', filter.type);
|
||||
|
||||
const endpoint = `/api/video/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;
|
||||
return APIClient(endpoint);
|
||||
return APIClient<VideoListByFilterResponseType>(endpoint);
|
||||
};
|
||||
|
||||
export default loadVideoListByFilter;
|
||||
|
||||
@@ -25,8 +25,8 @@ export type VideoNavResponseType = {
|
||||
};
|
||||
};
|
||||
|
||||
const loadVideoNav = async (youtubeVideoId: string): Promise<VideoNavResponseType[]> => {
|
||||
return APIClient(`/api/video/${youtubeVideoId}/nav/`);
|
||||
const loadVideoNav = async (youtubeVideoId: string) => {
|
||||
return APIClient<VideoNavResponseType[]>(`/api/video/${youtubeVideoId}/nav/`);
|
||||
};
|
||||
|
||||
export default loadVideoNav;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Fragment } from 'react';
|
||||
import StatsInfoBoxItem from './StatsInfoBoxItem';
|
||||
import formatNumbers from '../functions/formatNumbers';
|
||||
import { ChannelStatsType, PlaylistStatsType, DownloadStatsType } from '../pages/SettingsDashboard';
|
||||
import { ChannelStatsType } from '../api/loader/loadStatsChannel';
|
||||
import { PlaylistStatsType } from '../api/loader/loadStatsPlaylist';
|
||||
import { DownloadStatsType } from '../api/loader/loadStatsDownload';
|
||||
|
||||
type ApplicationStatsProps = {
|
||||
channelStats?: ChannelStatsType;
|
||||
|
||||
@@ -2,20 +2,20 @@ import humanFileSize from '../functions/humanFileSize';
|
||||
import formatNumbers from '../functions/formatNumbers';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Routes from '../configuration/routes/RouteList';
|
||||
import { BiggestChannelsStatsType } from '../pages/SettingsDashboard';
|
||||
import { BiggestChannelsStatsType } from '../api/loader/loadStatsBiggestChannels';
|
||||
|
||||
type BiggestChannelsStatsProps = {
|
||||
biggestChannelsStatsByCount?: BiggestChannelsStatsType;
|
||||
biggestChannelsStatsByDuration?: BiggestChannelsStatsType;
|
||||
biggestChannelsStatsByMediaSize?: BiggestChannelsStatsType;
|
||||
useSI: boolean;
|
||||
useSIUnits: boolean;
|
||||
};
|
||||
|
||||
const BiggestChannelsStats = ({
|
||||
biggestChannelsStatsByCount,
|
||||
biggestChannelsStatsByDuration,
|
||||
biggestChannelsStatsByMediaSize,
|
||||
useSI,
|
||||
useSIUnits,
|
||||
}: BiggestChannelsStatsProps) => {
|
||||
if (
|
||||
!biggestChannelsStatsByCount &&
|
||||
@@ -94,7 +94,9 @@ const BiggestChannelsStats = ({
|
||||
<td className="agg-channel-name">
|
||||
<Link to={Routes.Channel(id)}>{name}</Link>
|
||||
</td>
|
||||
<td className="agg-channel-right-align">{humanFileSize(media_size, useSI)}</td>
|
||||
<td className="agg-channel-right-align">
|
||||
{humanFileSize(media_size, useSIUnits)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -7,9 +7,15 @@ type ChannelIconProps = {
|
||||
};
|
||||
|
||||
const ChannelBanner = ({ channelId, channelBannerUrl }: ChannelIconProps) => {
|
||||
let src = `${getApiUrl()}${channelBannerUrl}`;
|
||||
|
||||
if (channelBannerUrl === undefined) {
|
||||
src = defaultChannelImage;
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={`${getApiUrl()}${channelBannerUrl}`}
|
||||
src={src}
|
||||
alt={`${channelId}-banner`}
|
||||
onError={({ currentTarget }) => {
|
||||
currentTarget.onerror = null; // prevents looping
|
||||
|
||||
@@ -7,9 +7,15 @@ type ChannelIconProps = {
|
||||
};
|
||||
|
||||
const ChannelIcon = ({ channelId, channelThumbUrl }: ChannelIconProps) => {
|
||||
let src = `${getApiUrl()}${channelThumbUrl}`;
|
||||
|
||||
if (channelThumbUrl === undefined) {
|
||||
src = defaultChannelIcon;
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={`${getApiUrl()}${channelThumbUrl}`}
|
||||
src={src}
|
||||
alt={`${channelId}-thumb`}
|
||||
onError={({ currentTarget }) => {
|
||||
currentTarget.onerror = null; // prevents looping
|
||||
|
||||
@@ -49,7 +49,9 @@ const ChannelList = ({ channelList, refreshChannelList }: ChannelListProps) => {
|
||||
<h3>
|
||||
<Link to={Routes.Channel(channel.channel_id)}>{channel.channel_name}</Link>
|
||||
</h3>
|
||||
<FormattedNumber text="Subscribers:" number={channel.channel_subs} />
|
||||
{channel.channel_subs !== null && (
|
||||
<FormattedNumber text="Subscribers:" number={channel.channel_subs} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="info-box-item">
|
||||
|
||||
@@ -38,7 +38,7 @@ const ChannelOverview = ({
|
||||
<Link to={Routes.ChannelVideo(channelId)}>{channelname}</Link>
|
||||
</h3>
|
||||
|
||||
<FormattedNumber text="Subscribers:" number={channelSubs} />
|
||||
{channelSubs !== null && <FormattedNumber text="Subscribers:" number={channelSubs} />}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import humanFileSize from '../functions/humanFileSize';
|
||||
import formatDate from '../functions/formatDates';
|
||||
import formatNumbers from '../functions/formatNumbers';
|
||||
import { DownloadHistoryStatsType } from '../pages/SettingsDashboard';
|
||||
import { DownloadHistoryStatsType } from '../api/loader/loadStatsDownloadHistory';
|
||||
|
||||
type DownloadHistoryStatsProps = {
|
||||
downloadHistoryStats?: DownloadHistoryStatsType;
|
||||
useSI: boolean;
|
||||
useSIUnits: boolean;
|
||||
};
|
||||
|
||||
const DownloadHistoryStats = ({ downloadHistoryStats, useSI }: DownloadHistoryStatsProps) => {
|
||||
const DownloadHistoryStats = ({ downloadHistoryStats, useSIUnits }: DownloadHistoryStatsProps) => {
|
||||
if (!downloadHistoryStats) {
|
||||
return <p id="loading">Loading...</p>;
|
||||
}
|
||||
@@ -31,7 +31,7 @@ const DownloadHistoryStats = ({ downloadHistoryStats, useSI }: DownloadHistorySt
|
||||
<p>
|
||||
+{formatNumbers(count)} {videoText}
|
||||
<br />
|
||||
{humanFileSize(media_size, useSI)}
|
||||
{humanFileSize(media_size, useSIUnits)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,9 +6,8 @@ import formatDate from '../functions/formatDates';
|
||||
import Button from './Button';
|
||||
import deleteDownloadById from '../api/actions/deleteDownloadById';
|
||||
import updateDownloadQueueStatusById from '../api/actions/updateDownloadQueueStatusById';
|
||||
import getApiUrl from '../configuration/getApiUrl';
|
||||
import { useUserConfigStore } from '../stores/UserConfigStore';
|
||||
import defaultVideoThumb from '/img/default-video-thumb.jpg';
|
||||
import VideoThumbnail from './VideoThumbail';
|
||||
|
||||
type DownloadListItemProps = {
|
||||
download: Download;
|
||||
@@ -26,14 +25,7 @@ const DownloadListItem = ({ download, setRefresh }: DownloadListItemProps) => {
|
||||
<div className={`video-item ${view}`} id={`dl-${download.youtube_id}`}>
|
||||
<div className={`video-thumb-wrap ${view}`}>
|
||||
<div className="video-thumb">
|
||||
<img
|
||||
src={`${getApiUrl()}${download.vid_thumb_url}`}
|
||||
alt="video_thumb"
|
||||
onError={({ currentTarget }) => {
|
||||
currentTarget.onerror = null; // prevents looping
|
||||
currentTarget.src = defaultVideoThumb;
|
||||
}}
|
||||
/>
|
||||
<VideoThumbnail videoThumbUrl={download.vid_thumb_url} />
|
||||
|
||||
<div className="video-tags">
|
||||
{showIgnored && <span>ignored</span>}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { VideoResponseType } from '../pages/Video';
|
||||
import VideoPlayer from './VideoPlayer';
|
||||
import loadVideoById from '../api/loader/loadVideoById';
|
||||
import loadVideoById, { VideoResponseType } from '../api/loader/loadVideoById';
|
||||
import iconClose from '/img/icon-close.svg';
|
||||
import iconEye from '/img/icon-eye.svg';
|
||||
import iconThumb from '/img/icon-thumb.svg';
|
||||
@@ -13,6 +12,7 @@ import { Link, useSearchParams } from 'react-router-dom';
|
||||
import Routes from '../configuration/routes/RouteList';
|
||||
import loadPlaylistById from '../api/loader/loadPlaylistById';
|
||||
import { useAppSettingsStore } from '../stores/AppSettingsStore';
|
||||
import { ApiResponseType } from '../functions/APIClient';
|
||||
|
||||
type Playlist = {
|
||||
id: string;
|
||||
@@ -32,9 +32,11 @@ const EmbeddableVideoPlayer = ({ videoId }: EmbeddableVideoPlayerProps) => {
|
||||
|
||||
const [refresh, setRefresh] = useState(true);
|
||||
|
||||
const [videoResponse, setVideoResponse] = useState<VideoResponseType>();
|
||||
const [videoResponse, setVideoResponse] = useState<ApiResponseType<VideoResponseType>>();
|
||||
const [playlists, setPlaylists] = useState<PlaylistList>();
|
||||
|
||||
const { data: videoResponseData } = videoResponse ?? {};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!videoId) {
|
||||
@@ -43,27 +45,31 @@ const EmbeddableVideoPlayer = ({ videoId }: EmbeddableVideoPlayerProps) => {
|
||||
|
||||
inlinePlayerRef.current?.scrollIntoView();
|
||||
|
||||
if (refresh || videoId !== videoResponse?.youtube_id) {
|
||||
if (refresh || videoId !== videoResponseData?.youtube_id) {
|
||||
const videoResponse = await loadVideoById(videoId);
|
||||
|
||||
const playlistIds = videoResponse.playlist;
|
||||
const { data: videoResponseData } = videoResponse ?? {};
|
||||
|
||||
const playlistIds = videoResponseData?.playlist || [];
|
||||
if (playlistIds !== undefined) {
|
||||
const playlists = await Promise.all(
|
||||
playlistIds.map(async playlistid => {
|
||||
const playlistResponse = await loadPlaylistById(playlistid);
|
||||
|
||||
return playlistResponse;
|
||||
const { data: playlistResponseData } = playlistResponse ?? {};
|
||||
|
||||
return playlistResponseData;
|
||||
}),
|
||||
);
|
||||
|
||||
const playlistsFiltered = playlists
|
||||
.filter(playlist => {
|
||||
return playlist.playlist_subscribed;
|
||||
return playlist?.playlist_subscribed;
|
||||
})
|
||||
.map(playlist => {
|
||||
return {
|
||||
id: playlist.playlist_id,
|
||||
name: playlist.playlist_name,
|
||||
id: playlist?.playlist_id || '',
|
||||
name: playlist?.playlist_name || '',
|
||||
};
|
||||
});
|
||||
|
||||
@@ -77,11 +83,11 @@ const EmbeddableVideoPlayer = ({ videoId }: EmbeddableVideoPlayerProps) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [videoId, refresh]);
|
||||
|
||||
if (videoResponse === undefined || videoId === null) {
|
||||
if (videoResponseData === undefined || videoId === null) {
|
||||
return <div ref={inlinePlayerRef} className="player-wrapper" />;
|
||||
}
|
||||
|
||||
const video = videoResponse;
|
||||
const video = videoResponseData;
|
||||
const name = video.title;
|
||||
const channelId = video.channel.channel_id;
|
||||
const channelName = video.channel.channel_name;
|
||||
@@ -99,7 +105,7 @@ const EmbeddableVideoPlayer = ({ videoId }: EmbeddableVideoPlayerProps) => {
|
||||
<div ref={inlinePlayerRef} className="player-wrapper">
|
||||
<div className="video-player">
|
||||
<VideoPlayer
|
||||
video={videoResponse}
|
||||
video={videoResponseData}
|
||||
sponsorBlock={sponsorblock}
|
||||
embed={true}
|
||||
autoplay={true}
|
||||
@@ -115,7 +121,11 @@ const EmbeddableVideoPlayer = ({ videoId }: EmbeddableVideoPlayerProps) => {
|
||||
alt="close-icon"
|
||||
title="Close player"
|
||||
onClick={() => {
|
||||
setSearchParams({});
|
||||
setSearchParams(params => {
|
||||
const newParams = new URLSearchParams(params);
|
||||
newParams.delete('videoId');
|
||||
return newParams;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -22,7 +22,11 @@ const Filterbar = ({ hideToggleText, viewStyleName, showSort = true }: Filterbar
|
||||
|
||||
const handleUserConfigUpdate = async (config: Partial<UserConfigType>) => {
|
||||
const updatedUserConfig = await updateUserConfig(config);
|
||||
setUserConfig(updatedUserConfig);
|
||||
const { data: updatedUserConfigData } = updatedUserConfig;
|
||||
|
||||
if (updatedUserConfigData) {
|
||||
setUserConfig(updatedUserConfigData);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -46,7 +46,12 @@ async function castVideoProgress(
|
||||
currentProgress: currentTime,
|
||||
});
|
||||
|
||||
if (videoProgressResponse.watched && video.player.watched !== videoProgressResponse.watched) {
|
||||
const { data: videoProgressResponseData } = videoProgressResponse ?? {};
|
||||
|
||||
if (
|
||||
videoProgressResponseData?.watched &&
|
||||
video.player.watched !== videoProgressResponseData.watched
|
||||
) {
|
||||
onWatchStateChanged?.(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
import { Fragment, useEffect, useState } from 'react';
|
||||
import loadNotifications, { NotificationPages } from '../api/loader/loadNotifications';
|
||||
import loadNotifications, {
|
||||
NotificationPages,
|
||||
NotificationResponseType,
|
||||
} from '../api/loader/loadNotifications';
|
||||
import iconStop from '/img/icon-stop.svg';
|
||||
import stopTaskByName from '../api/actions/stopTaskByName';
|
||||
|
||||
type NotificationType = {
|
||||
title: string;
|
||||
group: string;
|
||||
api_stop: boolean;
|
||||
level: string;
|
||||
id: string;
|
||||
command: boolean | string;
|
||||
messages: string[];
|
||||
progress: number;
|
||||
};
|
||||
|
||||
type NotificationResponseType = NotificationType[];
|
||||
import { ApiResponseType } from '../functions/APIClient';
|
||||
|
||||
type NotificationsProps = {
|
||||
pageName: NotificationPages;
|
||||
@@ -29,13 +20,17 @@ const Notifications = ({
|
||||
update,
|
||||
setShouldRefresh,
|
||||
}: NotificationsProps) => {
|
||||
const [notificationResponse, setNotificationResponse] = useState<NotificationResponseType>([]);
|
||||
const [notificationResponse, setNotificationResponse] =
|
||||
useState<ApiResponseType<NotificationResponseType>>();
|
||||
|
||||
const { data: notificationResponseData } = notificationResponse ?? {};
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = setInterval(async () => {
|
||||
const notifications = await loadNotifications(pageName, includeReindex);
|
||||
const { data: notificationsData } = notifications ?? {};
|
||||
|
||||
if (notifications.length === 0) {
|
||||
if (notificationsData?.length === 0) {
|
||||
setNotificationResponse(notifications);
|
||||
clearInterval(intervalId);
|
||||
setShouldRefresh?.(true);
|
||||
@@ -45,20 +40,20 @@ const Notifications = ({
|
||||
}
|
||||
|
||||
setNotificationResponse(notifications);
|
||||
}, 500);
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [pageName, update, setShouldRefresh, includeReindex]);
|
||||
|
||||
if (notificationResponse.length === 0) {
|
||||
if (notificationResponseData?.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{notificationResponse.map(notification => (
|
||||
{notificationResponseData?.map(notification => (
|
||||
<div
|
||||
id={notification.id}
|
||||
className={`notification ${notification.level}`}
|
||||
|
||||
@@ -2,14 +2,14 @@ import { Fragment } from 'react';
|
||||
import humanFileSize from '../functions/humanFileSize';
|
||||
import StatsInfoBoxItem from './StatsInfoBoxItem';
|
||||
import formatNumbers from '../functions/formatNumbers';
|
||||
import { VideoStatsType } from '../pages/SettingsDashboard';
|
||||
import { VideoStatsType } from '../api/loader/loadStatsVideo';
|
||||
|
||||
type OverviewStatsProps = {
|
||||
videoStats?: VideoStatsType;
|
||||
useSI: boolean;
|
||||
useSIUnits: boolean;
|
||||
};
|
||||
|
||||
const OverviewStats = ({ videoStats, useSI }: OverviewStatsProps) => {
|
||||
const OverviewStats = ({ videoStats, useSIUnits }: OverviewStatsProps) => {
|
||||
if (!videoStats) {
|
||||
return <p id="loading">Loading...</p>;
|
||||
}
|
||||
@@ -19,7 +19,7 @@ const OverviewStats = ({ videoStats, useSI }: OverviewStatsProps) => {
|
||||
title: 'All: ',
|
||||
data: {
|
||||
Videos: formatNumbers(videoStats?.doc_count || 0),
|
||||
['Media Size']: humanFileSize(videoStats?.media_size || 0, useSI),
|
||||
['Media Size']: humanFileSize(videoStats?.media_size || 0, useSIUnits),
|
||||
Duration: videoStats?.duration_str,
|
||||
},
|
||||
},
|
||||
@@ -27,7 +27,7 @@ const OverviewStats = ({ videoStats, useSI }: OverviewStatsProps) => {
|
||||
title: 'Active: ',
|
||||
data: {
|
||||
Videos: formatNumbers(videoStats?.active_true?.doc_count || 0),
|
||||
['Media Size']: humanFileSize(videoStats?.active_true?.media_size || 0, useSI),
|
||||
['Media Size']: humanFileSize(videoStats?.active_true?.media_size || 0, useSIUnits),
|
||||
Duration: videoStats?.active_true?.duration_str || 'NA',
|
||||
},
|
||||
},
|
||||
@@ -35,7 +35,7 @@ const OverviewStats = ({ videoStats, useSI }: OverviewStatsProps) => {
|
||||
title: 'Inactive: ',
|
||||
data: {
|
||||
Videos: formatNumbers(videoStats?.active_false?.doc_count || 0),
|
||||
['Media Size']: humanFileSize(videoStats?.active_false?.media_size || 0, useSI),
|
||||
['Media Size']: humanFileSize(videoStats?.active_false?.media_size || 0, useSIUnits),
|
||||
Duration: videoStats?.active_false?.duration_str || 'NA',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { Fragment } from 'react/jsx-runtime';
|
||||
import Routes from '../configuration/routes/RouteList';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
@@ -23,6 +23,9 @@ interface Props {
|
||||
const Pagination = ({ pagination, setPage }: Props) => {
|
||||
const { total_hits, params, prev_pages, current_page, next_pages, last_page, max_hits } =
|
||||
pagination;
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const videoId = searchParams.get('videoId');
|
||||
|
||||
const totalHits = Number(total_hits);
|
||||
const currentPage = Number(current_page);
|
||||
@@ -39,7 +42,7 @@ const Pagination = ({ pagination, setPage }: Props) => {
|
||||
(event: KeyboardEvent) => {
|
||||
const { code } = event;
|
||||
|
||||
if (code === 'ArrowRight') {
|
||||
if (code === 'ArrowRight' && videoId === null) {
|
||||
if (currentPage === 0 && totalHits > 1) {
|
||||
setPage(2);
|
||||
return;
|
||||
@@ -52,7 +55,7 @@ const Pagination = ({ pagination, setPage }: Props) => {
|
||||
setPage(currentPage + 1);
|
||||
}
|
||||
|
||||
if (code === 'ArrowLeft') {
|
||||
if (code === 'ArrowLeft' && videoId === null) {
|
||||
if (currentPage === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -65,7 +68,7 @@ const Pagination = ({ pagination, setPage }: Props) => {
|
||||
setPage(currentPage - 1);
|
||||
}
|
||||
},
|
||||
[currentPage, lastPage, setPage, totalHits],
|
||||
[currentPage, lastPage, setPage, totalHits, videoId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -7,9 +7,15 @@ type PlaylistThumbnailProps = {
|
||||
};
|
||||
|
||||
const PlaylistThumbnail = ({ playlistId, playlistThumbnail }: PlaylistThumbnailProps) => {
|
||||
let src = `${getApiUrl()}${playlistThumbnail}`;
|
||||
|
||||
if (playlistThumbnail === undefined) {
|
||||
src = defaultPlaylistThumbnail;
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={`${getApiUrl()}${playlistThumbnail}`}
|
||||
src={src}
|
||||
alt={`${playlistId}-thumbnail`}
|
||||
onError={({ currentTarget }) => {
|
||||
currentTarget.onerror = null; // prevents looping
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Link, useSearchParams } from 'react-router-dom';
|
||||
import Routes from '../configuration/routes/RouteList';
|
||||
import iconPlay from '/img/icon-play.svg';
|
||||
import Linkify from './Linkify';
|
||||
import getApiUrl from '../configuration/getApiUrl';
|
||||
import VideoThumbnail from './VideoThumbail';
|
||||
|
||||
type SubtitleListType = {
|
||||
subtitle_index: number;
|
||||
@@ -44,15 +44,17 @@ const SubtitleList = ({ subtitleList }: SubtitleListProps) => {
|
||||
<div className="video-item list">
|
||||
<a
|
||||
onClick={() => {
|
||||
setSearchParams({
|
||||
videoId: subtitle.youtube_id,
|
||||
t: stripNanoSecs(subtitle.subtitle_start) || '00:00:00',
|
||||
setSearchParams(params => {
|
||||
params.set('videoId', subtitle.youtube_id);
|
||||
params.set('t', stripNanoSecs(subtitle.subtitle_start) || '00:00:00');
|
||||
|
||||
return params;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="video-thumb-wrap list">
|
||||
<div className="video-thumb">
|
||||
<img src={`${getApiUrl()}${subtitle.vid_thumb_url}`} alt="video-thumb" />
|
||||
<VideoThumbnail videoThumbUrl={subtitle.vid_thumb_url} />
|
||||
</div>
|
||||
<div className="video-play">
|
||||
<img src={iconPlay} alt="play-icon" />
|
||||
|
||||
@@ -4,14 +4,13 @@ import { VideoType, ViewLayoutType } from '../pages/Home';
|
||||
import iconPlay from '/img/icon-play.svg';
|
||||
import iconDotMenu from '/img/icon-dot-menu.svg';
|
||||
import iconClose from '/img/icon-close.svg';
|
||||
import defaultVideoThumb from '/img/default-video-thumb.jpg';
|
||||
import updateWatchedState from '../api/actions/updateWatchedState';
|
||||
import formatDate from '../functions/formatDates';
|
||||
import WatchedCheckBox from './WatchedCheckBox';
|
||||
import MoveVideoMenu from './MoveVideoMenu';
|
||||
import { useState } from 'react';
|
||||
import getApiUrl from '../configuration/getApiUrl';
|
||||
import deleteVideoProgressById from '../api/actions/deleteVideoProgressById';
|
||||
import VideoThumbnail from './VideoThumbail';
|
||||
|
||||
type VideoListItemProps = {
|
||||
video: VideoType;
|
||||
@@ -40,19 +39,16 @@ const VideoListItem = ({
|
||||
<div className={`video-item ${viewLayout}`}>
|
||||
<a
|
||||
onClick={() => {
|
||||
setSearchParams({ videoId: video.youtube_id });
|
||||
setSearchParams(params => {
|
||||
const newParams = new URLSearchParams(params);
|
||||
newParams.set('videoId', video.youtube_id);
|
||||
return newParams;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className={`video-thumb-wrap ${viewLayout}`}>
|
||||
<div className="video-thumb">
|
||||
<img
|
||||
src={`${getApiUrl()}${video.vid_thumb_url}`}
|
||||
alt="video-thumb"
|
||||
onError={({ currentTarget }) => {
|
||||
currentTarget.onerror = null; // prevents looping
|
||||
currentTarget.src = defaultVideoThumb;
|
||||
}}
|
||||
/>
|
||||
<VideoThumbnail videoThumbUrl={video.vid_thumb_url} />
|
||||
|
||||
{video.player.progress && (
|
||||
<div
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import updateVideoProgressById from '../api/actions/updateVideoProgressById';
|
||||
import { SponsorBlockSegmentType, SponsorBlockType, VideoResponseType } from '../pages/Video';
|
||||
import { SponsorBlockSegmentType, SponsorBlockType } from '../pages/Video';
|
||||
import {
|
||||
Dispatch,
|
||||
Fragment,
|
||||
@@ -13,6 +13,7 @@ import formatTime from '../functions/formatTime';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import getApiUrl from '../configuration/getApiUrl';
|
||||
import { useKeyPress } from '../functions/useKeypressHook';
|
||||
import { VideoResponseType } from '../api/loader/loadVideoById';
|
||||
|
||||
const VIDEO_PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.25, 2.5, 2.75, 3];
|
||||
|
||||
@@ -95,7 +96,9 @@ const handleTimeUpdate =
|
||||
currentProgress: currentTime,
|
||||
});
|
||||
|
||||
if (videoProgressResponse.watched && watched !== videoProgressResponse.watched) {
|
||||
const { data: videoProgressResponseData } = videoProgressResponse ?? {};
|
||||
|
||||
if (videoProgressResponseData?.watched && watched !== videoProgressResponseData.watched) {
|
||||
onWatchStateChanged?.(true);
|
||||
}
|
||||
}
|
||||
@@ -185,7 +188,9 @@ const VideoPlayer = ({
|
||||
currentProgress: currentTime,
|
||||
});
|
||||
|
||||
if (videoProgressResponse.watched && watched !== videoProgressResponse.watched) {
|
||||
const { data: videoProgressResponseData } = videoProgressResponse;
|
||||
|
||||
if (videoProgressResponseData?.watched && watched !== videoProgressResponseData.watched) {
|
||||
onWatchStateChanged?.(true);
|
||||
}
|
||||
|
||||
|
||||
27
frontend/src/components/VideoThumbail.tsx
Normal file
27
frontend/src/components/VideoThumbail.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import getApiUrl from '../configuration/getApiUrl';
|
||||
import defaultVideoThumb from '/img/default-video-thumb.jpg';
|
||||
|
||||
type VideoThumbailProps = {
|
||||
videoThumbUrl: string | undefined;
|
||||
};
|
||||
|
||||
const VideoThumbnail = ({ videoThumbUrl }: VideoThumbailProps) => {
|
||||
let src = `${getApiUrl()}${videoThumbUrl}`;
|
||||
|
||||
if (videoThumbUrl === undefined) {
|
||||
src = defaultVideoThumb;
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt="video_thumb"
|
||||
onError={({ currentTarget }) => {
|
||||
currentTarget.onerror = null; // prevents looping
|
||||
currentTarget.src = defaultVideoThumb;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoThumbnail;
|
||||
@@ -2,14 +2,14 @@ import { Fragment } from 'react';
|
||||
import humanFileSize from '../functions/humanFileSize';
|
||||
import StatsInfoBoxItem from './StatsInfoBoxItem';
|
||||
import formatNumbers from '../functions/formatNumbers';
|
||||
import { VideoStatsType } from '../pages/SettingsDashboard';
|
||||
import { VideoStatsType } from '../api/loader/loadStatsVideo';
|
||||
|
||||
type VideoTypeStatsProps = {
|
||||
videoStats?: VideoStatsType;
|
||||
useSI: boolean;
|
||||
useSIUnits: boolean;
|
||||
};
|
||||
|
||||
const VideoTypeStats = ({ videoStats, useSI }: VideoTypeStatsProps) => {
|
||||
const VideoTypeStats = ({ videoStats, useSIUnits }: VideoTypeStatsProps) => {
|
||||
if (!videoStats) {
|
||||
return <p id="loading">Loading...</p>;
|
||||
}
|
||||
@@ -19,7 +19,7 @@ const VideoTypeStats = ({ videoStats, useSI }: VideoTypeStatsProps) => {
|
||||
title: 'Regular Videos: ',
|
||||
data: {
|
||||
Videos: formatNumbers(videoStats?.type_videos?.doc_count || 0),
|
||||
['Media Size']: humanFileSize(videoStats?.type_videos?.media_size || 0, useSI),
|
||||
['Media Size']: humanFileSize(videoStats?.type_videos?.media_size || 0, useSIUnits),
|
||||
Duration: videoStats?.type_videos?.duration_str || 'NA',
|
||||
},
|
||||
},
|
||||
@@ -27,7 +27,7 @@ const VideoTypeStats = ({ videoStats, useSI }: VideoTypeStatsProps) => {
|
||||
title: 'Shorts: ',
|
||||
data: {
|
||||
Videos: formatNumbers(videoStats?.type_shorts?.doc_count || 0),
|
||||
['Media Size']: humanFileSize(videoStats?.type_shorts?.media_size || 0, useSI),
|
||||
['Media Size']: humanFileSize(videoStats?.type_shorts?.media_size || 0, useSIUnits),
|
||||
Duration: videoStats?.type_shorts?.duration_str || 'NA',
|
||||
},
|
||||
},
|
||||
@@ -35,7 +35,7 @@ const VideoTypeStats = ({ videoStats, useSI }: VideoTypeStatsProps) => {
|
||||
title: 'Streams: ',
|
||||
data: {
|
||||
Videos: formatNumbers(videoStats?.type_streams?.doc_count || 0),
|
||||
['Media Size']: humanFileSize(videoStats?.type_streams?.media_size || 0, useSI),
|
||||
['Media Size']: humanFileSize(videoStats?.type_streams?.media_size || 0, useSIUnits),
|
||||
Duration: videoStats?.type_streams?.duration_str || 'NA',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Fragment } from 'react';
|
||||
import StatsInfoBoxItem from './StatsInfoBoxItem';
|
||||
import formatNumbers from '../functions/formatNumbers';
|
||||
import { WatchProgressStatsType } from '../pages/SettingsDashboard';
|
||||
import { WatchProgressStatsType } from '../api/loader/loadStatsWatchProgress';
|
||||
|
||||
const formatProgress = (progress: number) => {
|
||||
return (Number(progress) * 100).toFixed(2) ?? '0';
|
||||
|
||||
34
frontend/src/configuration/colours/Colours.tsx
Normal file
34
frontend/src/configuration/colours/Colours.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useUserConfigStore } from '../../stores/UserConfigStore';
|
||||
import { ColourConstant } from './colourConstant';
|
||||
import CustomStylesheet from './components/Custom';
|
||||
import DarkStylesheet from './components/Dark';
|
||||
import LightStylesheet from './components/Light';
|
||||
import MatrixStylesheet from './components/Matrix';
|
||||
import MidnightStylesheet from './components/Midnight';
|
||||
|
||||
const Colours = () => {
|
||||
const { userConfig } = useUserConfigStore();
|
||||
const stylesheet = userConfig?.stylesheet;
|
||||
|
||||
switch (stylesheet) {
|
||||
case ColourConstant.Dark:
|
||||
return <DarkStylesheet />;
|
||||
|
||||
case ColourConstant.Matrix:
|
||||
return <MatrixStylesheet />;
|
||||
|
||||
case ColourConstant.Midnight:
|
||||
return <MidnightStylesheet />;
|
||||
|
||||
case ColourConstant.Light:
|
||||
return <LightStylesheet />;
|
||||
|
||||
case ColourConstant.Custom:
|
||||
return <CustomStylesheet />;
|
||||
|
||||
default:
|
||||
return <DarkStylesheet />;
|
||||
}
|
||||
};
|
||||
|
||||
export default Colours;
|
||||
7
frontend/src/configuration/colours/colourConstant.ts
Normal file
7
frontend/src/configuration/colours/colourConstant.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const ColourConstant = {
|
||||
Dark: 'dark.css',
|
||||
Light: 'light.css',
|
||||
Matrix: 'matrix.css',
|
||||
Midnight: 'midnight.css',
|
||||
Custom: 'custom.css',
|
||||
};
|
||||
9
frontend/src/configuration/colours/components/Custom.tsx
Normal file
9
frontend/src/configuration/colours/components/Custom.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
const CustomStylesheet = () => {
|
||||
return (
|
||||
<head>
|
||||
<link rel="stylesheet" href="/css/custom.css" />
|
||||
</head>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomStylesheet;
|
||||
@@ -1,7 +1,9 @@
|
||||
import './css/dark.css';
|
||||
|
||||
const DarkStylesheet = () => {
|
||||
return <></>;
|
||||
return (
|
||||
<head>
|
||||
<link rel="stylesheet" href="/css/dark.css" />
|
||||
</head>
|
||||
);
|
||||
};
|
||||
|
||||
export default DarkStylesheet;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import './css/light.css';
|
||||
|
||||
const LightStylesheet = () => {
|
||||
return <></>;
|
||||
return (
|
||||
<head>
|
||||
<link rel="stylesheet" href="/css/light.css" />
|
||||
</head>
|
||||
);
|
||||
};
|
||||
|
||||
export default LightStylesheet;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import './css/matrix.css';
|
||||
|
||||
const MatrixStylesheet = () => {
|
||||
return <></>;
|
||||
return (
|
||||
<head>
|
||||
<link rel="stylesheet" href="/css/matrix.css" />
|
||||
</head>
|
||||
);
|
||||
};
|
||||
|
||||
export default MatrixStylesheet;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import './css/midnight.css';
|
||||
|
||||
const MidnightStylesheet = () => {
|
||||
return <></>;
|
||||
return (
|
||||
<head>
|
||||
<link rel="stylesheet" href="/css/midnight.css" />
|
||||
</head>
|
||||
);
|
||||
};
|
||||
|
||||
export default MidnightStylesheet;
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { useUserConfigStore } from '../../stores/UserConfigStore';
|
||||
|
||||
export const ColourConstant = {
|
||||
Dark: 'dark.css',
|
||||
Light: 'light.css',
|
||||
Matrix: 'matrix.css',
|
||||
Midnight: 'midnight.css',
|
||||
};
|
||||
|
||||
const useColours = () => {
|
||||
const { userConfig } = useUserConfigStore();
|
||||
const stylesheet = userConfig?.stylesheet;
|
||||
|
||||
switch (stylesheet) {
|
||||
case ColourConstant.Dark:
|
||||
return import('./components/Dark');
|
||||
|
||||
case ColourConstant.Matrix:
|
||||
return import('./components/Matrix');
|
||||
|
||||
case ColourConstant.Midnight:
|
||||
return import('./components/Midnight');
|
||||
|
||||
case ColourConstant.Light:
|
||||
return import('./components/Light');
|
||||
|
||||
default:
|
||||
return import('./components/Dark');
|
||||
}
|
||||
};
|
||||
|
||||
export default useColours;
|
||||
@@ -15,10 +15,20 @@ export interface ApiError {
|
||||
message: string;
|
||||
}
|
||||
|
||||
const APIClient = async (
|
||||
export type ResponseErrorType = {
|
||||
error: string;
|
||||
};
|
||||
|
||||
export type ApiResponseType<T> = {
|
||||
data?: T;
|
||||
error?: ResponseErrorType;
|
||||
status: number;
|
||||
};
|
||||
|
||||
const APIClient = async <T>(
|
||||
endpoint: string,
|
||||
{ method = 'GET', body, headers = {}, ...options }: ApiClientOptions = {},
|
||||
) => {
|
||||
): Promise<ApiResponseType<T>> => {
|
||||
const apiUrl = getApiUrl();
|
||||
const csrfToken = getCookie('csrftoken');
|
||||
|
||||
@@ -55,27 +65,37 @@ const APIClient = async (
|
||||
throw new Error('Forbidden: Access denied.');
|
||||
}
|
||||
|
||||
let data;
|
||||
|
||||
// expected empty response
|
||||
if (response.status === 204) {
|
||||
data = null;
|
||||
return data;
|
||||
return {
|
||||
data: undefined,
|
||||
error: undefined,
|
||||
status: response.status,
|
||||
};
|
||||
}
|
||||
|
||||
// Try parsing response data
|
||||
try {
|
||||
data = await response.json();
|
||||
const responseJson = await response.json();
|
||||
|
||||
const hasErrorMessage = responseJson.error;
|
||||
|
||||
return {
|
||||
data: !hasErrorMessage ? responseJson : undefined,
|
||||
error: hasErrorMessage ? responseJson : undefined,
|
||||
status: response.status,
|
||||
};
|
||||
} catch (error) {
|
||||
data = null;
|
||||
console.error(`error fetching data: ${error}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.detail || 'An error occurred while processing the request.');
|
||||
return {
|
||||
data: undefined,
|
||||
error: {
|
||||
error: `error fetching data: ${error}`,
|
||||
},
|
||||
status: response.status,
|
||||
};
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export default APIClient;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user