mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-25 02:57:57 +00:00
fix: improve remote logo fetching with timeout and caching for failures
This commit is contained in:
parent
c819a95cb5
commit
978714a74d
2 changed files with 89 additions and 15 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue