From 8f060e8b3af6a7c2841166390c59dc16d5adfbd4 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 4 Jun 2026 15:17:58 -0500 Subject: [PATCH] feat: Output SD program poster images during xml output and enhance caching logic --- apps/epg/api_views.py | 102 ++++++++++++++++++++++++++++- apps/epg/serializers.py | 6 ++ apps/epg/tasks.py | 35 +--------- apps/output/views.py | 3 + docker/init/03-init-dispatcharr.sh | 2 +- docker/nginx.conf | 10 ++- 6 files changed, 122 insertions(+), 36 deletions(-) diff --git a/apps/epg/api_views.py b/apps/epg/api_views.py index 96fc69e0..fd83f2de 100644 --- a/apps/epg/api_views.py +++ b/apps/epg/api_views.py @@ -1,6 +1,11 @@ -import logging, os, re +import hashlib +import logging +import os +import re +import time from rest_framework import viewsets, status, serializers from rest_framework.pagination import PageNumberPagination +from rest_framework.permissions import AllowAny from rest_framework.response import Response from rest_framework.views import APIView from rest_framework.decorators import action @@ -461,6 +466,10 @@ class ProgramViewSet(viewsets.ModelViewSet): queryset = ProgramData.objects.select_related("epg").all() serializer_class = ProgramDataSerializer + # Per-source in-memory caches (token and error state) + _sd_poster_token_cache: dict = {} + _sd_poster_error_cache: dict = {} + def get_permissions(self): try: return [perm() for perm in permission_classes_by_action[self.action]] @@ -477,6 +486,97 @@ class ProgramViewSet(viewsets.ModelViewSet): serializer = self.get_serializer(instance) return Response(serializer.data) + @action(detail=True, methods=['get'], url_path='poster', permission_classes=[AllowAny]) + def poster(self, request, pk=None): + """Proxy endpoint for SD program poster images. Nginx caches the response.""" + import requests as http_requests + from apps.epg.tasks import SD_BASE_URL + + program = self.get_object() + poster_sd_url = (program.custom_properties or {}).get('sd_icon') + if not poster_sd_url: + return Response(status=status.HTTP_404_NOT_FOUND) + + source = program.epg.epg_source if program.epg else None + if not source or source.source_type != 'schedules_direct': + return Response(status=status.HTTP_404_NOT_FOUND) + + error_cache = ProgramViewSet._sd_poster_error_cache.get(source.id) + if error_cache and time.time() < error_cache['until']: + return Response( + {'error': f"SD temporarily unavailable: {error_cache['reason']}"}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + + cached = ProgramViewSet._sd_poster_token_cache.get(source.id) + token = cached['token'] if cached and time.time() < cached['expires'] else None + + if not token: + sha1_password = hashlib.sha1(source.password.encode('utf-8')).hexdigest() + try: + auth_resp = http_requests.post( + f"{SD_BASE_URL}/token", + json={'username': source.username, 'password': sha1_password}, + headers={'Content-Type': 'application/json'}, + timeout=10, + ) + auth_data = auth_resp.json() + token = auth_data.get('token') + if not token: + ProgramViewSet._sd_poster_error_cache[source.id] = { + 'until': time.time() + 3600, + 'reason': auth_data.get('message', 'Authentication failed'), + } + return Response(status=status.HTTP_502_BAD_GATEWAY) + token_expires = auth_data.get('tokenExpires', time.time() + 86400) + ProgramViewSet._sd_poster_token_cache[source.id] = { + 'token': token, + 'expires': token_expires, + } + except http_requests.exceptions.RequestException: + ProgramViewSet._sd_poster_error_cache[source.id] = { + 'until': time.time() + 300, + 'reason': 'Network error reaching Schedules Direct', + } + return Response(status=status.HTTP_502_BAD_GATEWAY) + + try: + img_resp = http_requests.get( + poster_sd_url, + headers={'token': token}, + timeout=15, + allow_redirects=True, + ) + if img_resp.status_code in (401, 403): + ProgramViewSet._sd_poster_token_cache.pop(source.id, None) + ProgramViewSet._sd_poster_error_cache[source.id] = { + 'until': time.time() + 3600, + 'reason': f'SD returned {img_resp.status_code}', + } + return Response(status=status.HTTP_502_BAD_GATEWAY) + if img_resp.status_code == 400: + try: + err_code = img_resp.json().get('code') + except Exception: + err_code = None + if err_code == 5002: + ProgramViewSet._sd_poster_error_cache[source.id] = { + 'until': time.time() + 3600, + 'reason': 'Daily image download limit reached (SD error 5002)', + } + return Response(status=status.HTTP_429_TOO_MANY_REQUESTS) + if img_resp.status_code != 200: + return Response(status=status.HTTP_502_BAD_GATEWAY) + + from django.http import HttpResponse + content_type = img_resp.headers.get('Content-Type', 'image/jpeg') + response = HttpResponse(img_resp.content, content_type=content_type) + response['Cache-Control'] = 'public, max-age=86400' + return response + + except http_requests.exceptions.RequestException: + return Response(status=status.HTTP_502_BAD_GATEWAY) + def list(self, request, *args, **kwargs): logger.debug("Listing all EPG programs.") return super().list(request, *args, **kwargs) diff --git a/apps/epg/serializers.py b/apps/epg/serializers.py index 608636a9..7da93c13 100644 --- a/apps/epg/serializers.py +++ b/apps/epg/serializers.py @@ -155,6 +155,12 @@ class ProgramDetailSerializer(ProgramDataSerializer): data['icon'] = cp.get('icon') data['images'] = cp.get('images') or [] + # SD poster: expose as proxy URL so frontend never needs SD auth + if cp.get('sd_icon'): + data['poster_url'] = f"/api/epg/programs/{obj.id}/poster/" + else: + data['poster_url'] = None + return data diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py index fc43c36d..78900db9 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -3048,7 +3048,7 @@ def fetch_schedules_direct(source, stations_only=False, force=False): # Complete the URL if it's relative if not poster_url.startswith('http'): poster_url = f"{SD_BASE_URL}/image/{poster_url}" - artwork_map[entry_pid] = poster_url + artwork_map[entry_pid] = poster_url # raw SD endpoint URL logger.info(f"Artwork batch {batch_idx + 1}/{total_art_batches}: " f"{len(artwork_map)} posters found so far.") @@ -3067,7 +3067,7 @@ def fetch_schedules_direct(source, stations_only=False, force=False): poster = artwork_map.get(art_key) if art_key else None if poster: cp = prog.custom_properties or {} - cp['poster_url'] = poster + cp['sd_icon'] = poster prog.custom_properties = cp programs_to_update.append(prog) @@ -3155,37 +3155,6 @@ def fetch_schedules_direct(source, stations_only=False, force=False): except Exception as prune_err: logger.warning(f"Failed to prune stale SDProgramMD5 records: {prune_err}") - # ------------------------------------------------------------------------- - # Prune stale poster cache files (>30 days old or orphaned) - # ------------------------------------------------------------------------- - try: - cache_dir = '/data/cache/posters' - if os.path.exists(cache_dir): - import time as time_module - # Collect all poster hashes currently referenced by ProgramData - active_hashes = set() - for url in ProgramData.objects.filter( - epg__epg_source=source, - custom_properties__has_key='poster_url', - ).values_list('custom_properties__poster_url', flat=True): - if url: - active_hashes.add(url.rsplit('/', 1)[-1]) - - pruned_posters = 0 - for fname in os.listdir(cache_dir): - fpath = os.path.join(cache_dir, fname) - if not os.path.isfile(fpath): - continue - file_age = time_module.time() - os.path.getmtime(fpath) - # Remove if older than 30 days OR not referenced by any current program - if file_age > 30 * 24 * 3600 or fname not in active_hashes: - os.remove(fpath) - pruned_posters += 1 - if pruned_posters: - logger.info(f"Pruned {pruned_posters} stale poster cache files.") - except Exception as poster_prune_err: - logger.warning(f"Failed to prune poster cache: {poster_prune_err}") - # ------------------------------------------------------------------------- # Done # ------------------------------------------------------------------------- diff --git a/apps/output/views.py b/apps/output/views.py index 4d314857..854d3054 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -1646,6 +1646,7 @@ def generate_epg(request, profile_name=None, user=None): # to avoid skipping/duplicating rows if the table changes mid-stream. last_epg_id = 0 last_id = 0 + _poster_url_base = build_absolute_uri_with_port(request, "/api/epg/programs/") while True: program_chunk = list( @@ -1849,6 +1850,8 @@ def generate_epg(request, profile_name=None, user=None): if "icon" in custom_data: program_xml.append(f' ') + elif "sd_icon" in custom_data: + program_xml.append(f' ') # Add special flags as proper tags with enhanced handling if custom_data.get("previously_shown", False): diff --git a/docker/init/03-init-dispatcharr.sh b/docker/init/03-init-dispatcharr.sh index 05bcb192..10e3e874 100644 --- a/docker/init/03-init-dispatcharr.sh +++ b/docker/init/03-init-dispatcharr.sh @@ -7,6 +7,7 @@ DATA_DIRS=( "/data/backups" "/data/logos" + "/data/logo_cache" "/data/recordings" "/data/uploads/m3us" "/data/uploads/epgs" @@ -19,7 +20,6 @@ DATA_DIRS=( # APP_DIRS live on the image layer and are always locally writable. APP_DIRS=( - "/app/logo_cache" "/app/media" "/app/static" ) diff --git a/docker/nginx.conf b/docker/nginx.conf index c5cb429e..ba75f3fe 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -1,4 +1,4 @@ -proxy_cache_path /app/logo_cache levels=1:2 keys_zone=logo_cache:10m +proxy_cache_path /data/logo_cache levels=1:2 keys_zone=logo_cache:10m inactive=24h use_temp_path=off; server { @@ -58,6 +58,14 @@ server { proxy_cache_use_stale error timeout updating; # Serve stale if Django is slow } + location ~ ^/api/epg/programs/(?\d+)/poster/ { + proxy_pass http://127.0.0.1:5656; + proxy_cache logo_cache; + proxy_cache_key "$scheme$request_uri"; + proxy_cache_valid 200 24h; + proxy_cache_use_stale error timeout updating; + } + # admin disabled when not in dev mode location ~ ^/admin/?$ { return 301 /login;