mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 10:45:27 +00:00
370 lines
12 KiB
Python
370 lines
12 KiB
Python
import json
|
|
from urllib.parse import unquote
|
|
|
|
from django.db.models import OuterRef, Subquery, Value
|
|
from django.db.models.functions import Coalesce
|
|
from django.http import JsonResponse, FileResponse, Http404
|
|
from django.conf import settings
|
|
import os
|
|
from django.urls import reverse
|
|
from rest_framework import status, viewsets
|
|
from rest_framework.permissions import AllowAny, IsAuthenticated
|
|
from rest_framework.response import Response
|
|
from rest_framework.views import APIView
|
|
|
|
from apps.vod.models import (
|
|
VODCategory,
|
|
Movie,
|
|
Series,
|
|
Episode,
|
|
M3UMovieRelation,
|
|
M3USeriesRelation,
|
|
M3UEpisodeRelation,
|
|
)
|
|
from core.models import CoreSettings, FUSE_SETTINGS_KEY
|
|
from .serializers import FuseEntrySerializer, FuseSettingsSerializer
|
|
|
|
|
|
def _select_best_relation(relations):
|
|
"""
|
|
Pick the highest priority active relation.
|
|
"""
|
|
if relations is None:
|
|
return None
|
|
try:
|
|
iterable = list(relations.all()) if hasattr(relations, "all") else list(relations)
|
|
except TypeError:
|
|
iterable = []
|
|
if not iterable:
|
|
return None
|
|
return sorted(
|
|
iterable,
|
|
key=lambda rel: (-getattr(rel.m3u_account, "priority", 0), rel.id),
|
|
)[0]
|
|
|
|
|
|
class FuseBrowseView(APIView):
|
|
"""
|
|
Read-only filesystem-style browsing for Movies and TV.
|
|
"""
|
|
|
|
permission_classes = [AllowAny]
|
|
|
|
def get(self, request, mode):
|
|
path = request.query_params.get("path", "/")
|
|
path = unquote(path)
|
|
# Normalize
|
|
trimmed = path.strip("/")
|
|
parts = [p for p in trimmed.split("/") if p] if trimmed else []
|
|
|
|
if mode not in ("movies", "tv"):
|
|
return Response({"detail": "Invalid mode"}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
if mode == "movies":
|
|
return Response(self._browse_movies(parts, request))
|
|
return Response(self._browse_tv(parts, request))
|
|
|
|
def _browse_movies(self, parts, request):
|
|
# Root -> list categories
|
|
if len(parts) == 0:
|
|
category_ids = (
|
|
M3UMovieRelation.objects.filter(
|
|
m3u_account__is_active=True, category__isnull=False
|
|
)
|
|
.values_list("category_id", flat=True)
|
|
.distinct()
|
|
)
|
|
categories = VODCategory.objects.filter(
|
|
category_type="movie", id__in=category_ids
|
|
).order_by("name")
|
|
entries = [
|
|
{
|
|
"name": cat.name,
|
|
"path": f"/{cat.name}",
|
|
"is_dir": True,
|
|
"content_type": "category",
|
|
"uuid": None,
|
|
}
|
|
for cat in categories
|
|
]
|
|
return {"path": "/", "entries": FuseEntrySerializer(entries, many=True).data}
|
|
|
|
# Category -> list movies
|
|
category_name = parts[0]
|
|
category = (
|
|
VODCategory.objects.filter(
|
|
name=category_name, category_type="movie"
|
|
).first()
|
|
)
|
|
if not category:
|
|
return {"path": f"/{category_name}", "entries": []}
|
|
|
|
movies = (
|
|
Movie.objects.filter(
|
|
m3u_relations__category=category,
|
|
m3u_relations__m3u_account__is_active=True,
|
|
)
|
|
.distinct()
|
|
.annotate(
|
|
best_extension=Coalesce(
|
|
Subquery(
|
|
M3UMovieRelation.objects.filter(
|
|
m3u_account__is_active=True,
|
|
category=category,
|
|
movie_id=OuterRef("pk"),
|
|
)
|
|
.order_by("-m3u_account__priority", "id")
|
|
.values("container_extension")[:1]
|
|
),
|
|
Value("mp4"),
|
|
)
|
|
)
|
|
.only("id", "uuid", "name", "year")
|
|
.order_by("name")
|
|
)
|
|
|
|
entries = []
|
|
for movie in movies:
|
|
extension = getattr(movie, "best_extension", None) or "mp4"
|
|
name = f"{movie.name} ({movie.year})" if movie.year else movie.name
|
|
file_name = f"{name}.{extension}"
|
|
stream_url = None
|
|
if movie.uuid:
|
|
stream_url = request.build_absolute_uri(
|
|
reverse(
|
|
"proxy:vod_proxy:vod_stream",
|
|
kwargs={"content_type": "movie", "content_id": movie.uuid},
|
|
)
|
|
)
|
|
|
|
entries.append(
|
|
{
|
|
"name": file_name,
|
|
"path": f"/{category.name}/{file_name}",
|
|
"is_dir": False,
|
|
"content_type": "movie",
|
|
"uuid": movie.uuid,
|
|
"extension": extension,
|
|
"category": category.name,
|
|
# Report zero so clients don't prefetch/consume provider slots until a real read.
|
|
"size": 0,
|
|
# Omit stream_url to force clients to fetch it only when they actually read.
|
|
"stream_url": None,
|
|
}
|
|
)
|
|
|
|
return {
|
|
"path": f"/{category.name}",
|
|
"entries": FuseEntrySerializer(entries, many=True).data,
|
|
}
|
|
|
|
def _browse_tv(self, parts, request):
|
|
# Root -> list series
|
|
if len(parts) == 0:
|
|
series = (
|
|
Series.objects.filter(
|
|
m3u_relations__m3u_account__is_active=True,
|
|
)
|
|
.distinct()
|
|
.order_by("name")
|
|
)
|
|
entries = [
|
|
{
|
|
"name": serie.name,
|
|
"path": f"/{serie.name}",
|
|
"is_dir": True,
|
|
"content_type": "series",
|
|
"uuid": None,
|
|
}
|
|
for serie in series
|
|
]
|
|
return {"path": "/", "entries": FuseEntrySerializer(entries, many=True).data}
|
|
|
|
# Series -> list seasons
|
|
series_name = parts[0]
|
|
series_obj = Series.objects.filter(name=series_name).first()
|
|
if not series_obj:
|
|
return {"path": f"/{series_name}", "entries": []}
|
|
|
|
if len(parts) == 1:
|
|
seasons = (
|
|
Episode.objects.filter(series=series_obj)
|
|
.exclude(season_number__isnull=True)
|
|
.values_list("season_number", flat=True)
|
|
.distinct()
|
|
)
|
|
season_numbers = sorted(set(seasons)) or [0]
|
|
entries = []
|
|
for num in season_numbers:
|
|
label = f"Season {int(num):02d}"
|
|
entries.append(
|
|
{
|
|
"name": label,
|
|
"path": f"/{series_name}/{label}",
|
|
"is_dir": True,
|
|
"content_type": "season",
|
|
"uuid": None,
|
|
"season": int(num),
|
|
}
|
|
)
|
|
return {
|
|
"path": f"/{series_name}",
|
|
"entries": FuseEntrySerializer(entries, many=True).data,
|
|
}
|
|
|
|
# Season -> list episodes
|
|
season_label = parts[1]
|
|
try:
|
|
season_number = int(season_label.replace("Season", "").strip())
|
|
except Exception:
|
|
season_number = None
|
|
|
|
episodes = (
|
|
Episode.objects.filter(
|
|
series=series_obj,
|
|
season_number=season_number,
|
|
)
|
|
.filter(m3u_relations__m3u_account__is_active=True)
|
|
.distinct()
|
|
.annotate(
|
|
best_extension=Coalesce(
|
|
Subquery(
|
|
M3UEpisodeRelation.objects.filter(
|
|
m3u_account__is_active=True,
|
|
episode_id=OuterRef("pk"),
|
|
)
|
|
.order_by("-m3u_account__priority", "id")
|
|
.values("container_extension")[:1]
|
|
),
|
|
Value("mp4"),
|
|
)
|
|
)
|
|
.only("id", "uuid", "name", "series_id", "season_number", "episode_number")
|
|
.order_by("episode_number")
|
|
)
|
|
|
|
entries = []
|
|
for ep in episodes:
|
|
extension = getattr(ep, "best_extension", None) or "mp4"
|
|
ep_num = ep.episode_number or 0
|
|
season_num = ep.season_number or 0
|
|
name = f"S{season_num:02d}E{ep_num:02d} - {ep.name}"
|
|
file_name = f"{name}.{extension}"
|
|
stream_url = None
|
|
if ep.uuid:
|
|
stream_url = request.build_absolute_uri(
|
|
reverse(
|
|
"proxy:vod_proxy:vod_stream",
|
|
kwargs={"content_type": "episode", "content_id": ep.uuid},
|
|
)
|
|
)
|
|
entries.append(
|
|
{
|
|
"name": file_name,
|
|
"path": f"/{series_name}/{season_label}/{file_name}",
|
|
"is_dir": False,
|
|
"content_type": "episode",
|
|
"uuid": ep.uuid,
|
|
"extension": extension,
|
|
"season": season_num,
|
|
"episode_number": ep_num,
|
|
"size": 0,
|
|
"stream_url": None,
|
|
}
|
|
)
|
|
|
|
return {
|
|
"path": f"/{series_name}/{season_label}",
|
|
"entries": FuseEntrySerializer(entries, many=True).data,
|
|
}
|
|
|
|
|
|
class FuseSettingsViewSet(viewsets.ViewSet):
|
|
"""
|
|
Store FUSE client guidance in CoreSettings as JSON.
|
|
"""
|
|
|
|
permission_classes = [IsAuthenticated]
|
|
serializer_class = FuseSettingsSerializer
|
|
|
|
DEFAULTS = {
|
|
"enable_fuse": False,
|
|
"backend_base_url": "",
|
|
"movies_mount_path": "/mnt/vod_movies",
|
|
"tv_mount_path": "/mnt/vod_tv",
|
|
}
|
|
|
|
def _get_or_create(self):
|
|
try:
|
|
obj = CoreSettings.objects.get(key=FUSE_SETTINGS_KEY)
|
|
data = json.loads(obj.value)
|
|
except (CoreSettings.DoesNotExist, json.JSONDecodeError):
|
|
data = self.DEFAULTS.copy()
|
|
obj, _ = CoreSettings.objects.get_or_create(
|
|
key=FUSE_SETTINGS_KEY,
|
|
defaults={"name": "Fuse Settings", "value": json.dumps(data)},
|
|
)
|
|
return obj, data
|
|
|
|
def list(self, request):
|
|
obj, data = self._get_or_create()
|
|
serializer = FuseSettingsSerializer(data=data)
|
|
serializer.is_valid(raise_exception=True)
|
|
return Response(serializer.data)
|
|
|
|
def retrieve(self, request, pk=None):
|
|
return self.list(request)
|
|
|
|
def update(self, request, pk=None):
|
|
obj, current = self._get_or_create()
|
|
serializer = FuseSettingsSerializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
obj.value = json.dumps(serializer.validated_data)
|
|
obj.save()
|
|
return Response(serializer.validated_data)
|
|
|
|
|
|
class FuseStreamURLView(APIView):
|
|
"""
|
|
Provide a stable stream URL for a given movie/episode UUID.
|
|
"""
|
|
|
|
permission_classes = [AllowAny]
|
|
|
|
def get(self, request, content_type, content_id):
|
|
if content_type not in ("movie", "episode"):
|
|
return Response({"detail": "Invalid content type"}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
try:
|
|
model = Movie if content_type == "movie" else Episode
|
|
if not model.objects.filter(uuid=content_id).exists():
|
|
return Response({"detail": "Not found"}, status=status.HTTP_404_NOT_FOUND)
|
|
stream_url = request.build_absolute_uri(
|
|
reverse(
|
|
"proxy:vod_proxy:vod_stream",
|
|
kwargs={"content_type": content_type, "content_id": content_id},
|
|
)
|
|
)
|
|
return JsonResponse({"stream_url": stream_url})
|
|
except Exception:
|
|
return Response({"detail": "Not found"}, status=status.HTTP_404_NOT_FOUND)
|
|
|
|
|
|
class FuseClientDownloadView(APIView):
|
|
"""
|
|
Serve the fuse_client.py script from the local server.
|
|
"""
|
|
|
|
permission_classes = [AllowAny]
|
|
|
|
def get(self, request):
|
|
script_path = os.path.join(settings.BASE_DIR, "fuse_client", "fuse_client.py")
|
|
if not os.path.exists(script_path):
|
|
raise Http404("Fuse client script not found")
|
|
|
|
return FileResponse(
|
|
open(script_path, "rb"),
|
|
as_attachment=True,
|
|
filename="fuse_client.py",
|
|
content_type="text/x-python",
|
|
)
|