From b69aa6ad80a042464a7ab7c1f5bff97118a143f0 Mon Sep 17 00:00:00 2001 From: dekzter Date: Fri, 4 Apr 2025 09:21:53 -0400 Subject: [PATCH] logo caching --- apps/channels/api_urls.py | 4 +-- apps/channels/api_views.py | 26 +++++++++++++++++-- apps/channels/serializers.py | 22 +++++++++++----- docker/nginx.conf | 11 ++++++++ frontend/src/components/forms/Channel.jsx | 6 ++--- .../src/components/tables/ChannelsTable.jsx | 19 +++++++------- frontend/src/pages/Guide.jsx | 2 +- frontend/src/store/channels.jsx | 2 +- 8 files changed, 67 insertions(+), 25 deletions(-) diff --git a/apps/channels/api_urls.py b/apps/channels/api_urls.py index 7192608b..9dc9de37 100644 --- a/apps/channels/api_urls.py +++ b/apps/channels/api_urls.py @@ -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 diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 2ac5b5f8..2d98188d 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -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 diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index 45529f57..ca08ae72 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -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'] diff --git a/docker/nginx.conf b/docker/nginx.conf index 2a12bef3..50ebe07c 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -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/(?\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; diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index b5f18c56..e10abe9e 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -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 (
- +
); }; @@ -402,7 +402,7 @@ const Channel = ({ channel = null, isOpen, onClose }) => { { return selectedProfileChannels.find((channel) => row.id == channel.id) .enabled; }, + mantineTableBodyCellProps: { + align: 'center', + }, Cell: ({ row, cell }) => ( { }, { header: 'Logo', - accessorKey: 'logo.url', + accessorKey: 'logo', enableSorting: false, size: 55, mantineTableBodyCellProps: { @@ -425,9 +428,7 @@ const ChannelsTable = ({}) => { }} > 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} /> diff --git a/frontend/src/pages/Guide.jsx b/frontend/src/pages/Guide.jsx index 2654fcd9..1f7ac452 100644 --- a/frontend/src/pages/Guide.jsx +++ b/frontend/src/pages/Guide.jsx @@ -312,7 +312,7 @@ export default function TVChannelGuide({ startDate, endDate }) { }} > {channel.name} ({ channels: [],