From bab5571428d226f73a467d80dfe6af4c60cb5522 Mon Sep 17 00:00:00 2001 From: Nell Date: Thu, 13 Mar 2025 22:08:06 +0100 Subject: [PATCH] init --- .dockerignore | 5 + .env.dist | 35 + .gitignore | 22 + app/.dockerignore | 1 + app/.gitignore | 69 ++ app/Dockerfile | 60 ++ app/api/__init__.py | 0 app/api/admin.py | 3 + app/api/apps.py | 6 + app/api/migrations/__init__.py | 0 app/api/models.py | 3 + app/api/routers.py | 10 + app/api/tests.py | 3 + app/api/urls.py | 9 + app/api/views.py | 3 + app/app/__init__.py | 0 app/app/asgi.py | 16 + app/app/celery.py | 9 + app/app/settings.py | 239 +++++ app/app/urls.py | 42 + app/app/utils.py | 72 ++ app/app/ws_urls.py | 9 + app/app/wsgi.py | 16 + app/dev_run.sh | 6 + app/frontend/.gitignore | 24 + app/frontend/jsconfig.json | 8 + app/frontend/package.json | 30 + app/frontend/postcss.config.js | 7 + app/frontend/src/app/login.js | 7 + app/frontend/src/app/torrent.js | 8 + app/frontend/src/components/auth/App.vue | 49 + app/frontend/src/components/auth/Base.vue | 13 + app/frontend/src/components/auth/Friend.vue | 66 ++ .../src/components/auth/FriendForm.vue | 132 +++ .../src/components/auth/FriendManager.vue | 32 + app/frontend/src/components/torrent/App.vue | 175 ++++ .../src/components/torrent/FileItem.vue | 54 ++ .../src/components/torrent/FileList.vue | 48 + .../src/components/torrent/TorrentItem.vue | 126 +++ .../src/components/torrent/TorrentList.vue | 45 + .../src/components/torrent/TorrentShare.vue | 110 +++ .../src/components/torrent/UploadForm.vue | 105 +++ .../src/components/utils/VideoPlayer.vue | 41 + app/frontend/src/plugins/utils.js | 17 + app/frontend/src/plugins/vue_loader.js | 7 + app/frontend/src/plugins/vuetify.js | 30 + app/frontend/src/plugins/ws.js | 78 ++ app/frontend/vite.config.js | 51 ++ app/frontend/yarn.lock | 840 ++++++++++++++++++ app/manage.py | 22 + app/requirements.txt | 18 + app/templates/base.html | 24 + app/torrent/__init__.py | 0 app/torrent/admin.py | 13 + app/torrent/apps.py | 15 + app/torrent/consumers.py | 100 +++ .../management/commands/torrent_event.py | 62 ++ app/torrent/migrations/0001_initial.py | 40 + app/torrent/migrations/0002_initial.py | 47 + .../migrations/0003_torrent_date_modified.py | 18 + ...004_rename_date_shareduser_date_created.py | 18 + ..._rename_date_added_torrent_date_created.py | 18 + app/torrent/migrations/__init__.py | 0 app/torrent/models.py | 94 ++ app/torrent/serializers.py | 30 + app/torrent/signals.py | 47 + app/torrent/tasks.py | 26 + app/torrent/templates/torrent/home.html | 9 + app/torrent/templates/torrent/layout.html | 1 + app/torrent/tests.py | 3 + app/torrent/urls.py | 10 + app/torrent/utils.py | 119 +++ app/torrent/views.py | 157 ++++ app/user/__init__.py | 0 app/user/admin.py | 51 ++ app/user/apps.py | 6 + app/user/forms.py | 27 + .../management/commands/import_old_users.py | 46 + app/user/migrations/0001_initial.py | 72 ++ app/user/migrations/__init__.py | 0 app/user/models.py | 76 ++ app/user/serializers.py | 32 + app/user/templates/user/layout.html | 1 + app/user/templates/user/login.html | 9 + app/user/templates/user/register.html | 0 app/user/tests.py | 3 + app/user/urls.py | 9 + app/user/views.py | 107 +++ docker-compose.dev.yml | 29 + docker-compose.yml | 75 ++ nginx/default.nginx | 35 + nginx/dev.nginx | 31 + transmission/config/settings.json | 82 ++ 93 files changed, 4323 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.dist create mode 100644 .gitignore create mode 100644 app/.dockerignore create mode 100644 app/.gitignore create mode 100644 app/Dockerfile create mode 100644 app/api/__init__.py create mode 100644 app/api/admin.py create mode 100644 app/api/apps.py create mode 100644 app/api/migrations/__init__.py create mode 100644 app/api/models.py create mode 100644 app/api/routers.py create mode 100644 app/api/tests.py create mode 100644 app/api/urls.py create mode 100644 app/api/views.py create mode 100644 app/app/__init__.py create mode 100644 app/app/asgi.py create mode 100644 app/app/celery.py create mode 100644 app/app/settings.py create mode 100644 app/app/urls.py create mode 100644 app/app/utils.py create mode 100644 app/app/ws_urls.py create mode 100644 app/app/wsgi.py create mode 100644 app/dev_run.sh create mode 100644 app/frontend/.gitignore create mode 100644 app/frontend/jsconfig.json create mode 100644 app/frontend/package.json create mode 100644 app/frontend/postcss.config.js create mode 100644 app/frontend/src/app/login.js create mode 100644 app/frontend/src/app/torrent.js create mode 100644 app/frontend/src/components/auth/App.vue create mode 100644 app/frontend/src/components/auth/Base.vue create mode 100644 app/frontend/src/components/auth/Friend.vue create mode 100644 app/frontend/src/components/auth/FriendForm.vue create mode 100644 app/frontend/src/components/auth/FriendManager.vue create mode 100644 app/frontend/src/components/torrent/App.vue create mode 100644 app/frontend/src/components/torrent/FileItem.vue create mode 100644 app/frontend/src/components/torrent/FileList.vue create mode 100644 app/frontend/src/components/torrent/TorrentItem.vue create mode 100644 app/frontend/src/components/torrent/TorrentList.vue create mode 100644 app/frontend/src/components/torrent/TorrentShare.vue create mode 100644 app/frontend/src/components/torrent/UploadForm.vue create mode 100644 app/frontend/src/components/utils/VideoPlayer.vue create mode 100644 app/frontend/src/plugins/utils.js create mode 100644 app/frontend/src/plugins/vue_loader.js create mode 100644 app/frontend/src/plugins/vuetify.js create mode 100644 app/frontend/src/plugins/ws.js create mode 100644 app/frontend/vite.config.js create mode 100644 app/frontend/yarn.lock create mode 100644 app/manage.py create mode 100644 app/requirements.txt create mode 100644 app/templates/base.html create mode 100644 app/torrent/__init__.py create mode 100644 app/torrent/admin.py create mode 100644 app/torrent/apps.py create mode 100644 app/torrent/consumers.py create mode 100644 app/torrent/management/commands/torrent_event.py create mode 100644 app/torrent/migrations/0001_initial.py create mode 100644 app/torrent/migrations/0002_initial.py create mode 100644 app/torrent/migrations/0003_torrent_date_modified.py create mode 100644 app/torrent/migrations/0004_rename_date_shareduser_date_created.py create mode 100644 app/torrent/migrations/0005_rename_date_added_torrent_date_created.py create mode 100644 app/torrent/migrations/__init__.py create mode 100644 app/torrent/models.py create mode 100644 app/torrent/serializers.py create mode 100644 app/torrent/signals.py create mode 100644 app/torrent/tasks.py create mode 100644 app/torrent/templates/torrent/home.html create mode 100644 app/torrent/templates/torrent/layout.html create mode 100644 app/torrent/tests.py create mode 100644 app/torrent/urls.py create mode 100644 app/torrent/utils.py create mode 100644 app/torrent/views.py create mode 100644 app/user/__init__.py create mode 100644 app/user/admin.py create mode 100644 app/user/apps.py create mode 100644 app/user/forms.py create mode 100644 app/user/management/commands/import_old_users.py create mode 100644 app/user/migrations/0001_initial.py create mode 100644 app/user/migrations/__init__.py create mode 100644 app/user/models.py create mode 100644 app/user/serializers.py create mode 100644 app/user/templates/user/layout.html create mode 100644 app/user/templates/user/login.html create mode 100644 app/user/templates/user/register.html create mode 100644 app/user/tests.py create mode 100644 app/user/urls.py create mode 100644 app/user/views.py create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.yml create mode 100644 nginx/default.nginx create mode 100644 nginx/dev.nginx create mode 100644 transmission/config/settings.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4b6e4c6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +.env +*.pyc +__pycache__ +node_modules diff --git a/.env.dist b/.env.dist new file mode 100644 index 0000000..3428150 --- /dev/null +++ b/.env.dist @@ -0,0 +1,35 @@ +USER_ID=1000 +GROUP_ID=1000 + +LISTEN_PORT=8000 + +DEBUG=true +DEBUG_TOOLBAR=false +SECRET=Gei2thiFie0wiighoc4Aa9anei9hoCoiSie6wohT)aeng7To4a # generate with "pwgen -y 50" +ALLOWED_HOSTS='["127.0.0.1", "localhost"]' +CSRF_TRUSTED_ORIGINS='["http://127.0.0.1:8000", "http://localhost:8000"]' +BASE_URL='http://127.0.0.1/' + +### DEV RELATED +# VITE +DEV_SERVER_HOST='127.0.0.1' +DEV_SERVER_PORT='8080' + +REDIS_HOST=redis +REDIS_PORT=6379 + +EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend # django.core.mail.backends.smtp.EmailBackend +EMAIL_HOST=example.test # +EMAIL_PORT=587 # 587 +EMAIL_USER=test # +EMAIL_PASSWORD=test # +EMAIL_SENDER=no-reply@example.test + +TRANSMISSION_PROTOCOL=http +TRANSMISSION_HOST=transmission +TRANSMISSION_PORT=9091 +TRANSMISSION_USERNAME= +TRANSMISSION_PASSWORD= + +UPDATE_TRANSMISSION_DELAY=5 +TORRENT_TTL=2592000 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f6aaeb --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +.idea/ +.env + +transmission/config/* +!transmission/config/settings.json +!transmission/config/blocklists +transmission/config/blocklists/* +!transmission/config/blocklists/.keep +!transmission/config/resume +transmission/config/resume/* +!transmission/config/resume/.keep +!transmission/config/torrents +transmission/config/torrents/* +!transmission/config/torrents/.keep +transmission/downloads/* +!transmission/downloads/.keep +!transmission/downloads/complete +transmission/downloads/complete/* +!transmission/downloads/complete/.keep +!transmission/downloads/incomplete +transmission/downloads/incomplete/* +!transmission/downloads/incomplete/.keep \ No newline at end of file diff --git a/app/.dockerignore b/app/.dockerignore new file mode 100644 index 0000000..7b4d575 --- /dev/null +++ b/app/.dockerignore @@ -0,0 +1 @@ +celerybeat-schedule* \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..0702ca4 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,69 @@ +# Django +*.log +*.pot +*.pyc +__pycache__/ +local_settings.py +db.sqlite3 +db.sqlite3-journal +media/ + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# SQLite files +*.sqlite +*.sqlite3 +*.db + +# Celery +celerybeat-schedule +celerybeat-schedule* +celerybeat-schedule.* +celerybeat.pid +celerybeat-schedule.db +celerybeat-schedule.bak +celerybeat-schedule.dat +celerybeat-schedule.dir + diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 0000000..43efdf1 --- /dev/null +++ b/app/Dockerfile @@ -0,0 +1,60 @@ +FROM python:3.13-slim + +ARG puid=1000 +ARG pgid=1000 +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + LANG=C.UTF-8 \ + LC_ALL=C.UTF-8 + + +RUN groupadd -g ${pgid} -o custom_user +RUN useradd -m -u ${puid} -g ${pgid} -o -s /bin/bash custom_user + +#RUN apt update && apt install -y gcc graphviz graphviz-dev pkg-config libpq-dev g++ supervisor +RUN apt update && apt install -y curl inotify-tools + +# install nodejs 20 +#RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - +#RUN apt update && apt install -y nodejs +RUN apt-get update && apt-get install -y \ + ca-certificates \ + curl \ + gnupg \ + inotify-tools \ + && rm -rf /var/lib/apt/lists/* +RUN mkdir -p /etc/apt/keyrings +RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg +RUN echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list +RUN apt-get update +RUN apt-get install nodejs -y + +# install yarn +RUN npm install -g yarn + +# setup node dependencies +WORKDIR /app/frontend +COPY ./frontend/package.json ./package.json +RUN yarn install + +WORKDIR /app +RUN pip install --upgrade pip + +COPY ./requirements.txt ./requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +#COPY . . + +RUN chown ${puid}:${pgid} /app -R + +USER custom_user + +RUN mkdir -p ~/.ipython/profile_default/ +RUN echo "c.InteractiveShellApp.extensions = ['autoreload']\nc.InteractiveShellApp.exec_lines = ['%autoreload 2']" > ~/.ipython/profile_default/ipython_config.py + +EXPOSE 8000 + +#HEALTHCHECK --interval=30s --timeout=3s \ +# CMD curl -f http://127.0.0.1:8000/health/ || exit 1 + +CMD bash -c "sleep 2 && python manage.py collectstatic --noinput && python manage.py migrate && uvicorn oxpanel.asgi:application --workers 5 --host 0.0.0.0 --port 8000 --lifespan off --loop asyncio --ws websockets" diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/admin.py b/app/api/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/app/api/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/app/api/apps.py b/app/api/apps.py new file mode 100644 index 0000000..66656fd --- /dev/null +++ b/app/api/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'api' diff --git a/app/api/migrations/__init__.py b/app/api/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/models.py b/app/api/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/app/api/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/app/api/routers.py b/app/api/routers.py new file mode 100644 index 0000000..c1ed5ff --- /dev/null +++ b/app/api/routers.py @@ -0,0 +1,10 @@ +from rest_framework.routers import DefaultRouter +from user.views import UserViewSet +from torrent.views import TorrentViewSet, FileViewSet +from user.views import FriendRequestViewSet + +router = DefaultRouter() +router.register(r'users', UserViewSet, basename='user') +router.register(r'torrents', TorrentViewSet, basename='torrent') +router.register(r'torrent/files', FileViewSet, basename='file') +router.register(r'friend_requests', FriendRequestViewSet, basename='friend-request') diff --git a/app/api/tests.py b/app/api/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/app/api/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/app/api/urls.py b/app/api/urls.py new file mode 100644 index 0000000..f6657be --- /dev/null +++ b/app/api/urls.py @@ -0,0 +1,9 @@ + +from django.urls import path, include + +from .routers import router + +app_name = "api" +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/app/api/views.py b/app/api/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/app/api/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/app/app/__init__.py b/app/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/app/asgi.py b/app/app/asgi.py new file mode 100644 index 0000000..d31dee5 --- /dev/null +++ b/app/app/asgi.py @@ -0,0 +1,16 @@ +import os + +from django.core.asgi import get_asgi_application +from channels.auth import AuthMiddlewareStack +from channels.routing import ProtocolTypeRouter, URLRouter + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') +django_asgi_app = get_asgi_application() + +from .ws_urls import websocket_urlpatterns + + +application = ProtocolTypeRouter({ + "http": django_asgi_app, + "websocket": AuthMiddlewareStack(websocket_urlpatterns) +}) diff --git a/app/app/celery.py b/app/app/celery.py new file mode 100644 index 0000000..382b245 --- /dev/null +++ b/app/app/celery.py @@ -0,0 +1,9 @@ +import os + +from celery import Celery + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") +app = Celery("app") + +app.config_from_object("django.conf:settings", namespace="CELERY") +app.autodiscover_tasks() diff --git a/app/app/settings.py b/app/app/settings.py new file mode 100644 index 0000000..633e481 --- /dev/null +++ b/app/app/settings.py @@ -0,0 +1,239 @@ +""" +Django settings for app project. + +Generated by 'django-admin startproject' using Django 5.1.6. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.1/ref/settings/ +""" + +from pathlib import Path +from os import getenv +import ast +from datetime import timedelta + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent +BASE_URL = getenv("BASE_URL", "http://127.0.0.1:8000/") + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = getenv("SECRET", "random_need_to_be_generated") + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = getenv("DEBUG", "false") == "true" + +ALLOWED_HOSTS = ["app", "127.0.0.1", "localhost"] +if getenv("ALLOWED_HOSTS"): + ALLOWED_HOSTS += ast.literal_eval(getenv("ALLOWED_HOSTS")) + +CSRF_TRUSTED_ORIGINS = [] +for allowed_host in ALLOWED_HOSTS: + CSRF_TRUSTED_ORIGINS += ast.literal_eval(getenv("CSRF_TRUSTED_ORIGINS")) + +CORS_ALLOW_ALL_ORIGINS = True + + +# Application definition +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + + 'django_vite', + 'rest_framework', + 'corsheaders', + 'channels', + 'django_filters', + + 'user', + 'api', + 'torrent', +] +if DEBUG: + INSTALLED_APPS = ["daphne"] + INSTALLED_APPS + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'app.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [BASE_DIR / 'templates'], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'app.wsgi.application' +ASGI_APPLICATION = "app.asgi.application" + + +# Database +# https://docs.djangoproject.com/en/5.1/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + + +# Password validation +# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.1/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'Europe/Paris' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.1/howto/static-files/ + +STATIC_URL = '/static/' +STATICFILES_DIRS = [ + BASE_DIR / "frontend/build", +] +STATIC_ROOT = BASE_DIR / "static_collected" + +MEDIA_URL = "/media/" +MEDIA_ROOT = BASE_DIR / "media" + +# Default primary key field type +# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# User related +AUTH_USER_MODEL = "user.User" +LOGIN_REDIRECT_URL = "/" +LOGIN_URL = "/user/login/" +LOGOUT_REDIRECT_URL = "/user/login/" +SESSION_COOKIE_AGE = 60*60*24*365*5 + +# Django-vite related +# https://github.com/MrBin99/django-vite/tree/master +DJANGO_VITE = { + "default": { + "dev_mode": DEBUG, + "manifest_path": BASE_DIR / "frontend/dist/manifest.json", + "dev_server_host": getenv("DEV_SERVER_HOST", "localhost"), + "dev_server_port": int(getenv("DEV_SERVER_PORT", 8080)), + } +} + +REDIS_HOST = { + "host": getenv("REDIS_HOST"), + "port": int(getenv("REDIS_PORT", 6379)) +} + +# Email related +EMAIL_BACKEND = getenv("EMAIL_BACKEND", 'django.core.mail.backends.console.EmailBackend') +EMAIL_HOST = getenv("EMAIL_HOST", None) +EMAIL_PORT = getenv("EMAIL_PORT", None) +EMAIL_HOST_USER = getenv("EMAIL_USER", None) +EMAIL_HOST_PASSWORD = getenv("EMAIL_PASSWORD", None) +EMAIL_SENDER = getenv("EMAIL_SENDER", None) +EMAIL_USE_TLS = True +DEFAULT_FROM_EMAIL = EMAIL_SENDER +EMAIL_SUBJECT_PREFIX = getenv("EMAIL_SUBJECT_PREFIX", None) +EMAIL_ADMIN = getenv("EMAIL_ADMIN", None) + +# Celery related +CELERY_BROKER_URL = f"redis://{getenv('REDIS_HOST')}:{getenv('REDIS_PORT')}/1" +CELERY_TIMEZONE = "Europe/Paris" +CELERY_TASK_TRACK_STARTED = True +# CELERY_TASK_TIME_LIMIT = 30 * 60 +CELERY_TASK_SERIALIZER = "json" +CELERY_ACCEPT_CONTENT = ['json'] +CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True +CELERY_BEAT_SCHEDULE = { + "update_transmission_data": { + "task": "torrent.tasks.update_transmission_data", + "schedule": int(getenv("UPDATE_TRANSMISSION_DELAY", 5)) + } +} + +# Channel related +CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels_redis.pubsub.RedisPubSubChannelLayer", + "CONFIG": { + "hosts": [f"redis://{getenv('REDIS_HOST')}:{getenv('REDIS_PORT')}/2"], + }, + }, +} + +REST_FRAMEWORK = { + "DEFAULT_FILTER_BACKENDS": [ + 'django_filters.rest_framework.DjangoFilterBackend', + 'rest_framework.filters.OrderingFilter' + ], + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework.authentication.SessionAuthentication", + "rest_framework.authentication.BasicAuthentication", + ], + "DEFAULT_RENDERER_CLASSES": [ + "rest_framework.renderers.JSONRenderer", + "rest_framework.renderers.BrowsableAPIRenderer" + ] +} + +# Torrent related +DOWNLOAD_BASE_DIR = Path("/transmission/downloads/complete") +NGINX_ACCEL_BASE = "/dl/" +TRANSMISSION = { + "protocol": getenv("TRANSMISSION_PROTOCOL", "http"), + "host": getenv("TRANSMISSION_HOST", "127.0.0.1"), + "port": getenv("TRANSMISSION_PORT", 9091), + "username": getenv("TRANSMISSION_USERNAME"), + "password": getenv("TRANSMISSION_PASSWORD") +} \ No newline at end of file diff --git a/app/app/urls.py b/app/app/urls.py new file mode 100644 index 0000000..23e6c9e --- /dev/null +++ b/app/app/urls.py @@ -0,0 +1,42 @@ +""" +URL configuration for app project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.1/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path, include # Added include for including app URLs +from django.http import HttpResponse +from django.contrib.auth.views import ( + PasswordResetView, PasswordResetDoneView, PasswordResetConfirmView, PasswordResetCompleteView, PasswordChangeView, + PasswordChangeDoneView, LogoutView +) + +urlpatterns = [ + path('admin/', admin.site.urls), + path("health/", lambda request: HttpResponse("OK")), + path("", include("torrent.urls", "torrent")), + path("user/", include("user.urls", "user")), + path("api/", include("api.urls", "api")), + +# reset password related + path("password_reset/", PasswordResetView.as_view(), name="password_reset"), + path("password_reset_done/", PasswordResetDoneView.as_view(), name="password_reset_done"), + path("reset///", PasswordResetConfirmView.as_view(), name="password_reset_confirm"), + path("reset/done/", PasswordResetCompleteView.as_view(), name="password_reset_complete"), + path("password_change/", PasswordChangeView.as_view( + success_url="/" + ), name="password_change"), + path("password_change_done/", PasswordChangeDoneView.as_view(), name="password_change_done"), + path("logout/", LogoutView.as_view(), name="logout"), +] diff --git a/app/app/utils.py b/app/app/utils.py new file mode 100644 index 0000000..8b07ae7 --- /dev/null +++ b/app/app/utils.py @@ -0,0 +1,72 @@ +from django.http import StreamingHttpResponse + +import zlib +import datetime +import os +import aiofiles +from stat import S_IFREG +from stream_zip import ZIP_64, stream_zip, async_stream_zip +from channels.layers import get_channel_layer +from asgiref.sync import async_to_sync + + +def send_sync_channel_message(channel_name, context, data): + async_to_sync(get_channel_layer().group_send)(channel_name, { + "type": context, + "data": data + }) + + +class StreamingZipFileResponse(StreamingHttpResponse): + # https://stream-zip.docs.trade.gov.uk/ + # https://github.com/sandes/zipfly/tree/master + def __init__(self, filename, file_list, compression_level=0, is_async=False, *args, **kwargs): + self.file_list = file_list + super().__init__(content_type='application/octet-stream', *args, **kwargs) + self['Content-Disposition'] = f'attachment; filename="{filename}"' + # self['Cache-Control'] = "no-cache" + # self['X-Accel-Buffering'] = "no" + + self.zipped = None + + if is_async: + self.zipped = async_stream_zip( + self._async_local_files(), + get_compressobj=lambda: zlib.compressobj(wbits=-zlib.MAX_WBITS, level=compression_level) + ) + else: + self.zipped = stream_zip( + self._sync_local_files() + ) + self.streaming_content = self.zipped + + def _get_total_length(self): + return sum([os.stat(pathname).st_size for pathname, _ in self.file_list]) + + def _sync_local_files(self): + now = datetime.datetime.now() + + def contents(path): + with open(path, "rb") as f: + while chunk := f.read(64 * 1024): + yield chunk + + return ( + (dest, now, S_IFREG | 0o600, ZIP_64, contents(pathname)) + for pathname, dest in self.file_list + ) + + async def _async_local_files(self): + now = datetime.datetime.now() + + async def contents(path): + try: + async with aiofiles.open(path, "rb") as f: + while chunk := await f.read(64 * 1024): + yield chunk + except RuntimeError as e: + print("Event loop not running, maybe cancel by user ?") + raise e + + for pathname, dest in self.file_list: + yield dest, now, S_IFREG | 0o600, ZIP_64, contents(pathname) \ No newline at end of file diff --git a/app/app/ws_urls.py b/app/app/ws_urls.py new file mode 100644 index 0000000..07ebf1f --- /dev/null +++ b/app/app/ws_urls.py @@ -0,0 +1,9 @@ +from django.urls import path +from channels.routing import ProtocolTypeRouter, URLRouter + +from torrent.consumers import TorrentEventConsumer + + +websocket_urlpatterns = URLRouter([ + path("ws/torrent_event/", TorrentEventConsumer.as_asgi()), +]) diff --git a/app/app/wsgi.py b/app/app/wsgi.py new file mode 100644 index 0000000..829fcc7 --- /dev/null +++ b/app/app/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for app project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') + +application = get_wsgi_application() diff --git a/app/dev_run.sh b/app/dev_run.sh new file mode 100644 index 0000000..3058129 --- /dev/null +++ b/app/dev_run.sh @@ -0,0 +1,6 @@ +cd /app/frontend && yarn dev & +cd /app && python manage.py runserver 0.0.0.0:8000 & + +wait -n + +exit $? \ No newline at end of file diff --git a/app/frontend/.gitignore b/app/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/app/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/app/frontend/jsconfig.json b/app/frontend/jsconfig.json new file mode 100644 index 0000000..babf8fb --- /dev/null +++ b/app/frontend/jsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +} \ No newline at end of file diff --git a/app/frontend/package.json b/app/frontend/package.json new file mode 100644 index 0000000..60a437f --- /dev/null +++ b/app/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@mdi/font": "^7.4.47", + "@vitejs/plugin-vue": "^5.2.1", + "autoprefixer": "^10.4.20", + "postcss": "^8.5.2", + "postcss-import": "^16.1.0", + "postcss-simple-vars": "^7.0.1", + "video.js": "^8.21.0", + "vite": "^6.1.0" + }, + "dependencies": { + "events": "^3.3.0", + "filesize": "^10.1.6", + "js-cookie": "^3.0.5", + "moment": "^2.30.1", + "vite-plugin-vuetify": "^2.1.0", + "vue": "^3.5.13", + "vuetify": "^3.7.12" + } +} diff --git a/app/frontend/postcss.config.js b/app/frontend/postcss.config.js new file mode 100644 index 0000000..058a505 --- /dev/null +++ b/app/frontend/postcss.config.js @@ -0,0 +1,7 @@ +export default { + plugins: { + 'postcss-import': {}, + 'postcss-simple-vars': {}, + 'autoprefixer': {} + } +} \ No newline at end of file diff --git a/app/frontend/src/app/login.js b/app/frontend/src/app/login.js new file mode 100644 index 0000000..10ade14 --- /dev/null +++ b/app/frontend/src/app/login.js @@ -0,0 +1,7 @@ +import 'vite/modulepreload-polyfill' + +import App from '@/components/auth/App.vue'; + +import("@/plugins/vue_loader.js").then(utils => { + utils.createVue(App, "#app"); +}) diff --git a/app/frontend/src/app/torrent.js b/app/frontend/src/app/torrent.js new file mode 100644 index 0000000..20983f6 --- /dev/null +++ b/app/frontend/src/app/torrent.js @@ -0,0 +1,8 @@ +// add the beginning of your app entry +import 'vite/modulepreload-polyfill' + +import App from '../components/torrent/App.vue'; + +import("@/plugins/vue_loader.js").then(utils => { + utils.createVue(App, "#app"); +}) diff --git a/app/frontend/src/components/auth/App.vue b/app/frontend/src/components/auth/App.vue new file mode 100644 index 0000000..fa2d6be --- /dev/null +++ b/app/frontend/src/components/auth/App.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/app/frontend/src/components/auth/Base.vue b/app/frontend/src/components/auth/Base.vue new file mode 100644 index 0000000..8e678d9 --- /dev/null +++ b/app/frontend/src/components/auth/Base.vue @@ -0,0 +1,13 @@ + \ No newline at end of file diff --git a/app/frontend/src/components/auth/Friend.vue b/app/frontend/src/components/auth/Friend.vue new file mode 100644 index 0000000..0dcd5c4 --- /dev/null +++ b/app/frontend/src/components/auth/Friend.vue @@ -0,0 +1,66 @@ + + + + + \ No newline at end of file diff --git a/app/frontend/src/components/auth/FriendForm.vue b/app/frontend/src/components/auth/FriendForm.vue new file mode 100644 index 0000000..9464da7 --- /dev/null +++ b/app/frontend/src/components/auth/FriendForm.vue @@ -0,0 +1,132 @@ + + + \ No newline at end of file diff --git a/app/frontend/src/components/auth/FriendManager.vue b/app/frontend/src/components/auth/FriendManager.vue new file mode 100644 index 0000000..aaa0ae5 --- /dev/null +++ b/app/frontend/src/components/auth/FriendManager.vue @@ -0,0 +1,32 @@ + + + + + \ No newline at end of file diff --git a/app/frontend/src/components/torrent/App.vue b/app/frontend/src/components/torrent/App.vue new file mode 100644 index 0000000..09d123d --- /dev/null +++ b/app/frontend/src/components/torrent/App.vue @@ -0,0 +1,175 @@ + + + + + \ No newline at end of file diff --git a/app/frontend/src/components/torrent/FileItem.vue b/app/frontend/src/components/torrent/FileItem.vue new file mode 100644 index 0000000..33fb1d2 --- /dev/null +++ b/app/frontend/src/components/torrent/FileItem.vue @@ -0,0 +1,54 @@ + + + + + \ No newline at end of file diff --git a/app/frontend/src/components/torrent/FileList.vue b/app/frontend/src/components/torrent/FileList.vue new file mode 100644 index 0000000..d7a1524 --- /dev/null +++ b/app/frontend/src/components/torrent/FileList.vue @@ -0,0 +1,48 @@ + + + + + \ No newline at end of file diff --git a/app/frontend/src/components/torrent/TorrentItem.vue b/app/frontend/src/components/torrent/TorrentItem.vue new file mode 100644 index 0000000..c8d2223 --- /dev/null +++ b/app/frontend/src/components/torrent/TorrentItem.vue @@ -0,0 +1,126 @@ + + + + + \ No newline at end of file diff --git a/app/frontend/src/components/torrent/TorrentList.vue b/app/frontend/src/components/torrent/TorrentList.vue new file mode 100644 index 0000000..9948364 --- /dev/null +++ b/app/frontend/src/components/torrent/TorrentList.vue @@ -0,0 +1,45 @@ + + + + + \ No newline at end of file diff --git a/app/frontend/src/components/torrent/TorrentShare.vue b/app/frontend/src/components/torrent/TorrentShare.vue new file mode 100644 index 0000000..da83dd1 --- /dev/null +++ b/app/frontend/src/components/torrent/TorrentShare.vue @@ -0,0 +1,110 @@ + + + \ No newline at end of file diff --git a/app/frontend/src/components/torrent/UploadForm.vue b/app/frontend/src/components/torrent/UploadForm.vue new file mode 100644 index 0000000..1e31a94 --- /dev/null +++ b/app/frontend/src/components/torrent/UploadForm.vue @@ -0,0 +1,105 @@ + + + \ No newline at end of file diff --git a/app/frontend/src/components/utils/VideoPlayer.vue b/app/frontend/src/components/utils/VideoPlayer.vue new file mode 100644 index 0000000..2acccfa --- /dev/null +++ b/app/frontend/src/components/utils/VideoPlayer.vue @@ -0,0 +1,41 @@ + + + + + \ No newline at end of file diff --git a/app/frontend/src/plugins/utils.js b/app/frontend/src/plugins/utils.js new file mode 100644 index 0000000..93bd582 --- /dev/null +++ b/app/frontend/src/plugins/utils.js @@ -0,0 +1,17 @@ +import {filesize} from "filesize"; + +function fs_format(value){ + return filesize(value, {round: 2, exponent: 3}) +} + +function fs_speed_format(value){ + return filesize(value, {}) +} + +function toHHMMSS(time) { + let sec_num = parseInt(time), hours = Math.floor(sec_num / 3600), minutes = Math.floor(sec_num / 60) % 60, seconds = sec_num % 60; + return [hours, minutes, seconds] + .map(v => v < 10 ? "0" + v : v).filter((v, i) => v !== "00" || i > 0).join(":"); +} + +export {fs_format, fs_speed_format, toHHMMSS} \ No newline at end of file diff --git a/app/frontend/src/plugins/vue_loader.js b/app/frontend/src/plugins/vue_loader.js new file mode 100644 index 0000000..4135ff8 --- /dev/null +++ b/app/frontend/src/plugins/vue_loader.js @@ -0,0 +1,7 @@ +import { createApp } from 'vue'; +import Vuetify from "./vuetify" +import Ws from "./ws"; + +export function createVue(component, dom_id){ + return createApp(component).use(Vuetify).use(Ws).mount(dom_id) +} \ No newline at end of file diff --git a/app/frontend/src/plugins/vuetify.js b/app/frontend/src/plugins/vuetify.js new file mode 100644 index 0000000..4b7f4a0 --- /dev/null +++ b/app/frontend/src/plugins/vuetify.js @@ -0,0 +1,30 @@ +import '@mdi/font/css/materialdesignicons.css' // Ensure you are using css-loader +import 'vuetify/styles' +import { createVuetify } from 'vuetify' +import colors from 'vuetify/lib/util/colors.mjs' +// import { aliases, mdi } from 'vuetify/iconsets/mdi-svg' + +export default createVuetify({ + // components, + // directives, + icons: { + defaultSet: 'mdi', + }, + // ssr: false, + theme: { + defaultTheme: "dark", + themes: { + dark: { + colors: { + primary: colors.blue.darken2, + accent: colors.grey.darken3, + secondary: colors.amber.darken3, + info: colors.teal.lighten1, + warning: colors.amber.base, + error: colors.deepOrange.accent4, + success: colors.green.accent3 + } + } + } + } +}); diff --git a/app/frontend/src/plugins/ws.js b/app/frontend/src/plugins/ws.js new file mode 100644 index 0000000..115a079 --- /dev/null +++ b/app/frontend/src/plugins/ws.js @@ -0,0 +1,78 @@ +import { EventEmitter } from 'events'; + + +class WebSocketClient { + constructor() { + this.url = null; + this.socket = null; + this.reconnectInterval = 5000; + this.eventEmitter = new EventEmitter(); // Ajouter l'EventEmitter + } + + connect(url) { + this.url = url; + this.socket = new window.WebSocket(this.url); + + this.socket.onopen = () => { + console.log('WebSocket connected'); + this.eventEmitter.emit('connect'); + }; + + this.socket.onmessage = (message) => { + // console.log('Message received:', message.data); + // Émettre l'événement 'message' avec les données reçues + this.eventEmitter.emit('message', message.data); + this.handleMessage(message.data); + }; + + this.socket.onclose = () => { + console.log('WebSocket disconnected, retrying in 5 seconds...'); + this.eventEmitter.emit('disconnect'); + setTimeout(() => this.connect(this.url), this.reconnectInterval); + }; + + this.socket.onerror = (error) => { + console.error('WebSocket error:', error); + this.eventEmitter.emit('error', error); + this.socket.close(); + }; + } + + handleMessage(rawData){ + try { + const parsedData = JSON.parse(rawData); + this.eventEmitter.emit('json_message', parsedData); + if("context" in parsedData){ + this.eventEmitter.emit(parsedData.context, parsedData); + } + return parsedData; + } catch (error) { + console.error('Failed to parse message as JSON:', error); + this.eventEmitter.emit('invalid_message', { raw: rawData, error }); + return null; + } + } + + // Méthodes pour gérer les événements + on(event, callback) { + this.eventEmitter.on(event, callback); + } + + off(event, callback) { + this.eventEmitter.off(event, callback); + } + + send(data) { + if (this.socket.readyState === WebSocket.OPEN) { + this.socket.send(data); + } else { + console.error('WebSocket is not open. Cannot send data.'); + } + } +} + +export default { + install: (app, options) => { + app.config.globalProperties.$ws = new WebSocketClient(); + } +} \ No newline at end of file diff --git a/app/frontend/vite.config.js b/app/frontend/vite.config.js new file mode 100644 index 0000000..98b13e6 --- /dev/null +++ b/app/frontend/vite.config.js @@ -0,0 +1,51 @@ +import {defineConfig, loadEnv} from "vite"; +import {resolve, join} from "path"; +import vue from "@vitejs/plugin-vue"; +import vuetify from "vite-plugin-vuetify"; + +export default defineConfig((mode) => { + const env = loadEnv(mode, "..", ""), + SRC_DIR = resolve("./src"), + OUT_DIR = resolve("./dist") + + return { + plugins: [ + vue(), + vuetify() + ], + resolve: { + alias: { + "@": resolve(SRC_DIR), + "vue": "vue/dist/vue.esm-bundler.js" + } + }, + root: SRC_DIR, + base: "/static/", + css: { + postcss: "./postcss.config.js" + }, + server: { + host: "0.0.0.0", + port: env.DEV_SERVER_PORT, + origin: `http://${env.DEV_SERVER_HOST}:${env.DEV_SERVER_PORT}`, // hotfix, webfont was loaded from wrong url + watch: { + usePolling: true, + reloadDelay: 500, + } + }, + build: { + manifest: "manifest.json", + emptyOutDir: true, + outDir: OUT_DIR, + rollupOptions: { + input: { + // admin: join(SRC_DIR, "app/admin-entrypoint.js"), + login: join(SRC_DIR, "app/login.js"), + // main: join(SRC_DIR, "app/main-entrypoint.js"), + torrent: join(SRC_DIR, "app/torrent.js"), + // style: join(SRC_DIR, "style/main.css.js") + } + } + } + } +}) \ No newline at end of file diff --git a/app/frontend/yarn.lock b/app/frontend/yarn.lock new file mode 100644 index 0000000..e7f1aae --- /dev/null +++ b/app/frontend/yarn.lock @@ -0,0 +1,840 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@babel/helper-string-parser@^7.25.9": + version "7.25.9" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz#1aabb72ee72ed35789b4bbcad3ca2862ce614e8c" + integrity sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA== + +"@babel/helper-validator-identifier@^7.25.9": + version "7.25.9" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz#24b64e2c3ec7cd3b3c547729b8d16871f22cbdc7" + integrity sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ== + +"@babel/parser@^7.25.3": + version "7.26.9" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.26.9.tgz#d9e78bee6dc80f9efd8f2349dcfbbcdace280fd5" + integrity sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A== + dependencies: + "@babel/types" "^7.26.9" + +"@babel/runtime@^7.11.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.5.5": + version "7.26.9" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.26.9.tgz#aa4c6facc65b9cb3f87d75125ffd47781b475433" + integrity sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg== + dependencies: + regenerator-runtime "^0.14.0" + +"@babel/types@^7.26.9": + version "7.26.9" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.26.9.tgz#08b43dec79ee8e682c2ac631c010bdcac54a21ce" + integrity sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw== + dependencies: + "@babel/helper-string-parser" "^7.25.9" + "@babel/helper-validator-identifier" "^7.25.9" + +"@esbuild/aix-ppc64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz#38848d3e25afe842a7943643cbcd387cc6e13461" + integrity sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA== + +"@esbuild/android-arm64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz#f592957ae8b5643129fa889c79e69cd8669bb894" + integrity sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg== + +"@esbuild/android-arm@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.24.2.tgz#72d8a2063aa630308af486a7e5cbcd1e134335b3" + integrity sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q== + +"@esbuild/android-x64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.24.2.tgz#9a7713504d5f04792f33be9c197a882b2d88febb" + integrity sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw== + +"@esbuild/darwin-arm64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz#02ae04ad8ebffd6e2ea096181b3366816b2b5936" + integrity sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA== + +"@esbuild/darwin-x64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz#9ec312bc29c60e1b6cecadc82bd504d8adaa19e9" + integrity sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA== + +"@esbuild/freebsd-arm64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz#5e82f44cb4906d6aebf24497d6a068cfc152fa00" + integrity sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg== + +"@esbuild/freebsd-x64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz#3fb1ce92f276168b75074b4e51aa0d8141ecce7f" + integrity sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q== + +"@esbuild/linux-arm64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz#856b632d79eb80aec0864381efd29de8fd0b1f43" + integrity sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg== + +"@esbuild/linux-arm@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz#c846b4694dc5a75d1444f52257ccc5659021b736" + integrity sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA== + +"@esbuild/linux-ia32@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz#f8a16615a78826ccbb6566fab9a9606cfd4a37d5" + integrity sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw== + +"@esbuild/linux-loong64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz#1c451538c765bf14913512c76ed8a351e18b09fc" + integrity sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ== + +"@esbuild/linux-mips64el@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz#0846edeefbc3d8d50645c51869cc64401d9239cb" + integrity sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw== + +"@esbuild/linux-ppc64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz#8e3fc54505671d193337a36dfd4c1a23b8a41412" + integrity sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw== + +"@esbuild/linux-riscv64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz#6a1e92096d5e68f7bb10a0d64bb5b6d1daf9a694" + integrity sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q== + +"@esbuild/linux-s390x@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz#ab18e56e66f7a3c49cb97d337cd0a6fea28a8577" + integrity sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw== + +"@esbuild/linux-x64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz#8140c9b40da634d380b0b29c837a0b4267aff38f" + integrity sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q== + +"@esbuild/netbsd-arm64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz#65f19161432bafb3981f5f20a7ff45abb2e708e6" + integrity sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw== + +"@esbuild/netbsd-x64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz#7a3a97d77abfd11765a72f1c6f9b18f5396bcc40" + integrity sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw== + +"@esbuild/openbsd-arm64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz#58b00238dd8f123bfff68d3acc53a6ee369af89f" + integrity sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A== + +"@esbuild/openbsd-x64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz#0ac843fda0feb85a93e288842936c21a00a8a205" + integrity sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA== + +"@esbuild/sunos-x64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz#8b7aa895e07828d36c422a4404cc2ecf27fb15c6" + integrity sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig== + +"@esbuild/win32-arm64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz#c023afb647cabf0c3ed13f0eddfc4f1d61c66a85" + integrity sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ== + +"@esbuild/win32-ia32@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz#96c356132d2dda990098c8b8b951209c3cd743c2" + integrity sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA== + +"@esbuild/win32-x64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz#34aa0b52d0fbb1a654b596acfa595f0c7b77a77b" + integrity sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg== + +"@jridgewell/sourcemap-codec@^1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz#3188bcb273a414b0d215fd22a58540b989b9409a" + integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== + +"@mdi/font@^7.4.47": + version "7.4.47" + resolved "https://registry.yarnpkg.com/@mdi/font/-/font-7.4.47.tgz#2ae522867da3a5c88b738d54b403eb91471903af" + integrity sha512-43MtGpd585SNzHZPcYowu/84Vz2a2g31TvPMTm9uTiCSWzaheQySUcSyUH/46fPnuPQWof2yd0pGBtzee/IQWw== + +"@rollup/rollup-android-arm-eabi@4.34.8": + version "4.34.8" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.8.tgz#731df27dfdb77189547bcef96ada7bf166bbb2fb" + integrity sha512-q217OSE8DTp8AFHuNHXo0Y86e1wtlfVrXiAlwkIvGRQv9zbc6mE3sjIVfwI8sYUyNxwOg0j/Vm1RKM04JcWLJw== + +"@rollup/rollup-android-arm64@4.34.8": + version "4.34.8" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.34.8.tgz#4bea6db78e1f6927405df7fe0faf2f5095e01343" + integrity sha512-Gigjz7mNWaOL9wCggvoK3jEIUUbGul656opstjaUSGC3eT0BM7PofdAJaBfPFWWkXNVAXbaQtC99OCg4sJv70Q== + +"@rollup/rollup-darwin-arm64@4.34.8": + version "4.34.8" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.8.tgz#a7aab77d44be3c44a20f946e10160f84e5450e7f" + integrity sha512-02rVdZ5tgdUNRxIUrFdcMBZQoaPMrxtwSb+/hOfBdqkatYHR3lZ2A2EGyHq2sGOd0Owk80oV3snlDASC24He3Q== + +"@rollup/rollup-darwin-x64@4.34.8": + version "4.34.8" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.8.tgz#c572c024b57ee8ddd1b0851703ace9eb6cc0dd82" + integrity sha512-qIP/elwR/tq/dYRx3lgwK31jkZvMiD6qUtOycLhTzCvrjbZ3LjQnEM9rNhSGpbLXVJYQ3rq39A6Re0h9tU2ynw== + +"@rollup/rollup-freebsd-arm64@4.34.8": + version "4.34.8" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.34.8.tgz#cf74f8113b5a83098a5c026c165742277cbfb88b" + integrity sha512-IQNVXL9iY6NniYbTaOKdrlVP3XIqazBgJOVkddzJlqnCpRi/yAeSOa8PLcECFSQochzqApIOE1GHNu3pCz+BDA== + +"@rollup/rollup-freebsd-x64@4.34.8": + version "4.34.8" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.34.8.tgz#39561f3a2f201a4ad6a01425b1ff5928154ecd7c" + integrity sha512-TYXcHghgnCqYFiE3FT5QwXtOZqDj5GmaFNTNt3jNC+vh22dc/ukG2cG+pi75QO4kACohZzidsq7yKTKwq/Jq7Q== + +"@rollup/rollup-linux-arm-gnueabihf@4.34.8": + version "4.34.8" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.34.8.tgz#980d6061e373bfdaeb67925c46d2f8f9b3de537f" + integrity sha512-A4iphFGNkWRd+5m3VIGuqHnG3MVnqKe7Al57u9mwgbyZ2/xF9Jio72MaY7xxh+Y87VAHmGQr73qoKL9HPbXj1g== + +"@rollup/rollup-linux-arm-musleabihf@4.34.8": + version "4.34.8" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.34.8.tgz#f91a90f30dc00d5a64ac2d9bbedc829cd3cfaa78" + integrity sha512-S0lqKLfTm5u+QTxlFiAnb2J/2dgQqRy/XvziPtDd1rKZFXHTyYLoVL58M/XFwDI01AQCDIevGLbQrMAtdyanpA== + +"@rollup/rollup-linux-arm64-gnu@4.34.8": + version "4.34.8" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.8.tgz#fac700fa5c38bc13a0d5d34463133093da4c92a0" + integrity sha512-jpz9YOuPiSkL4G4pqKrus0pn9aYwpImGkosRKwNi+sJSkz+WU3anZe6hi73StLOQdfXYXC7hUfsQlTnjMd3s1A== + +"@rollup/rollup-linux-arm64-musl@4.34.8": + version "4.34.8" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.8.tgz#f50ecccf8c78841ff6df1706bc4782d7f62bf9c3" + integrity sha512-KdSfaROOUJXgTVxJNAZ3KwkRc5nggDk+06P6lgi1HLv1hskgvxHUKZ4xtwHkVYJ1Rep4GNo+uEfycCRRxht7+Q== + +"@rollup/rollup-linux-loongarch64-gnu@4.34.8": + version "4.34.8" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.34.8.tgz#5869dc0b28242da6553e2b52af41374f4038cd6e" + integrity sha512-NyF4gcxwkMFRjgXBM6g2lkT58OWztZvw5KkV2K0qqSnUEqCVcqdh2jN4gQrTn/YUpAcNKyFHfoOZEer9nwo6uQ== + +"@rollup/rollup-linux-powerpc64le-gnu@4.34.8": + version "4.34.8" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.34.8.tgz#5cdd9f851ce1bea33d6844a69f9574de335f20b1" + integrity sha512-LMJc999GkhGvktHU85zNTDImZVUCJ1z/MbAJTnviiWmmjyckP5aQsHtcujMjpNdMZPT2rQEDBlJfubhs3jsMfw== + +"@rollup/rollup-linux-riscv64-gnu@4.34.8": + version "4.34.8" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.34.8.tgz#ef5dc37f4388f5253f0def43e1440ec012af204d" + integrity sha512-xAQCAHPj8nJq1PI3z8CIZzXuXCstquz7cIOL73HHdXiRcKk8Ywwqtx2wrIy23EcTn4aZ2fLJNBB8d0tQENPCmw== + +"@rollup/rollup-linux-s390x-gnu@4.34.8": + version "4.34.8" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.34.8.tgz#7dbc3ccbcbcfb3e65be74538dfb6e8dd16178fde" + integrity sha512-DdePVk1NDEuc3fOe3dPPTb+rjMtuFw89gw6gVWxQFAuEqqSdDKnrwzZHrUYdac7A7dXl9Q2Vflxpme15gUWQFA== + +"@rollup/rollup-linux-x64-gnu@4.34.8": + version "4.34.8" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.8.tgz#5783fc0adcab7dc069692056e8ca8d83709855ce" + integrity sha512-8y7ED8gjxITUltTUEJLQdgpbPh1sUQ0kMTmufRF/Ns5tI9TNMNlhWtmPKKHCU0SilX+3MJkZ0zERYYGIVBYHIA== + +"@rollup/rollup-linux-x64-musl@4.34.8": + version "4.34.8" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.8.tgz#00b6c29b298197a384e3c659910b47943003a678" + integrity sha512-SCXcP0ZpGFIe7Ge+McxY5zKxiEI5ra+GT3QRxL0pMMtxPfpyLAKleZODi1zdRHkz5/BhueUrYtYVgubqe9JBNQ== + +"@rollup/rollup-win32-arm64-msvc@4.34.8": + version "4.34.8" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.8.tgz#cbfee01f1fe73791c35191a05397838520ca3cdd" + integrity sha512-YHYsgzZgFJzTRbth4h7Or0m5O74Yda+hLin0irAIobkLQFRQd1qWmnoVfwmKm9TXIZVAD0nZ+GEb2ICicLyCnQ== + +"@rollup/rollup-win32-ia32-msvc@4.34.8": + version "4.34.8" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.34.8.tgz#95cdbdff48fe6c948abcf6a1d500b2bd5ce33f62" + integrity sha512-r3NRQrXkHr4uWy5TOjTpTYojR9XmF0j/RYgKCef+Ag46FWUTltm5ziticv8LdNsDMehjJ543x/+TJAek/xBA2w== + +"@rollup/rollup-win32-x64-msvc@4.34.8": + version "4.34.8" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.8.tgz#4cdb2cfae69cdb7b1a3cc58778e820408075e928" + integrity sha512-U0FaE5O1BCpZSeE6gBl3c5ObhePQSfk9vDRToMmTkbhCOgW4jqvtS5LGyQ76L1fH8sM0keRp4uDTsbjiUyjk0g== + +"@types/estree@1.0.6": + version "1.0.6" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.6.tgz#628effeeae2064a1b4e79f78e81d87b7e5fc7b50" + integrity sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw== + +"@videojs/http-streaming@^3.16.2": + version "3.17.0" + resolved "https://registry.yarnpkg.com/@videojs/http-streaming/-/http-streaming-3.17.0.tgz#58433aa72206afae2bb65462f023d21afd766bf4" + integrity sha512-Ch1P3tvvIEezeZXyK11UfWgp4cWKX4vIhZ30baN/lRinqdbakZ5hiAI3pGjRy3d+q/Epyc8Csz5xMdKNNGYpcw== + dependencies: + "@babel/runtime" "^7.12.5" + "@videojs/vhs-utils" "^4.1.1" + aes-decrypter "^4.0.2" + global "^4.4.0" + m3u8-parser "^7.2.0" + mpd-parser "^1.3.1" + mux.js "7.1.0" + video.js "^7 || ^8" + +"@videojs/vhs-utils@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@videojs/vhs-utils/-/vhs-utils-4.0.0.tgz#4d4dbf5d61a9fbd2da114b84ec747c3a483bc60d" + integrity sha512-xJp7Yd4jMLwje2vHCUmi8MOUU76nxiwII3z4Eg3Ucb+6rrkFVGosrXlMgGnaLjq724j3wzNElRZ71D/CKrTtxg== + dependencies: + "@babel/runtime" "^7.12.5" + global "^4.4.0" + url-toolkit "^2.2.1" + +"@videojs/vhs-utils@^4.1.1": + version "4.1.1" + resolved "https://registry.yarnpkg.com/@videojs/vhs-utils/-/vhs-utils-4.1.1.tgz#44226fc5993f577490b5e08951ddc083714405cc" + integrity sha512-5iLX6sR2ownbv4Mtejw6Ax+naosGvoT9kY+gcuHzANyUZZ+4NpeNdKMUhb6ag0acYej1Y7cmr/F2+4PrggMiVA== + dependencies: + "@babel/runtime" "^7.12.5" + global "^4.4.0" + +"@videojs/xhr@2.7.0": + version "2.7.0" + resolved "https://registry.yarnpkg.com/@videojs/xhr/-/xhr-2.7.0.tgz#e272af6e2b5448aeb400905a5c6f4818f6b6ad47" + integrity sha512-giab+EVRanChIupZK7gXjHy90y3nncA2phIOyG3Ne5fvpiMJzvqYwiTOnEVW2S4CoYcuKJkomat7bMXA/UoUZQ== + dependencies: + "@babel/runtime" "^7.5.5" + global "~4.4.0" + is-function "^1.0.1" + +"@vitejs/plugin-vue@^5.2.1": + version "5.2.1" + resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-5.2.1.tgz#d1491f678ee3af899f7ae57d9c21dc52a65c7133" + integrity sha512-cxh314tzaWwOLqVes2gnnCtvBDcM1UMdn+iFR+UjAn411dPT3tOmqrJjbMd7koZpMAmBM/GqeV4n9ge7JSiJJQ== + +"@vue/compiler-core@3.5.13": + version "3.5.13" + resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.5.13.tgz#b0ae6c4347f60c03e849a05d34e5bf747c9bda05" + integrity sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q== + dependencies: + "@babel/parser" "^7.25.3" + "@vue/shared" "3.5.13" + entities "^4.5.0" + estree-walker "^2.0.2" + source-map-js "^1.2.0" + +"@vue/compiler-dom@3.5.13": + version "3.5.13" + resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.5.13.tgz#bb1b8758dbc542b3658dda973b98a1c9311a8a58" + integrity sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA== + dependencies: + "@vue/compiler-core" "3.5.13" + "@vue/shared" "3.5.13" + +"@vue/compiler-sfc@3.5.13": + version "3.5.13" + resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.5.13.tgz#461f8bd343b5c06fac4189c4fef8af32dea82b46" + integrity sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ== + dependencies: + "@babel/parser" "^7.25.3" + "@vue/compiler-core" "3.5.13" + "@vue/compiler-dom" "3.5.13" + "@vue/compiler-ssr" "3.5.13" + "@vue/shared" "3.5.13" + estree-walker "^2.0.2" + magic-string "^0.30.11" + postcss "^8.4.48" + source-map-js "^1.2.0" + +"@vue/compiler-ssr@3.5.13": + version "3.5.13" + resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.5.13.tgz#e771adcca6d3d000f91a4277c972a996d07f43ba" + integrity sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA== + dependencies: + "@vue/compiler-dom" "3.5.13" + "@vue/shared" "3.5.13" + +"@vue/reactivity@3.5.13": + version "3.5.13" + resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.5.13.tgz#b41ff2bb865e093899a22219f5b25f97b6fe155f" + integrity sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg== + dependencies: + "@vue/shared" "3.5.13" + +"@vue/runtime-core@3.5.13": + version "3.5.13" + resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.5.13.tgz#1fafa4bf0b97af0ebdd9dbfe98cd630da363a455" + integrity sha512-Fj4YRQ3Az0WTZw1sFe+QDb0aXCerigEpw418pw1HBUKFtnQHWzwojaukAs2X/c9DQz4MQ4bsXTGlcpGxU/RCIw== + dependencies: + "@vue/reactivity" "3.5.13" + "@vue/shared" "3.5.13" + +"@vue/runtime-dom@3.5.13": + version "3.5.13" + resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.5.13.tgz#610fc795de9246300e8ae8865930d534e1246215" + integrity sha512-dLaj94s93NYLqjLiyFzVs9X6dWhTdAlEAciC3Moq7gzAc13VJUdCnjjRurNM6uTLFATRHexHCTu/Xp3eW6yoog== + dependencies: + "@vue/reactivity" "3.5.13" + "@vue/runtime-core" "3.5.13" + "@vue/shared" "3.5.13" + csstype "^3.1.3" + +"@vue/server-renderer@3.5.13": + version "3.5.13" + resolved "https://registry.yarnpkg.com/@vue/server-renderer/-/server-renderer-3.5.13.tgz#429ead62ee51de789646c22efe908e489aad46f7" + integrity sha512-wAi4IRJV/2SAW3htkTlB+dHeRmpTiVIK1OGLWV1yeStVSebSQQOwGwIq0D3ZIoBj2C2qpgz5+vX9iEBkTdk5YA== + dependencies: + "@vue/compiler-ssr" "3.5.13" + "@vue/shared" "3.5.13" + +"@vue/shared@3.5.13": + version "3.5.13" + resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.13.tgz#87b309a6379c22b926e696893237826f64339b6f" + integrity sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ== + +"@vuetify/loader-shared@^2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@vuetify/loader-shared/-/loader-shared-2.1.0.tgz#29410dce04a78fa9cd40c4d9bc417b8d61ce5103" + integrity sha512-dNE6Ceym9ijFsmJKB7YGW0cxs7xbYV8+1LjU6jd4P14xOt/ji4Igtgzt0rJFbxu+ZhAzqz853lhB0z8V9Dy9cQ== + dependencies: + upath "^2.0.1" + +"@xmldom/xmldom@^0.8.3": + version "0.8.10" + resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.8.10.tgz#a1337ca426aa61cef9fe15b5b28e340a72f6fa99" + integrity sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw== + +aes-decrypter@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/aes-decrypter/-/aes-decrypter-4.0.2.tgz#90648181c68878f54093920a3b44776ec2dc4914" + integrity sha512-lc+/9s6iJvuaRe5qDlMTpCFjnwpkeOXp8qP3oiZ5jsj1MRg+SBVUmmICrhxHvc8OELSmc+fEyyxAuppY6hrWzw== + dependencies: + "@babel/runtime" "^7.12.5" + "@videojs/vhs-utils" "^4.1.1" + global "^4.4.0" + pkcs7 "^1.0.4" + +autoprefixer@^10.4.20: + version "10.4.20" + resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.20.tgz#5caec14d43976ef42e32dcb4bd62878e96be5b3b" + integrity sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g== + dependencies: + browserslist "^4.23.3" + caniuse-lite "^1.0.30001646" + fraction.js "^4.3.7" + normalize-range "^0.1.2" + picocolors "^1.0.1" + postcss-value-parser "^4.2.0" + +browserslist@^4.23.3: + version "4.24.4" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.24.4.tgz#c6b2865a3f08bcb860a0e827389003b9fe686e4b" + integrity sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A== + dependencies: + caniuse-lite "^1.0.30001688" + electron-to-chromium "^1.5.73" + node-releases "^2.0.19" + update-browserslist-db "^1.1.1" + +caniuse-lite@^1.0.30001646, caniuse-lite@^1.0.30001688: + version "1.0.30001700" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001700.tgz#26cd429cf09b4fd4e745daf4916039c794d720f6" + integrity sha512-2S6XIXwaE7K7erT8dY+kLQcpa5ms63XlRkMkReXjle+kf6c5g38vyMl+Z5y8dSxOFDhcFe+nxnn261PLxBSQsQ== + +csstype@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81" + integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== + +debug@^4.3.3: + version "4.4.0" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.0.tgz#2b3f2aea2ffeb776477460267377dc8710faba8a" + integrity sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA== + dependencies: + ms "^2.1.3" + +dom-walk@^0.1.0: + version "0.1.2" + resolved "https://registry.yarnpkg.com/dom-walk/-/dom-walk-0.1.2.tgz#0c548bef048f4d1f2a97249002236060daa3fd84" + integrity sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w== + +electron-to-chromium@^1.5.73: + version "1.5.101" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.101.tgz#c26490fb2c1363d804e798e138a2a544fc7f7075" + integrity sha512-L0ISiQrP/56Acgu4/i/kfPwWSgrzYZUnQrC0+QPFuhqlLP1Ir7qzPPDVS9BcKIyWTRU8+o6CC8dKw38tSWhYIA== + +entities@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" + integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== + +esbuild@^0.24.2: + version "0.24.2" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.24.2.tgz#b5b55bee7de017bff5fb8a4e3e44f2ebe2c3567d" + integrity sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA== + optionalDependencies: + "@esbuild/aix-ppc64" "0.24.2" + "@esbuild/android-arm" "0.24.2" + "@esbuild/android-arm64" "0.24.2" + "@esbuild/android-x64" "0.24.2" + "@esbuild/darwin-arm64" "0.24.2" + "@esbuild/darwin-x64" "0.24.2" + "@esbuild/freebsd-arm64" "0.24.2" + "@esbuild/freebsd-x64" "0.24.2" + "@esbuild/linux-arm" "0.24.2" + "@esbuild/linux-arm64" "0.24.2" + "@esbuild/linux-ia32" "0.24.2" + "@esbuild/linux-loong64" "0.24.2" + "@esbuild/linux-mips64el" "0.24.2" + "@esbuild/linux-ppc64" "0.24.2" + "@esbuild/linux-riscv64" "0.24.2" + "@esbuild/linux-s390x" "0.24.2" + "@esbuild/linux-x64" "0.24.2" + "@esbuild/netbsd-arm64" "0.24.2" + "@esbuild/netbsd-x64" "0.24.2" + "@esbuild/openbsd-arm64" "0.24.2" + "@esbuild/openbsd-x64" "0.24.2" + "@esbuild/sunos-x64" "0.24.2" + "@esbuild/win32-arm64" "0.24.2" + "@esbuild/win32-ia32" "0.24.2" + "@esbuild/win32-x64" "0.24.2" + +escalade@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + +estree-walker@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" + integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== + +events@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" + integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== + +filesize@^10.1.6: + version "10.1.6" + resolved "https://registry.yarnpkg.com/filesize/-/filesize-10.1.6.tgz#31194da825ac58689c0bce3948f33ce83aabd361" + integrity sha512-sJslQKU2uM33qH5nqewAwVB2QgR6w1aMNsYUp3aN5rMRyXEwJGmZvaWzeJFNTOXWlHQyBFCWrdj3fV/fsTOX8w== + +fraction.js@^4.3.7: + version "4.3.7" + resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7" + integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew== + +fsevents@~2.3.2, fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + +global@4.4.0, global@^4.3.1, global@^4.4.0, global@~4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/global/-/global-4.4.0.tgz#3e7b105179006a323ed71aafca3e9c57a5cc6406" + integrity sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w== + dependencies: + min-document "^2.19.0" + process "^0.11.10" + +hasown@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + +is-core-module@^2.16.0: + version "2.16.1" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4" + integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w== + dependencies: + hasown "^2.0.2" + +is-function@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-function/-/is-function-1.0.2.tgz#4f097f30abf6efadac9833b17ca5dc03f8144e08" + integrity sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ== + +js-cookie@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.5.tgz#0b7e2fd0c01552c58ba86e0841f94dc2557dcdbc" + integrity sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw== + +m3u8-parser@^7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/m3u8-parser/-/m3u8-parser-7.2.0.tgz#9e2eb50abb8349d248cd58842367da4acabdf297" + integrity sha512-CRatFqpjVtMiMaKXxNvuI3I++vUumIXVVT/JpCpdU/FynV/ceVw1qpPyyBNindL+JlPMSesx+WX1QJaZEJSaMQ== + dependencies: + "@babel/runtime" "^7.12.5" + "@videojs/vhs-utils" "^4.1.1" + global "^4.4.0" + +magic-string@^0.30.11: + version "0.30.17" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.17.tgz#450a449673d2460e5bbcfba9a61916a1714c7453" + integrity sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.0" + +min-document@^2.19.0: + version "2.19.0" + resolved "https://registry.yarnpkg.com/min-document/-/min-document-2.19.0.tgz#7bd282e3f5842ed295bb748cdd9f1ffa2c824685" + integrity sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ== + dependencies: + dom-walk "^0.1.0" + +moment@^2.30.1: + version "2.30.1" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae" + integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how== + +mpd-parser@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/mpd-parser/-/mpd-parser-1.3.1.tgz#557b6ac27411c2c177bb01e46e14440703a414a3" + integrity sha512-1FuyEWI5k2HcmhS1HkKnUAQV7yFPfXPht2DnRRGtoiiAAW+ESTbtEXIDpRkwdU+XyrQuwrIym7UkoPKsZ0SyFw== + dependencies: + "@babel/runtime" "^7.12.5" + "@videojs/vhs-utils" "^4.0.0" + "@xmldom/xmldom" "^0.8.3" + global "^4.4.0" + +ms@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +mux.js@7.1.0, mux.js@^7.0.1: + version "7.1.0" + resolved "https://registry.yarnpkg.com/mux.js/-/mux.js-7.1.0.tgz#aba5ed55a39cb790ef4b30b2c3ea0d2630b0264e" + integrity sha512-NTxawK/BBELJrYsZThEulyUMDVlLizKdxyAsMuzoCD1eFj97BVaA8D/CvKsKu6FOLYkFojN5CbM9h++ZTZtknA== + dependencies: + "@babel/runtime" "^7.11.2" + global "^4.4.0" + +nanoid@^3.3.8: + version "3.3.8" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf" + integrity sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w== + +node-releases@^2.0.19: + version "2.0.19" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.19.tgz#9e445a52950951ec4d177d843af370b411caf314" + integrity sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw== + +normalize-range@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942" + integrity sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA== + +path-parse@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== + +picocolors@^1.0.1, picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +pify@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" + integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog== + +pkcs7@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/pkcs7/-/pkcs7-1.0.4.tgz#6090b9e71160dabf69209d719cbafa538b00a1cb" + integrity sha512-afRERtHn54AlwaF2/+LFszyAANTCggGilmcmILUzEjvs3XgFZT+xE6+QWQcAGmu4xajy+Xtj7acLOPdx5/eXWQ== + dependencies: + "@babel/runtime" "^7.5.5" + +postcss-import@^16.1.0: + version "16.1.0" + resolved "https://registry.yarnpkg.com/postcss-import/-/postcss-import-16.1.0.tgz#258732175518129667fe1e2e2a05b19b5654b96a" + integrity sha512-7hsAZ4xGXl4MW+OKEWCnF6T5jqBw80/EE9aXg1r2yyn1RsVEU8EtKXbijEODa+rg7iih4bKf7vlvTGYR4CnPNg== + dependencies: + postcss-value-parser "^4.0.0" + read-cache "^1.0.0" + resolve "^1.1.7" + +postcss-simple-vars@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/postcss-simple-vars/-/postcss-simple-vars-7.0.1.tgz#836b3097a54dcd13dbd3c36a5dbdd512fad2954c" + integrity sha512-5GLLXaS8qmzHMOjVxqkk1TZPf1jMqesiI7qLhnlyERalG0sMbHIbJqrcnrpmZdKCLglHnRHoEBB61RtGTsj++A== + +postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" + integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== + +postcss@^8.4.48, postcss@^8.5.1, postcss@^8.5.2: + version "8.5.2" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.2.tgz#e7b99cb9d2ec3e8dd424002e7c16517cb2b846bd" + integrity sha512-MjOadfU3Ys9KYoX0AdkBlFEF1Vx37uCCeN4ZHnmwm9FfpbsGWMZeBLMmmpY+6Ocqod7mkdZ0DT31OlbsFrLlkA== + dependencies: + nanoid "^3.3.8" + picocolors "^1.1.1" + source-map-js "^1.2.1" + +process@^0.11.10: + version "0.11.10" + resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" + integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A== + +read-cache@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/read-cache/-/read-cache-1.0.0.tgz#e664ef31161166c9751cdbe8dbcf86b5fb58f774" + integrity sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA== + dependencies: + pify "^2.3.0" + +regenerator-runtime@^0.14.0: + version "0.14.1" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f" + integrity sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw== + +resolve@^1.1.7: + version "1.22.10" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.10.tgz#b663e83ffb09bbf2386944736baae803029b8b39" + integrity sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w== + dependencies: + is-core-module "^2.16.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +rollup@^4.30.1: + version "4.34.8" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.34.8.tgz#e859c1a51d899aba9bcf451d4eed1d11fb8e2a6e" + integrity sha512-489gTVMzAYdiZHFVA/ig/iYFllCcWFHMvUHI1rpFmkoUtRlQxqh6/yiNqnYibjMZ2b/+FUQwldG+aLsEt6bglQ== + dependencies: + "@types/estree" "1.0.6" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.34.8" + "@rollup/rollup-android-arm64" "4.34.8" + "@rollup/rollup-darwin-arm64" "4.34.8" + "@rollup/rollup-darwin-x64" "4.34.8" + "@rollup/rollup-freebsd-arm64" "4.34.8" + "@rollup/rollup-freebsd-x64" "4.34.8" + "@rollup/rollup-linux-arm-gnueabihf" "4.34.8" + "@rollup/rollup-linux-arm-musleabihf" "4.34.8" + "@rollup/rollup-linux-arm64-gnu" "4.34.8" + "@rollup/rollup-linux-arm64-musl" "4.34.8" + "@rollup/rollup-linux-loongarch64-gnu" "4.34.8" + "@rollup/rollup-linux-powerpc64le-gnu" "4.34.8" + "@rollup/rollup-linux-riscv64-gnu" "4.34.8" + "@rollup/rollup-linux-s390x-gnu" "4.34.8" + "@rollup/rollup-linux-x64-gnu" "4.34.8" + "@rollup/rollup-linux-x64-musl" "4.34.8" + "@rollup/rollup-win32-arm64-msvc" "4.34.8" + "@rollup/rollup-win32-ia32-msvc" "4.34.8" + "@rollup/rollup-win32-x64-msvc" "4.34.8" + fsevents "~2.3.2" + +source-map-js@^1.2.0, source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + +upath@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/upath/-/upath-2.0.1.tgz#50c73dea68d6f6b990f51d279ce6081665d61a8b" + integrity sha512-1uEe95xksV1O0CYKXo8vQvN1JEbtJp7lb7C5U9HMsIp6IVwntkH/oNUzyVNQSd4S1sYk2FpSSW44FqMc8qee5w== + +update-browserslist-db@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz#97e9c96ab0ae7bcac08e9ae5151d26e6bc6b5580" + integrity sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg== + dependencies: + escalade "^3.2.0" + picocolors "^1.1.1" + +url-toolkit@^2.2.1: + version "2.2.5" + resolved "https://registry.yarnpkg.com/url-toolkit/-/url-toolkit-2.2.5.tgz#58406b18e12c58803e14624df5e374f638b0f607" + integrity sha512-mtN6xk+Nac+oyJ/PrI7tzfmomRVNFIWKUbG8jdYFt52hxbiReFAXIjYskvu64/dvuW71IcB7lV8l0HvZMac6Jg== + +"video.js@^7 || ^8", video.js@^8.21.0: + version "8.21.0" + resolved "https://registry.yarnpkg.com/video.js/-/video.js-8.21.0.tgz#ce097aa2cd06dcbada5379215e3781d703d9d16b" + integrity sha512-zcwerRb257QAuWfi8NH9yEX7vrGKFthjfcONmOQ4lxFRpDAbAi+u5LAjCjMWqhJda6zEmxkgdDpOMW3Y21QpXA== + dependencies: + "@babel/runtime" "^7.12.5" + "@videojs/http-streaming" "^3.16.2" + "@videojs/vhs-utils" "^4.1.1" + "@videojs/xhr" "2.7.0" + aes-decrypter "^4.0.2" + global "4.4.0" + m3u8-parser "^7.2.0" + mpd-parser "^1.3.1" + mux.js "^7.0.1" + videojs-contrib-quality-levels "4.1.0" + videojs-font "4.2.0" + videojs-vtt.js "0.15.5" + +videojs-contrib-quality-levels@4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/videojs-contrib-quality-levels/-/videojs-contrib-quality-levels-4.1.0.tgz#44c2d2167114a5c8418548b10a25cb409d6cba51" + integrity sha512-TfrXJJg1Bv4t6TOCMEVMwF/CoS8iENYsWNKip8zfhB5kTcegiFYezEA0eHAJPU64ZC8NQbxQgOwAsYU8VXbOWA== + dependencies: + global "^4.4.0" + +videojs-font@4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/videojs-font/-/videojs-font-4.2.0.tgz#fbce803d347c565816e296f527e208dc65c9f235" + integrity sha512-YPq+wiKoGy2/M7ccjmlvwi58z2xsykkkfNMyIg4xb7EZQQNwB71hcSsB3o75CqQV7/y5lXkXhI/rsGAS7jfEmQ== + +videojs-vtt.js@0.15.5: + version "0.15.5" + resolved "https://registry.yarnpkg.com/videojs-vtt.js/-/videojs-vtt.js-0.15.5.tgz#567776eaf2a7a928d88b148a8b401ade2406f2ca" + integrity sha512-yZbBxvA7QMYn15Lr/ZfhhLPrNpI/RmCSCqgIff57GC2gIrV5YfyzLfLyZMj0NnZSAz8syB4N0nHXpZg9MyrMOQ== + dependencies: + global "^4.3.1" + +vite-plugin-vuetify@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/vite-plugin-vuetify/-/vite-plugin-vuetify-2.1.0.tgz#56767be099fd18a44ec2f9831d49269722e0e538" + integrity sha512-4wEAQtZaigPpwbFcZbrKpYwutOsWwWdeXn22B9XHzDPQNxVsKT+K9lKcXZnI5JESO1Iaql48S9rOk8RZZEt+Mw== + dependencies: + "@vuetify/loader-shared" "^2.1.0" + debug "^4.3.3" + upath "^2.0.1" + +vite@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/vite/-/vite-6.1.0.tgz#00a4e99a23751af98a2e4701c65ba89ce23858a6" + integrity sha512-RjjMipCKVoR4hVfPY6GQTgveinjNuyLw+qruksLDvA5ktI1150VmcMBKmQaEWJhg/j6Uaf6dNCNA0AfdzUb/hQ== + dependencies: + esbuild "^0.24.2" + postcss "^8.5.1" + rollup "^4.30.1" + optionalDependencies: + fsevents "~2.3.3" + +vue@^3.5.13: + version "3.5.13" + resolved "https://registry.yarnpkg.com/vue/-/vue-3.5.13.tgz#9f760a1a982b09c0c04a867903fc339c9f29ec0a" + integrity sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ== + dependencies: + "@vue/compiler-dom" "3.5.13" + "@vue/compiler-sfc" "3.5.13" + "@vue/runtime-dom" "3.5.13" + "@vue/server-renderer" "3.5.13" + "@vue/shared" "3.5.13" + +vuetify@^3.7.12: + version "3.7.12" + resolved "https://registry.yarnpkg.com/vuetify/-/vuetify-3.7.12.tgz#17fa7e7a88ba8495d7162ad64916ea91e63875cf" + integrity sha512-cBxWXKPNl3vWc10/EEpfK4RBrCZERAHEUZCWmrJPd6v+JU0sbm4sEgIpy8IU5d1BzA1kIhknpbgYy2IqiZponA== diff --git a/app/manage.py b/app/manage.py new file mode 100644 index 0000000..4931389 --- /dev/null +++ b/app/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..24036d5 --- /dev/null +++ b/app/requirements.txt @@ -0,0 +1,18 @@ +django +django-vite +django-cors-headers + +djangorestframework +django-filter + +channels[daphne] +channels_redis + +celery + +pytz +psycopg[binary] +uvicorn +transmission-rpc +stream-zip +aiofiles \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..a18fc04 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,24 @@ +{% load django_vite %} + + + + {% block base_title %}{% block title %}{% endblock %}{% endblock %} + {% block base_css %} +{# {% stylesheet_pack 'app' %}#} + + {% block css %}{% endblock %} + {% endblock %} + + + + {% if csrf_token %}{% endif %} + {% block base_content %} + {% block content %}
{% endblock %} + {% endblock %} + {% block base_js %} +{# {{ javascript_pack("main-entrypoint") }}#} + {% vite_hmr_client %} + {% block js %}{% endblock %} + {% endblock %} + + \ No newline at end of file diff --git a/app/torrent/__init__.py b/app/torrent/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/torrent/admin.py b/app/torrent/admin.py new file mode 100644 index 0000000..85261c5 --- /dev/null +++ b/app/torrent/admin.py @@ -0,0 +1,13 @@ +from django.contrib import admin +from django.template.defaultfilters import filesizeformat + +from .models import Torrent + + +@admin.register(Torrent) +class TorrentAdmin(admin.ModelAdmin): + list_display = ["name", "user", "display_size", "len_files"] + list_filter = ["user"] + + def display_size(self, obj): + return filesizeformat(obj.size) diff --git a/app/torrent/apps.py b/app/torrent/apps.py new file mode 100644 index 0000000..9a4a7f6 --- /dev/null +++ b/app/torrent/apps.py @@ -0,0 +1,15 @@ +from django.apps import AppConfig + + +class TorrentConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'torrent' + + def ready(self): + from django.db.models.signals import post_save, pre_delete, m2m_changed + from .signals import on_post_save_torrent, on_pre_delete_torrent, on_shared_user_changed + from .models import Torrent + + post_save.connect(on_post_save_torrent, sender=Torrent) + pre_delete.connect(on_pre_delete_torrent, sender=Torrent) + m2m_changed.connect(on_shared_user_changed, sender=Torrent.shared_users.through) diff --git a/app/torrent/consumers.py b/app/torrent/consumers.py new file mode 100644 index 0000000..75d72ae --- /dev/null +++ b/app/torrent/consumers.py @@ -0,0 +1,100 @@ +from django.db.models import Q +from django.db.models.functions import Coalesce + +from channels.generic.websocket import AsyncJsonWebsocketConsumer +from typing import Optional, Union +import asyncio + +from user.models import User +from .models import Torrent + + +class TorrentEventConsumer(AsyncJsonWebsocketConsumer): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.channel_groups = set() + self.user: Optional[User] = None + self.follow_user: Optional[User] = None + + async def connect(self): + self.user = self.scope['user'] + if not self.user.is_authenticated: + await self.close() + return + + self.follow_user = self.user + await self.channel_layer.group_add(f"user_{self.follow_user.id}", self.channel_name) + + # user_id = int(self.scope['url_route']["kwargs"]["user_id"]) + # if user_id == self.user.id: + # self.follow_user = self.user + # else: + # if await self.change_follow_user(user_id) is None: + # await self.close() + # return + + await self.channel_layer.group_add("torrent", self.channel_name) + await self.accept() + + async def disconnect(self, code): + await self.channel_layer.group_discard("torrent", self.channel_name) + + async def dispatch(self, message): + print("dispatch ws :", message) + return await super().dispatch(message) + + async def receive_json(self, content, **kwargs): + if "context" not in content: + return + match content["context"]: + case "change_follow_user": + await self.change_follow_user(content["user_id"]) + case _: + print("call websocket not supported", content) + + async def change_follow_user(self, user_id): + await self.channel_layer.group_discard(f"user_{self.follow_user.id}", self.channel_name) + if user_id == self.user.id: + self.follow_user = self.user + await self.channel_layer.group_add(f"user_{self.follow_user.id}", self.channel_name) + return self.follow_user + elif await self.user.friends.filter(id=user_id).aexists(): + self.follow_user = await User.objects.filter(id=user_id).aget() + await self.channel_layer.group_add(f"user_{self.follow_user.id}", self.channel_name) + return self.follow_user + else: + return None + + async def transmission_data_updated(self, datas): + torrent_stats = datas["data"] + qs = (Torrent.objects + .filter(Q(user_id=self.follow_user.id) | Q(shared_users=self.follow_user.id)) + .values_list("id", flat=True).distinct()) + torrent_ids = [i async for i in qs] + + for hash_string, data in torrent_stats.items(): + if hash_string in torrent_ids: + await self.send_json({ + "context": "transmission_data_updated", + "data": data + }) + + async def add_torrent(self, data): + await self.send_json({ + "context": "add_torrent", + "torrent_id": data["data"] + }) + + async def remove_torrent(self, data): + await self.send_json({ + "context": "remove_torrent", + "torrent_id": data["data"] + }) + + async def update_torrent(self, data): + await self.send_json({ + "context": "update_torrent", + "torrent_id": data["data"]["torrent_id"], + "updated_fields": data["data"]["updated_fields"] + }) diff --git a/app/torrent/management/commands/torrent_event.py b/app/torrent/management/commands/torrent_event.py new file mode 100644 index 0000000..9e03ac5 --- /dev/null +++ b/app/torrent/management/commands/torrent_event.py @@ -0,0 +1,62 @@ +from django.core.management.base import BaseCommand +from django.db import close_old_connections + +import time +import signal +import sys +import traceback + +from torrent.models import Torrent +from torrent.utils import transmission_handler +from app.utils import send_sync_channel_message + + +def update_transmission_data(): + data = transmission_handler.get_all_data() + + updated_torrents = [] + for torrent in Torrent.objects.all(): + if torrent.id in data and torrent.transmission_data != data[torrent.id]: + torrent.transmission_data = data[torrent.id] + updated_torrents.append(torrent) + if updated_torrents: + Torrent.objects.bulk_update(updated_torrents, ["transmission_data"]) + send_sync_channel_message("torrent", "transmission_data_updated", { + torrent.id: torrent.transmission_data + for torrent in updated_torrents + }) + + +class Command(BaseCommand): + task_schedule = { + "update_transmission_data": { + "func": update_transmission_data, + "schedule": 5.0 + } + } + histories = {} + run = True + + def handle(self, *args, **options): + signal.signal(signal.SIGINT, self.exit_gracefully) + signal.signal(signal.SIGTERM, self.exit_gracefully) + + self.stdout.write(self.style.SUCCESS("start")) + while self.run: + for name, task in self.task_schedule.items(): + if name not in self.histories or time.time() - self.histories[name] > task["schedule"]: + self.call_func(name) + time.sleep(0.5) + + def exit_gracefully(self, signum, frame): + self.stdout.write(self.style.SUCCESS("exit")) + self.run = False + + def call_func(self, name): + close_old_connections() + try: + self.task_schedule[name]["func"]() + self.histories[name] = time.time() + except Exception as e: + tb = traceback.format_exc() + self.stderr.write(self.style.ERROR(f"Error in {name}: {e}\n{tb}")) diff --git a/app/torrent/migrations/0001_initial.py b/app/torrent/migrations/0001_initial.py new file mode 100644 index 0000000..b4ae650 --- /dev/null +++ b/app/torrent/migrations/0001_initial.py @@ -0,0 +1,40 @@ +# Generated by Django 5.1.6 on 2025-03-04 23:41 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='File', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('rel_name', models.TextField()), + ('size', models.BigIntegerField()), + ], + ), + migrations.CreateModel( + name='SharedUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name='Torrent', + fields=[ + ('id', models.CharField(max_length=40, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=255)), + ('date_added', models.DateTimeField(auto_now_add=True)), + ('size', models.PositiveBigIntegerField()), + ('transmission_data', models.JSONField(default=dict)), + ], + ), + ] diff --git a/app/torrent/migrations/0002_initial.py b/app/torrent/migrations/0002_initial.py new file mode 100644 index 0000000..ee20786 --- /dev/null +++ b/app/torrent/migrations/0002_initial.py @@ -0,0 +1,47 @@ +# Generated by Django 5.1.6 on 2025-03-04 23:41 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('torrent', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='shareduser', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='torrent', + name='shared_users', + field=models.ManyToManyField(blank=True, related_name='torrents_shares', through='torrent.SharedUser', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='torrent', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='torrents', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='shareduser', + name='torrent', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='torrent.torrent'), + ), + migrations.AddField( + model_name='file', + name='torrent', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='torrent.torrent'), + ), + migrations.AlterUniqueTogether( + name='shareduser', + unique_together={('user', 'torrent')}, + ), + ] diff --git a/app/torrent/migrations/0003_torrent_date_modified.py b/app/torrent/migrations/0003_torrent_date_modified.py new file mode 100644 index 0000000..81b3ca5 --- /dev/null +++ b/app/torrent/migrations/0003_torrent_date_modified.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2025-03-10 16:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('torrent', '0002_initial'), + ] + + operations = [ + migrations.AddField( + model_name='torrent', + name='date_modified', + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/app/torrent/migrations/0004_rename_date_shareduser_date_created.py b/app/torrent/migrations/0004_rename_date_shareduser_date_created.py new file mode 100644 index 0000000..d7b1e87 --- /dev/null +++ b/app/torrent/migrations/0004_rename_date_shareduser_date_created.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2025-03-10 16:53 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('torrent', '0003_torrent_date_modified'), + ] + + operations = [ + migrations.RenameField( + model_name='shareduser', + old_name='date', + new_name='date_created', + ), + ] diff --git a/app/torrent/migrations/0005_rename_date_added_torrent_date_created.py b/app/torrent/migrations/0005_rename_date_added_torrent_date_created.py new file mode 100644 index 0000000..88dddb9 --- /dev/null +++ b/app/torrent/migrations/0005_rename_date_added_torrent_date_created.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2025-03-10 16:56 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('torrent', '0004_rename_date_shareduser_date_created'), + ] + + operations = [ + migrations.RenameField( + model_name='torrent', + old_name='date_added', + new_name='date_created', + ), + ] diff --git a/app/torrent/migrations/__init__.py b/app/torrent/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/torrent/models.py b/app/torrent/models.py new file mode 100644 index 0000000..5f79d3a --- /dev/null +++ b/app/torrent/models.py @@ -0,0 +1,94 @@ +from django.db import models +from django.conf import settings + +from functools import cached_property +from pathlib import Path +from urllib.parse import quote +import mimetypes +import uuid +import shlex + + +class Torrent(models.Model): + id = models.CharField(max_length=40, primary_key=True) + name = models.CharField(max_length=255) + date_created = models.DateTimeField(auto_now_add=True) + date_modified = models.DateTimeField(auto_now=True) + user = models.ForeignKey("user.User", on_delete=models.CASCADE, related_name="torrents") + shared_users = models.ManyToManyField("user.User", related_name="torrents_shares", blank=True, through="SharedUser") + size = models.PositiveBigIntegerField() + transmission_data = models.JSONField(default=dict) + + @cached_property + def len_files(self): + if hasattr(self, "_len_files"): + return self._len_files + else: + return File.objects.filter(torrent_id=self.id).count() + + @property + async def alen_files(self): + if hasattr(self, "_len_files"): + return self._len_files + else: + return await File.objects.filter(torrent_id=self.id).acount() + + @cached_property + def related_users(self): + return [ + self.user_id, + *self.shared_users.values_list("id", flat=True) + ] + + +class SharedUser(models.Model): + user = models.ForeignKey("user.User", models.CASCADE) + torrent = models.ForeignKey("Torrent", models.CASCADE) + date_created = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ("user", "torrent") + + + +class File(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4) + torrent = models.ForeignKey("Torrent", models.CASCADE, related_name="files") + rel_name = models.TextField() + size = models.BigIntegerField() + + @property + def pathname(self): + return Path(self.rel_name) + + @property + def filename(self): + return self.pathname.name + + @property + def abs_pathname(self): + return settings.DOWNLOAD_BASE_DIR / self.pathname + + @property + def mime_types(self): + mime = mimetypes.guess_type(self.pathname) + if mime: + return mime + else: + return "application/octet-stream" + + @property + def is_stream_video(self): + return self.pathname.stem in ["mp4", "flv", "webm"] + + @property + def is_video(self): + return self.pathname.stem in ["mp4", "flv", "webm", "avi", "mkv"] + + @property + def accel_redirect(self): + return shlex.quote(f"{settings.NGINX_ACCEL_BASE}/{self.pathname}") + + @property + def disposition(self): + return f'attachment; filename="{quote(self.filename)}"; filename*="{quote(self.filename)}"' diff --git a/app/torrent/serializers.py b/app/torrent/serializers.py new file mode 100644 index 0000000..7052b49 --- /dev/null +++ b/app/torrent/serializers.py @@ -0,0 +1,30 @@ +from django.urls import reverse + +from rest_framework import serializers + +from user.serializers import UserSerializer +from .models import Torrent, File + + +class TorrentSerializer(serializers.ModelSerializer): + count_files = serializers.IntegerField(read_only=True, source="len_files") + download_url = serializers.SerializerMethodField(read_only=True) + class Meta: + model = Torrent + fields = "__all__" + + def get_download_url(self, obj): + return reverse("torrent:download_torrent", kwargs={"torrent_id": obj.id}) + + +class FileSerializer(serializers.ModelSerializer): + is_stream_video = serializers.BooleanField(read_only=True) + is_video = serializers.BooleanField(read_only=True) + download_url = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = File + fields = "__all__" + + def get_download_url(self, obj): + return reverse("torrent:download_file", kwargs={"file_id": obj.id}) diff --git a/app/torrent/signals.py b/app/torrent/signals.py new file mode 100644 index 0000000..388aaad --- /dev/null +++ b/app/torrent/signals.py @@ -0,0 +1,47 @@ +from app.utils import send_sync_channel_message +from .utils import transmission_handler +from .models import Torrent, SharedUser + + +def on_post_save_torrent(instance: Torrent, created, **kwargs): + if created: + send_sync_channel_message(f"user_{instance.user_id}", "add_torrent", instance.id) + + +def on_pre_delete_torrent(instance: Torrent, **kwargs): + transmission_handler.delete(instance.id) + for user_id in instance.related_users: + send_sync_channel_message(f"user_{user_id}", "remove_torrent", instance.id) + + +def on_shared_user_changed(sender, instance: Torrent, action, pk_set, **kwargs): + # print("on_share_user_changed", sender, instance, action, pk_set) + # on_share_user_changed Torrent object (a9164e99d5181cfef0c23c209334103619080908) pre_add {3} + # on_share_user_changed Torrent object (a9164e99d5181cfef0c23c209334103619080908) post_add {3} + match action: + case "pre_add": + pass + case "post_add": + for user_id in pk_set: + send_sync_channel_message(f"user_{user_id}", "add_torrent", instance.id) + for user_id in instance.related_users: + send_sync_channel_message(f"user_{user_id}", "update_torrent", { + "torrent_id": instance.id, + "updated_fields": {"shared_users": list(instance.shared_users.all().values_list("id", flat=True))} + }) + case "pre_remove": + pass + case "post_remove": + for user_id in pk_set: + send_sync_channel_message(f"user_{user_id}", "remove_torrent", instance.id) + for user_id in instance.related_users: + send_sync_channel_message(f"user_{user_id}", "update_torrent", { + "torrent_id": instance.id, + "updated_fields": {"shared_users": list(instance.shared_users.all().values_list("id", flat=True))} + }) + case "pre_clear": + pass + case "post_clear": + pass + case _: + pass diff --git a/app/torrent/tasks.py b/app/torrent/tasks.py new file mode 100644 index 0000000..caeb91b --- /dev/null +++ b/app/torrent/tasks.py @@ -0,0 +1,26 @@ +from django.db import close_old_connections +from celery import shared_task +from channels.layers import get_channel_layer +from asgiref.sync import async_to_sync + +from .models import Torrent, File +from .utils import transmission_handler +from app.utils import send_sync_channel_message + + +@shared_task +def update_transmission_data(): + data = transmission_handler.get_all_data() + + updated_torrents = [] + for torrent in Torrent.objects.all(): + if torrent.id in data and torrent.transmission_data != data[torrent.id]: + torrent.transmission_data = data[torrent.id] + updated_torrents.append(torrent) + if updated_torrents: + Torrent.objects.bulk_update(updated_torrents, ["transmission_data"]) + send_sync_channel_message("torrent", "transmission_data_updated", { + torrent.id: torrent.transmission_data + for torrent in updated_torrents + }) + diff --git a/app/torrent/templates/torrent/home.html b/app/torrent/templates/torrent/home.html new file mode 100644 index 0000000..12c9e09 --- /dev/null +++ b/app/torrent/templates/torrent/home.html @@ -0,0 +1,9 @@ +{% extends "torrent/layout.html" %} +{% load django_vite %} + +{% block js %} + + {% vite_asset "app/torrent.js" %} +{% endblock %} \ No newline at end of file diff --git a/app/torrent/templates/torrent/layout.html b/app/torrent/templates/torrent/layout.html new file mode 100644 index 0000000..63913c1 --- /dev/null +++ b/app/torrent/templates/torrent/layout.html @@ -0,0 +1 @@ +{% extends "base.html" %} \ No newline at end of file diff --git a/app/torrent/tests.py b/app/torrent/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/app/torrent/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/app/torrent/urls.py b/app/torrent/urls.py new file mode 100644 index 0000000..067f118 --- /dev/null +++ b/app/torrent/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from .views import HomeView, download_file, download_torrent + +app_name = "torrent" +urlpatterns = [ + path("", HomeView.as_view(), name="home"), + path("download_file/", download_file, name="download_file"), + path("download_torrent/", download_torrent, name="download_torrent"), +] diff --git a/app/torrent/utils.py b/app/torrent/utils.py new file mode 100644 index 0000000..0fc3af4 --- /dev/null +++ b/app/torrent/utils.py @@ -0,0 +1,119 @@ +from django.conf import settings + +import traceback +from transmission_rpc import Client +from transmission_rpc.error import TransmissionError + +# from app.utils import send_sync_channel_message +from .models import Torrent, File +from user.models import User + + +class Transmission: + trpc_args = [ + "id", "percentDone", "uploadRatio", "rateUpload", "rateDownload", "hashString", "status", "sizeWhenDone", + "leftUntilDone", "name", "eta", "totalSize" + ] + + def __init__(self): + self.client = Client(**settings.TRANSMISSION) + + def add_torrent(self, file): + return self.client.add_torrent(file) + + def get_data(self, hash_string): + data = self.client.get_torrent(hash_string, self.trpc_args) + + return { + "progress": data.progress, + **data.fields + } + + def get_all_data(self, hash_strings=None): + return { + data.hashString: {"progress": data.progress, **data.fields} + for data in self.client.get_torrents(hash_strings, self.trpc_args) + } + + def get_files(self, hash_string): + return self.client.get_torrent(hash_string).get_files() + + def delete(self, hash_string): + return self.client.remove_torrent(hash_string, delete_data=True) + + +transmission_handler = Transmission() + + +def torrent_proceed(user, file): + r = { + "torrent": None, + "status": "error", + "message": "Unexpected error" + } + + user: User + if user.size_used > user.max_size: + r["message"] = "Size exceed" + return r + + try: + torrent_uploaded = transmission_handler.add_torrent(file) + except TransmissionError: + print(traceback.format_exc()) + r["message"] = "Transmission Error" + return r + except: + print(traceback.format_exc()) + return r + else: + r["status"] = "warn" + qs = Torrent.objects.filter(pk=torrent_uploaded.hashString) + if qs.exists(): + torrent = qs.get() + if torrent.user == user: + r["message"] = "Already exist" + return r + elif torrent.shared_users.filter(user=user).exists(): + r["message"] = "Already shared" + return r + else: + torrent.shared_users.add(user) + r["status"] = "success" + r["message"] = "Torrent downloaded by an other user, added to your list" + return r + else: + data = transmission_handler.get_data(torrent_uploaded.hashString) + torrent = Torrent.objects.create( + id=data["hashString"], + name=data["name"], + user=user, + size=data["totalSize"], + transmission_data=data + ) + File.objects.bulk_create([ + File( + torrent=torrent, + rel_name=file.name, + size=file.size, + ) + for file in transmission_handler.get_files(torrent.id) + ]) + + r["torrent"] = torrent + r["status"] = "success" + r["message"] = "Torrent added" + return r + + +def torrent_share(torrent, current_user, target_user_id): + from .models import Torrent, SharedUser + + torrent: Torrent + + if (torrent.user_id != target_user_id and + any([torrent.user == current_user, torrent.shared_users.filter(id=current_user.id)]) and + not SharedUser.objects.filter(torrent_id=torrent.id, user_id=target_user_id).exists()): + torrent.shared_users.add(target_user_id) + return True + return False \ No newline at end of file diff --git a/app/torrent/views.py b/app/torrent/views.py new file mode 100644 index 0000000..8021148 --- /dev/null +++ b/app/torrent/views.py @@ -0,0 +1,157 @@ +from django.shortcuts import redirect +from django.urls import reverse +from django.views.generic import TemplateView +from django.contrib.auth.mixins import LoginRequiredMixin +from django.db.models import Q, Count, OuterRef +from django.db.models.functions import Coalesce +from django.http import HttpResponse, Http404, StreamingHttpResponse + +import aiofiles +from rest_framework.viewsets import GenericViewSet +from rest_framework import mixins +from rest_framework.response import Response +from rest_framework.decorators import action + +from app.utils import StreamingZipFileResponse +from user.models import User +from .models import Torrent, File, SharedUser +from .serializers import TorrentSerializer, FileSerializer +from .utils import torrent_proceed, torrent_share + + +class HomeView(LoginRequiredMixin, TemplateView): + template_name = "torrent/home.html" + + +async def download_file(request, file_id): + user = await request.auser() + qs = File.objects.filter( + Q(torrent__user=user) + | Q(torrent__shared_users=user) + | Q(torrent__user__friends=user) + | Q(torrent__shared_users__friends=user), + torrent__transmission_data__progress__gte=100, + pk=file_id + ) + + try: + file = await qs.aget() + except File.DoesNotExist: + raise Http404() + else: + if request.GET.get("dl_hotfix", "0") == "1": + async def read_file(): + async with aiofiles.open(file.abs_pathname, "rb") as f: + while chunk := await f.read(128 * 1024): + yield chunk + response = StreamingHttpResponse(read_file()) + response["Content-Length"] = file.size + response["Content-Type"] = "application/octet-stream" + response["Content-Disposition"] = file.disposition + return response + else: + response = HttpResponse() + response["X-Accel-Redirect"] = file.accel_redirect + response["Content-Type"] = file.mime_types + response["Content-Disposition"] = file.disposition + return response + + +async def download_torrent(request, torrent_id): + # py version + user = await request.auser() + qs = Torrent.objects.filter( + Q(user=user) + | Q(shared_users=user) + | Q(user__friends=user) + | Q(shared_users__friends=user), + transmission_data__progress__gte=100, + pk=torrent_id + ).annotate(count_files=Count("files")) + + torrent = await qs.aget() + + if torrent.count_files == 1: + file = await torrent.files.afirst() + return redirect(reverse("torrent:download_file", kwargs={ + "file_id": file.pk + })) + + response = StreamingZipFileResponse( + filename="test.zip", + file_list=[ + (file.abs_pathname, file.rel_name) + async for file in torrent.files.all() + ], + is_async=True + ) + return response + + +class TorrentViewSet(mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + GenericViewSet): + queryset = Torrent.objects.all().annotate(count_files=Count("files")) + serializer_class = TorrentSerializer + + def get_queryset(self): + qs = super().get_queryset() + + qs.filter( + Q(user=self.request.user) + | Q(shared_users=self.request.user) + | Q(user__friends=self.request.user) + | Q(shared_users__friends=self.request.user) + ) + + # Récupération des torrents de l'utilisateur et de ceux partagé à celui-ci (ordonné par ordre de partage ou par date d'ajout du torrent) + user_id = self.request.query_params.get("user", None) + if user_id: + qs = qs.filter(Q(user_id=user_id) | Q(shared_users=user_id)) + else: + user_id = self.request.user.id + + sub = SharedUser.objects.filter(torrent_id=OuterRef("pk"), user_id=user_id).values("date_created") + qs = qs.annotate(last_date=Coalesce(sub, "date_created")).order_by("-last_date") + + return qs + + def create(self, request, *args, **kwargs): + file = request.data["file"] + r = torrent_proceed(self.request.user, file) + if r["torrent"]: + r["torrent"] = self.get_serializer_class()(r["torrent"]).data + return Response(r) + + def perform_destroy(self, instance): + instance: Torrent + if instance.user == self.request.user: + return super().perform_destroy(instance) + else: + if instance.shared_users.filter(id=self.request.user.id).exists(): + instance.shared_users.remove(self.request.user) + + @action(methods=["post"], detail=True) + def share(self, request, pk): + user_id = self.request.data.get("user_id") + torrent = self.get_object() + is_share_success = torrent_share(torrent=torrent, current_user=self.request.user, target_user_id=user_id) + return Response({"success": is_share_success}) + + @action(methods=["get"], detail=False) + def user_stats(self, request): + Torrent.objects.filter(user=self.request.user).aggregate(total_size=Sum("size")) + + +class FileViewSet(mixins.RetrieveModelMixin, + mixins.ListModelMixin, + GenericViewSet): + queryset = File.objects.all() + serializer_class = FileSerializer + filterset_fields = ["torrent"] + + def get_queryset(self): + qs = super().get_queryset() + return qs diff --git a/app/user/__init__.py b/app/user/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/user/admin.py b/app/user/admin.py new file mode 100644 index 0000000..3364f0c --- /dev/null +++ b/app/user/admin.py @@ -0,0 +1,51 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin +from django.template.defaultfilters import filesizeformat + +from .forms import UserCreationForm, UserChangeForm +from .models import User, FriendRequest, Invitation + + +@admin.register(User) +class UserAdmin(BaseUserAdmin): + # add_form = UserCreationForm + form = UserChangeForm + fieldsets = BaseUserAdmin.fieldsets + ( + ["Custom Fields", { + "fields": ["max_size", "friends"] + }] + ,) + list_display = ["username", "email", "is_superuser", "is_active", "is_staff", "display_max_size", "size_used"] + add_fieldsets = ( + (None, { + "classes": ("wide",), + "fields": ("username", "email", "max_size", "password1", "password2"), + }), + ) + + def display_max_size(self, obj: User): + return filesizeformat(obj.max_size) + display_max_size.short_description = "Max size" + + def size_used(self, obj: User): + return filesizeformat(obj.size_used) + size_used.short_description = "Size used" + + def save_formset(self, request, form, formset, change): + print("save_formset") + return super().save_formset(request, form, formset, change) + + def save_model(self, request, obj, form, change): + print("save_model") + return super().save_model(request, obj, form, change) + + + +@admin.register(Invitation) +class InvitationAdmin(admin.ModelAdmin): + pass + + +@admin.register(FriendRequest) +class FriendRequestAdmin(admin.ModelAdmin): + pass diff --git a/app/user/apps.py b/app/user/apps.py new file mode 100644 index 0000000..36cce4c --- /dev/null +++ b/app/user/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class UserConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'user' diff --git a/app/user/forms.py b/app/user/forms.py new file mode 100644 index 0000000..203f330 --- /dev/null +++ b/app/user/forms.py @@ -0,0 +1,27 @@ +from django import forms +from django.contrib.auth import forms as base_auth_forms +from django.contrib.auth.forms import AdminUserCreationForm + +from user.models import User + + +class RegisterForm(base_auth_forms.UserCreationForm): + email = forms.EmailField(required=True) + + class Meta(base_auth_forms.UserCreationForm.Meta): + model = User + + +class UserCreationForm(AdminUserCreationForm): + max_size = forms.IntegerField(required=True) + email = forms.EmailField(required=True) + class Meta(base_auth_forms.UserCreationForm): + model = User + fields = base_auth_forms.BaseUserCreationForm.Meta.fields + ("max_size",) + + +class UserChangeForm(base_auth_forms.UserChangeForm): + class Meta: + model = User + fields = ["max_size"] + diff --git a/app/user/management/commands/import_old_users.py b/app/user/management/commands/import_old_users.py new file mode 100644 index 0000000..27490f2 --- /dev/null +++ b/app/user/management/commands/import_old_users.py @@ -0,0 +1,46 @@ +from django.core.management.base import BaseCommand + +import json +import base64 +import sys + +from user.models import User + + +class Command(BaseCommand): + def handle(self, *args, **options): + # comment utiliser : + # se connect en bash à l'ancien environnement, ensuite taper : python manage.py dumpdata member.User | base64 -w 0 + # se connecter en bash au nouvel environnement et ensuite taper : echo -e "le_précédent_output..." | python manage.py import_old_users + + inp = "" + for i in sys.stdin: + inp += i + + old_users = json.loads(base64.b64decode(inp.encode("utf-8"))) + + old_new_users_maps = {} + old_friends = {} + + for data in old_users: + user_data = data["fields"] + user_pk = data["pk"] + if User.objects.filter(username__iexact=user_data["username"]).exists(): + old_new_users_maps[user_pk] = User.objects.filter(username__iexact=user_data["username"]).get() + else: + old_new_users_maps[user_pk] = User.objects.create( + email=user_data["email"], + is_active=user_data["is_active"], + is_staff=user_data["is_staff"], + is_superuser=user_data["is_superuser"], + username=user_data["username"], + password=user_data["password"], + max_size=user_data["limit_size"] + ) + old_friends[user_pk] = user_data["friends"] + + for old_user, friends in old_friends.items(): + current_user = old_new_users_maps[old_user] + for friend in friends: + if not current_user.friends.filter(id=old_new_users_maps[friend].id).exists(): + current_user.friends.add(old_new_users_maps[friend]) diff --git a/app/user/migrations/0001_initial.py b/app/user/migrations/0001_initial.py new file mode 100644 index 0000000..8783114 --- /dev/null +++ b/app/user/migrations/0001_initial.py @@ -0,0 +1,72 @@ +# Generated by Django 5.1.6 on 2025-03-04 23:41 + +import django.contrib.auth.validators +import django.db.models.deletion +import django.utils.timezone +import user.models +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('email', models.EmailField(max_length=254, unique=True)), + ('max_size', models.PositiveBigIntegerField(default=53687091200)), + ('is_trusted', models.BooleanField(default=False)), + ('friends', models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + managers=[ + ('objects', user.models.UsernameUserManager()), + ], + ), + migrations.CreateModel( + name='Invitation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('token', models.UUIDField(default=uuid.uuid4)), + ('date_created', models.DateTimeField(auto_now_add=True)), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to=settings.AUTH_USER_MODEL)), + ('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='invitation', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='FriendRequest', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateTimeField(auto_now_add=True)), + ('receiver', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='friend_request_receives', to=settings.AUTH_USER_MODEL)), + ('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='friend_request_sends', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('sender', 'receiver')}, + }, + ), + ] diff --git a/app/user/migrations/__init__.py b/app/user/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/user/models.py b/app/user/models.py new file mode 100644 index 0000000..8efded8 --- /dev/null +++ b/app/user/models.py @@ -0,0 +1,76 @@ +from django.db import models +from django.contrib.auth.models import AbstractUser, BaseUserManager +from django.db.models import Sum + +import uuid +from functools import cached_property +from torrent.models import Torrent + + +class UsernameUserManager(BaseUserManager): + use_in_migrations = True + + def _create_user(self, username, email, password, **extra_fields): + if not username: + raise ValueError("Un username doit être défini") + if not email: + raise ValueError("Un email doit être défini") + + email = self.normalize_email(email) + user = self.model(username=username, email=email, **extra_fields) + user.set_password(password) + user.save(using=self.db) + return user + + def create_user(self, username, email, password=None, **extra_fields): + extra_fields.setdefault("is_staff", False) + extra_fields.setdefault("is_superuser", False) + return self._create_user(username, email, password, **extra_fields) + + def create_superuser(self, username, email, password, **extra_fields): + extra_fields.setdefault("is_staff", True) + extra_fields.setdefault("is_superuser", True) + + if extra_fields.get("is_staff") is not True: + raise ValueError("Superuser doit être staff à True") + if extra_fields.get("is_superuser") is not True: + raise ValueError("SuperUser doit être is_superuser à True") + + return self._create_user(username, email, password, **extra_fields) + + +class User(AbstractUser): + email = models.EmailField(unique=True) + + max_size = models.PositiveBigIntegerField(default=53687091200) + friends = models.ManyToManyField("self", blank=True) + is_trusted = models.BooleanField(default=False) + + objects = UsernameUserManager() + + @cached_property + def size_used(self): + if hasattr(self, "total_size"): + return self.total_size + else: + return Torrent.objects.filter(user=self).aggregate(total_size=Sum("size", default=0))["total_size"] + + @property + def min_infos(self): + return {"username": self.username, "id": self.id} + + +class FriendRequest(models.Model): + sender = models.ForeignKey("User", on_delete=models.CASCADE, related_name="friend_request_sends") + receiver = models.ForeignKey("User", on_delete=models.CASCADE, related_name="friend_request_receives") + date = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ("sender", "receiver") + + +class Invitation(models.Model): + created_by = models.ForeignKey("User", models.CASCADE, related_name="invitations") + token = models.UUIDField(default=uuid.uuid4) + user = models.OneToOneField("User", models.CASCADE, related_name="invitation", null=True, blank=True) + date_created = models.DateTimeField(auto_now_add=True) diff --git a/app/user/serializers.py b/app/user/serializers.py new file mode 100644 index 0000000..7493514 --- /dev/null +++ b/app/user/serializers.py @@ -0,0 +1,32 @@ +from rest_framework import serializers + +from .models import User, FriendRequest, Invitation + + +class UserSerializer(serializers.ModelSerializer): + count_torrent = serializers.IntegerField(read_only=True) + + class Meta: + model = User + fields = ["id", "username", "count_torrent"] + + +class FriendRequestSerializer(serializers.ModelSerializer): + username = serializers.CharField(source="sender.username") + + class Meta: + model = FriendRequest + fields = ["id", "username"] + + +class InvitationSerializer(serializers.ModelSerializer): + created_by = serializers.PrimaryKeyRelatedField( + default=serializers.CurrentUserDefault(), + queryset=User.objects.all(), + ) + created_by_obj = UserSerializer(read_only=True, source="created_by") + url = serializers.URLField(read_only=True) + + class Meta: + model = Invitation + fields = "__all__" diff --git a/app/user/templates/user/layout.html b/app/user/templates/user/layout.html new file mode 100644 index 0000000..63913c1 --- /dev/null +++ b/app/user/templates/user/layout.html @@ -0,0 +1 @@ +{% extends "base.html" %} \ No newline at end of file diff --git a/app/user/templates/user/login.html b/app/user/templates/user/login.html new file mode 100644 index 0000000..44fe0a6 --- /dev/null +++ b/app/user/templates/user/login.html @@ -0,0 +1,9 @@ +{% extends "user/layout.html" %} +{% load django_vite %} + +{% block js %} + + {% vite_asset "app/login.js" %} +{% endblock %} \ No newline at end of file diff --git a/app/user/templates/user/register.html b/app/user/templates/user/register.html new file mode 100644 index 0000000..e69de29 diff --git a/app/user/tests.py b/app/user/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/app/user/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/app/user/urls.py b/app/user/urls.py new file mode 100644 index 0000000..b07711a --- /dev/null +++ b/app/user/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import UserLoginView, RegisterView + +app_name = "user" +urlpatterns = [ + path("login/", UserLoginView.as_view(), name="login"), + path("register//", RegisterView.as_view(), name="register"), +] diff --git a/app/user/views.py b/app/user/views.py new file mode 100644 index 0000000..0e8cb18 --- /dev/null +++ b/app/user/views.py @@ -0,0 +1,107 @@ +from django.contrib.auth.views import LoginView +from django.views.generic import CreateView +from django.contrib.auth import login +from django.urls import reverse_lazy +from django.db.models import Count, Sum, F, IntegerField + +from rest_framework.viewsets import ModelViewSet, GenericViewSet +from rest_framework import mixins +from rest_framework.decorators import action +from rest_framework.response import Response + +from .models import User, FriendRequest, Invitation +from .forms import RegisterForm +from .serializers import UserSerializer, FriendRequestSerializer, InvitationSerializer + + + +class UserLoginView(LoginView): + template_name = "user/login.html" + fields = "__all__" + redirect_authenticated_user = True + + +class RegisterView(CreateView): + template_name = "user/register.html" + form_class = RegisterForm + success_url = reverse_lazy("torrent:home") + + invitation = None + + def get_form(self, form_class=None): + self.invitation = Invitation.objects.get(token=self.kwargs.get("token"), user__isnull=True) + return super().get_form(form_class) + + def form_valid(self, form): + r = super().form_valid(form) + self.invitation.user = self.object + self.invitation.save() + login(self.request, self.object) + return r + + +class UserViewSet(mixins.RetrieveModelMixin, + mixins.ListModelMixin, + GenericViewSet): + queryset = User.objects.all().annotate( + count_torrent=Count("torrents") + Count("torrents_shares") + ) + serializer_class = UserSerializer + + def get_queryset(self): + qs = super().get_queryset() + + only_friends = self.request.query_params.get("only_friends", "false") == "true" + if only_friends: + qs = qs.filter(friends=self.request.user) + + return qs + + @action(methods=["get"], detail=True) + def add_friend_request(self, request, pk): + # receiver = User.objects.get(pk=pk) + if not User.objects.filter(username__iexact=pk).exists(): + return Response({"success": False, "message": f"User '{pk}' doesn't exist"}) + + receiver = User.objects.get(username__iexact=pk) + user: User = self.request.user + if user.friends.filter(id=receiver.id).exists(): + # déjà dans les amis + return Response({"success": False, "message": "Already friend"}) + elif FriendRequest.objects.filter(sender=user, receiver=receiver).exists(): + # déjà une demande en attente + return Response({"success": False, "message": "Friend request Already sent"}) + elif FriendRequest.objects.filter(sender=receiver, receiver=user).exists(): + # friend request en cours, on accepte + FriendRequest.objects.filter(sender=receiver, receiver=user).delete() + user.friends.add(receiver) + return Response({"success": True, "message": f"{receiver.username} added to your friend list"}) + else: + # aucune demande en cours, on créer un friend request + FriendRequest.objects.create( + sender=user, + receiver=receiver + ) + return Response({"success": True, "message": "Request sent"}) + + @action(methods=["get"], detail=True) + def remove_friend(self, request, pk): + friend = User.objects.get(pk=pk) + if self.request.user.friends.filter(pk=friend.pk).exists(): + self.request.user.friends.remove(friend) + return Response({"success": True, "message": f"The friend {friend.username} successfully removed"}) + return Response({"success": False, "message": f"error"}) + + +class FriendRequestViewSet(mixins.ListModelMixin, + mixins.DestroyModelMixin, + GenericViewSet): + queryset = FriendRequest.objects.all() + serializer_class = FriendRequestSerializer + + def get_queryset(self): + qs = super().get_queryset() + + qs = qs.filter(receiver=self.request.user) + + return qs diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..988c60b --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,29 @@ +services: + redis: + restart: "no" + + web: + restart: "no" + volumes: + - ./nginx/dev.nginx:/etc/nginx/conf.d/default.conf:ro + + transmission: + restart: "no" + ports: !reset [] + + app: + restart: "no" + ports: + - "${DEV_SERVER_PORT:-8080}:${DEV_SERVER_PORT:-8080}" + command: > + bash -c "sleep 2 + && python manage.py migrate + && ./dev_run.sh" + +# celery: +# restart: "no" +# command: > +# bash -c "sleep 5 & celery -A app worker -E -B" + + event: + restart: "no" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c4a65ea --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,75 @@ +services: + redis: + image: redis:alpine + restart: unless-stopped + + web: + image: nginx:alpine + volumes: + - ./app/static_collected:/app/static_collected:ro + - ./app/media:/app/media:ro + - ./nginx/default.nginx:/etc/nginx/conf.d/default.conf:ro + - ./transmission/downloads/complete/:/transmission/downloads/complete:ro + ports: + - "${LISTEN_PORT:-8000}:80" + restart: unless-stopped + depends_on: + - app + + transmission: + image: linuxserver/transmission + environment: + - TZ=Europe/Paris + - PUID=${USER_ID} + - PGID=${GROUP_ID} + ports: + - "51414:51414" + - "51414:51414/udp" + volumes: + - ./transmission/config:/config + - ./transmission/downloads:/downloads + restart: unless-stopped + + app: + build: + context: ./app + dockerfile: "Dockerfile" + args: + puid: ${USER_ID} + pgid: ${GROUP_ID} + env_file: + - .env + volumes: + - ./app:/app + - ./transmission:/transmission:ro + - /app/frontend/node_modules + restart: unless-stopped + depends_on: + - redis + command: > + bash -c "python manage.py collectstatic --noinput + && python manage.py migrate + && cd frontend && yarn build && cd .. + && python manage.py collectstatic --noinput + && python manage.py migrate + && uvicorn app.asgi:application --workers 3 --host 0.0.0.0 --port 8000 --lifespan off --loop asyncio --ws websockets" + +# celery: +# extends: +# service: app +# restart: unless-stopped +# depends_on: +# - redis +# - app +# command: > +# bash -c "sleep 5 & celery -A app worker -E -B" + + event: + extends: + service: app + restart: unless-stopped + depends_on: + - redis + - app + command: > + bash -c "sleep 5 & python manage.py torrent_event" diff --git a/nginx/default.nginx b/nginx/default.nginx new file mode 100644 index 0000000..5055df0 --- /dev/null +++ b/nginx/default.nginx @@ -0,0 +1,35 @@ +server { + gzip off; + + location /dl/ { + internal; + alias /transmission/downloads/complete/; + } + + location / { + proxy_redirect off; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_pass http://app:8000/; + } + + location /ws/ { + proxy_redirect off; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + + proxy_pass http://app:8000/ws/; + } + + location /static { + alias /app/static_collected; + } +} \ No newline at end of file diff --git a/nginx/dev.nginx b/nginx/dev.nginx new file mode 100644 index 0000000..06770be --- /dev/null +++ b/nginx/dev.nginx @@ -0,0 +1,31 @@ +server { + gzip off; + + location /dl/ { + internal; + alias /transmission/downloads/complete/; + } + + location / { + proxy_redirect off; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_pass http://app:8000/; + } + + location /ws/ { + proxy_redirect off; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + + proxy_pass http://app:8000/ws/; + } +} \ No newline at end of file diff --git a/transmission/config/settings.json b/transmission/config/settings.json new file mode 100644 index 0000000..57b3967 --- /dev/null +++ b/transmission/config/settings.json @@ -0,0 +1,82 @@ +{ + "alt-speed-down": 50, + "alt-speed-enabled": false, + "alt-speed-time-begin": 540, + "alt-speed-time-day": 127, + "alt-speed-time-enabled": false, + "alt-speed-time-end": 1020, + "alt-speed-up": 50, + "announce-ip": "", + "announce-ip-enabled": false, + "anti-brute-force-enabled": false, + "anti-brute-force-threshold": 100, + "bind-address-ipv4": "0.0.0.0", + "bind-address-ipv6": "::", + "blocklist-enabled": false, + "blocklist-url": "http://www.example.com/blocklist", + "cache-size-mb": 4, + "default-trackers": "", + "dht-enabled": true, + "download-dir": "/downloads/complete", + "download-queue-enabled": true, + "download-queue-size": 5, + "encryption": 1, + "idle-seeding-limit": 30, + "idle-seeding-limit-enabled": false, + "incomplete-dir": "/downloads/incomplete", + "incomplete-dir-enabled": true, + "lpd-enabled": false, + "message-level": 2, + "peer-congestion-algorithm": "", + "peer-id-ttl-hours": 6, + "peer-limit-global": 200, + "peer-limit-per-torrent": 50, + "peer-port": 51413, + "peer-port-random-high": 65535, + "peer-port-random-low": 49152, + "peer-port-random-on-start": false, + "peer-socket-tos": "le", + "pex-enabled": true, + "port-forwarding-enabled": true, + "preallocation": 1, + "prefetch-enabled": true, + "queue-stalled-enabled": true, + "queue-stalled-minutes": 30, + "ratio-limit": 2, + "ratio-limit-enabled": false, + "rename-partial-files": true, + "rpc-authentication-required": false, + "rpc-bind-address": "0.0.0.0", + "rpc-enabled": true, + "rpc-host-whitelist": "127.0.0.1", + "rpc-host-whitelist-enabled": false, + "rpc-password": "{1ddd3f1f6a71d655cde7767242a23a575b44c909n5YuRT.f", + "rpc-port": 9091, + "rpc-socket-mode": "0750", + "rpc-url": "/transmission/", + "rpc-username": "", + "rpc-whitelist": "127.0.0.1", + "rpc-whitelist-enabled": false, + "scrape-paused-torrents-enabled": true, + "script-torrent-added-enabled": false, + "script-torrent-added-filename": "", + "script-torrent-done-enabled": false, + "script-torrent-done-filename": "", + "script-torrent-done-seeding-enabled": false, + "script-torrent-done-seeding-filename": "", + "seed-queue-enabled": false, + "seed-queue-size": 10, + "speed-limit-down": 100, + "speed-limit-down-enabled": false, + "speed-limit-up": 100, + "speed-limit-up-enabled": false, + "start-added-torrents": true, + "tcp-enabled": true, + "torrent-added-verify-mode": "fast", + "trash-original-torrent-files": false, + "umask": "002", + "upload-slots-per-torrent": 14, + "utp-enabled": false, + "watch-dir": "/watch", + "watch-dir-enabled": true +}