User conf endpoints, fix channel parser, #build

Changed:
- [API] Added endpoints to CRUD user conf vars
- [API] Added backup endpoints
- Fix channel about page parsing
- Add custom CSS files
- Remember player volume
This commit is contained in:
Simon 2023-11-09 09:40:54 +07:00
commit 9c26357f76
No known key found for this signature in database
GPG Key ID: 2C15AA5E89985DD4
25 changed files with 420 additions and 245 deletions

View File

@ -96,6 +96,16 @@ urlpatterns = [
views.SnapshotApiView.as_view(), views.SnapshotApiView.as_view(),
name="api-snapshot", name="api-snapshot",
), ),
path(
"backup/",
views.BackupApiListView.as_view(),
name="api-backup-list",
),
path(
"backup/<str:filename>/",
views.BackupApiView.as_view(),
name="api-backup",
),
path( path(
"task-name/", "task-name/",
views.TaskListView.as_view(), views.TaskListView.as_view(),
@ -111,6 +121,11 @@ urlpatterns = [
views.TaskIDView.as_view(), views.TaskIDView.as_view(),
name="api-task-id", name="api-task-id",
), ),
path(
"config/user/",
views.UserConfigView.as_view(),
name="api-config-user",
),
path( path(
"cookie/", "cookie/",
views.CookieView.as_view(), views.CookieView.as_view(),

View File

@ -8,6 +8,7 @@ from home.src.download.subscriptions import (
PlaylistSubscription, PlaylistSubscription,
) )
from home.src.download.yt_dlp_base import CookieHandler from home.src.download.yt_dlp_base import CookieHandler
from home.src.es.backup import ElasticBackup
from home.src.es.connect import ElasticWrap from home.src.es.connect import ElasticWrap
from home.src.es.snapshot import ElasticSnapshot from home.src.es.snapshot import ElasticSnapshot
from home.src.frontend.searching import SearchForm from home.src.frontend.searching import SearchForm
@ -22,11 +23,13 @@ from home.src.ta.settings import EnvironmentSettings
from home.src.ta.ta_redis import RedisArchivist from home.src.ta.ta_redis import RedisArchivist
from home.src.ta.task_manager import TaskCommand, TaskManager from home.src.ta.task_manager import TaskCommand, TaskManager
from home.src.ta.urlparser import Parser from home.src.ta.urlparser import Parser
from home.src.ta.users import UserConfig
from home.tasks import ( from home.tasks import (
BaseTask, BaseTask,
check_reindex, check_reindex,
download_pending, download_pending,
extrac_dl, extrac_dl,
run_restore_backup,
subscribe_to, subscribe_to,
) )
from rest_framework import permissions from rest_framework import permissions
@ -764,6 +767,80 @@ class SnapshotApiView(ApiBaseView):
return Response(response) return Response(response)
class BackupApiListView(ApiBaseView):
"""resolves to /api/backup/
GET: returns list of available zip backups
POST: take zip backup now
"""
permission_classes = [AdminOnly]
task_name = "run_backup"
@staticmethod
def get(request):
"""handle get request"""
# pylint: disable=unused-argument
backup_files = ElasticBackup().get_all_backup_files()
return Response(backup_files)
def post(self, request):
"""handle post request"""
# pylint: disable=unused-argument
response = TaskCommand().start(self.task_name)
message = {
"message": "backup task started",
"task_id": response["task_id"],
}
return Response(message)
class BackupApiView(ApiBaseView):
"""resolves to /api/backup/<filename>/
GET: return a single backup
POST: restore backup
DELETE: delete backup
"""
permission_classes = [AdminOnly]
task_name = "restore_backup"
@staticmethod
def get(request, filename):
"""get single backup"""
# pylint: disable=unused-argument
backup_file = ElasticBackup().build_backup_file_data(filename)
if not backup_file:
message = {"message": "file not found"}
return Response(message, status=404)
return Response(backup_file)
def post(self, request, filename):
"""restore backup file"""
# pylint: disable=unused-argument
task = run_restore_backup.delay(filename)
message = {
"message": "backup restore task started",
"filename": filename,
"task_id": task.id,
}
return Response(message)
@staticmethod
def delete(request, filename):
"""delete backup file"""
# pylint: disable=unused-argument
backup_file = ElasticBackup().delete_file(filename)
if not backup_file:
message = {"message": "file not found"}
return Response(message, status=404)
message = {"message": f"file {filename} deleted"}
return Response(message)
class TaskListView(ApiBaseView): class TaskListView(ApiBaseView):
"""resolves to /api/task-name/ """resolves to /api/task-name/
GET: return a list of all stored task results GET: return a list of all stored task results
@ -905,6 +982,42 @@ class RefreshView(ApiBaseView):
return Response(data) return Response(data)
class UserConfigView(ApiBaseView):
"""resolves to /api/config/user/
GET: return current user config
POST: update user config
"""
def get(self, request):
"""get config"""
user_id = request.user.id
response = UserConfig(user_id).get_config()
response.update({"user_id": user_id})
return Response(response)
def post(self, request):
"""update config"""
user_id = request.user.id
data = request.data
user_conf = UserConfig(user_id)
for key, value in data.items():
try:
user_conf.set_value(key, value)
except ValueError as err:
message = {
"status": "Bad Request",
"message": f"failed updating {key} to '{value}', {err}",
}
return Response(message, status=400)
response = user_conf.get_config()
response.update({"user_id": user_id})
return Response(response)
class CookieView(ApiBaseView): class CookieView(ApiBaseView):
"""resolves to /api/cookie/ """resolves to /api/cookie/
GET: check if cookie is enabled GET: check if cookie is enabled

View File

@ -162,11 +162,11 @@ class Command(BaseCommand):
for user in users: for user in users:
new_conf = UserConfig(user) new_conf = UserConfig(user)
colors_key = f"{user}:colors" stylesheet_key = f"{user}:color"
colors = redis.get_message(colors_key).get("status") stylesheet = redis.get_message(stylesheet_key).get("status")
if colors: if stylesheet:
new_conf.set_value("colors", colors) new_conf.set_value("stylesheet", stylesheet)
redis.del_message(colors_key) redis.del_message(stylesheet_key)
sort_by_key = f"{user}:sort_by" sort_by_key = f"{user}:sort_by"
sort_by = redis.get_message(sort_by_key).get("status") sort_by = redis.get_message(sort_by_key).get("status")

View File

@ -20,10 +20,11 @@ class ElasticBackup:
"""dump index to nd-json files for later bulk import""" """dump index to nd-json files for later bulk import"""
INDEX_SPLIT = ["comment"] INDEX_SPLIT = ["comment"]
CACHE_DIR = EnvironmentSettings.CACHE_DIR
BACKUP_DIR = os.path.join(CACHE_DIR, "backup")
def __init__(self, reason=False, task=False): def __init__(self, reason=False, task=False):
self.config = AppConfig().config self.config = AppConfig().config
self.cache_dir = EnvironmentSettings.CACHE_DIR
self.timestamp = datetime.now().strftime("%Y%m%d") self.timestamp = datetime.now().strftime("%Y%m%d")
self.index_config = get_mapping() self.index_config = get_mapping()
self.reason = reason self.reason = reason
@ -79,14 +80,13 @@ class ElasticBackup:
def zip_it(self): def zip_it(self):
"""pack it up into single zip file""" """pack it up into single zip file"""
file_name = f"ta_backup-{self.timestamp}-{self.reason}.zip" file_name = f"ta_backup-{self.timestamp}-{self.reason}.zip"
folder = os.path.join(self.cache_dir, "backup")
to_backup = [] to_backup = []
for file in os.listdir(folder): for file in os.listdir(self.BACKUP_DIR):
if file.endswith(".json"): if file.endswith(".json"):
to_backup.append(os.path.join(folder, file)) to_backup.append(os.path.join(self.BACKUP_DIR, file))
backup_file = os.path.join(folder, file_name) backup_file = os.path.join(self.BACKUP_DIR, file_name)
comp = zipfile.ZIP_DEFLATED comp = zipfile.ZIP_DEFLATED
with zipfile.ZipFile(backup_file, "w", compression=comp) as zip_f: with zipfile.ZipFile(backup_file, "w", compression=comp) as zip_f:
@ -99,7 +99,7 @@ class ElasticBackup:
def post_bulk_restore(self, file_name): def post_bulk_restore(self, file_name):
"""send bulk to es""" """send bulk to es"""
file_path = os.path.join(self.cache_dir, file_name) file_path = os.path.join(self.CACHE_DIR, file_name)
with open(file_path, "r", encoding="utf-8") as f: with open(file_path, "r", encoding="utf-8") as f:
data = f.read() data = f.read()
@ -110,9 +110,7 @@ class ElasticBackup:
def get_all_backup_files(self): def get_all_backup_files(self):
"""build all available backup files for view""" """build all available backup files for view"""
backup_dir = os.path.join(self.cache_dir, "backup") all_backup_files = ignore_filelist(os.listdir(self.BACKUP_DIR))
backup_files = os.listdir(backup_dir)
all_backup_files = ignore_filelist(backup_files)
all_available_backups = [ all_available_backups = [
i i
for i in all_backup_files for i in all_backup_files
@ -121,24 +119,36 @@ class ElasticBackup:
all_available_backups.sort(reverse=True) all_available_backups.sort(reverse=True)
backup_dicts = [] backup_dicts = []
for backup_file in all_available_backups: for filename in all_available_backups:
file_split = backup_file.split("-") data = self.build_backup_file_data(filename)
if len(file_split) == 2: backup_dicts.append(data)
timestamp = file_split[1].strip(".zip")
reason = False
elif len(file_split) == 3:
timestamp = file_split[1]
reason = file_split[2].strip(".zip")
to_add = {
"filename": backup_file,
"timestamp": timestamp,
"reason": reason,
}
backup_dicts.append(to_add)
return backup_dicts return backup_dicts
def build_backup_file_data(self, filename):
"""build metadata of single backup file"""
file_path = os.path.join(self.BACKUP_DIR, filename)
if not os.path.exists(file_path):
return False
file_split = filename.split("-")
if len(file_split) == 2:
timestamp = file_split[1].strip(".zip")
reason = False
elif len(file_split) == 3:
timestamp = file_split[1]
reason = file_split[2].strip(".zip")
data = {
"filename": filename,
"file_path": file_path,
"file_size": os.path.getsize(file_path),
"timestamp": timestamp,
"reason": reason,
}
return data
def restore(self, filename): def restore(self, filename):
""" """
restore from backup zip file restore from backup zip file
@ -149,22 +159,19 @@ class ElasticBackup:
def _unpack_zip_backup(self, filename): def _unpack_zip_backup(self, filename):
"""extract backup zip and return filelist""" """extract backup zip and return filelist"""
backup_dir = os.path.join(self.cache_dir, "backup") file_path = os.path.join(self.BACKUP_DIR, filename)
file_path = os.path.join(backup_dir, filename)
with zipfile.ZipFile(file_path, "r") as z: with zipfile.ZipFile(file_path, "r") as z:
zip_content = z.namelist() zip_content = z.namelist()
z.extractall(backup_dir) z.extractall(self.BACKUP_DIR)
return zip_content return zip_content
def _restore_json_files(self, zip_content): def _restore_json_files(self, zip_content):
"""go through the unpacked files and restore""" """go through the unpacked files and restore"""
backup_dir = os.path.join(self.cache_dir, "backup")
for idx, json_f in enumerate(zip_content): for idx, json_f in enumerate(zip_content):
self._notify_restore(idx, json_f, len(zip_content)) self._notify_restore(idx, json_f, len(zip_content))
file_name = os.path.join(backup_dir, json_f) file_name = os.path.join(self.BACKUP_DIR, json_f)
if not json_f.startswith("es_") or not json_f.endswith(".json"): if not json_f.startswith("es_") or not json_f.endswith(".json"):
os.remove(file_name) os.remove(file_name)
@ -201,13 +208,21 @@ class ElasticBackup:
print("no backup files to rotate") print("no backup files to rotate")
return return
backup_dir = os.path.join(self.cache_dir, "backup")
all_to_delete = auto[rotate:] all_to_delete = auto[rotate:]
for to_delete in all_to_delete: for to_delete in all_to_delete:
file_path = os.path.join(backup_dir, to_delete["filename"]) self.delete_file(to_delete["filename"])
print(f"remove old backup file: {file_path}")
os.remove(file_path) def delete_file(self, filename):
"""delete backup file"""
file_path = os.path.join(self.BACKUP_DIR, filename)
if not os.path.exists(file_path):
print(f"backup file not found: {filename}")
return False
print(f"remove old backup file: {file_path}")
os.remove(file_path)
return file_path
class BackupCallback: class BackupCallback:

View File

@ -1,92 +0,0 @@
"""
Functionality:
- collection of functions and tasks from frontend
- called via user input
"""
from home.src.ta.users import UserConfig
from home.tasks import run_restore_backup
class PostData:
"""
map frontend http post values to backend funcs
handover long running tasks to celery
"""
def __init__(self, post_dict, current_user):
self.post_dict = post_dict
self.to_exec, self.exec_val = list(post_dict.items())[0]
self.current_user = current_user
def run_task(self):
"""execute and return task result"""
to_exec = self.exec_map()
task_result = to_exec()
return task_result
def exec_map(self):
"""map dict key and return function to execute"""
exec_map = {
"change_view": self._change_view,
"change_grid": self._change_grid,
"sort_order": self._sort_order,
"hide_watched": self._hide_watched,
"show_subed_only": self._show_subed_only,
"show_ignored_only": self._show_ignored_only,
"db-restore": self._db_restore,
}
return exec_map[self.to_exec]
def _change_view(self):
"""process view changes in home, channel, and downloads"""
view, setting = self.exec_val.split(":")
UserConfig(self.current_user).set_value(f"view_style_{view}", setting)
return {"success": True}
def _change_grid(self):
"""process change items in grid"""
grid_items = int(self.exec_val)
grid_items = max(grid_items, 3)
grid_items = min(grid_items, 7)
UserConfig(self.current_user).set_value("grid_items", grid_items)
return {"success": True}
def _sort_order(self):
"""change the sort between published to downloaded"""
if self.exec_val in ["asc", "desc"]:
UserConfig(self.current_user).set_value(
"sort_order", self.exec_val
)
else:
UserConfig(self.current_user).set_value("sort_by", self.exec_val)
return {"success": True}
def _hide_watched(self):
"""toggle if to show watched vids or not"""
UserConfig(self.current_user).set_value(
"hide_watched", bool(int(self.exec_val))
)
return {"success": True}
def _show_subed_only(self):
"""show or hide subscribed channels only on channels page"""
UserConfig(self.current_user).set_value(
"show_subed_only", bool(int(self.exec_val))
)
return {"success": True}
def _show_ignored_only(self):
"""switch view on /downloads/ to show ignored only"""
UserConfig(self.current_user).set_value(
"show_ignored_only", bool(int(self.exec_val))
)
return {"success": True}
def _db_restore(self):
"""restore es zip from settings page"""
print("restoring index from backup zip")
filename = self.exec_val
run_restore_backup.delay(filename)
return {"success": True}

View File

@ -2,9 +2,12 @@
- hold all form classes used in the views - hold all form classes used in the views
""" """
import os
from django import forms from django import forms
from django.contrib.auth.forms import AuthenticationForm from django.contrib.auth.forms import AuthenticationForm
from django.forms.widgets import PasswordInput, TextInput from django.forms.widgets import PasswordInput, TextInput
from home.src.ta.helper import get_stylesheets
class CustomAuthForm(AuthenticationForm): class CustomAuthForm(AuthenticationForm):
@ -29,14 +32,16 @@ class CustomAuthForm(AuthenticationForm):
class UserSettingsForm(forms.Form): class UserSettingsForm(forms.Form):
"""user configurations values""" """user configurations values"""
CHOICES = [ STYLESHEET_CHOICES = [("", "-- change stylesheet --")]
("", "-- change color scheme --"), STYLESHEET_CHOICES.extend(
("dark", "Dark"), [
("light", "Light"), (stylesheet, os.path.splitext(stylesheet)[0].title())
] for stylesheet in get_stylesheets()
]
)
colors = forms.ChoiceField( stylesheet = forms.ChoiceField(
widget=forms.Select, choices=CHOICES, required=False widget=forms.Select, choices=STYLESHEET_CHOICES, required=False
) )
page_size = forms.IntegerField(required=False) page_size = forms.IntegerField(required=False)

View File

@ -23,20 +23,13 @@ class YoutubeChannel(YouTubeItem):
es_path = False es_path = False
index_name = "ta_channel" index_name = "ta_channel"
yt_base = "https://www.youtube.com/channel/" yt_base = "https://www.youtube.com/channel/"
yt_obs = { yt_obs = {"playlist_items": "0,0"}
"extract_flat": True,
"allow_playlist_files": True,
}
def __init__(self, youtube_id, task=False): def __init__(self, youtube_id, task=False):
super().__init__(youtube_id) super().__init__(youtube_id)
self.all_playlists = False self.all_playlists = False
self.task = task self.task = task
def build_yt_url(self):
"""overwrite base to use channel about page"""
return f"{self.yt_base}{self.youtube_id}/about"
def build_json(self, upload=False, fallback=False): def build_json(self, upload=False, fallback=False):
"""get from es or from youtube""" """get from es or from youtube"""
self.get_from_es() self.get_from_es()
@ -69,7 +62,7 @@ class YoutubeChannel(YouTubeItem):
"channel_banner_url": self._get_banner_art(), "channel_banner_url": self._get_banner_art(),
"channel_thumb_url": self._get_thumb_art(), "channel_thumb_url": self._get_thumb_art(),
"channel_tvart_url": self._get_tv_art(), "channel_tvart_url": self._get_tv_art(),
"channel_views": self.youtube_meta.get("view_count", 0), "channel_views": self.youtube_meta.get("view_count") or 0,
} }
def _parse_tags(self, tags): def _parse_tags(self, tags):

View File

@ -83,7 +83,7 @@ class Scanner:
if self.task: if self.task:
self.task.send_progress( self.task.send_progress(
message_lines=[ message_lines=[
f"Index missing video {youtube_id}, {idx}/{total}" f"Index missing video {youtube_id}, {idx + 1}/{total}"
], ],
progress=(idx + 1) / total, progress=(idx + 1) / total,
) )

View File

@ -12,6 +12,7 @@ from datetime import datetime
from urllib.parse import urlparse from urllib.parse import urlparse
import requests import requests
from home.src.ta.settings import EnvironmentSettings
def ignore_filelist(filelist: list[str]) -> list[str]: def ignore_filelist(filelist: list[str]) -> list[str]:
@ -203,3 +204,21 @@ def ta_host_parser(ta_host: str) -> tuple[list[str], list[str]]:
csrf_trusted_origins.append(f"{parsed.scheme}://{parsed.hostname}") csrf_trusted_origins.append(f"{parsed.scheme}://{parsed.hostname}")
return allowed_hosts, csrf_trusted_origins return allowed_hosts, csrf_trusted_origins
def get_stylesheets():
"""Get all valid stylesheets from /static/css"""
app_root = EnvironmentSettings.APP_DIR
stylesheets = os.listdir(os.path.join(app_root, "static/css"))
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"

View File

@ -7,12 +7,13 @@ Functionality:
from typing import TypedDict from typing import TypedDict
from home.src.es.connect import ElasticWrap from home.src.es.connect import ElasticWrap
from home.src.ta.helper import get_stylesheets
class UserConfigType(TypedDict, total=False): class UserConfigType(TypedDict, total=False):
"""describes the user configuration""" """describes the user configuration"""
colors: str stylesheet: str
page_size: int page_size: int
sort_by: str sort_by: str
sort_order: str sort_order: str
@ -31,7 +32,7 @@ class UserConfig:
"""Handle settings for an individual user""" """Handle settings for an individual user"""
_DEFAULT_USER_SETTINGS = UserConfigType( _DEFAULT_USER_SETTINGS = UserConfigType(
colors="dark", stylesheet="dark.css",
page_size=12, page_size=12,
sort_by="published", sort_by="published",
sort_order="desc", sort_order="desc",
@ -46,9 +47,15 @@ class UserConfig:
sponsorblock_id=None, sponsorblock_id=None,
) )
VALID_STYLESHEETS = get_stylesheets()
VALID_VIEW_STYLE = ["grid", "list"]
VALID_SORT_ORDER = ["asc", "desc"]
VALID_SORT_BY = ["published", "downloaded", "views", "likes"]
VALID_GRID_ITEMS = range(3, 8)
def __init__(self, user_id: str): def __init__(self, user_id: str):
self._user_id: str = user_id self._user_id: str = user_id
self._config: UserConfigType = self._get_config() self._config: UserConfigType = self.get_config()
def get_value(self, key: str): def get_value(self, key: str):
"""Get the given key from the users configuration """Get the given key from the users configuration
@ -60,15 +67,8 @@ class UserConfig:
return self._config.get(key) or self._DEFAULT_USER_SETTINGS.get(key) return self._config.get(key) or self._DEFAULT_USER_SETTINGS.get(key)
def set_value(self, key: str, value: str | bool | int): def set_value(self, key: str, value: str | bool | int):
"""Set or replace a configuration value for the user """Set or replace a configuration value for the user"""
self._validate(key, value)
Throws a KeyError if the requested Key is not a permitted value"""
if not self._user_id:
raise ValueError("Unable to persist config for null user_id")
if key not in self._DEFAULT_USER_SETTINGS:
raise KeyError(f"Unable to persist config for unknown key '{key}'")
old = self.get_value(key) old = self.get_value(key)
self._config[key] = value self._config[key] = value
@ -79,9 +79,45 @@ class UserConfig:
if status < 200 or status > 299: if status < 200 or status > 299:
raise ValueError(f"Failed storing user value {status}: {response}") raise ValueError(f"Failed storing user value {status}: {response}")
print(f"User {self._user_id} value '{key}' change: {old} > {value}") print(f"User {self._user_id} value '{key}' change: {old} -> {value}")
def _get_config(self) -> UserConfigType: def _validate(self, key, value):
"""validate key and value"""
if not self._user_id:
raise ValueError("Unable to persist config for null user_id")
if key not in self._DEFAULT_USER_SETTINGS:
raise KeyError(
f"Unable to persist config for an unknown key '{key}'"
)
valid_values = {
"stylesheet": self.VALID_STYLESHEETS,
"sort_by": self.VALID_SORT_BY,
"sort_order": self.VALID_SORT_ORDER,
"view_style_home": self.VALID_VIEW_STYLE,
"view_style_channel": self.VALID_VIEW_STYLE,
"view_style_download": self.VALID_VIEW_STYLE,
"view_style_playlist": self.VALID_VIEW_STYLE,
"grid_items": self.VALID_GRID_ITEMS,
"page_size": int,
"hide_watched": bool,
"show_ignored_only": bool,
"show_subed_only": bool,
}
validation_value = valid_values.get(key)
if isinstance(validation_value, (list, range)):
if value not in validation_value:
raise ValueError(f"Invalid value for {key}: {value}")
elif validation_value == int:
if not isinstance(value, int):
raise ValueError(f"Invalid value for {key}: {value}")
elif validation_value == bool:
if not isinstance(value, bool):
raise ValueError(f"Invalid value for {key}: {value}")
def get_config(self) -> UserConfigType:
"""get config from ES or load from the application defaults""" """get config from ES or load from the application defaults"""
if not self._user_id: if not self._user_id:
# this is for a non logged-in user so use all the defaults # this is for a non logged-in user so use all the defaults

View File

@ -294,7 +294,7 @@ def run_restore_backup(self, filename):
if manager.is_pending(self): if manager.is_pending(self):
print(f"[task][{self.name}] restore is already running") print(f"[task][{self.name}] restore is already running")
self.send_progress("Restore is already running.") self.send_progress("Restore is already running.")
return return None
manager.init(self) manager.init(self)
self.send_progress(["Reset your Index"]) self.send_progress(["Reset your Index"])
@ -302,6 +302,8 @@ def run_restore_backup(self, filename):
ElasticBackup(task=self).restore(filename) ElasticBackup(task=self).restore(filename)
print("index restore finished") print("index restore finished")
return f"backup restore completed: {filename}"
@shared_task(bind=True, name="rescan_filesystem", base=BaseTask) @shared_task(bind=True, name="rescan_filesystem", base=BaseTask)
def rescan_filesystem(self): def rescan_filesystem(self):

View File

@ -23,11 +23,7 @@
{% else %} {% else %}
<title>TubeArchivist</title> <title>TubeArchivist</title>
{% endif %} {% endif %}
{% if colors == "dark" %} <link rel="stylesheet" href="{% static 'css/' %}{{ stylesheet }}">
<link rel="stylesheet" href="{% static 'css/dark.css' %}">
{% else %}
<link rel="stylesheet" href="{% static 'css/light.css' %}">
{% endif %}
<script type="text/javascript" src="{% static 'script.js' %}"></script> <script type="text/javascript" src="{% static 'script.js' %}"></script>
{% if cast %} {% if cast %}
<script type="text/javascript" src="https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"></script> <script type="text/javascript" src="https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"></script>
@ -39,12 +35,7 @@
<div class="boxed-content"> <div class="boxed-content">
<div class="top-banner"> <div class="top-banner">
<a href="{% url 'home' %}"> <a href="{% url 'home' %}">
{% if colors == 'dark' %} <img alt="tube-archivist-banner">
<img src="{% static 'img/banner-tube-archivist-dark.png' %}" alt="tube-archivist-banner">
{% endif %}
{% if colors == 'light' %}
<img src="{% static 'img/banner-tube-archivist-light.png' %}" alt="tube-archivist-banner">
{% endif %}
</a> </a>
</div> </div>
<div class="top-nav"> <div class="top-nav">

View File

@ -77,13 +77,13 @@
<div class="sort"> <div class="sort">
<div id="hidden-form"> <div id="hidden-form">
<span>Sort by:</span> <span>Sort by:</span>
<select name="sort" id="sort" onchange="sortChange(this.value)"> <select name="sort_by" id="sort" onchange="sortChange(this)">
<option value="published" {% if sort_by == "published" %}selected{% endif %}>date published</option> <option value="published" {% if sort_by == "published" %}selected{% endif %}>date published</option>
<option value="downloaded" {% if sort_by == "downloaded" %}selected{% endif %}>date downloaded</option> <option value="downloaded" {% if sort_by == "downloaded" %}selected{% endif %}>date downloaded</option>
<option value="views" {% if sort_by == "views" %}selected{% endif %}>views</option> <option value="views" {% if sort_by == "views" %}selected{% endif %}>views</option>
<option value="likes" {% if sort_by == "likes" %}selected{% endif %}>likes</option> <option value="likes" {% if sort_by == "likes" %}selected{% endif %}>likes</option>
</select> </select>
<select name="sord-order" id="sort-order" onchange="sortChange(this.value)"> <select name="sort_order" id="sort-order" onchange="sortChange(this)">
<option value="asc" {% if sort_order == "asc" %}selected{% endif %}>asc</option> <option value="asc" {% if sort_order == "asc" %}selected{% endif %}>asc</option>
<option value="desc" {% if sort_order == "desc" %}selected{% endif %}>desc</option> <option value="desc" {% if sort_order == "desc" %}selected{% endif %}>desc</option>
</select> </select>

View File

@ -60,13 +60,13 @@
<div class="sort"> <div class="sort">
<div id="hidden-form"> <div id="hidden-form">
<span>Sort by:</span> <span>Sort by:</span>
<select name="sort" id="sort" onchange="sortChange(this.value)"> <select name="sort_by" id="sort" onchange="sortChange(this)">
<option value="published" {% if sort_by == "published" %}selected{% endif %}>date published</option> <option value="published" {% if sort_by == "published" %}selected{% endif %}>date published</option>
<option value="downloaded" {% if sort_by == "downloaded" %}selected{% endif %}>date downloaded</option> <option value="downloaded" {% if sort_by == "downloaded" %}selected{% endif %}>date downloaded</option>
<option value="views" {% if sort_by == "views" %}selected{% endif %}>views</option> <option value="views" {% if sort_by == "views" %}selected{% endif %}>views</option>
<option value="likes" {% if sort_by == "likes" %}selected{% endif %}>likes</option> <option value="likes" {% if sort_by == "likes" %}selected{% endif %}>likes</option>
</select> </select>
<select name="sord-order" id="sort-order" onchange="sortChange(this.value)"> <select name="sort_order" id="sort-order" onchange="sortChange(this)">
<option value="asc" {% if sort_order == "asc" %}selected{% endif %}>asc</option> <option value="asc" {% if sort_order == "asc" %}selected{% endif %}>asc</option>
<option value="desc" {% if sort_order == "desc" %}selected{% endif %}>desc</option> <option value="desc" {% if sort_order == "desc" %}selected{% endif %}>desc</option>
</select> </select>

View File

@ -18,20 +18,11 @@
<meta name="msapplication-TileColor" content="#01202e"> <meta name="msapplication-TileColor" content="#01202e">
<meta name="msapplication-config" content="{% static 'favicon/browserconfig.xml' %}"> <meta name="msapplication-config" content="{% static 'favicon/browserconfig.xml' %}">
<meta name="theme-color" content="#01202e"> <meta name="theme-color" content="#01202e">
{% if colors == "dark" %} <link rel="stylesheet" href="{% static 'css/' %}{{ stylesheet }}">
<link rel="stylesheet" href="{% static 'css/dark.css' %}">
{% else %}
<link rel="stylesheet" href="{% static 'css/light.css' %}">
{% endif %}
</head> </head>
<body> <body>
<div class="boxed-content login-page"> <div class="boxed-content login-page">
{% if colors == 'dark' %} <img alt="tube-archivist-logo">
<img src="{% static 'img/logo-tube-archivist-dark.png' %}" alt="tube-archivist-logo">
{% endif %}
{% if colors == 'light' %}
<img src="{% static 'img/logo-tube-archivist-light.png' %}" alt="tube-archivist-banner">
{% endif %}
<h1>Tube Archivist</h1> <h1>Tube Archivist</h1>
<h2>Your Self Hosted YouTube Media Server</h2> <h2>Your Self Hosted YouTube Media Server</h2>
{% if form_error %} {% if form_error %}

View File

@ -7,11 +7,11 @@
<form action="{% url 'settings_user' %}" method="POST" name="user-update"> <form action="{% url 'settings_user' %}" method="POST" name="user-update">
{% csrf_token %} {% csrf_token %}
<div class="settings-group"> <div class="settings-group">
<h2>Color scheme</h2> <h2>Stylesheet</h2>
<div class="settings-item"> <div class="settings-item">
<p>Current color scheme: <span class="settings-current">{{ colors }}</span></p> <p>Current stylesheet: <span class="settings-current">{{ stylesheet }}</span></p>
<i>Select your preferred color scheme between dark and light mode.</i><br> <i>Select your preferred stylesheet.</i><br>
{{ user_form.colors }} {{ user_form.stylesheet }}
</div> </div>
</div> </div>
<div class="settings-group"> <div class="settings-group">

View File

@ -58,7 +58,6 @@ urlpatterns = [
login_required(views.SettingsActionsView.as_view()), login_required(views.SettingsActionsView.as_view()),
name="settings_actions", name="settings_actions",
), ),
path("process/", login_required(views.process), name="process"),
path( path(
"channel/", "channel/",
login_required(views.ChannelView.as_view()), login_required(views.ChannelView.as_view()),

View File

@ -4,7 +4,6 @@ Functionality:
- holds base classes to inherit from - holds base classes to inherit from
""" """
import enum import enum
import json
import urllib.parse import urllib.parse
from time import sleep from time import sleep
@ -14,7 +13,7 @@ from django.conf import settings
from django.contrib.auth import login from django.contrib.auth import login
from django.contrib.auth.decorators import user_passes_test from django.contrib.auth.decorators import user_passes_test
from django.contrib.auth.forms import AuthenticationForm from django.contrib.auth.forms import AuthenticationForm
from django.http import Http404, JsonResponse from django.http import Http404
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views import View from django.views import View
@ -23,7 +22,6 @@ from home.src.download.yt_dlp_base import CookieHandler
from home.src.es.backup import ElasticBackup from home.src.es.backup import ElasticBackup
from home.src.es.connect import ElasticWrap from home.src.es.connect import ElasticWrap
from home.src.es.snapshot import ElasticSnapshot from home.src.es.snapshot import ElasticSnapshot
from home.src.frontend.api_calls import PostData
from home.src.frontend.forms import ( from home.src.frontend.forms import (
AddToQueueForm, AddToQueueForm,
ApplicationSettingsForm, ApplicationSettingsForm,
@ -41,7 +39,7 @@ from home.src.index.playlist import YoutubePlaylist
from home.src.index.reindex import ReindexProgress from home.src.index.reindex import ReindexProgress
from home.src.index.video_constants import VideoTypeEnum from home.src.index.video_constants import VideoTypeEnum
from home.src.ta.config import AppConfig, ReleaseVersion, ScheduleBuilder from home.src.ta.config import AppConfig, ReleaseVersion, ScheduleBuilder
from home.src.ta.helper import time_parser from home.src.ta.helper import check_stylesheet, time_parser
from home.src.ta.settings import EnvironmentSettings from home.src.ta.settings import EnvironmentSettings
from home.src.ta.ta_redis import RedisArchivist from home.src.ta.ta_redis import RedisArchivist
from home.src.ta.users import UserConfig from home.src.ta.users import UserConfig
@ -75,7 +73,9 @@ class ArchivistViewConfig(View):
self.user_conf = UserConfig(self.user_id) self.user_conf = UserConfig(self.user_id)
self.context = { self.context = {
"colors": self.user_conf.get_value("colors"), "stylesheet": check_stylesheet(
self.user_conf.get_value("stylesheet")
),
"cast": EnvironmentSettings.ENABLE_CAST, "cast": EnvironmentSettings.ENABLE_CAST,
"sort_by": self.user_conf.get_value("sort_by"), "sort_by": self.user_conf.get_value("sort_by"),
"sort_order": self.user_conf.get_value("sort_order"), "sort_order": self.user_conf.get_value("sort_order"),
@ -222,7 +222,9 @@ class MinView(View):
def get_min_context(request): def get_min_context(request):
"""build minimal vars for context""" """build minimal vars for context"""
return { return {
"colors": UserConfig(request.user.id).get_value("colors"), "stylesheet": check_stylesheet(
UserConfig(request.user.id).get_value("stylesheet")
),
"version": settings.TA_VERSION, "version": settings.TA_VERSION,
"ta_update": ReleaseVersion().get_update(), "ta_update": ReleaseVersion().get_update(),
} }
@ -979,9 +981,9 @@ class SettingsUserView(MinView):
config_handler = UserConfig(request.user.id) config_handler = UserConfig(request.user.id)
if user_form.is_valid(): if user_form.is_valid():
user_form_post = user_form.cleaned_data user_form_post = user_form.cleaned_data
if user_form_post.get("colors"): if user_form_post.get("stylesheet"):
config_handler.set_value( config_handler.set_value(
"colors", user_form_post.get("colors") "stylesheet", user_form_post.get("stylesheet")
) )
if user_form_post.get("page_size"): if user_form_post.get("page_size"):
config_handler.set_value( config_handler.set_value(
@ -1133,16 +1135,3 @@ class SettingsActionsView(MinView):
) )
return render(request, "home/settings_actions.html", context) return render(request, "home/settings_actions.html", context)
def process(request):
"""handle all the buttons calls via POST ajax"""
if request.method == "POST":
current_user = request.user.id
post_dict = json.loads(request.body.decode())
post_handler = PostData(post_dict, current_user)
if post_handler.to_exec:
task_result = post_handler.run_task()
return JsonResponse(task_result)
return JsonResponse({"success": False})

View File

@ -1,6 +1,6 @@
apprise==1.6.0 apprise==1.6.0
celery==5.3.4 celery==5.3.4
Django==4.2.6 Django==4.2.7
django-auth-ldap==4.6.0 django-auth-ldap==4.6.0
django-cors-headers==4.3.0 django-cors-headers==4.3.0
djangorestframework==3.14.0 djangorestframework==3.14.0
@ -8,6 +8,6 @@ Pillow==10.1.0
redis==5.0.1 redis==5.0.1
requests==2.31.0 requests==2.31.0
ryd-client==0.0.6 ryd-client==0.0.6
uWSGI==2.0.22 uWSGI==2.0.23
whitenoise==6.6.0 whitenoise==6.6.0
yt-dlp==2023.10.13 yt-dlp==2023.10.13

View File

@ -9,4 +9,6 @@
--accent-font-light: #97d4c8; --accent-font-light: #97d4c8;
--img-filter: invert(50%) sepia(9%) saturate(2940%) hue-rotate(122deg) brightness(94%) contrast(90%); --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%); --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");
} }

View File

@ -9,4 +9,6 @@
--accent-font-light: #35b399; --accent-font-light: #35b399;
--img-filter: invert(50%) sepia(9%) saturate(2940%) hue-rotate(122deg) brightness(94%) contrast(90%); --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%); --img-filter-error: invert(16%) sepia(60%) saturate(3717%) hue-rotate(349deg) brightness(86%) contrast(120%);
--banner: url("../img/banner-tube-archivist-light.png");
--logo: url("../img/logo-tube-archivist-light.png");
} }

View File

@ -0,0 +1,75 @@
:root {
--main-bg: #000000;
--highlight-bg: #080808;
--highlight-error: #880000;
--highlight-error-light: #aa0000;
--highlight-bg-transparent: #0c0c0caf;
--main-font: #00aa00;
--accent-font-dark: #007700;
--accent-font-light: #00aa00;
--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");
--outline: 1px solid green;
--filter: hue-rotate(310deg);
}
.settings-group {
outline: var(--outline);
}
.info-box-item {
outline: var(--outline);
}
.footer {
outline: var(--outline);
}
.nav-icons {
filter: var(--filter);
}
.view-icons {
filter: var(--filter);
}
.top-banner {
filter: var(--filter);
}
.icon-text {
outline: var(--outline);
}
.video-item {
outline: var(--outline);
}
.channel-banner {
outline: var(--outline);
}
.description-box {
outline: var(--outline);
}
.video-player {
outline: var(--outline);
}
#notification {
outline: var(--outline);
}
textarea {
background-color: var(--highlight-bg);
outline: var(--outline);
color: var(--main-font);
}
input {
background-color: var(--highlight-bg);
color: var(--main-font);
}

