mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-25 11:04:07 +00:00
365 lines
14 KiB
Python
365 lines
14 KiB
Python
import logging
|
|
|
|
from django.conf import settings
|
|
from django.core.signing import TimestampSigner
|
|
from django.db.models import Prefetch, Q
|
|
from django.urls import reverse
|
|
from django_filters.rest_framework import DjangoFilterBackend
|
|
from rest_framework import mixins, status, viewsets
|
|
from rest_framework.filters import OrderingFilter, SearchFilter
|
|
from rest_framework.decorators import action
|
|
from rest_framework.response import Response
|
|
from rest_framework.exceptions import NotFound, ValidationError
|
|
|
|
from apps.accounts.permissions import Authenticated
|
|
from apps.media_library import models, serializers
|
|
from apps.media_library.tasks import enqueue_library_scan, sync_metadata_task
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class LibraryViewSet(viewsets.ModelViewSet):
|
|
queryset = models.Library.objects.all().prefetch_related("locations")
|
|
serializer_class = serializers.LibrarySerializer
|
|
permission_classes = [Authenticated]
|
|
filter_backends = [DjangoFilterBackend, OrderingFilter, SearchFilter]
|
|
filterset_fields = ["library_type", "auto_scan_enabled"]
|
|
search_fields = ["name", "description"]
|
|
ordering_fields = ["name", "created_at", "updated_at", "last_scan_at"]
|
|
ordering = ["name"]
|
|
|
|
def perform_create(self, serializer):
|
|
library = serializer.save()
|
|
if library.auto_scan_enabled:
|
|
enqueue_library_scan(library_id=library.id, user_id=self.request.user.id)
|
|
|
|
def perform_update(self, serializer):
|
|
library = serializer.save()
|
|
if library.auto_scan_enabled and self.request.data.get("trigger_scan"):
|
|
enqueue_library_scan(library_id=library.id, user_id=self.request.user.id)
|
|
|
|
def perform_destroy(self, instance):
|
|
# Explicitly clean up related media items and files before removing the library
|
|
instance.items.all().delete()
|
|
instance.files.all().delete()
|
|
super().perform_destroy(instance)
|
|
|
|
@action(detail=True, methods=["post"], url_path="scan")
|
|
def scan(self, request, pk=None):
|
|
library = self.get_object()
|
|
user_id = request.user.id if request.user and request.user.is_authenticated else None
|
|
scan = enqueue_library_scan(library_id=library.id, user_id=user_id, force_full=request.data.get("full", False))
|
|
serializer = serializers.LibraryScanSerializer(scan)
|
|
return Response(serializer.data, status=status.HTTP_202_ACCEPTED)
|
|
|
|
|
|
class LibraryScanViewSet(viewsets.ReadOnlyModelViewSet):
|
|
queryset = models.LibraryScan.objects.select_related("library", "created_by")
|
|
serializer_class = serializers.LibraryScanSerializer
|
|
permission_classes = [Authenticated]
|
|
filter_backends = [DjangoFilterBackend, OrderingFilter]
|
|
filterset_fields = ["library", "status"]
|
|
ordering_fields = ["created_at", "started_at", "finished_at"]
|
|
ordering = ["-created_at"]
|
|
|
|
|
|
class MediaItemViewSet(viewsets.ModelViewSet):
|
|
serializer_class = serializers.MediaItemSerializer
|
|
permission_classes = [Authenticated]
|
|
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
|
filterset_fields = [
|
|
"library",
|
|
"item_type",
|
|
"status",
|
|
"release_year",
|
|
"season_number",
|
|
"parent",
|
|
]
|
|
search_fields = ["title", "synopsis", "tags"]
|
|
ordering_fields = [
|
|
"sort_title",
|
|
"release_year",
|
|
"first_imported_at",
|
|
"updated_at",
|
|
"season_number",
|
|
"episode_number",
|
|
]
|
|
ordering = ["sort_title"]
|
|
http_method_names = ["get", "head", "options", "patch", "post"]
|
|
pagination_class = None
|
|
_stream_signer = TimestampSigner(salt="media-library-stream")
|
|
|
|
def get_serializer_class(self):
|
|
if self.action == "list":
|
|
return serializers.MediaItemListSerializer
|
|
return super().get_serializer_class()
|
|
|
|
def get_queryset(self):
|
|
user = getattr(self.request, "user", None)
|
|
if user and user.is_authenticated:
|
|
watch_prefetch = Prefetch(
|
|
"watch_progress",
|
|
queryset=models.WatchProgress.objects.filter(user=user),
|
|
to_attr="_user_watch_progress",
|
|
)
|
|
episode_watch_prefetch = Prefetch(
|
|
"watch_progress",
|
|
queryset=models.WatchProgress.objects.filter(user=user),
|
|
to_attr="_user_watch_progress",
|
|
)
|
|
else:
|
|
watch_prefetch = Prefetch(
|
|
"watch_progress",
|
|
queryset=models.WatchProgress.objects.none(),
|
|
to_attr="_user_watch_progress",
|
|
)
|
|
episode_watch_prefetch = Prefetch(
|
|
"watch_progress",
|
|
queryset=models.WatchProgress.objects.none(),
|
|
to_attr="_user_watch_progress",
|
|
)
|
|
|
|
base_queryset = models.MediaItem.objects.select_related(
|
|
"library",
|
|
"parent",
|
|
"vod_movie",
|
|
"vod_series",
|
|
"vod_episode",
|
|
)
|
|
|
|
if self.action == "list":
|
|
children_qs = (
|
|
models.MediaItem.objects.filter(item_type=models.MediaItem.TYPE_EPISODE)
|
|
.select_related("parent")
|
|
.prefetch_related(episode_watch_prefetch)
|
|
.order_by("season_number", "episode_number", "id")
|
|
)
|
|
return base_queryset.prefetch_related(
|
|
watch_prefetch,
|
|
Prefetch(
|
|
"children",
|
|
queryset=children_qs,
|
|
to_attr="_prefetched_children",
|
|
),
|
|
)
|
|
|
|
return base_queryset.prefetch_related("files", "artwork", watch_prefetch)
|
|
|
|
def filter_queryset(self, queryset):
|
|
queryset = super().filter_queryset(queryset)
|
|
search = self.request.query_params.get("search")
|
|
if search:
|
|
search = search.strip()
|
|
queryset = queryset.filter(
|
|
Q(title__icontains=search)
|
|
| Q(synopsis__icontains=search)
|
|
| Q(tags__icontains=search)
|
|
)
|
|
return queryset
|
|
|
|
def get_serializer_context(self):
|
|
context = super().get_serializer_context()
|
|
context["request"] = self.request
|
|
return context
|
|
|
|
def partial_update(self, request, *args, **kwargs):
|
|
allowed_fields = {
|
|
"title",
|
|
"synopsis",
|
|
"tagline",
|
|
"genres",
|
|
"cast",
|
|
"crew",
|
|
"tags",
|
|
"poster_url",
|
|
"backdrop_url",
|
|
"rating",
|
|
"runtime_ms",
|
|
"metadata",
|
|
"status",
|
|
}
|
|
unknown = {key for key in request.data.keys() if key not in allowed_fields}
|
|
if unknown:
|
|
raise ValidationError(
|
|
{"detail": f"Cannot update fields: {', '.join(sorted(unknown))}"}
|
|
)
|
|
return super().partial_update(request, *args, **kwargs)
|
|
|
|
@action(detail=True, methods=["post"], url_path="refresh-metadata")
|
|
def refresh_metadata(self, request, pk=None):
|
|
item = self.get_object()
|
|
sync_metadata_task.delay(item.id)
|
|
return Response({"status": "queued"}, status=status.HTTP_202_ACCEPTED)
|
|
|
|
@action(detail=True, methods=["get"], url_path="stream")
|
|
def stream(self, request, pk=None):
|
|
item = self.get_object()
|
|
file_id = request.query_params.get("file")
|
|
files_qs = item.files.all()
|
|
if file_id:
|
|
file = files_qs.filter(pk=file_id).first()
|
|
if not file:
|
|
raise NotFound("Requested media file not found")
|
|
else:
|
|
file = files_qs.order_by("id").first()
|
|
if not file:
|
|
return Response(
|
|
{"detail": "No media files available for this item."},
|
|
status=status.HTTP_404_NOT_FOUND,
|
|
)
|
|
|
|
payload = {"file_id": file.id, "user_id": request.user.id}
|
|
token = self._stream_signer.sign_object(payload)
|
|
stream_url = request.build_absolute_uri(
|
|
reverse("api:media:stream-file", args=[token])
|
|
)
|
|
ttl = getattr(settings, "MEDIA_LIBRARY_STREAM_TOKEN_TTL", 3600)
|
|
return Response(
|
|
{
|
|
"url": stream_url,
|
|
"file_id": file.id,
|
|
"expires_in": ttl,
|
|
"type": "direct",
|
|
"duration_ms": file.duration_ms,
|
|
"bit_rate": file.bit_rate,
|
|
"container": file.container,
|
|
}
|
|
)
|
|
|
|
@action(detail=True, methods=["post"], url_path="mark-watched")
|
|
def mark_watched(self, request, pk=None):
|
|
item = self.get_object()
|
|
duration = item.runtime_ms
|
|
if not duration:
|
|
primary_file = item.files.order_by("-duration_ms").first()
|
|
duration = primary_file.duration_ms if primary_file else 0
|
|
if not duration:
|
|
duration = 1000 # default to one second to allow completion state
|
|
|
|
progress, _ = models.WatchProgress.objects.update_or_create(
|
|
user=request.user,
|
|
media_item=item,
|
|
defaults={
|
|
"position_ms": duration or 0,
|
|
"duration_ms": duration or 0,
|
|
"completed": True,
|
|
},
|
|
)
|
|
progress.update_progress(position_ms=duration or 0, duration_ms=duration or 0)
|
|
return Response({"status": "ok"})
|
|
|
|
@action(detail=True, methods=["post"], url_path="mark-series-watched")
|
|
def mark_series_watched(self, request, pk=None):
|
|
item = self.get_object()
|
|
if item.item_type != models.MediaItem.TYPE_SHOW:
|
|
return Response(
|
|
{"detail": "Series-level actions are only available for shows."},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
episodes = item.children.filter(item_type=models.MediaItem.TYPE_EPISODE)
|
|
updated = 0
|
|
for episode in episodes:
|
|
duration = episode.runtime_ms
|
|
if not duration:
|
|
primary_file = episode.files.order_by("-duration_ms").first()
|
|
duration = primary_file.duration_ms if primary_file else 0
|
|
if not duration:
|
|
duration = 1000
|
|
models.WatchProgress.objects.update_or_create(
|
|
user=request.user,
|
|
media_item=episode,
|
|
defaults={
|
|
"position_ms": duration,
|
|
"duration_ms": duration,
|
|
"completed": True,
|
|
},
|
|
)
|
|
updated += 1
|
|
|
|
models.WatchProgress.objects.update_or_create(
|
|
user=request.user,
|
|
media_item=item,
|
|
defaults={
|
|
"position_ms": 0,
|
|
"duration_ms": item.runtime_ms or 0,
|
|
"completed": True,
|
|
},
|
|
)
|
|
|
|
serializer = self.get_serializer(item)
|
|
return Response({"updated": updated, "item": serializer.data})
|
|
|
|
@action(detail=True, methods=["post"], url_path="mark-series-unwatched")
|
|
def mark_series_unwatched(self, request, pk=None):
|
|
item = self.get_object()
|
|
if item.item_type != models.MediaItem.TYPE_SHOW:
|
|
return Response(
|
|
{"detail": "Series-level actions are only available for shows."},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
episodes = item.children.filter(item_type=models.MediaItem.TYPE_EPISODE)
|
|
cleared, _ = models.WatchProgress.objects.filter(
|
|
user=request.user,
|
|
media_item__in=episodes,
|
|
).delete()
|
|
models.WatchProgress.objects.filter(user=request.user, media_item=item).delete()
|
|
serializer = self.get_serializer(item)
|
|
return Response({"cleared": cleared, "item": serializer.data})
|
|
|
|
@action(detail=True, methods=["post"], url_path="clear-progress")
|
|
def clear_progress(self, request, pk=None):
|
|
item = self.get_object()
|
|
models.WatchProgress.objects.filter(user=request.user, media_item=item).delete()
|
|
return Response({"status": "cleared"})
|
|
|
|
|
|
class MediaFileViewSet(viewsets.ReadOnlyModelViewSet):
|
|
queryset = models.MediaFile.objects.select_related("library", "media_item", "location")
|
|
serializer_class = serializers.MediaFileSerializer
|
|
permission_classes = [Authenticated]
|
|
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
|
filterset_fields = ["library", "media_item", "location", "has_subtitles"]
|
|
search_fields = ["relative_path", "file_name", "absolute_path"]
|
|
ordering_fields = ["relative_path", "file_name", "size_bytes", "updated_at", "last_seen_at"]
|
|
ordering = ["relative_path"]
|
|
|
|
|
|
class WatchProgressViewSet(mixins.CreateModelMixin, mixins.UpdateModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet):
|
|
serializer_class = serializers.WatchProgressSerializer
|
|
permission_classes = [Authenticated]
|
|
|
|
def get_queryset(self):
|
|
queryset = models.WatchProgress.objects.select_related("media_item", "user")
|
|
user_only = self.request.query_params.get("mine", "true").lower() != "false"
|
|
if user_only:
|
|
queryset = queryset.filter(user=self.request.user)
|
|
return queryset
|
|
|
|
@action(detail=False, methods=["post"], url_path="set")
|
|
def set_progress(self, request):
|
|
serializer = self.get_serializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
progress = serializer.save()
|
|
progress.update_progress(
|
|
position_ms=serializer.validated_data.get("position_ms", 0),
|
|
duration_ms=serializer.validated_data.get("duration_ms"),
|
|
)
|
|
return Response(self.get_serializer(progress).data)
|
|
|
|
@action(detail=True, methods=["post"], url_path="resume")
|
|
def resume(self, request, pk=None):
|
|
progress = self.get_object()
|
|
if progress.duration_ms:
|
|
percentage = progress.position_ms / progress.duration_ms
|
|
else:
|
|
percentage = 0
|
|
remaining_ms = max(progress.duration_ms - progress.position_ms, 0)
|
|
data = {
|
|
"position_ms": progress.position_ms,
|
|
"duration_ms": progress.duration_ms,
|
|
"percentage": percentage,
|
|
"remaining_ms": remaining_ms,
|
|
"completed": progress.completed,
|
|
"resume_allowed": progress.duration_ms * 0.04 < remaining_ms,
|
|
}
|
|
return Response(data)
|