This commit is contained in:
2025-03-13 22:08:06 +01:00
commit bab5571428
93 changed files with 4323 additions and 0 deletions

1
app/.dockerignore Normal file
View File

@@ -0,0 +1 @@
celerybeat-schedule*

69
app/.gitignore vendored Normal file
View 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
View 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
View File

3
app/api/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
app/api/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ApiConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'api'

View File

3
app/api/models.py Normal file
View File

@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

10
app/api/routers.py Normal file
View 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
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

9
app/api/urls.py Normal file
View 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
View File

@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

0
app/app/__init__.py Normal file
View File

16
app/app/asgi.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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?

View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

30
app/frontend/package.json Normal file
View 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"
}
}

View File

@@ -0,0 +1,7 @@
export default {
plugins: {
'postcss-import': {},
'postcss-simple-vars': {},
'autoprefixer': {}
}
}

View 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");
})

View 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");
})

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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}

View 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)
}

View 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
}
}
}
}
});

View 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();
}
}

View 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
View 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
View 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
View 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
View 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
View File

13
app/torrent/admin.py Normal file
View 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
View 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
View 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"]
})

View 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}"))

View 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)),
],
),
]

View 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')},
),
]

View 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),
),
]

View File

@@ -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',
),
]

View File

@@ -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',
),
]

View File

94
app/torrent/models.py Normal file
View 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)}"'

View 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
View 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
View 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
})

View 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 %}

View File

@@ -0,0 +1 @@
{% extends "base.html" %}

3
app/torrent/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

10
app/torrent/urls.py Normal file
View 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
View 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
View 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
View File

51
app/user/admin.py Normal file
View 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
View 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
View 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"]

View 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])

View 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')},
},
),
]

View File

76
app/user/models.py Normal file
View 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
View 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__"

View File

@@ -0,0 +1 @@
{% extends "base.html" %}

View 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 %}

View File

3
app/user/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

9
app/user/urls.py Normal file
View 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
View 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