View File

@ -0,0 +1,14 @@
:root {
--main-bg: #000000;
--highlight-bg: #0c0c0c;
--highlight-error: #220000;
--highlight-error-light: #330000;
--highlight-bg-transparent: #0c0c0caf;
--main-font: #888888;
--accent-font-dark: #555555;
--accent-font-light: #999999;
--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");
}

View File

@ -165,12 +165,13 @@ button:hover {
.top-banner img { .top-banner img {
width: 100%; width: 100%;
max-width: 700px; max-width: 700px;
content: var(--banner);
} }
.footer { .footer {
margin: 0; margin: 0;
padding: 20px 0; padding: 20px 0;
background-color: var(--accent-font-dark); background-color: var(--highlight-bg);
grid-row-start: 2; grid-row-start: 2;
grid-row-end: 3; grid-row-end: 3;
} }
@ -725,6 +726,7 @@ video:-webkit-full-screen {
max-width: 200px; max-width: 200px;
max-height: 200px; max-height: 200px;
margin-bottom: 40px; margin-bottom: 40px;
content: var(--logo);
} }
.login-page form { .login-page form {

View File

@ -2,9 +2,11 @@
/* globals checkMessages */ /* globals checkMessages */
function sortChange(sortValue) { function sortChange(button) {
let payload = JSON.stringify({ sort_order: sortValue }); let apiEndpoint = '/api/config/user/';
sendPost(payload); let data = {};
data[button.name] = button.value;
apiRequest(apiEndpoint, 'POST', data);
setTimeout(function () { setTimeout(function () {
location.reload(); location.reload();
}, 500); }, 500);
@ -105,17 +107,21 @@ function subscribeStatus(subscribeButton) {
function changeView(image) { function changeView(image) {
let sourcePage = image.getAttribute('data-origin'); let sourcePage = image.getAttribute('data-origin');
let newView = image.getAttribute('data-value'); let newView = image.getAttribute('data-value');
let payload = JSON.stringify({ change_view: sourcePage + ':' + newView }); let apiEndpoint = '/api/config/user/';
sendPost(payload); let data = {};
data[`view_style_${sourcePage}`] = newView;
console.log(data);
apiRequest(apiEndpoint, 'POST', data);
setTimeout(function () { setTimeout(function () {
location.reload(); location.reload();
}, 500); }, 500);
} }
function changeGridItems(image) { function changeGridItems(image) {
let operator = image.getAttribute('data-value'); let newGridItems = Number(image.getAttribute('data-value'));
let payload = JSON.stringify({ change_grid: operator }); let apiEndpoint = '/api/config/user/';
sendPost(payload); let data = { grid_items: newGridItems };
apiRequest(apiEndpoint, 'POST', data);
setTimeout(function () { setTimeout(function () {
location.reload(); location.reload();
}, 500); }, 500);
@ -123,12 +129,10 @@ function changeGridItems(image) {
function toggleCheckbox(checkbox) { function toggleCheckbox(checkbox) {
// pass checkbox id as key and checkbox.checked as value // pass checkbox id as key and checkbox.checked as value
let toggleId = checkbox.id; let apiEndpoint = '/api/config/user/';
let toggleVal = checkbox.checked; let data = {};
let payloadDict = {}; data[checkbox.id] = checkbox.checked;
payloadDict[toggleId] = toggleVal; apiRequest(apiEndpoint, 'POST', data);
let payload = JSON.stringify(payloadDict);
sendPost(payload);
setTimeout(function () { setTimeout(function () {
let currPage = window.location.pathname; let currPage = window.location.pathname;
window.location.replace(currPage); window.location.replace(currPage);
@ -283,7 +287,7 @@ function reEmbed() {
} }
function dbBackup() { function dbBackup() {
let apiEndpoint = '/api/task-name/run_backup/'; let apiEndpoint = '/api/backup/';
apiRequest(apiEndpoint, 'POST'); apiRequest(apiEndpoint, 'POST');
// clear button // clear button
let message = document.createElement('p'); let message = document.createElement('p');
@ -299,8 +303,8 @@ function dbBackup() {
function dbRestore(button) { function dbRestore(button) {
let fileName = button.getAttribute('data-id'); let fileName = button.getAttribute('data-id');
let payload = JSON.stringify({ 'db-restore': fileName }); let apiEndpoint = `/api/backup/${fileName}/`;
sendPost(payload); apiRequest(apiEndpoint, 'POST');
// clear backup row // clear backup row
let message = document.createElement('p'); let message = document.createElement('p');
message.innerText = 'restoring from backup'; message.innerText = 'restoring from backup';
@ -542,7 +546,7 @@ function createVideoTag(videoData, videoProgress) {
} }
let videoTag = ` let videoTag = `
<video poster="${videoThumbUrl}" ontimeupdate="onVideoProgress()" onpause="onVideoPause()" onended="onVideoEnded()" controls autoplay width="100%" playsinline id="video-item"> <video poster="${videoThumbUrl}" onvolumechange="onVolumeChange(this)" onloadstart="this.volume=getPlayerVolume()" ontimeupdate="onVideoProgress()" onpause="onVideoPause()" onended="onVideoEnded()" controls autoplay width="100%" playsinline id="video-item">
<source src="${videoUrl}#t=${videoProgress}" type="video/mp4" id="video-source" videoid="${videoId}"> <source src="${videoUrl}#t=${videoProgress}" type="video/mp4" id="video-source" videoid="${videoId}">
${subtitles} ${subtitles}
</video> </video>
@ -550,6 +554,14 @@ function createVideoTag(videoData, videoProgress) {
return videoTag; return videoTag;
} }
function onVolumeChange(videoTag) {
localStorage.setItem('playerVolume', videoTag.volume);
}
function getPlayerVolume() {
return localStorage.getItem('playerVolume') ?? 1;
}
// Gets video tag // Gets video tag
function getVideoPlayer() { function getVideoPlayer() {
let videoElement = document.getElementById('video-item'); let videoElement = document.getElementById('video-item');
@ -1304,14 +1316,6 @@ function populateEmpty() {
// generic // generic
function sendPost(payload) {
let http = new XMLHttpRequest();
http.open('POST', '/process/', true);
http.setRequestHeader('X-CSRFToken', getCookie('csrftoken'));
http.setRequestHeader('Content-type', 'application/json');
http.send(payload);
}
function getCookie(c_name) { function getCookie(c_name) {
if (document.cookie.length > 0) { if (document.cookie.length > 0) {
let c_start = document.cookie.indexOf(c_name + '='); let c_start = document.cookie.indexOf(c_name + '=');