fix: improve remote logo fetching with timeout and caching for failures

This commit is contained in:
SergeantPanda 2026-04-14 13:04:45 -05:00
parent c819a95cb5
commit 978714a74d
2 changed files with 89 additions and 15 deletions

View file

@ -62,7 +62,7 @@ from rest_framework.filters import SearchFilter, OrderingFilter
from apps.epg.models import EPGData
from apps.vod.models import Movie, Series
from django.db.models import Q
from django.http import StreamingHttpResponse, FileResponse, Http404
from django.http import HttpResponse, StreamingHttpResponse, FileResponse, Http404
from django.utils import timezone
import mimetypes
from django.conf import settings
@ -2057,7 +2057,7 @@ class LogoViewSet(viewsets.ModelViewSet):
return response
else: # Remote image
# Skip URLs that recently failed to avoid blocking Daphne workers
# Skip URLs that recently failed to avoid blocking workers
# on unreachable hosts (e.g., dead CDNs referenced by old recordings).
fail_expiry = _logo_fetch_failures.get(logo_url)
if fail_expiry and time.monotonic() < fail_expiry:
@ -2073,17 +2073,39 @@ class LogoViewSet(viewsets.ModelViewSet):
# Fallback to hardcoded if default not found
user_agent = 'Dispatcharr/1.0'
# Add proper timeouts to prevent hanging
# Hard total timeout (connect + full download) prevents a slow
# server dripping bytes from holding a greenlet indefinitely.
_LOGO_TOTAL_TIMEOUT = 10 # seconds
_LOGO_MAX_BYTES = 5 * 1024 * 1024 # 5 MB
remote_response = requests.get(
logo_url,
stream=True,
timeout=(3, 5), # (connect_timeout, read_timeout)
timeout=(3, 5), # (connect_timeout, read_timeout per chunk)
headers={'User-Agent': user_agent}
)
if remote_response.status_code == 200:
# Success — clear any previous failure entry
_logo_fetch_failures.pop(logo_url, None)
# Eagerly read the full image with a total time + size cap
# so the greenlet is released quickly.
chunks = []
total = 0
deadline = time.monotonic() + _LOGO_TOTAL_TIMEOUT
for chunk in remote_response.iter_content(chunk_size=8192):
total += len(chunk)
if total > _LOGO_MAX_BYTES:
remote_response.close()
raise Http404("Remote image too large")
if time.monotonic() > deadline:
remote_response.close()
now = time.monotonic()
_logo_fetch_failures[logo_url] = now + _LOGO_FAIL_TTL
raise Http404("Remote image fetch timed out")
chunks.append(chunk)
body = b"".join(chunks)
# Try to get content type from response headers first
content_type = remote_response.headers.get("Content-Type")
@ -2095,13 +2117,14 @@ class LogoViewSet(viewsets.ModelViewSet):
if not content_type:
content_type = "image/jpeg"
response = StreamingHttpResponse(
remote_response.iter_content(chunk_size=8192),
response = HttpResponse(
body,
content_type=content_type,
)
if(remote_response.headers.get("Cache-Control")):
response["Content-Length"] = str(len(body))
if remote_response.headers.get("Cache-Control"):
response["Cache-Control"] = remote_response.headers.get("Cache-Control")
if(remote_response.headers.get("Last-Modified")):
if remote_response.headers.get("Last-Modified"):
response["Last-Modified"] = remote_response.headers.get("Last-Modified")
response["Content-Disposition"] = 'inline; filename="{}"'.format(
os.path.basename(logo_url)

View file

@ -11,6 +11,7 @@ from django.db.models import Q
import django_filters
import logging
import os
import time
import requests
from apps.accounts.permissions import (
Authenticated,
@ -36,6 +37,11 @@ from datetime import timedelta
logger = logging.getLogger(__name__)
# Negative cache for remote VOD logo URLs that failed to fetch.
# Prevents repeated blocking requests to unreachable hosts.
_vod_logo_fetch_failures = {}
_VOD_LOGO_FAIL_TTL = 300 # seconds
class VODPagination(PageNumberPagination):
page_size = 20 # Default page size to match frontend default
@ -830,17 +836,62 @@ class VODLogoViewSet(viewsets.ModelViewSet):
return HttpResponse(status=500)
else:
# It's a remote URL - proxy it
# Skip URLs that recently failed to avoid blocking workers
fail_expiry = _vod_logo_fetch_failures.get(logo.url)
if fail_expiry and time.monotonic() < fail_expiry:
return HttpResponse(status=404)
try:
response = requests.get(logo.url, stream=True, timeout=10)
response.raise_for_status()
_LOGO_TOTAL_TIMEOUT = 10 # seconds
_LOGO_MAX_BYTES = 5 * 1024 * 1024 # 5 MB
content_type = response.headers.get('Content-Type', 'image/png')
return StreamingHttpResponse(
response.iter_content(chunk_size=8192),
content_type=content_type
remote_response = requests.get(
logo.url,
stream=True,
timeout=(3, 5), # (connect_timeout, read_timeout per chunk)
)
if remote_response.status_code != 200:
now = time.monotonic()
_vod_logo_fetch_failures[logo.url] = now + _VOD_LOGO_FAIL_TTL
return HttpResponse(status=404)
# Success — clear any previous failure entry
_vod_logo_fetch_failures.pop(logo.url, None)
# Eagerly read the full image with a total time + size cap
# so the greenlet is released quickly.
chunks = []
total = 0
deadline = time.monotonic() + _LOGO_TOTAL_TIMEOUT
for chunk in remote_response.iter_content(chunk_size=8192):
total += len(chunk)
if total > _LOGO_MAX_BYTES:
remote_response.close()
return HttpResponse(status=404)
if time.monotonic() > deadline:
remote_response.close()
now = time.monotonic()
_vod_logo_fetch_failures[logo.url] = now + _VOD_LOGO_FAIL_TTL
return HttpResponse(status=404)
chunks.append(chunk)
body = b"".join(chunks)
content_type = remote_response.headers.get('Content-Type', 'image/png')
response = HttpResponse(body, content_type=content_type)
response["Content-Length"] = str(len(body))
if remote_response.headers.get("Cache-Control"):
response["Cache-Control"] = remote_response.headers.get("Cache-Control")
if remote_response.headers.get("Last-Modified"):
response["Last-Modified"] = remote_response.headers.get("Last-Modified")
response["Content-Disposition"] = 'inline; filename="{}"'.format(
os.path.basename(logo.url)
)
return response
except requests.exceptions.RequestException as e:
now = time.monotonic()
_vod_logo_fetch_failures[logo.url] = now + _VOD_LOGO_FAIL_TTL
logger.error(f"Error fetching remote VOD logo {logo.url}: {str(e)}")
return HttpResponse(status=404)