diff --git a/app/app/asgi.py b/app/app/asgi.py index 6ae490c..54792e0 100644 --- a/app/app/asgi.py +++ b/app/app/asgi.py @@ -1,6 +1,8 @@ import os - +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') from django.core.asgi import get_asgi_application +django_asgi_app = get_asgi_application() + from channels.auth import AuthMiddlewareStack from channels.sessions import SessionMiddlewareStack from channels.routing import ProtocolTypeRouter, URLRouter @@ -8,8 +10,7 @@ from channels.routing import ProtocolTypeRouter, URLRouter from app.channels_middleware import JwtOrSessionAuthMiddleware -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') -django_asgi_app = get_asgi_application() + from .ws_urls import websocket_urlpatterns diff --git a/app/app/settings.py b/app/app/settings.py index 49ef1ab..188fbc1 100644 --- a/app/app/settings.py +++ b/app/app/settings.py @@ -59,8 +59,6 @@ INSTALLED_APPS = [ 'api', 'torrent', ] -if DEBUG: - INSTALLED_APPS = ["daphne"] + INSTALLED_APPS MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', diff --git a/app/app/utils.py b/app/app/utils.py index 8b07ae7..075bf1c 100644 --- a/app/app/utils.py +++ b/app/app/utils.py @@ -3,7 +3,7 @@ from django.http import StreamingHttpResponse import zlib import datetime import os -import aiofiles +import anyio from stat import S_IFREG from stream_zip import ZIP_64, stream_zip, async_stream_zip from channels.layers import get_channel_layer @@ -61,7 +61,7 @@ class StreamingZipFileResponse(StreamingHttpResponse): async def contents(path): try: - async with aiofiles.open(path, "rb") as f: + async with await anyio.open_file(path, "rb") as f: while chunk := await f.read(64 * 1024): yield chunk except RuntimeError as e: diff --git a/app/dev_run.sh b/app/dev_run.sh index 3058129..a8e76f2 100644 --- a/app/dev_run.sh +++ b/app/dev_run.sh @@ -1,5 +1,10 @@ +#!/bin/bash + +CORES=$(nproc) +WORKERS=$((2 * CORES + 1)) + cd /app/frontend && yarn dev & -cd /app && python manage.py runserver 0.0.0.0:8000 & +cd /app && uvicorn app.asgi:application --workers $WORKERS --host 0.0.0.0 --port 8000 --lifespan off --loop uvloop --ws websockets --reload & wait -n diff --git a/app/frontend/src/components/torrent/App.vue b/app/frontend/src/components/torrent/App.vue index 6d2efca..8151c3c 100644 --- a/app/frontend/src/components/torrent/App.vue +++ b/app/frontend/src/components/torrent/App.vue @@ -11,24 +11,26 @@ - + + + {{dm_status}} + - - - - + + Oxpanel - - - - - + + + + + + @@ -67,6 +69,7 @@ import TorrentList from "@/components/torrent/TorrentList.vue"; import UploadForm from "@/components/torrent/UploadForm.vue"; import Friend from "@/components/auth/Friend.vue"; import UserStats from "@/components/torrent/UserStats.vue"; +import DM from "@/components/torrent/DM.vue"; \ No newline at end of file + + + \ No newline at end of file diff --git a/app/frontend/src/components/torrent/DM.vue b/app/frontend/src/components/torrent/DM.vue new file mode 100644 index 0000000..f6d1919 --- /dev/null +++ b/app/frontend/src/components/torrent/DM.vue @@ -0,0 +1,75 @@ + + + + + + + + + {{ dm_status.pause ? 'Start':'Pause' }} + + + + + {{fs_speed_format(dm_status.speed)}}/s + + + + + {{dm_status.downloaded_files}}/{{dm_status.total_files}} + + + + + {{fs_format(dm_status.downloaded_size)}}/{{fs_format(dm_status.total_size)}} + + + {{percentDownloaded.toFixed(1)}}% + + + + + + + + + + \ No newline at end of file diff --git a/app/frontend/src/components/torrent/DMDetails.vue b/app/frontend/src/components/torrent/DMDetails.vue new file mode 100644 index 0000000..a3696e7 --- /dev/null +++ b/app/frontend/src/components/torrent/DMDetails.vue @@ -0,0 +1,123 @@ + + + + + + + + + + Fichiers en téléchargement + + + + {{ file.rel_path }} + + + + + + + + + + Fichiers en attente + + + + {{ file.rel_path }} + + + En attente + + + + + + + + Fichiers terminés + + + + {{ file.rel_path }} + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/frontend/src/components/torrent/FileItem.vue b/app/frontend/src/components/torrent/FileItem.vue index 33fb1d2..b6ce765 100644 --- a/app/frontend/src/components/torrent/FileItem.vue +++ b/app/frontend/src/components/torrent/FileItem.vue @@ -44,10 +44,15 @@ export default { 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(); + if(this.$qt.is_active){ + this.$qt.callMethod("add_files", this.file); + }else{ + let a = document.createElement("a"); + a.href = this.file.download_url; + a.setAttribute("download", "download"); + a.click(); + } + } } } diff --git a/app/frontend/src/components/torrent/FileList.vue b/app/frontend/src/components/torrent/FileList.vue index e5230e9..4b12f46 100644 --- a/app/frontend/src/components/torrent/FileList.vue +++ b/app/frontend/src/components/torrent/FileList.vue @@ -42,6 +42,7 @@ export default { let response = await fetch(`/api/torrent/files?${new URLSearchParams(this.filters)}`); this.files = await response.json(); this.loading = false; + return this.files; } } } diff --git a/app/frontend/src/components/torrent/TorrentItem.vue b/app/frontend/src/components/torrent/TorrentItem.vue index c8d2223..e357c73 100644 --- a/app/frontend/src/components/torrent/TorrentItem.vue +++ b/app/frontend/src/components/torrent/TorrentItem.vue @@ -2,14 +2,14 @@ - + + + + + + @@ -52,7 +52,11 @@ - + @@ -88,10 +92,16 @@ export default { 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(); + if(this.$qt.is_active){ + let response = await fetch(`/api/torrent/files/?torrent=${this.torrent.id}`); + let files = await response.json(); + this.$qt.callMethod("add_files", files); + }else{ + 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); diff --git a/app/frontend/src/plugins/params.js b/app/frontend/src/plugins/params.js new file mode 100644 index 0000000..e0c3585 --- /dev/null +++ b/app/frontend/src/plugins/params.js @@ -0,0 +1,13 @@ +import { reactive } from "vue"; + +class Params { + constructor() { + this.dl_hotfix_enabled = false; + } +} + +export default { + install: (app, options) => { + app.config.globalProperties.$params = reactive(new Params()); + } +} \ No newline at end of file diff --git a/app/frontend/src/plugins/qtwebchannel.js b/app/frontend/src/plugins/qtwebchannel.js new file mode 100644 index 0000000..323dcfc --- /dev/null +++ b/app/frontend/src/plugins/qtwebchannel.js @@ -0,0 +1,76 @@ +import { EventEmitter } from 'events'; + +class QtWebChannel { + constructor() { + this.handler = null; + this.eventEmitter = new EventEmitter(); // Ajouter l'EventEmitter + } + + get is_active(){ + return Boolean(this.handler) + } + + connect() { + if(typeof QWebChannel !== 'undefined') { + this.handler = new QWebChannel(qt.webChannelTransport, channel => { + channel.objects.handler.on_message.connect((message) => { + this.handleMessage(message); + }); + + this.callMethod('on_site_ready', args => { + this.eventEmitter.emit('ready'); + }) + console.log(this.getProperty("dm_status")) + }) + return true; + }else{ + return false; + } + } + + callMethod(function_name, ...args){ + console.log("call method", function_name, args) + if(this.handler !== null){ + return this.handler.objects.handler[function_name](...args) + }else{ + return null; + } + } + + getProperty(property_name, default_value){ + if(this.handler !== null){ + let property = this.handler.objects.handler[property_name]; + if(property !== undefined){ + return property; + } + } + return default_value; + } + + handleMessage(message){ + try { + if("context" in message){ + this.eventEmitter.emit(message.context, message.content); + } + return message; + } catch (error) { + console.error('Failed to parse message as JSON:', error); + this.eventEmitter.emit('invalid_message', { raw: message, error }); + return null; + } + } + + on(event, callback) { + this.eventEmitter.on(event, callback); + } + + off(event, callback) { + this.eventEmitter.off(event, callback); + } +} + +export default { + install: (app, options) => { + app.config.globalProperties.$qt = new QtWebChannel(); + } +} \ No newline at end of file diff --git a/app/frontend/src/plugins/vue_loader.js b/app/frontend/src/plugins/vue_loader.js index 4135ff8..93f50c0 100644 --- a/app/frontend/src/plugins/vue_loader.js +++ b/app/frontend/src/plugins/vue_loader.js @@ -1,7 +1,8 @@ import { createApp } from 'vue'; import Vuetify from "./vuetify" import Ws from "./ws"; +import QtWC from "./qtwebchannel.js" export function createVue(component, dom_id){ - return createApp(component).use(Vuetify).use(Ws).mount(dom_id) + return createApp(component).use(Vuetify).use(Ws).use(QtWC).mount(dom_id) } \ No newline at end of file diff --git a/app/frontend/src/plugins/ws.js b/app/frontend/src/plugins/ws.js index 115a079..f169ad6 100644 --- a/app/frontend/src/plugins/ws.js +++ b/app/frontend/src/plugins/ws.js @@ -2,75 +2,100 @@ import { EventEmitter } from 'events'; class WebSocketClient { - constructor() { - this.url = null; - this.socket = null; - this.reconnectInterval = 5000; - this.eventEmitter = new EventEmitter(); // Ajouter l'EventEmitter + 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.formatWebSocketUrl(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.'); + } + } + + formatWebSocketUrl(url) { + // Vérifier si l'URL est déjà un protocole WebSocket + if (url.startsWith('ws://') || url.startsWith('wss://')) { + return url; } - connect(url) { - this.url = url; - this.socket = new window.WebSocket(this.url); + // Déterminer si on utilise le protocole sécurisé (wss) ou non (ws) + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - 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(); - }; + // Si l'URL est relative (/mon_url) + if (url.startsWith('/')) { + return `${protocol}//${window.location.host}${url}`; } - 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; - } + // Si l'URL est complète (http://monsite.com/mon_url) + if (url.startsWith('http://') || url.startsWith('https://')) { + return url.replace(/^http(s)?:\/\//, (_, s) => s ? 'wss://' : 'ws://'); } - // Méthodes pour gérer les événements - on(event, callback) { - this.eventEmitter.on(event, callback); - } + // Si c'est juste un chemin sans slash au début + return `${protocol}//${window.location.host}/${url}`; + } - 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(); diff --git a/app/frontend/src/styles/main.css b/app/frontend/src/styles/main.css new file mode 100644 index 0000000..0176fd6 --- /dev/null +++ b/app/frontend/src/styles/main.css @@ -0,0 +1,26 @@ +::-webkit-scrollbar { + width: 10px; + height: 10px; + display: block !important; +} + +::-webkit-scrollbar-track { + background: #f1f1f1; + display: block !important; +} + +::-webkit-scrollbar-thumb { + background: #888; + border-radius: 5px; + display: block !important; +} + +::-webkit-scrollbar-thumb:hover { + background: #555; +} + +/* Pour Firefox */ +* { + scrollbar-width: thin; + scrollbar-color: #888 #f1f1f1; +} \ No newline at end of file diff --git a/app/requirements.txt b/app/requirements.txt index 689f1fb..d252e3b 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -4,7 +4,7 @@ django-cors-headers djangorestframework django-filter djangorestframework-simplejwt -channels[daphne] +channels channels_redis celery @@ -14,4 +14,7 @@ psycopg[binary] uvicorn transmission-rpc stream-zip -aiofiles \ No newline at end of file +anyio +websockets +uvloop +watchfiles diff --git a/app/torrent/models.py b/app/torrent/models.py index 5f79d3a..6c0d5ae 100644 --- a/app/torrent/models.py +++ b/app/torrent/models.py @@ -73,7 +73,7 @@ class File(models.Model): def mime_types(self): mime = mimetypes.guess_type(self.pathname) if mime: - return mime + return mime[0] or mime[1] or "application/octet-stream" else: return "application/octet-stream" @@ -87,7 +87,21 @@ class File(models.Model): @property def accel_redirect(self): - return shlex.quote(f"{settings.NGINX_ACCEL_BASE}/{self.pathname}") + # Encode chaque partie du chemin séparément pour préserver la structure + encoded_parts = [] + for part in self.pathname.parts: + # Ignorer un slash initial si présent + if part == '/' or part == '\\': + continue + encoded_parts.append(quote(part)) + + # Construction du chemin final avec le préfixe Nginx + if settings.NGINX_ACCEL_BASE.endswith('/'): + base = settings.NGINX_ACCEL_BASE.rstrip('/') + else: + base = settings.NGINX_ACCEL_BASE + + return f"{base}/{'/'.join(encoded_parts)}" @property def disposition(self): diff --git a/app/torrent/templates/torrent/home.html b/app/torrent/templates/torrent/home.html index 12c9e09..b7514aa 100644 --- a/app/torrent/templates/torrent/home.html +++ b/app/torrent/templates/torrent/home.html @@ -6,4 +6,14 @@ const current_user = {{ request.user.min_infos|safe }}; {% vite_asset "app/torrent.js" %} + + {% endblock %} \ No newline at end of file diff --git a/app/torrent/urls.py b/app/torrent/urls.py index 067f118..af6e255 100644 --- a/app/torrent/urls.py +++ b/app/torrent/urls.py @@ -1,10 +1,11 @@ from django.urls import path -from .views import HomeView, download_file, download_torrent +from .views import HomeView, download_file, download_torrent, pping app_name = "torrent" urlpatterns = [ path("", HomeView.as_view(), name="home"), + path("pping/", pping, name="pping"), path("download_file/", download_file, name="download_file"), path("download_torrent/", download_torrent, name="download_torrent"), ] diff --git a/app/torrent/views.py b/app/torrent/views.py index c768c00..969b0d8 100644 --- a/app/torrent/views.py +++ b/app/torrent/views.py @@ -6,11 +6,11 @@ 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 +import anyio from app.utils import StreamingZipFileResponse from user.models import User @@ -23,6 +23,10 @@ class HomeView(LoginRequiredMixin, TemplateView): template_name = "torrent/home.html" +def pping(request): + return HttpResponse(str(dict(request.session))) + + async def download_file(request, file_id): user = await request.auser() qs = File.objects.filter( @@ -32,16 +36,16 @@ async def download_file(request, file_id): | Q(torrent__shared_users__friends=user), torrent__transmission_data__progress__gte=100, pk=file_id - ) + ).distinct() try: file = await qs.aget() except File.DoesNotExist: raise Http404() else: - if request.GET.get("dl_hotfix", "0") == "1": + if int(request.GET.get("dl_hotfix", 0)) == 1: async def read_file(): - async with aiofiles.open(file.abs_pathname, "rb") as f: + async with await anyio.open_file(file.abs_pathname, "rb") as f: while chunk := await f.read(128 * 1024): yield chunk response = StreamingHttpResponse(read_file()) @@ -57,6 +61,30 @@ async def download_file(request, file_id): return response +async def flux_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 + ).distinct() + + try: + file = await qs.aget() + except File.DoesNotExist: + raise Http404() + else: + response = HttpResponse() + response["X-Accel-Redirect"] = file.accel_redirect + response["X-Accel-Buffering"] = "no" + 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() @@ -67,11 +95,11 @@ async def download_torrent(request, torrent_id): | Q(shared_users__friends=user), transmission_data__progress__gte=100, pk=torrent_id - ).annotate(count_files=Count("files")) + ).annotate(count_files=Count("files")).distinct() torrent = await qs.aget() - if torrent.count_files == 1: + if await torrent.alen_files == 1: file = await torrent.files.afirst() return redirect(reverse("torrent:download_file", kwargs={ "file_id": file.pk diff --git a/docker-compose.yml b/docker-compose.yml index c4a65ea..838594c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -52,7 +52,7 @@ services: && cd frontend && yarn build && cd .. && python manage.py collectstatic --noinput && python manage.py migrate - && uvicorn app.asgi:application --workers 3 --host 0.0.0.0 --port 8000 --lifespan off --loop asyncio --ws websockets" + && uvicorn app.asgi:application --workers 3 --host 0.0.0.0 --port 8000 --lifespan off --loop uvloop --ws websockets" # celery: # extends: