This commit is contained in:
Dispatcharr 2025-04-04 08:26:50 -05:00
commit a7fa6ff55e
8 changed files with 67 additions and 25 deletions

View file

@ -18,8 +18,8 @@ router = DefaultRouter()
router.register(r'streams', StreamViewSet, basename='stream')
router.register(r'groups', ChannelGroupViewSet, basename='channel-group')
router.register(r'channels', ChannelViewSet, basename='channel')
router.register(r'logos', LogoViewSet, basename='logos')
router.register(r'profiles', ChannelProfileViewSet, basename='profiles')
router.register(r'logos', LogoViewSet, basename='logo')
router.register(r'profiles', ChannelProfileViewSet, basename='profile')
urlpatterns = [
# Bulk delete is a single APIView, not a ViewSet

View file

@ -1,14 +1,14 @@
from rest_framework import viewsets, status
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.decorators import action
from rest_framework.parsers import MultiPartParser, FormParser
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from django.shortcuts import get_object_or_404
from django.db import transaction
import os, json
import os, json, requests
from .models import Stream, Channel, ChannelGroup, Logo, ChannelProfile, ChannelProfileMembership
from .serializers import StreamSerializer, ChannelSerializer, ChannelGroupSerializer, LogoSerializer, ChannelProfileMembershipSerializer, BulkChannelProfileMembershipSerializer, ChannelProfileSerializer
@ -18,6 +18,8 @@ from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter, OrderingFilter
from apps.epg.models import EPGData
from django.db.models import Q
from django.http import StreamingHttpResponse, FileResponse, Http404
from rest_framework.pagination import PageNumberPagination
@ -490,6 +492,26 @@ class LogoViewSet(viewsets.ModelViewSet):
return Response({'id': logo.id, 'name': logo.name, 'url': logo.url}, status=status.HTTP_201_CREATED)
@action(detail=True, methods=['get'], permission_classes=[AllowAny])
def cache(self, request, pk=None):
"""Streams the logo file, whether it's local or remote."""
logo = self.get_object()
logo_url = logo.url
if logo_url.startswith("/data"): # Local file
if not os.path.exists(logo_url):
raise Http404("Image not found")
return FileResponse(open(logo_url, "rb"), content_type="image/*")
else: # Remote image
try:
remote_response = requests.get(logo_url, stream=True)
if remote_response.status_code == 200:
return StreamingHttpResponse(remote_response.iter_content(chunk_size=8192), content_type="image/*")
raise Http404("Remote image not found")
except requests.RequestException:
raise Http404("Error fetching remote image")
class ChannelProfileViewSet(viewsets.ModelViewSet):
queryset = ChannelProfile.objects.all()
serializer_class = ChannelProfileSerializer

View file

@ -3,6 +3,21 @@ from .models import Stream, Channel, ChannelGroup, ChannelStream, ChannelGroupM3
from apps.epg.serializers import EPGDataSerializer
from core.models import StreamProfile
from apps.epg.models import EPGData
from django.urls import reverse
class LogoSerializer(serializers.ModelSerializer):
cache_url = serializers.SerializerMethodField()
class Meta:
model = Logo
fields = ['id', 'name', 'url', 'cache_url']
def get_cache_url(self, obj):
# return f"/api/channels/logos/{obj.id}/cache/"
request = self.context.get('request')
if request:
return request.build_absolute_uri(reverse('api:channels:logo-cache', args=[obj.id])) # Use 'logo-cache'
return reverse('api:channels:logo-cache', args=[obj.id])
#
# Stream
@ -126,7 +141,7 @@ class ChannelSerializer(serializers.ModelSerializer):
queryset=Stream.objects.all(), many=True, write_only=True, required=False
)
logo = serializers.SerializerMethodField()
logo = LogoSerializer(read_only=True)
logo_id = serializers.PrimaryKeyRelatedField(
queryset=Logo.objects.all(),
source='logo',
@ -215,8 +230,3 @@ class ChannelGroupM3UAccountSerializer(serializers.ModelSerializer):
# Optionally, if you only need the id of the ChannelGroup, you can customize it like this:
# channel_group = serializers.PrimaryKeyRelatedField(queryset=ChannelGroup.objects.all())
class LogoSerializer(serializers.ModelSerializer):
class Meta:
model = Logo
fields = ['id', 'name', 'url']

View file

@ -1,3 +1,6 @@
proxy_cache_path /app/logo_cache levels=1:2 keys_zone=logo_cache:10m
inactive=24h use_temp_path=off;
server {
listen 9191;
@ -26,6 +29,14 @@ server {
root /data;
}
location /api/logos/(?<logo_id>\d+)/cache/ {
proxy_pass http://127.0.0.1:5656;
proxy_cache logo_cache;
proxy_cache_key "$scheme$request_uri"; # Cache per logo URL
proxy_cache_valid 200 24h; # Cache for 24 hours
proxy_cache_use_stale error timeout updating; # Serve stale if Django is slow
}
# admin disabled when not in dev mode
location /admin {
return 301 /login;

View file

@ -68,7 +68,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
if (files.length === 1) {
console.log(files[0]);
const retval = await API.uploadLogo(files[0]);
setLogoPreview(retval.url);
setLogoPreview(retval.cache_url);
formik.setFieldValue('logo_id', retval.id);
} else {
setLogoPreview(null);
@ -150,7 +150,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
const renderLogoOption = ({ option, checked }) => {
return (
<Center style={{ width: '100%' }}>
<img src={logos[option.value].url} width="30" />
<img src={logos[option.value].cache_url} width="30" />
</Center>
);
};
@ -402,7 +402,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => {
<img
src={
logos[formik.values.logo_id]
? logos[formik.values.logo_id].url
? logos[formik.values.logo_id].cache_url
: logo
}
height="40"

View file

@ -327,6 +327,9 @@ const ChannelsTable = ({}) => {
return selectedProfileChannels.find((channel) => row.id == channel.id)
.enabled;
},
mantineTableBodyCellProps: {
align: 'center',
},
Cell: ({ row, cell }) => (
<Switch
size="xs"
@ -407,7 +410,7 @@ const ChannelsTable = ({}) => {
},
{
header: 'Logo',
accessorKey: 'logo.url',
accessorKey: 'logo',
enableSorting: false,
size: 55,
mantineTableBodyCellProps: {
@ -425,9 +428,7 @@ const ChannelsTable = ({}) => {
}}
>
<img
src={
cell.getValue() ? cell.getValue().replace(/^\/data/, '') : logo
}
src={cell.getValue() ? cell.getValue().cache_url : logo}
height="20"
alt="channel logo"
/>
@ -880,12 +881,10 @@ const ChannelsTable = ({}) => {
size="xs"
value={selectedProfileId}
onChange={setSelectedProfileId}
data={[{ label: 'All', value: '0' }].concat(
Object.values(profiles).map((profile) => ({
label: profile.name,
value: `${profile.id}`,
}))
)}
data={Object.values(profiles).map((profile) => ({
label: profile.name,
value: `${profile.id}`,
}))}
renderOption={renderProfileOption}
/>

View file

@ -312,7 +312,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
}}
>
<img
src={channel.logo.url.replace(/^\/data/, '') || logo}
src={channel.logo.cache_url || logo}
alt={channel.name}
style={{
width: '100%',

View file

@ -2,7 +2,7 @@ import { create } from 'zustand';
import api from '../api';
import { notifications } from '@mantine/notifications';
const defaultProfiles = { 0: { name: 'All', channels: [] } };
const defaultProfiles = { 0: { id: '0', name: 'All', channels: [] } };
const useChannelsStore = create((set, get) => ({
channels: [],