diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index dc82d482..15534202 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -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) diff --git a/apps/vod/api_views.py b/apps/vod/api_views.py index edb1b110..f7bdf83c 100644 --- a/apps/vod/api_views.py +++ b/apps/vod/api_views.py @@ -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)