aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--apps/administration/urls.py26
-rw-r--r--apps/administration/views.py189
-rw-r--r--apps/anime/views.py155
-rw-r--r--internal/admin_utilities.py145
-rw-r--r--internal/cache_utils.py118
-rw-r--r--requirements.txt2
-rw-r--r--static/css/anime/video_player.css32
-rw-r--r--static/css/shared/directory.css197
-rw-r--r--static/images/core/gifs/buffering.gifbin0 -> 7054 bytes
-rw-r--r--static/images/core/icons/folder.pngbin0 -> 481 bytes
-rw-r--r--static/images/core/icons/music.pngbin0 -> 684 bytes
-rw-r--r--static/js/libs/videoPlayer.js159
-rw-r--r--static/js/shared/directory.js58
-rw-r--r--templates/en/administration/manage_storage_buckets_bucket.html257
-rw-r--r--templates/en/administration/manage_storage_buckets_home.html26
-rw-r--r--templates/en/anime/anime.html53
-rw-r--r--templates/shared/left_sidebar.html4
-rw-r--r--thatcomputerscientist/settings.py40
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
new file mode 100644
index 00000000..524e91f9
--- /dev/null
+++ b/static/images/core/gifs/buffering.gif
Binary files differ
diff --git a/static/images/core/icons/folder.png b/static/images/core/icons/folder.png
new file mode 100644
index 00000000..807f1690
--- /dev/null
+++ b/static/images/core/icons/folder.png
Binary files differ
diff --git a/static/images/core/icons/music.png b/static/images/core/icons/music.png
new file mode 100644
index 00000000..68bb414d
--- /dev/null
+++ b/static/images/core/icons/music.png
Binary files differ
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">&times;</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"