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

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