Dispatcharr/apps/fuse_api/api_views.py
Dispatcharr ce0acca8bc FUSE Testing
Exploratory branch for FUSE testing
2025-12-11 19:15:52 -06:00

358 lines
12 KiB
Python

import json
from urllib.parse import unquote
from django.db.models import Prefetch
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()
.select_related("logo")
.prefetch_related(
Prefetch(
"m3u_relations",
queryset=M3UMovieRelation.objects.filter(
m3u_account__is_active=True
).select_related("m3u_account"),
)
)
.order_by("name")
)
entries = []
for movie in movies:
relation = _select_best_relation(getattr(movie, "m3u_relations", []))
extension = getattr(relation, "container_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,
)
.select_related("series")
.prefetch_related(
Prefetch(
"m3u_relations",
queryset=M3UEpisodeRelation.objects.filter(
m3u_account__is_active=True
).select_related("m3u_account"),
)
)
.order_by("episode_number")
)
entries = []
for ep in episodes:
relation = _select_best_relation(getattr(ep, "m3u_relations", []))
extension = getattr(relation, "container_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",
)