mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
feat: Output SD program poster images during xml output and enhance caching logic
This commit is contained in:
parent
763d1430fd
commit
8f060e8b3a
6 changed files with 122 additions and 36 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# -------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue