- Swagger/OpenAPI Migration: Migrated from drf-yasg (OpenAPI 2.0) to drf-spectacular (OpenAPI 3.0) for API documentation. This provides:

- Native Bearer token authentication support in Swagger UI - users can now enter just the JWT token and the "Bearer " prefix is automatically added
  - Modern OpenAPI 3.0 specification compliance
  - Better auto-generation of request/response schemas
  - Improved documentation accuracy with serializer introspection
This commit is contained in:
SergeantPanda 2026-01-27 13:33:33 -06:00
parent 8616933ad7
commit 5364123745
11 changed files with 290 additions and 479 deletions

View file

@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Changed
- Swagger/OpenAPI Migration: Migrated from `drf-yasg` (OpenAPI 2.0) to `drf-spectacular` (OpenAPI 3.0) for API documentation. This provides:
- Native Bearer token authentication support in Swagger UI - users can now enter just the JWT token and the "Bearer " prefix is automatically added
- Modern OpenAPI 3.0 specification compliance
- Better auto-generation of request/response schemas
- Improved documentation accuracy with serializer introspection
## [0.18.1] - 2026-01-27
### Fixed

View file

@ -4,9 +4,9 @@ from django.http import JsonResponse, HttpResponse
from django.views.decorators.csrf import csrf_exempt
from rest_framework.decorators import api_view, permission_classes, action
from rest_framework.response import Response
from rest_framework import viewsets, status
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from rest_framework import viewsets, status, serializers
from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer
from drf_spectacular.types import OpenApiTypes
import json
from .permissions import IsAdmin, Authenticated
from dispatcharr.utils import network_access_allowed
@ -147,19 +147,15 @@ class AuthViewSet(viewsets.ViewSet):
return [IsAuthenticated()]
return []
@swagger_auto_schema(
operation_description="Authenticate and log in a user",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=["username", "password"],
properties={
"username": openapi.Schema(type=openapi.TYPE_STRING),
"password": openapi.Schema(
type=openapi.TYPE_STRING, format=openapi.FORMAT_PASSWORD
),
@extend_schema(
description="Authenticate and log in a user",
request=inline_serializer(
name="LoginRequest",
fields={
"username": serializers.CharField(),
"password": serializers.CharField(),
},
),
responses={200: "Login successful", 400: "Invalid credentials"},
)
def login(self, request):
"""Logs in a user and returns user details"""
@ -209,9 +205,8 @@ class AuthViewSet(viewsets.ViewSet):
)
return Response({"error": "Invalid credentials"}, status=400)
@swagger_auto_schema(
operation_description="Log out the current user",
responses={200: "Logout successful"},
@extend_schema(
description="Log out the current user",
)
def logout(self, request):
"""Logs out the authenticated user"""
@ -245,32 +240,31 @@ class UserViewSet(viewsets.ModelViewSet):
return [IsAdmin()]
@swagger_auto_schema(
operation_description="Retrieve a list of users",
@extend_schema(
description="Retrieve a list of users",
responses={200: UserSerializer(many=True)},
)
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
@swagger_auto_schema(operation_description="Retrieve a specific user by ID")
@extend_schema(description="Retrieve a specific user by ID")
def retrieve(self, request, *args, **kwargs):
return super().retrieve(request, *args, **kwargs)
@swagger_auto_schema(operation_description="Create a new user")
@extend_schema(description="Create a new user")
def create(self, request, *args, **kwargs):
return super().create(request, *args, **kwargs)
@swagger_auto_schema(operation_description="Update a user")
@extend_schema(description="Update a user")
def update(self, request, *args, **kwargs):
return super().update(request, *args, **kwargs)
@swagger_auto_schema(operation_description="Delete a user")
@extend_schema(description="Delete a user")
def destroy(self, request, *args, **kwargs):
return super().destroy(request, *args, **kwargs)
@swagger_auto_schema(
method="get",
operation_description="Get active user information",
@extend_schema(
description="Get active user information",
)
@action(detail=False, methods=["get"], url_path="me")
def me(self, request):
@ -287,34 +281,33 @@ class GroupViewSet(viewsets.ModelViewSet):
serializer_class = GroupSerializer
permission_classes = [Authenticated]
@swagger_auto_schema(
operation_description="Retrieve a list of groups",
@extend_schema(
description="Retrieve a list of groups",
responses={200: GroupSerializer(many=True)},
)
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
@swagger_auto_schema(operation_description="Retrieve a specific group by ID")
@extend_schema(description="Retrieve a specific group by ID")
def retrieve(self, request, *args, **kwargs):
return super().retrieve(request, *args, **kwargs)
@swagger_auto_schema(operation_description="Create a new group")
@extend_schema(description="Create a new group")
def create(self, request, *args, **kwargs):
return super().create(request, *args, **kwargs)
@swagger_auto_schema(operation_description="Update a group")
@extend_schema(description="Update a group")
def update(self, request, *args, **kwargs):
return super().update(request, *args, **kwargs)
@swagger_auto_schema(operation_description="Delete a group")
@extend_schema(description="Delete a group")
def destroy(self, request, *args, **kwargs):
return super().destroy(request, *args, **kwargs)
# 🔹 4) Permissions List API
@swagger_auto_schema(
method="get",
operation_description="Retrieve a list of all permissions",
@extend_schema(
description="Retrieve a list of all permissions",
responses={200: PermissionSerializer(many=True)},
)
@api_view(["GET"])

View file

@ -1,23 +1,8 @@
from django.urls import path, include, re_path
from drf_yasg.views import get_schema_view
from drf_yasg import openapi
from rest_framework.permissions import AllowAny
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView
app_name = 'api'
schema_view = get_schema_view(
openapi.Info(
title="Dispatcharr API",
default_version='v1',
description="API documentation for Dispatcharr",
terms_of_service="https://www.google.com/policies/terms/",
contact=openapi.Contact(email="support@dispatcharr.local"),
license=openapi.License(name="Unlicense"),
),
public=True,
permission_classes=(AllowAny,),
)
urlpatterns = [
path('accounts/', include(('apps.accounts.api_urls', 'accounts'), namespace='accounts')),
path('channels/', include(('apps.channels.api_urls', 'channels'), namespace='channels')),
@ -35,8 +20,9 @@ urlpatterns = [
# Swagger Documentation api_urls
re_path(r'^swagger/?$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
path('redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'),
path('swagger.json', schema_view.without_ui(cache_timeout=0), name='schema-json'),
# OpenAPI Schema and Documentation (drf-spectacular)
path('schema/', SpectacularAPIView.as_view(), name='schema'),
re_path(r'^swagger/?$', SpectacularSwaggerView.as_view(url_name='api:schema'), name='swagger-ui'),
path('redoc/', SpectacularRedocView.as_view(url_name='api:schema'), name='redoc'),
path('swagger.json', SpectacularAPIView.as_view(), name='schema-json'),
]

View file

@ -4,8 +4,9 @@ from rest_framework.views import APIView
from rest_framework.permissions import AllowAny
from rest_framework.decorators import action
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer
from drf_spectacular.types import OpenApiTypes
from rest_framework import serializers
from django.shortcuts import get_object_or_404, get_list_or_404
from django.db import transaction
from django.db.models import Count, F
@ -269,17 +270,15 @@ class StreamViewSet(viewsets.ModelViewSet):
]
})
@swagger_auto_schema(
method="post",
operation_description="Retrieve streams by a list of IDs using POST to avoid URL length limitations",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=["ids"],
properties={
"ids": openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Items(type=openapi.TYPE_INTEGER),
description="List of stream IDs to retrieve"
@extend_schema(
methods=["POST"],
description="Retrieve streams by a list of IDs using POST to avoid URL length limitations",
request=inline_serializer(
name="StreamByIdsRequest",
fields={
"ids": serializers.ListField(
child=serializers.IntegerField(),
help_text="List of stream IDs to retrieve"
),
},
),
@ -342,10 +341,9 @@ class ChannelGroupViewSet(viewsets.ModelViewSet):
return super().partial_update(request, *args, **kwargs)
@swagger_auto_schema(
method="post",
operation_description="Delete all channel groups that have no associations (no channels or M3U accounts)",
responses={200: "Cleanup completed"},
@extend_schema(
methods=["POST"],
description="Delete all channel groups that have no associations (no channels or M3U accounts)",
)
@action(detail=False, methods=["post"], url_path="cleanup")
def cleanup_unused_groups(self, request):
@ -831,25 +829,22 @@ class ChannelViewSet(viewsets.ModelViewSet):
# Return the response with the list of IDs
return Response(list(channel_ids))
@swagger_auto_schema(
method="post",
operation_description="Auto-assign channel_number in bulk by an ordered list of channel IDs.",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=["channel_ids"],
properties={
"starting_number": openapi.Schema(
type=openapi.TYPE_NUMBER,
description="Starting channel number to assign (can be decimal)",
@extend_schema(
methods=["POST"],
description="Auto-assign channel_number in bulk by an ordered list of channel IDs.",
request=inline_serializer(
name="AssignChannelsRequest",
fields={
"starting_number": serializers.FloatField(
help_text="Starting channel number to assign (can be decimal)",
required=False,
),
"channel_ids": openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Items(type=openapi.TYPE_INTEGER),
description="Channel IDs to assign",
"channel_ids": serializers.ListField(
child=serializers.IntegerField(),
help_text="Channel IDs to assign",
),
},
),
responses={200: "Channels have been auto-assigned!"},
)
@action(detail=False, methods=["post"], url_path="assign")
def assign(self, request):
@ -869,33 +864,28 @@ class ChannelViewSet(viewsets.ModelViewSet):
{"message": "Channels have been auto-assigned!"}, status=status.HTTP_200_OK
)
@swagger_auto_schema(
method="post",
operation_description=(
@extend_schema(
methods=["POST"],
description=(
"Create a new channel from an existing stream. "
"If 'channel_number' is provided, it will be used (if available); "
"otherwise, the next available channel number is assigned. "
"If 'channel_profile_ids' is provided, the channel will only be added to those profiles. "
"Accepts either a single ID or an array of IDs."
),
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=["stream_id"],
properties={
"stream_id": openapi.Schema(
type=openapi.TYPE_INTEGER, description="ID of the stream to link"
request=inline_serializer(
name="FromStreamRequest",
fields={
"stream_id": serializers.IntegerField(help_text="ID of the stream to link"),
"channel_number": serializers.FloatField(
help_text="(Optional) Desired channel number. Must not be in use.",
required=False,
),
"channel_number": openapi.Schema(
type=openapi.TYPE_NUMBER,
description="(Optional) Desired channel number. Must not be in use.",
),
"name": openapi.Schema(
type=openapi.TYPE_STRING, description="Desired channel name"
),
"channel_profile_ids": openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Items(type=openapi.TYPE_INTEGER),
description="(Optional) Channel profile ID(s). Behavior: omitted = add to ALL profiles (default); empty array [] = add to NO profiles; [0] = add to ALL profiles (explicit); [1,2,...] = add only to specified profiles."
"name": serializers.CharField(help_text="Desired channel name", required=False),
"channel_profile_ids": serializers.ListField(
child=serializers.IntegerField(),
help_text="(Optional) Channel profile ID(s). Behavior: omitted = add to ALL profiles (default); empty array [] = add to NO profiles; [0] = add to ALL profiles (explicit); [1,2,...] = add only to specified profiles.",
required=False,
),
},
),
@ -1055,34 +1045,31 @@ class ChannelViewSet(viewsets.ModelViewSet):
return Response(serializer.data, status=status.HTTP_201_CREATED)
@swagger_auto_schema(
method="post",
operation_description=(
@extend_schema(
methods=["POST"],
description=(
"Asynchronously bulk create channels from stream IDs. "
"Returns a task ID to track progress via WebSocket. "
"This is the recommended approach for large bulk operations."
),
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=["stream_ids"],
properties={
"stream_ids": openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Items(type=openapi.TYPE_INTEGER),
description="List of stream IDs to create channels from"
request=inline_serializer(
name="FromStreamBulkRequest",
fields={
"stream_ids": serializers.ListField(
child=serializers.IntegerField(),
help_text="List of stream IDs to create channels from"
),
"channel_profile_ids": openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Items(type=openapi.TYPE_INTEGER),
description="(Optional) Channel profile ID(s). Behavior: omitted = add to ALL profiles (default); empty array [] = add to NO profiles; [0] = add to ALL profiles (explicit); [1,2,...] = add only to specified profiles."
"channel_profile_ids": serializers.ListField(
child=serializers.IntegerField(),
help_text="(Optional) Channel profile ID(s). Behavior: omitted = add to ALL profiles (default); empty array [] = add to NO profiles; [0] = add to ALL profiles (explicit); [1,2,...] = add only to specified profiles.",
required=False,
),
"starting_channel_number": openapi.Schema(
type=openapi.TYPE_INTEGER,
description="(Optional) Starting channel number mode: null=use provider numbers, 0=lowest available, other=start from specified number"
"starting_channel_number": serializers.IntegerField(
help_text="(Optional) Starting channel number mode: null=use provider numbers, 0=lowest available, other=start from specified number",
required=False,
),
},
),
responses={202: "Task started successfully"},
)
@action(detail=False, methods=["post"], url_path="from-stream/bulk")
def from_stream_bulk(self, request):
@ -1122,20 +1109,19 @@ class ChannelViewSet(viewsets.ModelViewSet):
# ─────────────────────────────────────────────────────────
# 6) EPG Fuzzy Matching
# ─────────────────────────────────────────────────────────
@swagger_auto_schema(
method="post",
operation_description="Kick off a Celery task that tries to fuzzy-match channels with EPG data. If channel_ids are provided, only those channels will be processed.",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'channel_ids': openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Schema(type=openapi.TYPE_INTEGER),
description='List of channel IDs to process. If empty or not provided, all channels without EPG will be processed.'
@extend_schema(
methods=["POST"],
description="Kick off a Celery task that tries to fuzzy-match channels with EPG data. If channel_ids are provided, only those channels will be processed.",
request=inline_serializer(
name="MatchEpgRequest",
fields={
'channel_ids': serializers.ListField(
child=serializers.IntegerField(),
help_text='List of channel IDs to process. If empty or not provided, all channels without EPG will be processed.',
required=False,
)
}
),
responses={202: "EPG matching task initiated"},
)
@action(detail=False, methods=["post"], url_path="match-epg")
def match_epg(self, request):
@ -1156,10 +1142,9 @@ class ChannelViewSet(viewsets.ModelViewSet):
{"message": message}, status=status.HTTP_202_ACCEPTED
)
@swagger_auto_schema(
method="post",
operation_description="Try to auto-match this specific channel with EPG data.",
responses={200: "EPG matching completed", 202: "EPG matching task initiated"},
@extend_schema(
methods=["POST"],
description="Try to auto-match this specific channel with EPG data.",
)
@action(detail=True, methods=["post"], url_path="match-epg")
def match_channel_epg(self, request, pk=None):
@ -1186,16 +1171,13 @@ class ChannelViewSet(viewsets.ModelViewSet):
# ─────────────────────────────────────────────────────────
# 7) Set EPG and Refresh
# ─────────────────────────────────────────────────────────
@swagger_auto_schema(
method="post",
operation_description="Set EPG data for a channel and refresh program data",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=["epg_data_id"],
properties={
"epg_data_id": openapi.Schema(
type=openapi.TYPE_INTEGER, description="EPG data ID to link"
)
@extend_schema(
methods=["POST"],
description="Set EPG data for a channel and refresh program data",
request=inline_serializer(
name="SetEpgRequest",
fields={
"epg_data_id": serializers.IntegerField(help_text="EPG data ID to link")
},
),
responses={200: "EPG data linked and refresh triggered"},
@ -1251,28 +1233,22 @@ class ChannelViewSet(viewsets.ModelViewSet):
except Exception as e:
return Response({"error": str(e)}, status=400)
@swagger_auto_schema(
method="post",
operation_description=(
@extend_schema(
description=(
"Reorder a channel by moving it after another channel (or to the start if insert_after_id is null). "
"The channel will receive the next whole number after the target channel, and all subsequent "
"channels will be renumbered accordingly."
),
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
"insert_after_id": openapi.Schema(
type=openapi.TYPE_INTEGER,
description="ID of the channel to insert after. Use null to move to the beginning.",
nullable=True,
request=inline_serializer(
name="ReorderChannelRequest",
fields={
"insert_after_id": serializers.IntegerField(
help_text="ID of the channel to insert after. Use null to move to the beginning.",
required=False,
allow_null=True,
),
},
),
responses={
200: "Channel reordered successfully",
404: "Channel not found",
400: "Invalid request",
},
)
@action(detail=True, methods=["post"], url_path="reorder")
def reorder(self, request, pk=None):
@ -1340,25 +1316,23 @@ class ChannelViewSet(viewsets.ModelViewSet):
status=status.HTTP_200_OK,
)
@swagger_auto_schema(
method="post",
operation_description="Associate multiple channels with EPG data without triggering a full refresh",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
"associations": openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
"channel_id": openapi.Schema(type=openapi.TYPE_INTEGER),
"epg_data_id": openapi.Schema(type=openapi.TYPE_INTEGER),
@extend_schema(
methods=["POST"],
description="Associate multiple channels with EPG data without triggering a full refresh",
request=inline_serializer(
name="BatchSetEpgRequest",
fields={
"associations": serializers.ListField(
child=inline_serializer(
name="EpgAssociation",
fields={
"channel_id": serializers.IntegerField(),
"epg_data_id": serializers.IntegerField(),
},
),
)
},
),
responses={200: "EPG data linked for multiple channels"},
)
@action(detail=False, methods=["post"], url_path="batch-set-epg")
def batch_set_epg(self, request):
@ -1456,20 +1430,17 @@ class BulkDeleteStreamsAPIView(APIView):
except KeyError:
return [Authenticated()]
@swagger_auto_schema(
operation_description="Bulk delete streams by ID",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=["stream_ids"],
properties={
"stream_ids": openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Items(type=openapi.TYPE_INTEGER),
description="Stream IDs to delete",
@extend_schema(
description="Bulk delete streams by ID",
request=inline_serializer(
name="BulkDeleteStreamsRequest",
fields={
"stream_ids": serializers.ListField(
child=serializers.IntegerField(),
help_text="Stream IDs to delete",
)
},
),
responses={204: "Streams deleted"},
)
def delete(self, request, *args, **kwargs):
stream_ids = request.data.get("stream_ids", [])
@ -1492,20 +1463,17 @@ class BulkDeleteChannelsAPIView(APIView):
except KeyError:
return [Authenticated()]
@swagger_auto_schema(
operation_description="Bulk delete channels by ID",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=["channel_ids"],
properties={
"channel_ids": openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Items(type=openapi.TYPE_INTEGER),
description="Channel IDs to delete",
@extend_schema(
description="Bulk delete channels by ID",
request=inline_serializer(
name="BulkDeleteChannelsRequest",
fields={
"channel_ids": serializers.ListField(
child=serializers.IntegerField(),
help_text="Channel IDs to delete",
)
},
),
responses={204: "Channels deleted"},
)
def delete(self, request):
channel_ids = request.data.get("channel_ids", [])
@ -1527,20 +1495,17 @@ class BulkDeleteLogosAPIView(APIView):
except KeyError:
return [Authenticated()]
@swagger_auto_schema(
operation_description="Bulk delete logos by ID",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=["logo_ids"],
properties={
"logo_ids": openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Items(type=openapi.TYPE_INTEGER),
description="Logo IDs to delete",
@extend_schema(
description="Bulk delete logos by ID",
request=inline_serializer(
name="BulkDeleteLogosRequest",
fields={
"logo_ids": serializers.ListField(
child=serializers.IntegerField(),
help_text="Logo IDs to delete",
)
},
),
responses={204: "Logos deleted"},
)
def delete(self, request):
logo_ids = request.data.get("logo_ids", [])
@ -1597,19 +1562,18 @@ class CleanupUnusedLogosAPIView(APIView):
except KeyError:
return [Authenticated()]
@swagger_auto_schema(
operation_description="Delete all channel logos that are not used by any channels",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
"delete_files": openapi.Schema(
type=openapi.TYPE_BOOLEAN,
description="Whether to delete local logo files from disk",
default=False
@extend_schema(
description="Delete all channel logos that are not used by any channels",
request=inline_serializer(
name="CleanupUnusedLogosRequest",
fields={
"delete_files": serializers.BooleanField(
help_text="Whether to delete local logo files from disk",
default=False,
required=False,
)
},
),
responses={200: "Cleanup completed"},
)
def post(self, request):
"""Delete all channel logos with no channel associations"""
@ -2019,29 +1983,9 @@ class BulkUpdateChannelMembershipAPIView(APIView):
except KeyError:
return [Authenticated()]
@swagger_auto_schema(
operation_description="Bulk enable or disable channels for a specific profile. Creates membership records if they don't exist.",
request_body=BulkChannelProfileMembershipSerializer,
responses={
200: openapi.Response(
description="Channels updated successfully",
schema=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
"status": openapi.Schema(type=openapi.TYPE_STRING, example="success"),
"updated": openapi.Schema(type=openapi.TYPE_INTEGER, description="Number of channels updated"),
"created": openapi.Schema(type=openapi.TYPE_INTEGER, description="Number of new memberships created"),
"invalid_channels": openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Schema(type=openapi.TYPE_INTEGER),
description="List of channel IDs that don't exist"
),
},
),
),
400: "Invalid request data",
404: "Profile not found",
},
@extend_schema(
description="Bulk enable or disable channels for a specific profile. Creates membership records if they don't exist.",
request=BulkChannelProfileMembershipSerializer,
)
def patch(self, request, profile_id):
"""Bulk enable or disable channels for a specific profile"""
@ -2406,71 +2350,24 @@ class SeriesRulesAPIView(APIView):
except KeyError:
return [Authenticated()]
@swagger_auto_schema(
operation_summary="List all series rules",
operation_description="Retrieve all configured DVR series recording rules.",
responses={
200: openapi.Response(
description="List of series rules",
schema=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'rules': openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'tvg_id': openapi.Schema(type=openapi.TYPE_STRING, description='Channel TVG ID'),
'mode': openapi.Schema(type=openapi.TYPE_STRING, enum=['all', 'new'], description='Recording mode: all episodes or new only'),
'title': openapi.Schema(type=openapi.TYPE_STRING, description='Series title'),
},
),
description='List of series recording rules'
),
},
),
),
},
@extend_schema(
summary="List all series rules",
description="Retrieve all configured DVR series recording rules.",
)
def get(self, request):
return Response({"rules": CoreSettings.get_dvr_series_rules()})
@swagger_auto_schema(
operation_summary="Create or update a series rule",
operation_description="Add a new series recording rule or update an existing one. Rules will be evaluated immediately to find matching episodes.",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=['tvg_id'],
properties={
'tvg_id': openapi.Schema(type=openapi.TYPE_STRING, description='Channel TVG ID'),
'mode': openapi.Schema(type=openapi.TYPE_STRING, enum=['all', 'new'], default='all', description='all: record all episodes, new: record only new episodes'),
'title': openapi.Schema(type=openapi.TYPE_STRING, description='Series title'),
@extend_schema(
summary="Create or update a series rule",
description="Add a new series recording rule or update an existing one. Rules will be evaluated immediately to find matching episodes.",
request=inline_serializer(
name="SeriesRuleRequest",
fields={
'tvg_id': serializers.CharField(help_text='Channel TVG ID'),
'mode': serializers.ChoiceField(choices=['all', 'new'], default='all', help_text='all: record all episodes, new: record only new episodes'),
'title': serializers.CharField(help_text='Series title', required=False),
},
),
responses={
200: openapi.Response(
description="Series rule created/updated successfully",
schema=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'success': openapi.Schema(type=openapi.TYPE_BOOLEAN),
'rules': openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'tvg_id': openapi.Schema(type=openapi.TYPE_STRING),
'mode': openapi.Schema(type=openapi.TYPE_STRING),
'title': openapi.Schema(type=openapi.TYPE_STRING),
},
),
description='Updated list of all rules'
),
},
),
),
400: openapi.Response(description="Bad request (missing tvg_id or invalid mode)"),
},
)
def post(self, request):
data = request.data or {}
@ -2504,35 +2401,12 @@ class DeleteSeriesRuleAPIView(APIView):
except KeyError:
return [Authenticated()]
@swagger_auto_schema(
operation_summary="Delete a series rule",
operation_description="Remove a series recording rule by TVG ID. This does not remove already scheduled recordings.",
manual_parameters=[
openapi.Parameter('tvg_id', openapi.IN_PATH, type=openapi.TYPE_STRING, required=True, description='Channel TVG ID'),
@extend_schema(
summary="Delete a series rule",
description="Remove a series recording rule by TVG ID. This does not remove already scheduled recordings.",
parameters=[
OpenApiParameter('tvg_id', str, OpenApiParameter.PATH, required=True, description='Channel TVG ID'),
],
responses={
200: openapi.Response(
description="Series rule deleted successfully",
schema=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'success': openapi.Schema(type=openapi.TYPE_BOOLEAN),
'rules': openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'tvg_id': openapi.Schema(type=openapi.TYPE_STRING),
'mode': openapi.Schema(type=openapi.TYPE_STRING),
'title': openapi.Schema(type=openapi.TYPE_STRING),
},
),
description='Updated list of all rules'
),
},
),
),
},
)
def delete(self, request, tvg_id):
tvg_id = unquote(str(tvg_id or ""))
@ -2548,26 +2422,15 @@ class EvaluateSeriesRulesAPIView(APIView):
except KeyError:
return [Authenticated()]
@swagger_auto_schema(
operation_summary="Evaluate series rules",
operation_description="Trigger evaluation of series recording rules to find and schedule matching episodes. Can evaluate all rules or a specific channel.",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'tvg_id': openapi.Schema(type=openapi.TYPE_STRING, description='Optional: evaluate only rules for this channel TVG ID. If omitted, all rules are evaluated.'),
@extend_schema(
summary="Evaluate series rules",
description="Trigger evaluation of series recording rules to find and schedule matching episodes. Can evaluate all rules or a specific channel.",
request=inline_serializer(
name="EvaluateSeriesRulesRequest",
fields={
"tvg_id": serializers.CharField(required=False, help_text="Optional: evaluate only rules for this channel TVG ID. If omitted, all rules are evaluated."),
},
),
responses={
200: openapi.Response(
description="Evaluation completed successfully",
schema=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'success': openapi.Schema(type=openapi.TYPE_BOOLEAN),
},
),
),
},
)
def post(self, request):
tvg_id = request.data.get("tvg_id")
@ -2590,31 +2453,17 @@ class BulkRemoveSeriesRecordingsAPIView(APIView):
except KeyError:
return [Authenticated()]
@swagger_auto_schema(
operation_summary="Bulk remove scheduled recordings for a series",
operation_description="Delete future scheduled recordings for a series rule. Useful for stopping a rule without losing the configuration. Matches by channel and optionally by series title.",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=['tvg_id'],
properties={
'tvg_id': openapi.Schema(type=openapi.TYPE_STRING, description='Channel TVG ID (required)'),
'title': openapi.Schema(type=openapi.TYPE_STRING, description='Series title - when scope=title, only recordings matching this title are removed'),
'scope': openapi.Schema(type=openapi.TYPE_STRING, enum=['title', 'channel'], default='title', description='title: remove only matching title on channel, channel: remove all future recordings on channel'),
@extend_schema(
summary="Bulk remove scheduled recordings for a series",
description="Delete future scheduled recordings for a series rule. Useful for stopping a rule without losing the configuration. Matches by channel and optionally by series title.",
request=inline_serializer(
name="BulkRemoveSeriesRecordingsRequest",
fields={
"tvg_id": serializers.CharField(required=True, help_text="Channel TVG ID (required)"),
"title": serializers.CharField(required=False, help_text="Series title - when scope=title, only recordings matching this title are removed"),
"scope": serializers.ChoiceField(choices=["title", "channel"], default="title", required=False, help_text="title: remove only matching title on channel, channel: remove all future recordings on channel"),
},
),
responses={
200: openapi.Response(
description="Recordings removed successfully",
schema=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'success': openapi.Schema(type=openapi.TYPE_BOOLEAN),
'removed': openapi.Schema(type=openapi.TYPE_INTEGER, description='Number of recordings deleted'),
},
),
),
400: openapi.Response(description="Bad request (missing tvg_id)"),
},
)
def post(self, request):
from django.utils import timezone

View file

@ -1,10 +1,10 @@
import logging, os
from rest_framework import viewsets, status
from rest_framework import viewsets, status, serializers
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.decorators import action
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer
from drf_spectacular.types import OpenApiTypes
from django.utils import timezone
from datetime import timedelta
from .models import EPGSource, ProgramData, EPGData # Added ProgramData
@ -122,8 +122,8 @@ class EPGGridAPIView(APIView):
except KeyError:
return [Authenticated()]
@swagger_auto_schema(
operation_description="Retrieve programs from the previous hour, currently running and upcoming for the next 24 hours",
@extend_schema(
description="Retrieve programs from the previous hour, currently running and upcoming for the next 24 hours",
responses={200: ProgramDataSerializer(many=True)},
)
def get(self, request, format=None):
@ -371,9 +371,8 @@ class EPGImportAPIView(APIView):
except KeyError:
return [Authenticated()]
@swagger_auto_schema(
operation_description="Triggers an EPG data import",
responses={202: "EPG data import initiated"},
@extend_schema(
description="Triggers an EPG data import",
)
def post(self, request, format=None):
logger.info("EPGImportAPIView: Received request to import EPG data.")
@ -435,20 +434,20 @@ class CurrentProgramsAPIView(APIView):
except KeyError:
return [Authenticated()]
@swagger_auto_schema(
operation_description="Get currently playing programs for specified channels or all channels",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'channel_ids': openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Schema(type=openapi.TYPE_INTEGER),
description="Array of channel IDs. If null or omitted, returns all channels with current programs.",
nullable=True,
)
@extend_schema(
description="Get currently playing programs for specified channels or all channels",
request=inline_serializer(
name="CurrentProgramsRequest",
fields={
"channel_ids": serializers.ListField(
child=serializers.IntegerField(),
required=False,
allow_null=True,
help_text="Array of channel IDs. If null or omitted, returns all channels with current programs.",
),
},
),
responses={200: openapi.Response('Current programs', ProgramDataSerializer(many=True))},
responses={200: ProgramDataSerializer(many=True)},
)
def post(self, request, format=None):
# Get channel IDs from request body

View file

@ -4,8 +4,8 @@ from rest_framework.views import APIView
from apps.accounts.permissions import Authenticated, permission_classes_by_action
from django.http import JsonResponse, HttpResponseForbidden, HttpResponse
import logging
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
from django.shortcuts import get_object_or_404
from django.db import models
from apps.channels.models import Channel, ChannelProfile, Stream
@ -47,9 +47,8 @@ class HDHRDeviceViewSet(viewsets.ModelViewSet):
class DiscoverAPIView(APIView):
"""Returns device discovery information"""
@swagger_auto_schema(
operation_description="Retrieve HDHomeRun device discovery information",
responses={200: openapi.Response("HDHR Discovery JSON")},
@extend_schema(
description="Retrieve HDHomeRun device discovery information",
)
def get(self, request, profile=None):
uri_parts = ["hdhr"]
@ -100,9 +99,8 @@ class DiscoverAPIView(APIView):
class LineupAPIView(APIView):
"""Returns available channel lineup"""
@swagger_auto_schema(
operation_description="Retrieve the available channel lineup",
responses={200: openapi.Response("Channel Lineup JSON")},
@extend_schema(
description="Retrieve the available channel lineup",
)
def get(self, request, profile=None):
if profile is not None:
@ -141,9 +139,8 @@ class LineupAPIView(APIView):
class LineupStatusAPIView(APIView):
"""Returns the current status of the HDHR lineup"""
@swagger_auto_schema(
operation_description="Retrieve the HDHomeRun lineup status",
responses={200: openapi.Response("Lineup Status JSON")},
@extend_schema(
description="Retrieve the HDHomeRun lineup status",
)
def get(self, request, profile=None):
data = {
@ -159,9 +156,8 @@ class LineupStatusAPIView(APIView):
class HDHRDeviceXMLAPIView(APIView):
"""Returns HDHomeRun device configuration in XML"""
@swagger_auto_schema(
operation_description="Retrieve the HDHomeRun device XML configuration",
responses={200: openapi.Response("HDHR Device XML")},
@extend_schema(
description="Retrieve the HDHomeRun device XML configuration",
)
def get(self, request):
base_url = request.build_absolute_uri("/hdhr/").rstrip("/")

View file

@ -3,8 +3,8 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from apps.accounts.permissions import Authenticated, permission_classes_by_action
from django.http import JsonResponse, HttpResponseForbidden, HttpResponse
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
from django.shortcuts import get_object_or_404
from apps.channels.models import Channel
from .models import HDHRDevice
@ -42,9 +42,8 @@ class HDHRDeviceViewSet(viewsets.ModelViewSet):
class DiscoverAPIView(APIView):
"""Returns device discovery information"""
@swagger_auto_schema(
operation_description="Retrieve HDHomeRun device discovery information",
responses={200: openapi.Response("HDHR Discovery JSON")},
@extend_schema(
description="Retrieve HDHomeRun device discovery information",
)
def get(self, request):
base_url = request.build_absolute_uri("/hdhr/").rstrip("/")
@ -81,9 +80,8 @@ class DiscoverAPIView(APIView):
class LineupAPIView(APIView):
"""Returns available channel lineup"""
@swagger_auto_schema(
operation_description="Retrieve the available channel lineup",
responses={200: openapi.Response("Channel Lineup JSON")},
@extend_schema(
description="Retrieve the available channel lineup",
)
def get(self, request):
channels = Channel.objects.all().order_by("channel_number")
@ -102,9 +100,8 @@ class LineupAPIView(APIView):
class LineupStatusAPIView(APIView):
"""Returns the current status of the HDHR lineup"""
@swagger_auto_schema(
operation_description="Retrieve the HDHomeRun lineup status",
responses={200: openapi.Response("Lineup Status JSON")},
@extend_schema(
description="Retrieve the HDHomeRun lineup status",
)
def get(self, request):
data = {
@ -120,9 +117,8 @@ class LineupStatusAPIView(APIView):
class HDHRDeviceXMLAPIView(APIView):
"""Returns HDHomeRun device configuration in XML"""
@swagger_auto_schema(
operation_description="Retrieve the HDHomeRun device XML configuration",
responses={200: openapi.Response("HDHR Device XML")},
@extend_schema(
description="Retrieve the HDHomeRun device XML configuration",
)
def get(self, request):
base_url = request.build_absolute_uri("/hdhr/").rstrip("/")

View file

@ -6,8 +6,8 @@ from apps.accounts.permissions import (
permission_classes_by_action,
permission_classes_by_method,
)
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
from django.shortcuts import get_object_or_404
from django.http import JsonResponse
from django.core.cache import cache
@ -357,9 +357,8 @@ class RefreshM3UAPIView(APIView):
except KeyError:
return [Authenticated()]
@swagger_auto_schema(
operation_description="Triggers a refresh of all active M3U accounts",
responses={202: "M3U refresh initiated"},
@extend_schema(
description="Triggers a refresh of all active M3U accounts",
)
def post(self, request, format=None):
refresh_m3u_accounts.delay()
@ -380,9 +379,8 @@ class RefreshSingleM3UAPIView(APIView):
except KeyError:
return [Authenticated()]
@swagger_auto_schema(
operation_description="Triggers a refresh of a single M3U account",
responses={202: "M3U account refresh initiated"},
@extend_schema(
description="Triggers a refresh of a single M3U account",
)
def post(self, request, account_id, format=None):
refresh_single_m3u_account.delay(account_id)
@ -406,9 +404,8 @@ class RefreshAccountInfoAPIView(APIView):
except KeyError:
return [Authenticated()]
@swagger_auto_schema(
operation_description="Triggers a refresh of account information for a specific M3U profile",
responses={202: "Account info refresh initiated", 400: "Profile not found or not XtreamCodes"},
@extend_schema(
description="Triggers a refresh of account information for a specific M3U profile",
)
def post(self, request, profile_id, format=None):
try:

View file

@ -9,8 +9,8 @@ from rest_framework.views import APIView
from django.shortcuts import get_object_or_404
from rest_framework.permissions import IsAuthenticated
from rest_framework.decorators import api_view, permission_classes, action
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
from .models import (
UserAgent,
StreamProfile,
@ -241,10 +241,8 @@ class ProxySettingsViewSet(viewsets.ViewSet):
@swagger_auto_schema(
method="get",
operation_description="Endpoint for environment details",
responses={200: "Environment variables"},
@extend_schema(
description="Endpoint for environment details",
)
@api_view(["GET"])
@permission_classes([Authenticated])
@ -314,10 +312,8 @@ def environment(request):
)
@swagger_auto_schema(
method="get",
operation_description="Get application version information",
responses={200: "Version information"},
@extend_schema(
description="Get application version information",
)
@api_view(["GET"])
@ -333,10 +329,8 @@ def version(request):
)
@swagger_auto_schema(
method="post",
operation_description="Trigger rehashing of all streams",
responses={200: "Rehash task started"},
@extend_schema(
description="Trigger rehashing of all streams",
)
@api_view(["POST"])
@permission_classes([Authenticated])
@ -383,9 +377,8 @@ class TimezoneListView(APIView):
def get_permissions(self):
return [Authenticated()]
@swagger_auto_schema(
operation_description="Get list of all supported timezones",
responses={200: openapi.Response('List of timezones with grouping by region')}
@extend_schema(
description="Get list of all supported timezones",
)
def get(self, request):
import pytz

View file

@ -32,7 +32,7 @@ INSTALLED_APPS = [
"apps.vod.apps.VODConfig",
"core",
"daphne",
"drf_yasg",
"drf_spectacular",
"channels",
"django.contrib.admin",
"django.contrib.auth",
@ -151,7 +151,7 @@ AUTH_PASSWORD_VALIDATORS = [
]
REST_FRAMEWORK = {
"DEFAULT_SCHEMA_CLASS": "rest_framework.schemas.coreapi.AutoSchema",
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
"DEFAULT_RENDERER_CLASSES": [
"rest_framework.renderers.JSONRenderer",
"rest_framework.renderers.BrowsableAPIRenderer",
@ -162,10 +162,22 @@ REST_FRAMEWORK = {
"DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
}
SWAGGER_SETTINGS = {
"SECURITY_DEFINITIONS": {
"Bearer": {"type": "apiKey", "name": "Authorization", "in": "header"}
}
SPECTACULAR_SETTINGS = {
"TITLE": "Dispatcharr API",
"DESCRIPTION": "API documentation for Dispatcharr",
"VERSION": "1.0.0",
"SERVE_INCLUDE_SCHEMA": False,
"SECURITY": [{"BearerAuth": []}],
"COMPONENTS": {
"securitySchemes": {
"BearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT",
"description": "Enter your JWT access token. The 'Bearer ' prefix is added automatically.",
}
}
},
}
LANGUAGE_CODE = "en-us"

View file

@ -3,32 +3,20 @@ from django.urls import path, include, re_path
from django.conf import settings
from django.conf.urls.static import static
from django.views.generic import TemplateView, RedirectView
from rest_framework import permissions
from drf_yasg.views import get_schema_view
from drf_yasg import openapi
from .routing import websocket_urlpatterns
from apps.output.views import xc_player_api, xc_panel_api, xc_get, xc_xmltv
from apps.proxy.ts_proxy.views import stream_xc
from apps.output.views import xc_movie_stream, xc_series_stream
# Define schema_view for Swagger
schema_view = get_schema_view(
openapi.Info(
title="Dispatcharr API",
default_version="v1",
description="API documentation for Dispatcharr",
terms_of_service="https://www.google.com/policies/terms/",
contact=openapi.Contact(email="contact@dispatcharr.local"),
license=openapi.License(name="Creative Commons by-nc-sa"),
),
public=True,
permission_classes=(permissions.AllowAny,),
)
urlpatterns = [
# API Routes
path("api/", include(("apps.api.urls", "api"), namespace="api")),
path("api", RedirectView.as_view(url="/api/", permanent=True)),
# Swagger redirects (Swagger UI is served at /api/swagger/)
path("swagger/", RedirectView.as_view(url="/api/swagger/", permanent=True)),
path("swagger", RedirectView.as_view(url="/api/swagger/", permanent=True)),
path("redoc/", RedirectView.as_view(url="/api/redoc/", permanent=True)),
path("redoc", RedirectView.as_view(url="/api/redoc/", permanent=True)),
# Admin
path("admin", RedirectView.as_view(url="/admin/", permanent=True)),
path("admin/", admin.site.urls),
@ -68,12 +56,6 @@ urlpatterns = [
name="xc_series_stream",
),
re_path(r"^swagger/?$", schema_view.with_ui("swagger", cache_timeout=0), name="schema-swagger-ui"),
# ReDoc UI
path("redoc/", schema_view.with_ui("redoc", cache_timeout=0), name="schema-redoc"),
# Optionally, serve the raw Swagger JSON
path("swagger.json", schema_view.without_ui(cache_timeout=0), name="schema-json"),
# VOD proxy is now handled by the main proxy URLs above
# Catch-all routes should always be last
path("", TemplateView.as_view(template_name="index.html")), # React entry point