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;