init
This commit is contained in:
1
app/.dockerignore
Normal file
1
app/.dockerignore
Normal file
@@ -0,0 +1 @@
|
||||
celerybeat-schedule*
|
||||
69
app/.gitignore
vendored
Normal file
69
app/.gitignore
vendored
Normal file
@@ -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
|
||||
|
||||
60
app/Dockerfile
Normal file
60
app/Dockerfile
Normal file
@@ -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"
|
||||
0
app/api/__init__.py
Normal file
0
app/api/__init__.py
Normal file
3
app/api/admin.py
Normal file
3
app/api/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
6
app/api/apps.py
Normal file
6
app/api/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ApiConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'api'
|
||||
0
app/api/migrations/__init__.py
Normal file
0
app/api/migrations/__init__.py
Normal file
3
app/api/models.py
Normal file
3
app/api/models.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
10
app/api/routers.py
Normal file
10
app/api/routers.py
Normal file
@@ -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')
|
||||
3
app/api/tests.py
Normal file
3
app/api/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
9
app/api/urls.py
Normal file
9
app/api/urls.py
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
from django.urls import path, include
|
||||
|
||||
from .routers import router
|
||||
|
||||
app_name = "api"
|
||||
urlpatterns = [
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
3
app/api/views.py
Normal file
3
app/api/views.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
0
app/app/__init__.py
Normal file
0
app/app/__init__.py
Normal file
16
app/app/asgi.py
Normal file
16
app/app/asgi.py
Normal file
@@ -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)
|
||||
})
|
||||
9
app/app/celery.py
Normal file
9
app/app/celery.py
Normal file
@@ -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()
|
||||
239
app/app/settings.py
Normal file
239
app/app/settings.py
Normal file
@@ -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")
|
||||
}
|
||||
42
app/app/urls.py
Normal file
42
app/app/urls.py
Normal file
@@ -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/<str:uidb64>/<str:token>/", 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"),
|
||||
]
|
||||
72
app/app/utils.py
Normal file
72
app/app/utils.py
Normal file
@@ -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)
|
||||
9
app/app/ws_urls.py
Normal file
9
app/app/ws_urls.py
Normal file
@@ -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()),
|
||||
])
|
||||
16
app/app/wsgi.py
Normal file
16
app/app/wsgi.py
Normal file
@@ -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()
|
||||
6
app/dev_run.sh
Normal file
6
app/dev_run.sh
Normal file
@@ -0,0 +1,6 @@
|
||||
cd /app/frontend && yarn dev &
|
||||
cd /app && python manage.py runserver 0.0.0.0:8000 &
|
||||
|
||||
wait -n
|
||||
|
||||
exit $?
|
||||
24
app/frontend/.gitignore
vendored
Normal file
24
app/frontend/.gitignore
vendored
Normal file
@@ -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?
|
||||
8
app/frontend/jsconfig.json
Normal file
8
app/frontend/jsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
30
app/frontend/package.json
Normal file
30
app/frontend/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
7
app/frontend/postcss.config.js
Normal file
7
app/frontend/postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'postcss-import': {},
|
||||
'postcss-simple-vars': {},
|
||||
'autoprefixer': {}
|
||||
}
|
||||
}
|
||||
7
app/frontend/src/app/login.js
Normal file
7
app/frontend/src/app/login.js
Normal file
@@ -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");
|
||||
})
|
||||
8
app/frontend/src/app/torrent.js
Normal file
8
app/frontend/src/app/torrent.js
Normal file
@@ -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");
|
||||
})
|
||||
49
app/frontend/src/components/auth/App.vue
Normal file
49
app/frontend/src/components/auth/App.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<Base>
|
||||
<v-card class="elevation-12">
|
||||
<v-toolbar color="primary" dark :flat="true">
|
||||
<v-toolbar-title>Login form</v-toolbar-title>
|
||||
<v-spacer/>
|
||||
</v-toolbar>
|
||||
<v-card-text class="red--text">
|
||||
{{error_message}}
|
||||
</v-card-text>
|
||||
<v-card-text>
|
||||
<!-- <v-form @submit.prevent="checkForm" id="check-login-form">-->
|
||||
<v-form id="check-login-form" method="post" action="/user/login/">
|
||||
<input type="hidden" :value="Cookies.get('csrftoken')" name="csrfmiddlewaretoken">
|
||||
<v-text-field id="username" v-model="username" label="Username" name="username" prepend-icon="mdi-account" type="text"/>
|
||||
<v-text-field id="password" v-model="password" label="Password" name="password" prepend-icon="mdi-lock" type="password"/>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn color="warning" href="/password_reset/" target="_blank" variant="plain">Password lost</v-btn>
|
||||
<v-spacer/>
|
||||
<v-btn type="submit" color="primary" form="check-login-form" variant="elevated" class="text-overline">Login</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</Base>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Cookies from "js-cookie";
|
||||
import Base from "./Base.vue";
|
||||
</script>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data(){
|
||||
return {
|
||||
error_message: "",
|
||||
username: "",
|
||||
password: ""
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if(form_error){
|
||||
this.error_message = "Bad login/password";
|
||||
console.log(JSON.stringify(form_error));
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
13
app/frontend/src/components/auth/Base.vue
Normal file
13
app/frontend/src/components/auth/Base.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<v-app id="inspire">
|
||||
<v-main>
|
||||
<v-container class="fill-height" :fluid="true">
|
||||
<v-row align="center" justify="center">
|
||||
<v-col cols="12" sm="8" md="4">
|
||||
<slot></slot>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-main>
|
||||
</v-app>
|
||||
</template>
|
||||
66
app/frontend/src/components/auth/Friend.vue
Normal file
66
app/frontend/src/components/auth/Friend.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<v-list>
|
||||
<v-list-item>
|
||||
<v-list-item-action class="justify-center">
|
||||
<FriendManager :friends="friends" @friends-updated="fetchFriends"/>
|
||||
</v-list-item-action>
|
||||
</v-list-item>
|
||||
<v-divider/>
|
||||
<v-list-item
|
||||
:active="active_user === default_user_id"
|
||||
@click="$emit('userSelected')"
|
||||
>
|
||||
<template v-slot:title>
|
||||
My torrents
|
||||
</template>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
v-for="(friend, i) in friends"
|
||||
:key="i"
|
||||
:active="active_user === friend.id"
|
||||
@click="$emit('userSelected', friend)"
|
||||
>
|
||||
<template v-slot:title>
|
||||
{{friend.username}}
|
||||
</template>
|
||||
<template v-slot:subtitle>
|
||||
{{friend.count_torrent}} torrent{{friend.count_torrent !== 1 ? 's':''}}
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import FriendManager from "./FriendManager.vue";
|
||||
</script>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
emits: ["userSelected"],
|
||||
props: {
|
||||
active_user: {
|
||||
type: Number,
|
||||
required: false
|
||||
}
|
||||
},
|
||||
data(){
|
||||
return {
|
||||
friends: [],
|
||||
default_user_id: current_user.id
|
||||
}
|
||||
},
|
||||
async mounted(){
|
||||
await this.fetchFriends();
|
||||
},
|
||||
methods: {
|
||||
|
||||
async fetchFriends(){
|
||||
let filters = {
|
||||
"only_friends": true
|
||||
}
|
||||
let response = await fetch(`/api/users/?${new URLSearchParams(filters)}`);
|
||||
this.friends = await response.json();
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
132
app/frontend/src/components/auth/FriendForm.vue
Normal file
132
app/frontend/src/components/auth/FriendForm.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title class="text-center">Manage friends</v-card-title>
|
||||
<v-card-text
|
||||
v-if="add_status"
|
||||
class="text-center justify-center"
|
||||
:style="{
|
||||
'background-color': add_status.success ? 'green': 'red',
|
||||
'padding-top': '13px'
|
||||
}"
|
||||
v-text="add_status.message"
|
||||
/>
|
||||
<v-card-actions class="justify-center">
|
||||
<v-text-field @keyup.enter="addFriendRequest" v-model="username_model" prepend-icon="mdi-account-plus"/>
|
||||
</v-card-actions>
|
||||
<v-card v-if="friend_requests.length">
|
||||
<v-card-title>Friends requests</v-card-title>
|
||||
<v-list>
|
||||
<v-list-item
|
||||
v-for="(fr, i) in friend_requests"
|
||||
:key="i"
|
||||
:title="fr.username"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<v-row>
|
||||
<v-col>
|
||||
<v-btn icon="mdi-check" color="green" @click="acceptFriendRequest(i)"/>
|
||||
</v-col>
|
||||
<v-col>
|
||||
<v-btn icon="mdi-cancel" color="red" @click="removeFriendRequest(i)"/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
<v-divider/>
|
||||
<v-card v-if="friends.length">
|
||||
<v-card-title>Friends</v-card-title>
|
||||
<v-list>
|
||||
<v-list-item
|
||||
v-for="(user, i) in friends"
|
||||
:key="i"
|
||||
prepend-icon="mdi-account"
|
||||
:title="user.username"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<v-btn icon="mdi-minus" color="red" variant="plain" @click="removeFriend(i)"/>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
let initial_add_status = {
|
||||
message: "",
|
||||
success: null
|
||||
}
|
||||
|
||||
export default {
|
||||
emits: ["friendsUpdated"],
|
||||
props: {
|
||||
friends: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data(){
|
||||
return {
|
||||
enabled: false,
|
||||
username_model: "",
|
||||
friend_requests: [],
|
||||
add_status: null
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.fetchFriendRequests();
|
||||
},
|
||||
methods: {
|
||||
// friend request related
|
||||
async fetchFriendRequests(){
|
||||
let response = await fetch("/api/friend_requests/");
|
||||
this.friend_requests = await response.json()
|
||||
},
|
||||
async addFriendRequest(username=null){
|
||||
if(username === null || typeof username !== "string"){
|
||||
username = this.username_model
|
||||
}
|
||||
|
||||
let response = await fetch(`/api/users/${username}/add_friend_request/`);
|
||||
this.add_status = await response.json();
|
||||
setTimeout(() => {
|
||||
this.add_status = null;
|
||||
}, 3000)
|
||||
this.username_model = "";
|
||||
await this.fetchFriendRequests();
|
||||
this.$emit("friendsUpdated");
|
||||
},
|
||||
async acceptFriendRequest(i){
|
||||
let friend_request = this.friend_requests[i];
|
||||
await this.addFriendRequest(friend_request.username);
|
||||
this.friend_requests.splice(i, 1);
|
||||
this.$emit("friendsUpdated");
|
||||
},
|
||||
async removeFriendRequest(i){
|
||||
let friend_request = this.friend_requests[i];
|
||||
let response = await fetch(`/api/friend_requests/${friend_request.id}/`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"X-CSRFToken": Cookies.get('csrftoken')
|
||||
},
|
||||
}
|
||||
);
|
||||
if(response.ok) this.friend_requests.splice(i, 1);
|
||||
// todo : check success
|
||||
},
|
||||
//friends related
|
||||
async removeFriend(i){
|
||||
let user = this.friends[i];
|
||||
let response = await fetch(`/api/users/${user.id}/remove_friend/`);
|
||||
let json = await response.json();
|
||||
if(json.success) this.friends.splice(i, 1);
|
||||
this.$emit("friendsUpdated");
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
32
app/frontend/src/components/auth/FriendManager.vue
Normal file
32
app/frontend/src/components/auth/FriendManager.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<v-dialog v-model="enabled" max-width="500">
|
||||
<template v-slot:activator="{props}">
|
||||
<v-btn color="blue" prepend-icon="mdi-account-group" text="Manage friends" v-bind="props"/>
|
||||
</template>
|
||||
<FriendForm :friends="friends" @friends-updated="$emit('friendsUpdated')"/>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import FriendForm from "./FriendForm.vue";
|
||||
</script>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
emits: ["friendsUpdated"],
|
||||
props: {
|
||||
friends: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data(){
|
||||
return {
|
||||
enabled: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
}
|
||||
}
|
||||
</script>
|
||||
175
app/frontend/src/components/torrent/App.vue
Normal file
175
app/frontend/src/components/torrent/App.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<v-app>
|
||||
<v-navigation-drawer :permanent="true">
|
||||
<v-list>
|
||||
<v-list-item>
|
||||
<v-list-item-action class="justify-center">
|
||||
<v-row>
|
||||
<v-col class="text-center">
|
||||
<UploadForm @torrent_added="torrentAdded"/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-list-item-action>
|
||||
</v-list-item>
|
||||
<v-divider/>
|
||||
</v-list>
|
||||
</v-navigation-drawer>
|
||||
<v-navigation-drawer v-model="right_drawer" location="right" temporary>
|
||||
<Friend v-if="right_drawer" :active_user="display_user.id" @userSelected="userSelectedUpdated"/>
|
||||
</v-navigation-drawer>
|
||||
<v-app-bar>
|
||||
<v-app-bar-title><v-btn variant="plain" href="/">Oxpanel</v-btn></v-app-bar-title>
|
||||
<v-spacer/>
|
||||
<v-menu>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn v-bind="props">Manage</v-btn>
|
||||
</template>
|
||||
<v-list>
|
||||
<v-list-item href="/password_change/" title="Change password"/>
|
||||
<v-list-item>
|
||||
<v-list-item-action>
|
||||
<form action="/logout/" method="post">
|
||||
<input type="hidden" name="csrfmiddlewaretoken" :value="Cookies.get('csrftoken')">
|
||||
<button>Disconnect</button>
|
||||
</form>
|
||||
</v-list-item-action>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<v-btn
|
||||
@click="right_drawer = !right_drawer"
|
||||
prepend-icon="mdi-account-group"
|
||||
:color="display_user.id === user.id ? 'green':'orange'"
|
||||
:text="display_user.username"
|
||||
/>
|
||||
</v-app-bar>
|
||||
<v-main>
|
||||
<TorrentList :loading="loading_torrents" :torrents="torrents" :delete_disabled="user.id !== display_user.id"/>
|
||||
</v-main>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Cookies from "js-cookie";
|
||||
import TorrentList from "@/components/torrent/TorrentList.vue";
|
||||
import UploadForm from "@/components/torrent/UploadForm.vue";
|
||||
import Friend from "@/components/auth/Friend.vue";
|
||||
</script>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data(){
|
||||
return {
|
||||
right_drawer: false,
|
||||
user: current_user,
|
||||
display_user: {
|
||||
id: current_user.id,
|
||||
username: "My torrents"
|
||||
},
|
||||
loading_torrents: false,
|
||||
filters: {},
|
||||
torrents: []
|
||||
}
|
||||
},
|
||||
async mounted(){
|
||||
this.$ws.connect("/ws/torrent_event/");
|
||||
this.$ws.on("connect", () => {
|
||||
this.$ws.send(JSON.stringify({"context": "change_follow_user", "user_id": this.display_user.id}));
|
||||
});
|
||||
this.$ws.on("json_message", (message) => {
|
||||
switch(message.context){
|
||||
case "transmission_data_updated":
|
||||
this.updateTransmissionData(message.data);
|
||||
break;
|
||||
case "add_torrent":
|
||||
this.fetchTorrent(message.torrent_id);
|
||||
break;
|
||||
case "remove_torrent":
|
||||
this.removeTorrent(message.torrent_id);
|
||||
break;
|
||||
case "update_torrent":
|
||||
this.updateTorrent(message.torrent_id, message.updated_fields)
|
||||
}
|
||||
})
|
||||
await this.fetchTorrents();
|
||||
},
|
||||
methods: {
|
||||
torrentAdded(torrent){
|
||||
if(!(torrent.id in this.torrentsAsObject)){
|
||||
this.torrents.unshift(torrent);
|
||||
}
|
||||
},
|
||||
changeDisplayUser(user){
|
||||
if(user){
|
||||
this.display_user = {
|
||||
"username": this.user.id === user.id ? "My torrents": `${user.username} torrents`,
|
||||
"id": user.id
|
||||
}
|
||||
}else{
|
||||
|
||||
}
|
||||
this.display_user = {
|
||||
"username": this.user.id === user.id ? "My torrents": `${user.username} torrents`,
|
||||
"id": user.id
|
||||
}
|
||||
this.$ws.send(JSON.stringify({"context": "change_follow_user", "user_id": this.display_user.id}));
|
||||
},
|
||||
updateTransmissionData(data){
|
||||
if(data.hashString in this.torrentsAsObject){
|
||||
this.torrentsAsObject[data.hashString].transmission_data = data
|
||||
}
|
||||
},
|
||||
async fetchTorrents(){
|
||||
this.loading_torrents = true;
|
||||
let filters = {...this.filters, user: this.display_user.id},
|
||||
url = `/api/torrents/?${new URLSearchParams(filters)}`;
|
||||
|
||||
let response = await fetch(url);
|
||||
this.torrents = await response.json();
|
||||
this.loading_torrents = false;
|
||||
},
|
||||
async fetchTorrent(torrent_id){
|
||||
if(torrent_id in this.torrentsAsObject) return;
|
||||
let url = `/api/torrents/${torrent_id}/`;
|
||||
let response = await fetch(url);
|
||||
if(response.ok){
|
||||
let torrent = await response.json();
|
||||
if(!(torrent.id in this.torrentsAsObject)) this.torrents.unshift(torrent);
|
||||
}else{
|
||||
setTimeout(() => this.fetchTorrent(torrent_id), 1000);
|
||||
}
|
||||
},
|
||||
async updateTorrent(torrent_id, updated_fields){
|
||||
if(torrent_id in this.torrentsAsObject){
|
||||
for(let key in updated_fields){
|
||||
this.torrentsAsObject[torrent_id][key] = updated_fields[key];
|
||||
}
|
||||
}
|
||||
},
|
||||
async removeTorrent(torrent_id){
|
||||
if(!(torrent_id in this.torrentsAsObject)) return;
|
||||
for(let i = 0; i < this.torrents.length; i++){
|
||||
if(this.torrents[i].id === torrent_id){
|
||||
this.torrents.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
async userSelectedUpdated(user){
|
||||
await this.changeDisplayUser(user ? user: this.user);
|
||||
await this.fetchTorrents();
|
||||
|
||||
this.right_drawer = false
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
torrentsAsObject(){
|
||||
let r = {};
|
||||
this.torrents.forEach(torrent => {
|
||||
r[torrent.id] = torrent;
|
||||
});
|
||||
return r;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
54
app/frontend/src/components/torrent/FileItem.vue
Normal file
54
app/frontend/src/components/torrent/FileItem.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<v-list-item
|
||||
style="background-color: #434343"
|
||||
@click.stop="downloadClicked"
|
||||
:title="file.rel_name"
|
||||
:subtitle="fs_format(file.size)"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-btn @click.stop="downloadClicked" :disabled="!is_download_finished" icon="mdi-download" color="green" variant="text"/>
|
||||
<v-dialog v-if="file.is_stream_video" v-model="video_modal" width="75%" height="100%">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn v-bind="props" icon="mdi-play" variant="text"/>
|
||||
</template>
|
||||
<v-card>
|
||||
<VideoPlayer class="text-center"/>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {fs_format} from "@/plugins/utils.js";
|
||||
import VideoPlayer from "@/components/utils/VideoPlayer.vue";
|
||||
</script>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
file: {
|
||||
required: true,
|
||||
type: Object,
|
||||
},
|
||||
is_download_finished: {
|
||||
required: true,
|
||||
type: Boolean,
|
||||
}
|
||||
},
|
||||
data(){
|
||||
return {
|
||||
video_modal: false,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
downloadClicked(){
|
||||
if(!this.is_download_finished) return;
|
||||
let a = document.createElement("a");
|
||||
a.href = this.file.download_url;
|
||||
a.setAttribute("download", "download");
|
||||
a.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
48
app/frontend/src/components/torrent/FileList.vue
Normal file
48
app/frontend/src/components/torrent/FileList.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<v-list density="compact">
|
||||
<div v-if="loading">
|
||||
<v-progress-circular indeterminate/>
|
||||
Loading files ...
|
||||
</div>
|
||||
<FileItem v-for="file in files" :key="file.id" :file="file" :is_download_finished="is_download_finished"/>
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import FileItem from "@/components/torrent/FileItem.vue";
|
||||
</script>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
torrent_id: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
is_download_finished: {
|
||||
required: true,
|
||||
type: Boolean,
|
||||
}
|
||||
},
|
||||
data(){
|
||||
return {
|
||||
loading: true,
|
||||
files: [],
|
||||
filters: {
|
||||
torrent: this.torrent_id,
|
||||
}
|
||||
}
|
||||
},
|
||||
async mounted(){
|
||||
await this.fetchFiles();
|
||||
},
|
||||
methods: {
|
||||
async fetchFiles(){
|
||||
this.loading = true;
|
||||
let response = await fetch(`/api/torrent/files?${new URLSearchParams(this.filters)}`);
|
||||
this.files = await response.json();
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
126
app/frontend/src/components/torrent/TorrentItem.vue
Normal file
126
app/frontend/src/components/torrent/TorrentItem.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<v-card @click="show_files = !show_files">
|
||||
<v-progress-linear height="20" :color="progressData.color" :model-value="progressData.value">
|
||||
<!-- barre de progression -->
|
||||
<v-progress-circular
|
||||
v-if="!torrent.transmission_data.rateDownload && !torrent.transmission_data.rateUpload"
|
||||
size="15"
|
||||
indeterminate
|
||||
:color="torrent.transmission_data.progress < 100 ? 'green':'red'"
|
||||
/>
|
||||
<v-icon
|
||||
v-else
|
||||
:color="torrent.transmission_data.rateDownload ? 'green':'red'"
|
||||
:icon="torrent.transmission_data.rateDownload ? 'mdi-arrow-down-bold':'mdi-arrow-up-bold'"
|
||||
/>
|
||||
<strong style="padding-left: 5px">
|
||||
{{progressData.value}}%
|
||||
<strong
|
||||
v-if="torrent.transmission_data.rateDownload || torrent.transmission_data.rateUpload"
|
||||
>
|
||||
({{torrent.transmission_data.rateDownload ? fs_speed_format(torrent.transmission_data.rateDownload):fs_speed_format(torrent.transmission_data.rateUpload)}}/s)
|
||||
</strong>
|
||||
</strong>
|
||||
</v-progress-linear>
|
||||
<v-row no-gutters>
|
||||
<!-- ligne du haut -->
|
||||
<v-col>
|
||||
<v-card-text v-text="torrent.name"></v-card-text>
|
||||
</v-col>
|
||||
<v-col lg="1">
|
||||
<v-card-text v-text="fs_format(torrent.size)"></v-card-text>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="torrent.transmission_data.progress < 100" justify="end" no-gutters>
|
||||
<!-- ligne du milieu -->
|
||||
<v-col lg="2">
|
||||
<v-card-text>remaining : {{fs_format(torrent.transmission_data.leftUntilDone)}}/{{progressData.eta}}</v-card-text>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-card-actions>
|
||||
<v-btn :disabled="torrent.transmission_data.progress < 100" @click.stop="downloadClicked" color="green" :icon="torrent.count_files === 1 ? 'mdi-download':'mdi-folder-download'"/>
|
||||
<v-dialog v-model="share_modal" max-width="600">
|
||||
<template v-slot:activator="{props}">
|
||||
<v-btn icon="mdi-share-variant" v-bind="props"/>
|
||||
</template>
|
||||
<TorrentShare :torrent="torrent"/>
|
||||
</v-dialog>
|
||||
|
||||
<v-spacer/>
|
||||
<v-btn @click.stop="deleteClicked" color="red" icon="mdi-delete-variant" :disabled="delete_disabled"/>
|
||||
</v-card-actions>
|
||||
<v-expand-transition>
|
||||
<div v-if="show_files">
|
||||
<v-divider />
|
||||
<FileList v-if="show_files" :torrent_id="torrent.id" :is_download_finished="torrent.transmission_data.progress >= 100"/>
|
||||
</div>
|
||||
</v-expand-transition>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {fs_format, fs_speed_format} from "@/plugins/utils.js";
|
||||
import FileList from "@/components/torrent/FileList.vue";
|
||||
import TorrentShare from "@/components/torrent/TorrentShare.vue";
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import Cookies from "js-cookie";
|
||||
import {toHHMMSS} from "@/plugins/utils.js";
|
||||
export default {
|
||||
emits: ["delete"],
|
||||
props: {
|
||||
torrent: {
|
||||
required: true,
|
||||
type: Object,
|
||||
},
|
||||
delete_disabled: {
|
||||
required: true,
|
||||
type: Boolean,
|
||||
}
|
||||
},
|
||||
data(){
|
||||
return {
|
||||
show_files: false,
|
||||
share_modal: false,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async downloadClicked(){
|
||||
if(this.torrent.transmission_data.progress < 100) return;
|
||||
let a = document.createElement("a");
|
||||
a.href = this.torrent.download_url;
|
||||
a.setAttribute("download", "download");
|
||||
a.click();
|
||||
},
|
||||
async deleteClicked(){
|
||||
this.$emit("delete", this.torrent.id);
|
||||
await fetch(`/api/torrents/${this.torrent.id}/`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"X-CSRFToken": Cookies.get("csrftoken"),
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
progressData(){
|
||||
let color = "red", value = 0, eta;
|
||||
if(this.torrent.transmission_data.progress < 100){
|
||||
color = "blue";
|
||||
value = this.torrent.transmission_data.progress;
|
||||
if(this.torrent.transmission_data.eta !== -1){
|
||||
eta = toHHMMSS(this.torrent.transmission_data.eta);
|
||||
}else{
|
||||
eta = "-";
|
||||
}
|
||||
}else{
|
||||
color = "green";
|
||||
value = Number((this.torrent.transmission_data.uploadRatio / 5) * 100).toFixed(2)
|
||||
if(value > 100) value = 100;
|
||||
}
|
||||
return {color, value, eta};
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
45
app/frontend/src/components/torrent/TorrentList.vue
Normal file
45
app/frontend/src/components/torrent/TorrentList.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<v-container fluid>
|
||||
<TorrentItem
|
||||
v-if="torrents.length"
|
||||
v-for="(torrent, i) in torrents"
|
||||
:key="torrent.id"
|
||||
:torrent="torrent"
|
||||
:delete_disabled="delete_disabled"
|
||||
@delete="torrents.splice(i, 1)"
|
||||
/>
|
||||
<v-card v-else :color="loading ? 'orange':'red'">
|
||||
<v-card-text v-if="loading" class="text-center">loading torrents...</v-card-text>
|
||||
<v-card-text v-else class="text-center">There is no torrent to display</v-card-text>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import TorrentItem from "./TorrentItem.vue";
|
||||
</script>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
torrents: {
|
||||
required: true,
|
||||
type: Array
|
||||
},
|
||||
loading: {
|
||||
required: true,
|
||||
type: Boolean
|
||||
},
|
||||
delete_disabled: {
|
||||
required: true,
|
||||
type: Boolean,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
deleteClicked(i){
|
||||
console.log("delete clicked !", i)
|
||||
this.torrents.splice(i, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
110
app/frontend/src/components/torrent/TorrentShare.vue
Normal file
110
app/frontend/src/components/torrent/TorrentShare.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<v-container>
|
||||
<v-card>
|
||||
<v-card-title>Sharing of `{{ torrent.name }}`</v-card-title>
|
||||
<v-divider/>
|
||||
<v-row>
|
||||
<v-col>
|
||||
<v-card class="justify-center">
|
||||
<v-card-title>Share with others users</v-card-title>
|
||||
<v-card-text
|
||||
v-if="message"
|
||||
class="text-center justify-center"
|
||||
style="background-color: green; padding-top: 13px"
|
||||
v-text="message"
|
||||
/>
|
||||
<v-card-text>
|
||||
<v-autocomplete
|
||||
v-model="share_input"
|
||||
:items="filteredUser"
|
||||
item-title="username"
|
||||
item-value="id"
|
||||
/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-divider :vertical="true"/>
|
||||
<!-- <v-col>-->
|
||||
<!-- <v-card-text>(public share W.I.P., only enabled to trusted User)</v-card-text>-->
|
||||
<!-- </v-col>-->
|
||||
</v-row>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
export default {
|
||||
props: {
|
||||
torrent: {
|
||||
required: true,
|
||||
type: Object,
|
||||
}
|
||||
},
|
||||
data(){
|
||||
return {
|
||||
users: [],
|
||||
share_input: "",
|
||||
message: ""
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
share_input(newValue, oldValue){
|
||||
if(newValue){
|
||||
this.sharedFieldChange(newValue);
|
||||
}
|
||||
}
|
||||
},
|
||||
async mounted(){
|
||||
await this.fetchUsers();
|
||||
},
|
||||
methods: {
|
||||
async sharedFieldChange(user_id){
|
||||
let username;
|
||||
for(let user of this.users){
|
||||
if(user_id === user.id){
|
||||
username = user.username;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let form = new FormData();
|
||||
form.append("user_id", user_id);
|
||||
let response = await fetch(`/api/torrents/${this.torrent.id}/share/`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-CSRFToken": Cookies.get('csrftoken')
|
||||
},
|
||||
body: form
|
||||
})
|
||||
let status = await response.json();
|
||||
if(status.success){
|
||||
this.share_message_status = `Shared with '${username}' successfully`
|
||||
setTimeout(() => {
|
||||
this.share_message_status = ""
|
||||
}, 3000);
|
||||
this.torrent.shared_users.push(user_id)
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
this.share_input = "";
|
||||
})
|
||||
},
|
||||
async fetchUsers(){
|
||||
let response = await fetch("/api/users/");
|
||||
this.users = await response.json();
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredUser(){
|
||||
let r = [];
|
||||
this.users.forEach(user => {
|
||||
if(this.torrent.user !== user.id && user.id !== current_user.id && !(this.torrent.shared_users.includes(user.id))){
|
||||
r.push(user)
|
||||
}
|
||||
})
|
||||
return r
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
105
app/frontend/src/components/torrent/UploadForm.vue
Normal file
105
app/frontend/src/components/torrent/UploadForm.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<v-dialog v-model="enabled" max-width="500">
|
||||
<template v-slot:activator="{props}">
|
||||
<v-btn color="blue" prepend-icon="mdi-file-upload" text=".torrent" v-bind="props"/>
|
||||
</template>
|
||||
<input type="file" multiple="multiple" ref="fileinput" @change="uploadFieldChange" hidden="hidden" accept=".torrent">
|
||||
<v-card>
|
||||
<v-card-title class="text-center">Send Torrent Files</v-card-title>
|
||||
<v-card-actions class="justify-center">
|
||||
<v-btn @click="uploadFieldTriggered" color="blue" prepend-icon="mdi-plus" variant="tonal" text="add .torrent" />
|
||||
<v-btn :disabled="!attachments.length" color="green" variant="tonal" icon="mdi-check" @click="sendFiles"/>
|
||||
<v-btn @click="attachments.splice(0, attachments.length)" :disabled="!attachments.length" color="red" variant="tonal" icon="mdi-cancel"/>
|
||||
</v-card-actions>
|
||||
<v-list >
|
||||
<v-list-item
|
||||
v-for="(attachment, i) in attachments"
|
||||
:key="i"
|
||||
@click="this.attachments.splice(i, 1)"
|
||||
prepend-icon="mdi-file"
|
||||
:title="attachment.file_object.name"
|
||||
>
|
||||
<v-list-item-subtitle :class="`text-${attachment.text_color}`" v-text="attachment.response ? attachment.response.message:'waiting'"/>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
export default {
|
||||
emits: ["torrent_added"],
|
||||
data(){
|
||||
return {
|
||||
enabled: false,
|
||||
attachments: []
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
enabled(newValue, oldValue){
|
||||
if(newValue){
|
||||
this.$nextTick(() => {
|
||||
this.uploadFieldTriggered();
|
||||
})
|
||||
}else{
|
||||
this.attachments.splice(0, this.attachments.length)
|
||||
}
|
||||
},
|
||||
// attachments(newValue, oldValue){
|
||||
// this.status.pop()
|
||||
// }
|
||||
},
|
||||
methods: {
|
||||
async sendFiles(){
|
||||
let form, response, data;
|
||||
|
||||
for (const attachment of this.attachments) {
|
||||
if(!attachment.response){
|
||||
form = new FormData();
|
||||
form.append("file", attachment.file_object);
|
||||
// form.append("csrfmiddlewaretoken", Cookies.get('csrftoken'))
|
||||
response = await fetch("/api/torrents/", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-CSRFToken": Cookies.get('csrftoken')
|
||||
},
|
||||
body: form
|
||||
})
|
||||
data = await response.json()
|
||||
attachment.response = data
|
||||
switch (data.status){
|
||||
case "error":
|
||||
attachment.text_color = "red";
|
||||
break;
|
||||
case "warn":
|
||||
attachment.text_color = "yellow";
|
||||
this.$emit("torrent_added", data.torrent);
|
||||
break;
|
||||
case "success":
|
||||
attachment.text_color = "green"
|
||||
this.$emit("torrent_added", data.torrent);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
uploadFieldChange(event){
|
||||
let files = event.target.files || event.dataTransfer.files;
|
||||
if(files.length){
|
||||
Array.prototype.forEach.call(files, file => {
|
||||
this.attachments.push({
|
||||
file_object: file,
|
||||
response: null,
|
||||
text_color: "blue"
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
uploadFieldTriggered(){
|
||||
this.$refs.fileinput.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
41
app/frontend/src/components/utils/VideoPlayer.vue
Normal file
41
app/frontend/src/components/utils/VideoPlayer.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div>
|
||||
<video ref="videoPlayer" id="my-video" controls preload="auto" class="video-js vjs-default-skin vjs-16-9"></video>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import "video.js/dist/video-js.min.css";
|
||||
import videojs from "video.js";
|
||||
|
||||
export default {
|
||||
name: "VideoPlayer",
|
||||
props: {
|
||||
options: {
|
||||
type: Object,
|
||||
default(){
|
||||
return {};
|
||||
}
|
||||
}
|
||||
},
|
||||
data(){
|
||||
return {
|
||||
player: null,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.player = videojs(this.$refs.videoPlayer, this.options, () => {
|
||||
this.player.log("onPlayerReady", this);
|
||||
})
|
||||
},
|
||||
beforeUnmount() {
|
||||
if(this.player) {
|
||||
this.player.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
17
app/frontend/src/plugins/utils.js
Normal file
17
app/frontend/src/plugins/utils.js
Normal file
@@ -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}
|
||||
7
app/frontend/src/plugins/vue_loader.js
Normal file
7
app/frontend/src/plugins/vue_loader.js
Normal file
@@ -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)
|
||||
}
|
||||
30
app/frontend/src/plugins/vuetify.js
Normal file
30
app/frontend/src/plugins/vuetify.js
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
78
app/frontend/src/plugins/ws.js
Normal file
78
app/frontend/src/plugins/ws.js
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
51
app/frontend/vite.config.js
Normal file
51
app/frontend/vite.config.js
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
840
app/frontend/yarn.lock
Normal file
840
app/frontend/yarn.lock
Normal file
@@ -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==
|
||||
22
app/manage.py
Normal file
22
app/manage.py
Normal file
@@ -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()
|
||||
18
app/requirements.txt
Normal file
18
app/requirements.txt
Normal file
@@ -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
|
||||
24
app/templates/base.html
Normal file
24
app/templates/base.html
Normal file
@@ -0,0 +1,24 @@
|
||||
{% load django_vite %}<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>{% block base_title %}{% block title %}{% endblock %}{% endblock %}</title>
|
||||
{% block base_css %}
|
||||
{# {% stylesheet_pack 'app' %}#}
|
||||
<link data-n-head="ssr" rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900">
|
||||
{% block css %}{% endblock %}
|
||||
{% endblock %}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
{% if csrf_token %}{% endif %}
|
||||
{% block base_content %}
|
||||
{% block content %}<div id="app"></div>{% endblock %}
|
||||
{% endblock %}
|
||||
{% block base_js %}
|
||||
{# {{ javascript_pack("main-entrypoint") }}#}
|
||||
{% vite_hmr_client %}
|
||||
{% block js %}{% endblock %}
|
||||
{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
0
app/torrent/__init__.py
Normal file
0
app/torrent/__init__.py
Normal file
13
app/torrent/admin.py
Normal file
13
app/torrent/admin.py
Normal file
@@ -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)
|
||||
15
app/torrent/apps.py
Normal file
15
app/torrent/apps.py
Normal file
@@ -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)
|
||||
100
app/torrent/consumers.py
Normal file
100
app/torrent/consumers.py
Normal file
@@ -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"]
|
||||
})
|
||||
62
app/torrent/management/commands/torrent_event.py
Normal file
62
app/torrent/management/commands/torrent_event.py
Normal file
@@ -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}"))
|
||||
40
app/torrent/migrations/0001_initial.py
Normal file
40
app/torrent/migrations/0001_initial.py
Normal file
@@ -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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
47
app/torrent/migrations/0002_initial.py
Normal file
47
app/torrent/migrations/0002_initial.py
Normal file
@@ -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')},
|
||||
),
|
||||
]
|
||||
18
app/torrent/migrations/0003_torrent_date_modified.py
Normal file
18
app/torrent/migrations/0003_torrent_date_modified.py
Normal file
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
0
app/torrent/migrations/__init__.py
Normal file
0
app/torrent/migrations/__init__.py
Normal file
94
app/torrent/models.py
Normal file
94
app/torrent/models.py
Normal file
@@ -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)}"'
|
||||
30
app/torrent/serializers.py
Normal file
30
app/torrent/serializers.py
Normal file
@@ -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})
|
||||
47
app/torrent/signals.py
Normal file
47
app/torrent/signals.py
Normal file
@@ -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 <class 'torrent.models.SharedUser'> Torrent object (a9164e99d5181cfef0c23c209334103619080908) pre_add {3}
|
||||
# on_share_user_changed <class 'torrent.models.SharedUser'> 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
|
||||
26
app/torrent/tasks.py
Normal file
26
app/torrent/tasks.py
Normal file
@@ -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
|
||||
})
|
||||
|
||||
9
app/torrent/templates/torrent/home.html
Normal file
9
app/torrent/templates/torrent/home.html
Normal file
@@ -0,0 +1,9 @@
|
||||
{% extends "torrent/layout.html" %}
|
||||
{% load django_vite %}
|
||||
|
||||
{% block js %}
|
||||
<script>
|
||||
const current_user = {{ request.user.min_infos|safe }};
|
||||
</script>
|
||||
{% vite_asset "app/torrent.js" %}
|
||||
{% endblock %}
|
||||
1
app/torrent/templates/torrent/layout.html
Normal file
1
app/torrent/templates/torrent/layout.html
Normal file
@@ -0,0 +1 @@
|
||||
{% extends "base.html" %}
|
||||
3
app/torrent/tests.py
Normal file
3
app/torrent/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
10
app/torrent/urls.py
Normal file
10
app/torrent/urls.py
Normal file
@@ -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/<uuid:file_id>", download_file, name="download_file"),
|
||||
path("download_torrent/<str:torrent_id>", download_torrent, name="download_torrent"),
|
||||
]
|
||||
119
app/torrent/utils.py
Normal file
119
app/torrent/utils.py
Normal file
@@ -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
|
||||
157
app/torrent/views.py
Normal file
157
app/torrent/views.py
Normal file
@@ -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
|
||||
0
app/user/__init__.py
Normal file
0
app/user/__init__.py
Normal file
51
app/user/admin.py
Normal file
51
app/user/admin.py
Normal file
@@ -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
|
||||
6
app/user/apps.py
Normal file
6
app/user/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class UserConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'user'
|
||||
27
app/user/forms.py
Normal file
27
app/user/forms.py
Normal file
@@ -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"]
|
||||
|
||||
46
app/user/management/commands/import_old_users.py
Normal file
46
app/user/management/commands/import_old_users.py
Normal file
@@ -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])
|
||||
72
app/user/migrations/0001_initial.py
Normal file
72
app/user/migrations/0001_initial.py
Normal file
@@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
0
app/user/migrations/__init__.py
Normal file
0
app/user/migrations/__init__.py
Normal file
76
app/user/models.py
Normal file
76
app/user/models.py
Normal file
@@ -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)
|
||||
32
app/user/serializers.py
Normal file
32
app/user/serializers.py
Normal file
@@ -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__"
|
||||
1
app/user/templates/user/layout.html
Normal file
1
app/user/templates/user/layout.html
Normal file
@@ -0,0 +1 @@
|
||||
{% extends "base.html" %}
|
||||
9
app/user/templates/user/login.html
Normal file
9
app/user/templates/user/login.html
Normal file
@@ -0,0 +1,9 @@
|
||||
{% extends "user/layout.html" %}
|
||||
{% load django_vite %}
|
||||
|
||||
{% block js %}
|
||||
<script>
|
||||
let form_error = {% if form.erros %}{{ form.errors.as_json|safe }}{% else %}false{% endif %};
|
||||
</script>
|
||||
{% vite_asset "app/login.js" %}
|
||||
{% endblock %}
|
||||
0
app/user/templates/user/register.html
Normal file
0
app/user/templates/user/register.html
Normal file
3
app/user/tests.py
Normal file
3
app/user/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
9
app/user/urls.py
Normal file
9
app/user/urls.py
Normal file
@@ -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/<uuid:token>/", RegisterView.as_view(), name="register"),
|
||||
]
|
||||
107
app/user/views.py
Normal file
107
app/user/views.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user