diff options
| -rw-r--r-- | apps/administration/urls.py | 26 | ||||
| -rw-r--r-- | apps/administration/views.py | 189 | ||||
| -rw-r--r-- | apps/anime/views.py | 155 | ||||
| -rw-r--r-- | internal/admin_utilities.py | 145 | ||||
| -rw-r--r-- | internal/cache_utils.py | 118 | ||||
| -rw-r--r-- | requirements.txt | 2 | ||||
| -rw-r--r-- | static/css/anime/video_player.css | 32 | ||||
| -rw-r--r-- | static/css/shared/directory.css | 197 | ||||
| -rw-r--r-- | static/images/core/gifs/buffering.gif | bin | 0 -> 7054 bytes | |||
| -rw-r--r-- | static/images/core/icons/folder.png | bin | 0 -> 481 bytes | |||
| -rw-r--r-- | static/images/core/icons/music.png | bin | 0 -> 684 bytes | |||
| -rw-r--r-- | static/js/libs/videoPlayer.js | 159 | ||||
| -rw-r--r-- | static/js/shared/directory.js | 58 | ||||
| -rw-r--r-- | templates/en/administration/manage_storage_buckets_bucket.html | 257 | ||||
| -rw-r--r-- | templates/en/administration/manage_storage_buckets_home.html | 26 | ||||
| -rw-r--r-- | templates/en/anime/anime.html | 53 | ||||
| -rw-r--r-- | templates/shared/left_sidebar.html | 4 | ||||
| -rw-r--r-- | thatcomputerscientist/settings.py | 40 |
18 files changed, 1363 insertions, 98 deletions
diff --git a/apps/administration/urls.py b/apps/administration/urls.py index d4781c66..1aa0146f 100644 --- a/apps/administration/urls.py +++ b/apps/administration/urls.py @@ -3,4 +3,28 @@ from django.urls import path from . import views app_name = "administration" -urlpatterns = [] +urlpatterns = [ + path( + "storage-buckets/", views.manage_storage_buckets, name="manage_storage_buckets" + ), + path( + "storage-buckets/create-folder/", + views.create_folder, + name="manage_storage_bucket_create_folder", + ), + path( + "storage-buckets/upload-file/", + views.upload_file, + name="manage_storage_bucket_upload_file", + ), + path( + "storage-buckets/<str:bucket_name>/", + views.view_single_bucket, + name="view_single_bucket", + ), + path( + "storage-buckets/<str:bucket_name>/<path:object_path>/", + views.view_object_path, + name="view_object_path", + ), +] diff --git a/apps/administration/views.py b/apps/administration/views.py index 91ea44a2..bfe2cc57 100644 --- a/apps/administration/views.py +++ b/apps/administration/views.py @@ -1,3 +1,192 @@ +import os +from uuid import uuid4 + +from django.contrib.auth.decorators import login_required, user_passes_test +from django.http import JsonResponse +from django.views.decorators.http import require_http_methods +from django.core.files.storage import default_storage +from internal.admin_utilities import MinioFileManager from django.shortcuts import render + # Create your views here. +@login_required +@user_passes_test(lambda u: u.is_superuser) +def manage_storage_buckets(request): + file_manager = MinioFileManager() + buckets = file_manager.list_buckets() + + context = { + "buckets": buckets, + } + + return render( + request, "en/administration/manage_storage_buckets_home.html", context + ) + + +@login_required +@user_passes_test(lambda u: u.is_superuser) +def view_single_bucket(request, bucket_name): + file_manager = MinioFileManager() + objects = file_manager.list_objects(bucket_name) + for obj in objects: + file_type = obj["path"].split(".")[-1] + obj["file_type"] = file_type.lower() + + context = { + "bucket_name": bucket_name, + "objects": objects, + } + + return render( + request, "en/administration/manage_storage_buckets_bucket.html", context + ) + + +@login_required +@user_passes_test(lambda u: u.is_superuser) +def view_object_path(request, bucket_name, object_path): + file_manager = MinioFileManager() + objects = file_manager.list_objects(bucket_name, object_path) + for obj in objects: + file_type = obj["path"].split(".")[-1] + obj["file_type"] = file_type.lower() + + object_path_s = "" + if "/" in object_path: + object_path_s = object_path.split("/")[-2] + else: + object_path = f"{object_path}/" + + up_url = f"/admin/storage-buckets/{bucket_name}/{object_path_s}" + context = { + "bucket_name": bucket_name, + "object_path": object_path, + "objects": objects, + "up_url": up_url, + } + + return render( + request, "en/administration/manage_storage_buckets_bucket.html", context + ) + + +@login_required +@user_passes_test(lambda u: u.is_superuser) +@require_http_methods(["POST"]) +def create_folder(request): + """ + Create a folder in a specified bucket. + Requires staff permissions. + """ + try: + # For Django, parse POST data differently + bucket_name = request.POST.get("bucket") + folder_path = request.POST.get("folder_path", "").strip("/") + + # Additional debug logging + print(f"Bucket: {bucket_name}") + print(f"Folder Path: {folder_path}") + print(f"Request POST: {request.POST}") + print(f"Request body: {request.body}") + + # Validate inputs + if not bucket_name or not folder_path: + return JsonResponse( + {"status": "error", "message": "Bucket and folder path are required"}, + status=400, + ) + + # Initialize file manager without user context + file_manager = MinioFileManager() + + # Ensure folder path ends with a slash for Minio + full_folder_path = f"{folder_path}/" + + # Attempt to create folder + success = file_manager.create_folder( + bucket=bucket_name, folder_path=full_folder_path + ) + + if success: + return JsonResponse( + { + "status": "success", + "message": "Folder created successfully", + "folder_path": folder_path, + } + ) + else: + return JsonResponse( + {"status": "error", "message": "Failed to create folder"}, status=500 + ) + + except Exception as e: + # Log the full error for debugging + import traceback + + traceback.print_exc() + return JsonResponse({"status": "error", "message": str(e)}, status=500) + + +@login_required +@require_http_methods(["POST"]) +def upload_file(request): + """ + Upload a file to a specified bucket. + Requires user to be logged in. + """ + try: + # Get uploaded file + uploaded_file = request.FILES.get("file") + bucket_name = request.POST.get("bucket") + current_path = request.POST.get("current_path", "") + + # Validate inputs + if not uploaded_file or not bucket_name: + return JsonResponse( + {"status": "error", "message": "File and bucket are required"}, + status=400, + ) + + # Generate unique filename + file_ext = os.path.splitext(uploaded_file.name)[1] + unique_filename = f"{uuid4()}{file_ext}" + + # Construct full path + full_path = ( + os.path.join(current_path, unique_filename) + if current_path + else unique_filename + ) + + # Temporarily save file to local storage + temp_file_path = default_storage.save(f"temp/{unique_filename}", uploaded_file) + + # Initialize file manager without user context + file_manager = MinioFileManager() + + # Upload file + success = file_manager.upload_file( + bucket=bucket_name, file_path=temp_file_path, object_name=full_path + ) + + # Clean up temporary file + default_storage.delete(temp_file_path) + + if success: + return JsonResponse( + { + "status": "success", + "message": "File uploaded successfully", + "filename": unique_filename, + } + ) + else: + return JsonResponse( + {"status": "error", "message": "Failed to upload file"}, status=500 + ) + + except Exception as e: + return JsonResponse({"status": "error", "message": str(e)}, status=500) diff --git a/apps/anime/views.py b/apps/anime/views.py index c1012d09..fe1e90dc 100644 --- a/apps/anime/views.py +++ b/apps/anime/views.py @@ -2,10 +2,8 @@ import os from django.urls import reverse import requests from django.shortcuts import redirect, render -from django.views.decorators.cache import cache_page -from functools import wraps -from django.core.cache import cache from thatcomputerscientist.utils import i18npatterns +from internal.cache_utils import cache_data genres = [ "Action", @@ -43,6 +41,24 @@ CONSUMET_BASE_URL = os.getenv("CONSUMET_URL") ZORO_URL = os.getenv("ZORO_URL") +def sort_mapper(sort_by, order): + sort_mappings = { + "popularity": "POPULARITY", + "trending": "TRENDING", + "start_date": "START_DATE", + "end_date": "END_DATE", + "score": "SCORE", + "favourites": "FAVOURITES", + "title": "TITLE_ROMAJI", + } + + if sort_by not in sort_mappings or order not in ["asc", "desc"]: + return None + + return f"{sort_mappings[sort_by]}{'_DESC' if order == 'desc' else ''}" + + +@cache_data(timeout=60 * 60, prefix="anime_data") def get_anime(anime_id, dub=False): provider = ANIME_PROVIDER_MAP.get(anime_id, "zoro") params = {"dub": "true"} if dub else {} @@ -79,6 +95,25 @@ def get_anime(anime_id, dub=False): return data +def find_optimal_server(episode_id, dub): + params = {"animeEpisodeId": episode_id} + response = requests.get(f"{ZORO_URL}/api/v2/hianime/episode/servers", params=params) + response = response.json() + if "message" in response: + return None + + response = response["data"] + if dub and "dub" in response and len(response["dub"]) > 0: + return response["dub"][0]["serverName"] + elif len(response["sub"]) > 0 and "sub" in response: + return response["sub"][0]["serverName"] + elif len(response["raw"]) > 0: + return response["raw"][0]["serverName"] + else: + return None + + +@cache_data(timeout=60 * 60 * 24 * 7, prefix="streaming_data") def get_anime_streaming_data(anime_id, current_episode, dub=False): provider = ANIME_PROVIDER_MAP.get(anime_id, "zoro") current_episode_id = current_episode.get("id") @@ -92,34 +127,52 @@ def get_anime_streaming_data(anime_id, current_episode, dub=False): response = requests.get( f"{ZORO_URL}/api/v2/hianime/episode/sources", params=params ) - return response.json() + if response.status_code == 200: + return response.json() + else: + server = find_optimal_server(episode_id, dub) + params["server"] = server + response = requests.get( + f"{ZORO_URL}/api/v2/hianime/episode/sources", params=params + ) + if response.status_code == 200: + return response.json() + else: + ANIME_PROVIDER_MAP[anime_id] = "gogoanime" + return get_anime_streaming_data(anime_id, current_episode, dub) else: response = requests.get( f"{CONSUMET_BASE_URL}/meta/anilist/watch/{current_episode_id}", ) - return response.json() + data = { + "tracks": [], + "intro": {"start": 0, "end": 0}, + "outro": {"start": 0, "end": 0}, + "sources": [], + "anilistID": 0, + "malID": 0, + } + + if not "message" in response.json(): + default_source = next( + (s for s in response.json()["sources"] if s["quality"] == "default"), + None, + ) + data["sources"].append({"url": default_source["url"], "type": "hls"}) + else: + data["sources"].append( + { + "url": "", + "type": "", + } + ) -def sort_mapper(sort_by, order): - sort_mappings = { - "popularity": "POPULARITY", - "trending": "TRENDING", - "start_date": "START_DATE", - "end_date": "END_DATE", - "score": "SCORE", - "favourites": "FAVOURITES", - "title": "TITLE_ROMAJI", - } - - if sort_by not in sort_mappings or order not in ["asc", "desc"]: - return None - - return f"{sort_mappings[sort_by]}{'_DESC' if order == 'desc' else ''}" + return {"data": data} -def anime_results( - sort="trending", order="desc", genre="", query="", page=1, per_page=12, status="" -): +@cache_data(timeout=60 * 60, prefix="anime_results") +def anime_results(**kwargs): supported_status = [ "releasing", "not_yet_released", @@ -127,6 +180,15 @@ def anime_results( "finished", "hiatus", ] + + sort = kwargs.get("sort", "trending") + order = kwargs.get("order", "desc") + genre = kwargs.get("genre", "") + query = kwargs.get("query", "") + page = kwargs.get("page", 1) + per_page = kwargs.get("per_page", 12) + status = kwargs.get("status", "") + params = { "page": page, "perPage": per_page, @@ -144,27 +206,6 @@ def anime_results( return response.json() -def cache_anime_page(timeout=60 * 15): - def decorator(view_func): - @wraps(view_func) - def _wrapped_view(request, anime_id, e=None, *args, **kwargs): - cache_key = f"anime_page:{anime_id}:ep{e}:dub{request.COOKIES.get('anime_dub', False)}" - cached_response = cache.get(cache_key) - - if cached_response: - return cached_response - - response = view_func(request, anime_id, e, *args, **kwargs) - if response.status_code == 200: - cache.set(cache_key, response, timeout) - return response - - return _wrapped_view - - return decorator - - -# @cache_page(60 * 15) def home(request): LANGUAGE_CODE = i18npatterns(request.LANGUAGE_CODE) request.meta.update({"title": "Anime: Home"}) @@ -211,7 +252,6 @@ def search(request): return render(request, f"{LANGUAGE_CODE}/anime/search.html", context) -# @cache_anime_page(timeout=60 * 15) def anime(request, anime_id, e=None): dub = request.COOKIES.get("anime_dub", False) anime_data = get_anime(anime_id, dub) @@ -243,25 +283,36 @@ def anime(request, anime_id, e=None): return redirect( reverse("anime:anime", kwargs={"anime_id": anime_id, "e": 1}) ) + anime_data["current_episode"] = ( + anime_data.get("episodes", [])[e - 1] if e else None + ) - anime_data["current_episode"] = anime_data.get("episodes", [])[e - 1] if e else None anime_data["totalEpisodes"] = ( len(episode_numbers) if not anime_data["totalEpisodes"] else anime_data["totalEpisodes"] ) - streaming_data = get_anime_streaming_data( - anime_id, anime_data["current_episode"], dub - ) + if "current_episode" in anime_data: + streaming_data = get_anime_streaming_data( + anime_id, anime_data["current_episode"], dub + ) + print(streaming_data) + streaming_data["data"]["sources"] = streaming_data["data"]["sources"][0] + else: + streaming_data = {} + LANGUAGE_CODE = i18npatterns(request.LANGUAGE_CODE) request.meta.update( {"title": f"Anime: {anime_data.get('title', {}).get('romaji')}"} ) - streaming_data["data"]["sources"] = streaming_data["data"]["sources"][0] - print(streaming_data) + context = { + "anime": anime_data, + "streaming_data": streaming_data, + } + return render( request, f"{LANGUAGE_CODE}/anime/anime.html", - {"anime": anime_data, "streaming_data": streaming_data}, + context, ) diff --git a/internal/admin_utilities.py b/internal/admin_utilities.py new file mode 100644 index 00000000..c8014fe1 --- /dev/null +++ b/internal/admin_utilities.py @@ -0,0 +1,145 @@ +from typing import List, Dict, Union +from io import BytesIO +from uuid import uuid4 +from django.conf import settings +from minio import Minio +from minio.error import S3Error + + +class MinioFileManager: + def __init__(self): + """Initialize MinIO client with settings credentials.""" + self.client = Minio( + endpoint=settings.MINIO_ENDPOINT, + access_key=settings.MINIO_ACCESS_KEY, + secret_key=settings.MINIO_SECRET_KEY, + secure=getattr(settings, "MINIO_SECURE", True), + ) + + def list_buckets(self) -> List[Dict[str, str]]: + """List all buckets with creation dates.""" + try: + buckets = self.client.list_buckets() + return [ + { + "name": bucket.name, + "created": ( + bucket.creation_date.isoformat() + if bucket.creation_date + else None + ), + } + for bucket in buckets + ] + except S3Error: + return [] + + def list_objects( + self, bucket: str, prefix: str = "" + ) -> List[Dict[str, Union[str, bool]]]: + """List objects in a directory within bucket.""" + try: + if not self.client.bucket_exists(bucket): + return [] + + # Ensure prefix ends with slash for directory listing + prefix = prefix.rstrip("/") + "/" if prefix else "" + objects = self.client.list_objects(bucket, prefix=prefix, recursive=False) + + result = [] + seen_prefixes = set() + + for obj in objects: + # Skip the directory object itself + if obj.object_name == prefix: + continue + + # Get relative path from prefix + name = obj.object_name[len(prefix) :] + if not name: + continue + + # Handle nested directories + if "/" in name: + dir_name = name.split("/")[0] + "/" + if dir_name not in seen_prefixes: + seen_prefixes.add(dir_name) + result.append( + { + "name": dir_name.rstrip("/"), + "path": (prefix + dir_name).rstrip("/"), + "type": "folder", + "size": 0, + "last_modified": None, + } + ) + else: + result.append( + { + "name": name, + "path": obj.object_name, + "type": "file", + "size": obj.size, + "last_modified": ( + obj.last_modified.isoformat() + if obj.last_modified + else None + ), + } + ) + + return result + except S3Error as e: + print(f"MinIO Error: {e}") + return [] + + def create_folder(self, bucket: str, folder_path: str) -> bool: + """Create a folder in the specified bucket.""" + try: + if not self.client.bucket_exists(bucket): + self.client.make_bucket(bucket) + + folder_path = folder_path.rstrip("/") + "/" + self.client.put_object(bucket, folder_path, BytesIO(b""), 0) + return True + except S3Error: + return False + + def upload_file(self, bucket: str, file_path: str, object_name: str = None) -> bool: + """Upload a file to MinIO.""" + try: + if not self.client.bucket_exists(bucket): + self.client.make_bucket(bucket) + + object_name = object_name or str(uuid4()) + self.client.fput_object(bucket, object_name, file_path) + return True + except S3Error: + return False + + def delete_object( + self, bucket: str, object_path: str, recursive: bool = False + ) -> bool: + """Delete a file or folder from bucket.""" + try: + if recursive and not object_path.endswith("/"): + object_path += "/" + objects = self.client.list_objects( + bucket, prefix=object_path, recursive=True + ) + for obj in objects: + self.client.remove_object(bucket, obj.object_name) + + self.client.remove_object(bucket, object_path) + return True + except S3Error: + return False + + def generate_presigned_url( + self, bucket: str, object_name: str, expiry: int = 3600 + ) -> str: + """Generate a presigned URL for file access.""" + try: + return self.client.presigned_get_object(bucket, object_name, expiry) + except S3Error: + return "" diff --git a/internal/cache_utils.py b/internal/cache_utils.py new file mode 100644 index 00000000..7be70e57 --- /dev/null +++ b/internal/cache_utils.py @@ -0,0 +1,118 @@ +from django.core.cache import cache +from django_redis import get_redis_connection +from functools import wraps +import hashlib +import logging + +logger = logging.getLogger(__name__) + + +def generate_cache_key(*args, prefix="", **kwargs): + """Generate a unique cache key based on args and kwargs""" + # Sort kwargs to ensure consistent key generation + sorted_kwargs = sorted(kwargs.items()) + + # Convert all arguments to strings and join them + key_parts = [str(arg) for arg in args] + [f"{k}:{v}" for k, v in sorted_kwargs] + key_string = ":".join(key_parts) + + # Create an MD5 hash of the key string to ensure safe key length + hash_object = hashlib.md5(key_string.encode()) + hashed_key = hash_object.hexdigest() + + return f"{prefix}:{hashed_key}" if prefix else hashed_key + + +def safe_cache_get(key): + """Safely get data from cache with error handling""" + try: + return cache.get(key) + except Exception as e: + logger.error(f"Cache get error for key {key}: {str(e)}") + return None + + +def safe_cache_set(key, value, timeout=None): + """Safely set data in cache with error handling""" + try: + cache.set(key, value, timeout) + return True + except Exception as e: + logger.error(f"Cache set error for key {key}: {str(e)}") + return False + + +def cache_data(prefix, timeout=60 * 15): + """ + Generic cache decorator that can be used for any function. + + Args: + prefix (str): Prefix for the cache key (e.g., 'anime_data', 'streaming_data') + timeout (int): Cache timeout in seconds + """ + + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + # Generate cache key using all arguments + cache_key = generate_cache_key(*args, prefix=prefix, **kwargs) + + # Try to get from cache + cached_data = safe_cache_get(cache_key) + if cached_data is not None: + return cached_data + + try: + # Get fresh data + result = func(*args, **kwargs) + # Cache the result + safe_cache_set(cache_key, result, timeout) + return result + except Exception as e: + logger.error( + f"Error in {prefix} for args {args}, kwargs {kwargs}: {str(e)}" + ) + # On error, return fresh data without caching + return func(*args, **kwargs) + + return wrapper + + return decorator + + +def clear_cache(pattern=None, prefix=None): + """ + Clear cache entries based on a pattern or prefix. + + Args: + pattern (str): Direct Redis key pattern to match (e.g., '*anime_id*') + prefix (str): Cache prefix to clear (e.g., 'anime_data') + """ + try: + redis_conn = get_redis_connection("default") + + if not pattern and not prefix: + # Clear all known prefixes if neither pattern nor prefix specified + prefixes = ["anime_data:*", "streaming_data:*", "search_results:*"] + total_cleared = 0 + for p in prefixes: + keys = redis_conn.keys(p) + if keys: + redis_conn.delete(*keys) + total_cleared += len(keys) + logger.info(f"Cleared {len(keys)} entries for pattern {p}") + return total_cleared + + if prefix: + pattern = f"{prefix}:*" + + keys = redis_conn.keys(pattern) + if keys: + redis_conn.delete(*keys) + logger.info(f"Cleared {len(keys)} cache entries matching {pattern}") + return len(keys) + return 0 + + except Exception as e: + logger.error(f"Error clearing cache with pattern {pattern}: {str(e)}") + return 0 diff --git a/requirements.txt b/requirements.txt index d3847555..1f6caa80 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ django +django-redis python-dotenv daphne channels @@ -6,3 +7,4 @@ dnspython requests bs4 pillow +minio diff --git a/static/css/anime/video_player.css b/static/css/anime/video_player.css index 39abb527..e4b2f0b7 100644 --- a/static/css/anime/video_player.css +++ b/static/css/anime/video_player.css @@ -39,6 +39,17 @@ box-shadow: none; } +.win98-player:fullscreen .retro-buffer { + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +.win98-player:fullscreen .retro-buffer img { + height: 64px; + width: 64px; +} + .win98-player:fullscreen .win98-title-bar, .win98-player:fullscreen .episode-title { display: none; @@ -287,6 +298,8 @@ input[type="range"].volume-slider { min-width: 100px; margin-bottom: 4px; z-index: 10; + max-height: 180px; + overflow: scroll; } .quality-menu.show, @@ -412,4 +425,23 @@ video::-webkit-media-text-track-container { video::-webkit-media-text-track { display: none !important; +} + +/* Retro Buffer Styles */ +.retro-buffer { + position: absolute; + top: 246px; + left: 50%; + transform: translateX(-50%); + z-index: 5; + display: none; +} + +.retro-buffer img { + height: 32px; + width: 32px; +} + +.video-loading .retro-buffer { + display: block; }
\ No newline at end of file diff --git a/static/css/shared/directory.css b/static/css/shared/directory.css new file mode 100644 index 00000000..94941560 --- /dev/null +++ b/static/css/shared/directory.css @@ -0,0 +1,197 @@ +.directory-viewer { + position: relative; + user-select: none; + padding: 4px; + height: 100%; +} + +.directory-content { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); + gap: 8px; + padding: 4px; +} + +.folder-item { + display: flex; + flex-direction: column; + align-items: center; + padding: 4px; + cursor: default; + position: relative; +} + +.folder-icon { + width: 32px; + height: 32px; + position: relative; +} + +.folder-icon img { + width: 100%; + height: 100%; + display: block; +} + +.selection-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: transparent; + mix-blend-mode: multiply; + pointer-events: none; +} + +.folder-item.selected .selection-overlay { + background: rgba(0, 0, 255, 0.5); +} + +.folder-name { + margin-top: 4px; + font-size: 11px; + text-align: center; + max-width: 76px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.folder-item.selected .folder-name { + color: #fff; + background: #000080; +} + +/* Selection rectangle */ +.selection-rectangle { + position: absolute; + border: 1px dotted #000; + background: rgba(0, 0, 255, 0.1); + pointer-events: none; +} + +/* Context Menu */ +.context-menu { + display: none; + position: absolute; + background: #C0C0C0; + border: 2px solid; + border-color: #FFFFFF #808080 #808080 #FFFFFF; + padding: 2px; + font-size: 11px; + box-shadow: 1px 1px 0 0 #000; + min-width: 160px; + z-index: 1000; + color: #000; +} + +.context-menu.show { + display: block; +} + +.menu-item { + padding: 3px 20px; + cursor: default; +} + +.menu-item:hover { + background: #000080; + color: #FFF; +} + +.menu-separator { + height: 1px; + background: #808080; + margin: 3px 2px; + border-bottom: 1px solid #FFFFFF; +} + +.windows98-modal { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background-color: #c0c0c0; + border: 2px solid; + border-color: #ffffff #808080 #808080 #ffffff; + box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.3); + z-index: 1000; + min-width: 300px; +} + +.windows98-modal .modal-titlebar { + background-color: navy; + color: white; + padding: 2px 4px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.windows98-modal .modal-title { + font-weight: bold; +} + +.windows98-modal .modal-close { + background: none; + border: none; + color: white; + font-weight: bold; + cursor: pointer; +} + +.windows98-modal .modal-content { + padding: 20px; + background-color: #c0c0c0; +} + +.windows98-modal .modal-buttons { + background-color: #c0c0c0; + padding: 10px; + display: flex; + justify-content: flex-end; + gap: 10px; + border-top: 1px solid #808080; +} + +.windows98-modal button { + min-width: 75px; + padding: 4px 8px; + background-color: #c0c0c0; + border: 2px solid; + border-color: #ffffff #808080 #808080 #ffffff; + cursor: pointer; +} + +.windows98-modal button:active { + border-color: #808080 #ffffff #ffffff #808080; +} + +.windows98-modal .file-upload-form, +.windows98-modal .folder-creation-form { + display: flex; + flex-direction: column; + gap: 10px; +} + +.windows98-modal .upload-progress { + background-color: white; + border: 1px solid #808080; + height: 20px; + margin-top: 10px; +} + +.windows98-modal .progress-bar { + background-color: navy; + height: 100%; + width: 0; + transition: width 0.3s ease; +} + +.windows98-modal .progress-text { + position: absolute; + left: 50%; + transform: translateX(-50%); + color: navy; +}
\ No newline at end of file diff --git a/static/images/core/gifs/buffering.gif b/static/images/core/gifs/buffering.gif Binary files differnew file mode 100644 index 00000000..524e91f9 --- /dev/null +++ b/static/images/core/gifs/buffering.gif diff --git a/static/images/core/icons/folder.png b/static/images/core/icons/folder.png Binary files differnew file mode 100644 index 00000000..807f1690 --- /dev/null +++ b/static/images/core/icons/folder.png diff --git a/static/images/core/icons/music.png b/static/images/core/icons/music.png Binary files differnew file mode 100644 index 00000000..68bb414d --- /dev/null +++ b/static/images/core/icons/music.png diff --git a/static/js/libs/videoPlayer.js b/static/js/libs/videoPlayer.js index 30febb29..076d2aed 100644 --- a/static/js/libs/videoPlayer.js +++ b/static/js/libs/videoPlayer.js @@ -81,6 +81,11 @@ class VideoPlayer { } }; + static STORAGE_KEYS = { + VOLUME: 'videoplayer_volume', + QUALITY: 'videoplayer_quality' + }; + constructor(config = {}) { this.config = this.mergeConfig(VideoPlayer.defaultConfig, config); this.initializeElements(); @@ -89,6 +94,8 @@ class VideoPlayer { this.setupSubtitles(); this.setupFullscreenHandling(); this.setupVideoInteractions(); + this.setupBufferingIndicator(); + this.loadVolume(); if (this.config.keyboard.enabled) { this.setupKeyboardControls(); @@ -176,6 +183,7 @@ class VideoPlayer { setupSource() { const { url, type } = this.config.source; + if (!url) return; if (type === 'hls') { if (!Hls.isSupported()) return; @@ -237,7 +245,9 @@ class VideoPlayer { setupMenuEvents() { const closeMenus = (except) => { Object.entries(this.elements.menus).forEach(([key, menu]) => { - if (key !== except) menu.classList.remove('show'); + try { + if (key !== except) menu.classList.remove('show'); + } catch (e) { } }); }; @@ -261,12 +271,135 @@ class VideoPlayer { // Close menus when clicking outside document.addEventListener('click', (e) => { - if (!e.target.closest('.quality-control')) this.elements.menus.quality.classList.remove('show'); - if (!e.target.closest('.sub-dub-control')) this.elements.menus.subDub.classList.remove('show'); - if (!e.target.closest('#ccBtn')) this.elements.menus.caption.classList.remove('show'); + try { + if (!e.target.closest('.quality-control')) this.elements.menus.quality.classList.remove('show'); + } catch (e) { } + + try { + if (!e.target.closest('.sub-dub-control')) this.elements.menus.subDub.classList.remove('show'); + } catch (e) { } + + try { + if (!e.target.closest('#ccBtn')) this.elements.menus.caption.classList.remove('show'); + } catch (e) { } }); } + saveVolume(volume) { + try { + localStorage.setItem(VideoPlayer.STORAGE_KEYS.VOLUME, volume); + } catch (e) { } + } + + loadVolume() { + try { + const savedVolume = localStorage.getItem(VideoPlayer.STORAGE_KEYS.VOLUME); + if (savedVolume !== null) { + this.updateVolume(parseFloat(savedVolume)); + } + } catch (e) { } + } + + saveQuality(quality) { + try { + localStorage.setItem(VideoPlayer.STORAGE_KEYS.QUALITY, quality); + } catch (e) { } + } + + loadQuality() { + try { + const savedQuality = localStorage.getItem(VideoPlayer.STORAGE_KEYS.QUALITY); + if (savedQuality !== null && this.hls) { + this.hls.currentLevel = parseInt(savedQuality); + // Update quality button text + const qualityButton = this.elements.controls.quality; + const qualityText = savedQuality === '-1' ? 'Auto' : + `${this.hls.levels[savedQuality].height}p`; + qualityButton.innerHTML = this.getQualityButtonHTML(qualityText); + } + } catch (e) { } + } + + getQualityButtonHTML(text) { + return `<svg class="win98-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /> + <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /> + </svg> + <svg class="win98-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 15 12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9" /> + </svg>${text}`; + } + + setupBufferingIndicator() { + const playerContainer = this.elements.video.closest('.win98-player-content'); + let loadingTimeout; + + const isBuffering = () => { + const video = this.elements.video; + + // If seeking or initial load + if (video.seeking || video.readyState < 3) return true; + + // If playing but not enough data + if (!video.paused && video.currentTime > 0) { + // Check if we have data for the current position + for (let i = 0; i < video.buffered.length; i++) { + if (video.currentTime >= video.buffered.start(i) && + video.currentTime <= video.buffered.end(i)) { + // Check if we have enough buffer ahead + const aheadBuffer = video.buffered.end(i) - video.currentTime; + if (aheadBuffer < 0.5) return true; // Less than 0.5 seconds ahead + return false; + } + } + return true; // Current time not in any buffer range + } + return false; + }; + + const showLoading = () => { + if (loadingTimeout) clearTimeout(loadingTimeout); + loadingTimeout = setTimeout(() => { + if (isBuffering()) { + playerContainer.classList.add('video-loading'); + } + }, 100); + }; + + const hideLoading = () => { + if (loadingTimeout) clearTimeout(loadingTimeout); + loadingTimeout = setTimeout(() => { + if (!isBuffering()) { + playerContainer.classList.remove('video-loading'); + } + }, 100); + }; + + // Video events + this.elements.video.addEventListener('waiting', showLoading); + this.elements.video.addEventListener('canplay', hideLoading); + this.elements.video.addEventListener('playing', hideLoading); + this.elements.video.addEventListener('progress', showLoading); // Check on data load + this.elements.video.addEventListener('timeupdate', () => { + if (isBuffering()) showLoading(); + else hideLoading(); + }); + this.elements.video.addEventListener('seeked', hideLoading); + this.elements.video.addEventListener('stalled', showLoading); + + // HLS specific events + if (this.hls) { + this.hls.on(Hls.Events.FRAG_LOADING, showLoading); + this.hls.on(Hls.Events.FRAG_BUFFERED, hideLoading); + this.hls.on(Hls.Events.ERROR, showLoading); + } + + // Initial loading state + if (!this.elements.video.readyState || this.elements.video.readyState < 3) { + playerContainer.classList.add('video-loading'); + } + } + setupQualityMenu(levels) { this.elements.menus.quality.innerHTML = ` <button data-quality="-1">Auto</button> @@ -279,16 +412,14 @@ class VideoPlayer { if (e.target.tagName === 'BUTTON') { const quality = parseInt(e.target.dataset.quality); this.hls.currentLevel = quality; - this.elements.controls.quality.innerHTML = `<svg class="win98-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke="currentColor"> - <path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /> - <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /> - </svg> - <svg class="win98-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke="currentColor"> - <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 15 12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9" /> - </svg>${e.target.textContent}`; + this.elements.controls.quality.innerHTML = this.getQualityButtonHTML(e.target.textContent); this.elements.menus.quality.classList.remove('show'); + this.saveQuality(quality); // Save quality setting } }); + + // Load saved quality after menu setup + this.loadQuality(); } setupSubtitles() { @@ -536,7 +667,10 @@ class VideoPlayer { updateTimeDisplay() { const progress = (this.elements.video.currentTime / this.elements.video.duration) * 100; - this.elements.displays.timeCurrent.textContent = this.formatTime(this.elements.video.currentTime); + // If there is a single digit in minutes, add a leading zero + const currentTime = this.formatTime(this.elements.video.currentTime); + const singleDigitMinutes = currentTime.length === 4 && currentTime[1] === ':'; + this.elements.displays.timeCurrent.textContent = singleDigitMinutes ? `0${currentTime}` : currentTime; this.updateSeekDisplay(progress); } @@ -559,6 +693,7 @@ class VideoPlayer { this.updateVolumeSliders(volume); this.updateMuteButton(volume > 0); if (this.elements.video.muted && volume > 0) this.elements.video.muted = false; + this.saveVolume(volume); // Save volume setting } updateVolumeSliders(volume) { diff --git a/static/js/shared/directory.js b/static/js/shared/directory.js new file mode 100644 index 00000000..c3153175 --- /dev/null +++ b/static/js/shared/directory.js @@ -0,0 +1,58 @@ +class DirectoryViewer { + constructor(element) { + this.element = element; + this.folders = element.querySelectorAll('.folder-item'); + this.selectedFolder = null; + + this.setupEventListeners(); + } + + setupEventListeners() { + this.folders.forEach(folder => { + folder.addEventListener('click', (e) => this.handleFolderClick(e, folder)); + folder.addEventListener('dblclick', (e) => this.handleFolderDoubleClick(e, folder)); + }); + + // Clear selection when clicking empty space + this.element.addEventListener('click', (e) => { + if (e.target === this.element || e.target.classList.contains('directory-content')) { + this.clearSelection(); + } + }); + } + + handleFolderClick(e, folder) { + e.preventDefault(); + e.stopPropagation(); + + this.clearSelection(); + this.selectFolder(folder); + } + + handleFolderDoubleClick(e, folder) { + e.preventDefault(); + const path = folder.dataset.path; + if (!path) return; + window.location.href = path; + } + + selectFolder(folder) { + if (this.selectedFolder) { + this.selectedFolder.classList.remove('selected'); + } + folder.classList.add('selected'); + this.selectedFolder = folder; + } + + clearSelection() { + if (this.selectedFolder) { + this.selectedFolder.classList.remove('selected'); + this.selectedFolder = null; + } + } +} + +// Initialize the directory viewer +document.addEventListener('DOMContentLoaded', () => { + const dirViewer = new DirectoryViewer(document.querySelector('.directory-viewer')); +});
\ No newline at end of file diff --git a/templates/en/administration/manage_storage_buckets_bucket.html b/templates/en/administration/manage_storage_buckets_bucket.html new file mode 100644 index 00000000..13ce36a5 --- /dev/null +++ b/templates/en/administration/manage_storage_buckets_bucket.html @@ -0,0 +1,257 @@ +{% extends 'shared/base.html' %} +{% load static %} +{% block head %} +<link rel="stylesheet" href="{% static 'css/shared/directory.css' %}"> +{% endblock head %} +{% block content %} +<h2 style="margin: 8px 0;">Viewing Bucket: {{ bucket_name }}/{{ object_path }}</h2> +<hr> +<div class="bucket-navigation"> + <button class="bucket-navigation-button" onclick="showCreateFolderForm()">Create Folder</button> + <button class="bucket-navigation-button" onclick="showUploadFileForm()">Upload File</button> +</div> +<div class="directory-viewer"> + <div class="directory-content"> + {% if up_url %} + <div class="folder-item" data-path="{{ up_url }}"> + {% else %} + <div class="folder-item" data-path="{% url "administration:manage_storage_buckets" %}"> + {% endif %} + <div class="folder-icon"> + <img src="{% static 'images/core/icons/folder.png' %}" alt="Folder"> + <div class="selection-overlay"></div> + </div> + <div class="folder-name">..</div> + </div> + {% for object in objects %} + {% if object.type == "file" and object.file_type != object.name %} + <div class="folder-item" data-path="https://cdn.rize.moe/{{ bucket_name }}/{{ object.path }}"> + {% else %} + <div class="folder-item" data-path="{% url "administration:view_object_path" bucket_name=bucket_name object_path=object.path %}"> + {% endif %} + <div class="folder-icon"> + {% if object.type == "file" and object.file_type != object.name %} + {% if object.file_type == "mp3" %} + <img src="{% static 'images/core/icons/music.png' %}" alt="Music"> + {% elif object.file_type == "jpg" or object.file_type == "png" %} + <img src="https://cdn.rize.moe/{{ bucket_name }}/{{ object.path }}" alt="Image" style="width: 64px; height: auto;"> + {% else %} + <img src="{% static 'images/core/icons/file.png' %}" alt="File"> + {% endif %} + {% else %} + <img src="{% static 'images/core/icons/folder.png' %}" alt="Folder"> + {% endif %} + <div class="selection-overlay"></div> + </div> + <div class="folder-name">{{ object.name }}</div> + </div> + {% endfor %} + </div> +</div> +{% endblock content %} +{% block scripts %} +<script src="{% static 'js/shared/directory.js' %}"></script> +<script> + // Windows 98 style modals for file upload and folder creation + function createWindows98Modal(title, content, buttons) { + // Create modal container + const modal = document.createElement('div'); + modal.className = 'windows98-modal'; + modal.innerHTML = ` + <div class="modal-titlebar"> + <span class="modal-title">${title}</span> + <button class="modal-close">×</button> + </div> + <div class="modal-content"> + ${content} + </div> + <div class="modal-buttons"> + ${buttons.map(btn => `<button class="${btn.class}">${btn.text}</button>`).join('')} + </div> + `; + + // Close button functionality + const closeBtn = modal.querySelector('.modal-close'); + closeBtn.addEventListener('click', () => { + document.body.removeChild(modal); + }); + + // Add to body + document.body.appendChild(modal); + + return modal; + } + + function showCreateFolderForm() { + const content = ` + <div class="folder-creation-form"> + <label for="folder-name">Folder Name:</label> + <input type="text" id="folder-name" name="folder_path" required> + </div> + `; + + const modal = createWindows98Modal('Create Folder', content, [ + { + text: 'OK', + class: 'btn-ok' + }, + { + text: 'Cancel', + class: 'btn-cancel' + } + ]); + + const okBtn = modal.querySelector('.btn-ok'); + const cancelBtn = modal.querySelector('.btn-cancel'); + const folderNameInput = modal.querySelector('#folder-name'); + + okBtn.addEventListener('click', () => { + const folderName = folderNameInput.value.trim(); + + if (!folderName) { + alert('Please enter a folder name'); + return; + } + + // AJAX call to create folder + fetch('{% url "administration:manage_storage_bucket_create_folder" %}', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-CSRFToken': getCookie('csrftoken') + }, + body: new URLSearchParams({ + 'bucket': '{{ bucket_name }}', + 'folder_path': '{{ object_path }}' + folderName + }) + }) + .then(response => { + console.log('Response status:', response.status); + return response.json(); + }) + .then(data => { + console.log('Response data:', data); + if (data.status === 'success') { + location.reload(); + } else { + alert(data.message); + } + }) + .catch(error => { + console.error('Full error:', error); + alert('An error occurred while creating the folder'); + }); + + document.body.removeChild(modal); + }); + + cancelBtn.addEventListener('click', () => { + document.body.removeChild(modal); + }); + } + + function showUploadFileForm() { + const content = ` + <div class="file-upload-form"> + <label for="file-upload">Select File:</label> + <input type="file" id="file-upload" name="file" required> + <div class="upload-progress" style="display:none;"> + <div class="progress-bar"></div> + <span class="progress-text">0%</span> + </div> + </div> + `; + + const modal = createWindows98Modal('Upload File', content, [ + { + text: 'OK', + class: 'btn-ok' + }, + { + text: 'Cancel', + class: 'btn-cancel' + } + ]); + + const okBtn = modal.querySelector('.btn-ok'); + const cancelBtn = modal.querySelector('.btn-cancel'); + const fileInput = modal.querySelector('#file-upload'); + const progressContainer = modal.querySelector('.upload-progress'); + const progressBar = modal.querySelector('.progress-bar'); + const progressText = modal.querySelector('.progress-text'); + + okBtn.addEventListener('click', () => { + const file = fileInput.files[0]; + + if (!file) { + alert('Please select a file'); + return; + } + + // Create FormData for file upload + const formData = new FormData(); + formData.append('file', file); + formData.append('bucket', '{{ bucket_name }}'); + formData.append('current_path', '{{ object_path }}'); + + // Show progress bar + progressContainer.style.display = 'block'; + okBtn.disabled = true; + + // AJAX upload with progress + const xhr = new XMLHttpRequest(); + xhr.open('POST', '{% url "administration:manage_storage_bucket_upload_file" %}', true); + xhr.setRequestHeader('X-CSRFToken', getCookie('csrftoken')); + + xhr.upload.onprogress = (e) => { + if (e.lengthComputable) { + const percentComplete = Math.round((e.loaded / e.total) * 100); + progressBar.style.width = `${percentComplete}%`; + progressText.textContent = `${percentComplete}%`; + } + }; + + xhr.onload = () => { + const response = JSON.parse(xhr.responseText); + + if (xhr.status === 200) { + // Success - reload page or update directory view + location.reload(); + } else { + alert(response.message || 'Upload failed'); + progressContainer.style.display = 'none'; + okBtn.disabled = false; + } + }; + + xhr.onerror = () => { + alert('Upload failed'); + progressContainer.style.display = 'none'; + okBtn.disabled = false; + }; + + xhr.send(formData); + }); + + cancelBtn.addEventListener('click', () => { + document.body.removeChild(modal); + }); + } + + // Utility function to get CSRF token + function getCookie(name) { + let cookieValue = null; + if (document.cookie && document.cookie !== '') { + const cookies = document.cookie.split(';'); + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].trim(); + if (cookie.substring(0, name.length + 1) === (name + '=')) { + cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); + break; + } + } + } + return cookieValue; + } +</script> +{% endblock scripts %}
\ No newline at end of file diff --git a/templates/en/administration/manage_storage_buckets_home.html b/templates/en/administration/manage_storage_buckets_home.html new file mode 100644 index 00000000..90bed85b --- /dev/null +++ b/templates/en/administration/manage_storage_buckets_home.html @@ -0,0 +1,26 @@ +{% extends 'shared/base.html' %} +{% load static %} +{% block head %} +<link rel="stylesheet" href="{% static 'css/shared/directory.css' %}"> +{% endblock head %} +{% block content %} +<h2 style="margin: 8px 0;">Manage Storage Buckets</h2> +<hr> +<div class="directory-viewer"> + <div class="directory-content"> + {% for bucket in buckets %} + <div class="folder-item" data-path="{% url "administration:view_single_bucket" bucket.name %}"> + <div class="folder-icon"> + <img src="{% static 'images/core/icons/folder.png' %}" alt="Folder"> + <div class="selection-overlay"></div> + </div> + <div class="folder-name">{{ bucket.name }}</div> + </div> + {% endfor %} + </div> + +</div> +{% endblock content %} +{% block scripts %} +<script src="{% static 'js/shared/directory.js' %}"></script> +{% endblock scripts %}
\ No newline at end of file diff --git a/templates/en/anime/anime.html b/templates/en/anime/anime.html index 36ee2dd1..2f9d5cf3 100644 --- a/templates/en/anime/anime.html +++ b/templates/en/anime/anime.html @@ -17,9 +17,10 @@ </div> <div class="win98-player-content"> {% if user.is_authenticated %} - <video id="video-player"> - <!-- captions added dynamically --> - </video> + <div class="retro-buffer"> + <img src="{% static 'images/core/gifs/buffering.gif' %}" alt="Loading..."> + </div> + <video id="video-player"></video> <div id="custom-subtitles" class="custom-subtitles"></div> {% else %} <div id="video-player" class="unauth-video"> @@ -260,26 +261,40 @@ <script> document.addEventListener('DOMContentLoaded', () => { const videoStreamingURL = `/services/stream/anime/?url=${encodeURIComponent('{{ streaming_data.data.sources.url }}')}`; - const tracks = JSON.parse(document.getElementById('tracks-data').textContent) - .filter(track => track.kind === 'captions') - .map(track => ({ - ...track, - file: `/services/stream/anime/?url=${encodeURIComponent(track.file)}` - })); + var tracks = []; + try { + tracks = JSON.parse(document.getElementById('tracks-data').textContent) + .filter(track => track.kind === 'captions') + .map(track => ({ + ...track, + file: `/services/stream/anime/?url=${encodeURIComponent(track.file)}` + })); + } catch (error) {} - // hide captions menu if no captions available if (tracks.length === 0) { - document.querySelector('.caption-control').style.display = 'none'; + try { + document.querySelector('.caption-control').style.display = 'none'; + } catch (error) {} } - - const player = new VideoPlayer({ - source: { - url: videoStreamingURL, - type: 'hls', - tracks: tracks - } - }); + let player; + if (videoStreamingURL !== '/services/stream/anime/?url=') { + player = new VideoPlayer({ + source: { + url: videoStreamingURL, + type: 'hls', + tracks: tracks + } + }); + } else { + player = new VideoPlayer({ + source: { + url: '', + type: 'video', + tracks: tracks + } + }); + } const currentEpisode = document.getElementById('currentEpisode'); if (currentEpisode) { diff --git a/templates/shared/left_sidebar.html b/templates/shared/left_sidebar.html index 323f1779..878fb399 100644 --- a/templates/shared/left_sidebar.html +++ b/templates/shared/left_sidebar.html @@ -194,6 +194,10 @@ <img src="{% static 'images/core/gifs/right_hand.gif' %}" alt="Manage Users Icon" /> <a href="#manage-users">{% if request.LANGUAGE_CODE == 'ja' %}ユーザー管理{% else %}Manage Users{% endif %}</a> </div> + <div class="navigation-item"> + <img src="{% static 'images/core/gifs/right_hand.gif' %}" alt="Manage Storage Buckets Icon" /> + <a href="{% url "administration:manage_storage_buckets" %}">{% if request.LANGUAGE_CODE == 'ja' %}ストレージバケット管理{% else %}Manage Storage Buckets{% endif %}</a> + </div> </div> </div> {% endif %} diff --git a/thatcomputerscientist/settings.py b/thatcomputerscientist/settings.py index 917a8109..7f8bb7d6 100644 --- a/thatcomputerscientist/settings.py +++ b/thatcomputerscientist/settings.py @@ -13,6 +13,7 @@ https://docs.djangoproject.com/en/4.0/ref/settings/ import os from pathlib import Path from django.utils.translation import gettext_lazy as _ +from internal.cache_utils import clear_cache from dotenv import load_dotenv @@ -40,6 +41,9 @@ LOGIN_URL = "/" # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = os.getenv("AUTHORIZATION_STRING") +MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT") +MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY") +MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY") # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True if os.getenv("ENVIRONMENT") == "development" else False @@ -210,21 +214,29 @@ AUTH_PASSWORD_VALIDATORS = [ }, ] -# CACHES = { -# "default": { -# "BACKEND": "django_redis.cache.RedisCache", -# "LOCATION": f"redis://{os.getenv('REDIS_HOST')}:{os.getenv('REDIS_PORT')}", -# "OPTIONS": { -# "CLIENT_CLASS": "django_redis.client.DefaultClient", -# "PASSWORD": os.getenv("REDIS_PASSWORD"), -# }, -# } -# } -# from django.core.cache import cache +# Redis Cache Configuration +REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0") + +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": REDIS_URL, + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + "SOCKET_CONNECT_TIMEOUT": 5, + "SOCKET_TIMEOUT": 5, + "RETRY_ON_TIMEOUT": True, + "MAX_CONNECTIONS": 1000, + "CONNECTION_POOL_KWARGS": {"max_connections": 100}, + "COMPRESSOR": "django_redis.compressors.zlib.ZlibCompressor", + "PICKLE_VERSION": -1, + "IGNORE_EXCEPTIONS": True, + }, + "TIMEOUT": 60 * 15, + } +} -# clear the cache -# for key in cache.keys("presence_*"): -# cache.delete(key) +clear_cache(pattern="*presence*") SESSION_ENGINE = "django.contrib.sessions.backends.cached_db" |
