feat: Output SD program poster images during xml output and enhance caching logic

This commit is contained in:
SergeantPanda 2026-06-04 15:17:58 -05:00
parent 763d1430fd
commit 8f060e8b3a
6 changed files with 122 additions and 36 deletions

View file

@ -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)

View file

@ -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

View file

@ -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
# -------------------------------------------------------------------------

View file

@ -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' <icon src="{html.escape(custom_data["icon"])}" />')
elif "sd_icon" in custom_data:
program_xml.append(f' <icon src="{html.escape(_poster_url_base)}{prog["id"]}/poster/" />')
# Add special flags as proper tags with enhanced handling
if custom_data.get("previously_shown", False):

View file

@ -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"
)

View file

@ -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/(?<prog_id>\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;