init
This commit is contained in:
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.git
|
||||||
|
.env
|
||||||
|
*.pyc
|
||||||
|
__pycache__
|
||||||
|
node_modules
|
||||||
35
.env.dist
Normal file
35
.env.dist
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
USER_ID=1000
|
||||||
|
GROUP_ID=1000
|
||||||
|
|
||||||
|
LISTEN_PORT=8000
|
||||||
|
|
||||||
|
DEBUG=true
|
||||||
|
DEBUG_TOOLBAR=false
|
||||||
|
SECRET=Gei2thiFie0wiighoc4Aa9anei9hoCoiSie6wohT)aeng7To4a # generate with "pwgen -y 50"
|
||||||
|
ALLOWED_HOSTS='["127.0.0.1", "localhost"]'
|
||||||
|
CSRF_TRUSTED_ORIGINS='["http://127.0.0.1:8000", "http://localhost:8000"]'
|
||||||
|
BASE_URL='http://127.0.0.1/'
|
||||||
|
|
||||||
|
### DEV RELATED
|
||||||
|
# VITE
|
||||||
|
DEV_SERVER_HOST='127.0.0.1'
|
||||||
|
DEV_SERVER_PORT='8080'
|
||||||
|
|
||||||
|
REDIS_HOST=redis
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend # django.core.mail.backends.smtp.EmailBackend
|
||||||
|
EMAIL_HOST=example.test #
|
||||||
|
EMAIL_PORT=587 # 587
|
||||||
|
EMAIL_USER=test #
|
||||||
|
EMAIL_PASSWORD=test #
|
||||||
|
EMAIL_SENDER=no-reply@example.test
|
||||||
|
|
||||||
|
TRANSMISSION_PROTOCOL=http
|
||||||
|
TRANSMISSION_HOST=transmission
|
||||||
|
TRANSMISSION_PORT=9091
|
||||||
|
TRANSMISSION_USERNAME=
|
||||||
|
TRANSMISSION_PASSWORD=
|
||||||
|
|
||||||
|
UPDATE_TRANSMISSION_DELAY=5
|
||||||
|
TORRENT_TTL=2592000
|
||||||
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
.idea/
|
||||||
|
.env
|
||||||
|
|
||||||
|
transmission/config/*
|
||||||
|
!transmission/config/settings.json
|
||||||
|
!transmission/config/blocklists
|
||||||
|
transmission/config/blocklists/*
|
||||||
|
!transmission/config/blocklists/.keep
|
||||||
|
!transmission/config/resume
|
||||||
|
transmission/config/resume/*
|
||||||
|
!transmission/config/resume/.keep
|
||||||
|
!transmission/config/torrents
|
||||||
|
transmission/config/torrents/*
|
||||||
|
!transmission/config/torrents/.keep
|
||||||
|
transmission/downloads/*
|
||||||
|
!transmission/downloads/.keep
|
||||||
|
!transmission/downloads/complete
|
||||||
|
transmission/downloads/complete/*
|
||||||
|
!transmission/downloads/complete/.keep
|
||||||
|
!transmission/downloads/incomplete
|
||||||
|
transmission/downloads/incomplete/*
|
||||||
|
!transmission/downloads/incomplete/.keep
|
||||||
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
|
||||||
29
docker-compose.dev.yml
Normal file
29
docker-compose.dev.yml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
services:
|
||||||
|
redis:
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
web:
|
||||||
|
restart: "no"
|
||||||
|
volumes:
|
||||||
|
- ./nginx/dev.nginx:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
|
||||||
|
transmission:
|
||||||
|
restart: "no"
|
||||||
|
ports: !reset []
|
||||||
|
|
||||||
|
app:
|
||||||
|
restart: "no"
|
||||||
|
ports:
|
||||||
|
- "${DEV_SERVER_PORT:-8080}:${DEV_SERVER_PORT:-8080}"
|
||||||
|
command: >
|
||||||
|
bash -c "sleep 2
|
||||||
|
&& python manage.py migrate
|
||||||
|
&& ./dev_run.sh"
|
||||||
|
|
||||||
|
# celery:
|
||||||
|
# restart: "no"
|
||||||
|
# command: >
|
||||||
|
# bash -c "sleep 5 & celery -A app worker -E -B"
|
||||||
|
|
||||||
|
event:
|
||||||
|
restart: "no"
|
||||||
75
docker-compose.yml
Normal file
75
docker-compose.yml
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis:alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
web:
|
||||||
|
image: nginx:alpine
|
||||||
|
volumes:
|
||||||
|
- ./app/static_collected:/app/static_collected:ro
|
||||||
|
- ./app/media:/app/media:ro
|
||||||
|
- ./nginx/default.nginx:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
- ./transmission/downloads/complete/:/transmission/downloads/complete:ro
|
||||||
|
ports:
|
||||||
|
- "${LISTEN_PORT:-8000}:80"
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- app
|
||||||
|
|
||||||
|
transmission:
|
||||||
|
image: linuxserver/transmission
|
||||||
|
environment:
|
||||||
|
- TZ=Europe/Paris
|
||||||
|
- PUID=${USER_ID}
|
||||||
|
- PGID=${GROUP_ID}
|
||||||
|
ports:
|
||||||
|
- "51414:51414"
|
||||||
|
- "51414:51414/udp"
|
||||||
|
volumes:
|
||||||
|
- ./transmission/config:/config
|
||||||
|
- ./transmission/downloads:/downloads
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: ./app
|
||||||
|
dockerfile: "Dockerfile"
|
||||||
|
args:
|
||||||
|
puid: ${USER_ID}
|
||||||
|
pgid: ${GROUP_ID}
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- ./app:/app
|
||||||
|
- ./transmission:/transmission:ro
|
||||||
|
- /app/frontend/node_modules
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
command: >
|
||||||
|
bash -c "python manage.py collectstatic --noinput
|
||||||
|
&& python manage.py migrate
|
||||||
|
&& cd frontend && yarn build && cd ..
|
||||||
|
&& python manage.py collectstatic --noinput
|
||||||
|
&& python manage.py migrate
|
||||||
|
&& uvicorn app.asgi:application --workers 3 --host 0.0.0.0 --port 8000 --lifespan off --loop asyncio --ws websockets"
|
||||||
|
|
||||||
|
# celery:
|
||||||
|
# extends:
|
||||||
|
# service: app
|
||||||
|
# restart: unless-stopped
|
||||||
|
# depends_on:
|
||||||
|
# - redis
|
||||||
|
# - app
|
||||||
|
# command: >
|
||||||
|
# bash -c "sleep 5 & celery -A app worker -E -B"
|
||||||
|
|
||||||
|
event:
|
||||||
|
extends:
|
||||||
|
service: app
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
- app
|
||||||
|
command: >
|
||||||
|
bash -c "sleep 5 & python manage.py torrent_event"
|
||||||
35
nginx/default.nginx
Normal file
35
nginx/default.nginx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
server {
|
||||||
|
gzip off;
|
||||||
|
|
||||||
|
location /dl/ {
|
||||||
|
internal;
|
||||||
|
alias /transmission/downloads/complete/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_redirect off;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
proxy_pass http://app:8000/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /ws/ {
|
||||||
|
proxy_redirect off;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "Upgrade";
|
||||||
|
|
||||||
|
proxy_pass http://app:8000/ws/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /static {
|
||||||
|
alias /app/static_collected;
|
||||||
|
}
|
||||||
|
}
|
||||||
31
nginx/dev.nginx
Normal file
31
nginx/dev.nginx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
server {
|
||||||
|
gzip off;
|
||||||
|
|
||||||
|
location /dl/ {
|
||||||
|
internal;
|
||||||
|
alias /transmission/downloads/complete/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_redirect off;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
proxy_pass http://app:8000/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /ws/ {
|
||||||
|
proxy_redirect off;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "Upgrade";
|
||||||
|
|
||||||
|
proxy_pass http://app:8000/ws/;
|
||||||
|
}
|
||||||
|
}
|
||||||
82
transmission/config/settings.json
Normal file
82
transmission/config/settings.json
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
{
|
||||||
|
"alt-speed-down": 50,
|
||||||
|
"alt-speed-enabled": false,
|
||||||
|
"alt-speed-time-begin": 540,
|
||||||
|
"alt-speed-time-day": 127,
|
||||||
|
"alt-speed-time-enabled": false,
|
||||||
|
"alt-speed-time-end": 1020,
|
||||||
|
"alt-speed-up": 50,
|
||||||
|
"announce-ip": "",
|
||||||
|
"announce-ip-enabled": false,
|
||||||
|
"anti-brute-force-enabled": false,
|
||||||
|
"anti-brute-force-threshold": 100,
|
||||||
|
"bind-address-ipv4": "0.0.0.0",
|
||||||
|
"bind-address-ipv6": "::",
|
||||||
|
"blocklist-enabled": false,
|
||||||
|
"blocklist-url": "http://www.example.com/blocklist",
|
||||||
|
"cache-size-mb": 4,
|
||||||
|
"default-trackers": "",
|
||||||
|
"dht-enabled": true,
|
||||||
|
"download-dir": "/downloads/complete",
|
||||||
|
"download-queue-enabled": true,
|
||||||
|
"download-queue-size": 5,
|
||||||
|
"encryption": 1,
|
||||||
|
"idle-seeding-limit": 30,
|
||||||
|
"idle-seeding-limit-enabled": false,
|
||||||
|
"incomplete-dir": "/downloads/incomplete",
|
||||||
|
"incomplete-dir-enabled": true,
|
||||||
|
"lpd-enabled": false,
|
||||||
|
"message-level": 2,
|
||||||
|
"peer-congestion-algorithm": "",
|
||||||
|
"peer-id-ttl-hours": 6,
|
||||||
|
"peer-limit-global": 200,
|
||||||
|
"peer-limit-per-torrent": 50,
|
||||||
|
"peer-port": 51413,
|
||||||
|
"peer-port-random-high": 65535,
|
||||||
|
"peer-port-random-low": 49152,
|
||||||
|
"peer-port-random-on-start": false,
|
||||||
|
"peer-socket-tos": "le",
|
||||||
|
"pex-enabled": true,
|
||||||
|
"port-forwarding-enabled": true,
|
||||||
|
"preallocation": 1,
|
||||||
|
"prefetch-enabled": true,
|
||||||
|
"queue-stalled-enabled": true,
|
||||||
|
"queue-stalled-minutes": 30,
|
||||||
|
"ratio-limit": 2,
|
||||||
|
"ratio-limit-enabled": false,
|
||||||
|
"rename-partial-files": true,
|
||||||
|
"rpc-authentication-required": false,
|
||||||
|
"rpc-bind-address": "0.0.0.0",
|
||||||
|
"rpc-enabled": true,
|
||||||
|
"rpc-host-whitelist": "127.0.0.1",
|
||||||
|
"rpc-host-whitelist-enabled": false,
|
||||||
|
"rpc-password": "{1ddd3f1f6a71d655cde7767242a23a575b44c909n5YuRT.f",
|
||||||
|
"rpc-port": 9091,
|
||||||
|
"rpc-socket-mode": "0750",
|
||||||
|
"rpc-url": "/transmission/",
|
||||||
|
"rpc-username": "",
|
||||||
|
"rpc-whitelist": "127.0.0.1",
|
||||||
|
"rpc-whitelist-enabled": false,
|
||||||
|
"scrape-paused-torrents-enabled": true,
|
||||||
|
"script-torrent-added-enabled": false,
|
||||||
|
"script-torrent-added-filename": "",
|
||||||
|
"script-torrent-done-enabled": false,
|
||||||
|
"script-torrent-done-filename": "",
|
||||||
|
"script-torrent-done-seeding-enabled": false,
|
||||||
|
"script-torrent-done-seeding-filename": "",
|
||||||
|
"seed-queue-enabled": false,
|
||||||
|
"seed-queue-size": 10,
|
||||||
|
"speed-limit-down": 100,
|
||||||
|
"speed-limit-down-enabled": false,
|
||||||
|
"speed-limit-up": 100,
|
||||||
|
"speed-limit-up-enabled": false,
|
||||||
|
"start-added-torrents": true,
|
||||||
|
"tcp-enabled": true,
|
||||||
|
"torrent-added-verify-mode": "fast",
|
||||||
|
"trash-original-torrent-files": false,
|
||||||
|
"umask": "002",
|
||||||
|
"upload-slots-per-torrent": 14,
|
||||||
|
"utp-enabled": false,
|
||||||
|
"watch-dir": "/watch",
|
||||||
|
"watch-dir-enabled": true
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user