Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/sethwv/1336

This commit is contained in:
SergeantPanda 2026-06-11 12:31:39 -05:00
commit 37cc72a040
11 changed files with 130 additions and 52 deletions

View file

@ -2800,8 +2800,9 @@ class LogoViewSet(viewsets.ModelViewSet):
user_agent_obj = UserAgent.objects.get(id=int(default_user_agent_id))
user_agent = user_agent_obj.user_agent
except (CoreSettings.DoesNotExist, UserAgent.DoesNotExist, ValueError):
# Fallback to hardcoded if default not found
user_agent = 'Dispatcharr/1.0'
# Fallback if default not found
from core.utils import dispatcharr_user_agent
user_agent = dispatcharr_user_agent()
# Hard total timeout (connect + full download) prevents a slow
# server dripping bytes from holding a greenlet indefinitely.

View file

@ -1181,12 +1181,13 @@ def _dvr_ffmpeg_retry_backoff_seconds(retry_index):
def _dvr_build_ffmpeg_cmd(stream_url, recording_id, hls_m3u8, hls_seg_pattern, hls_start_number):
"""Build the FFmpeg command for DVR HLS segment recording."""
from core.utils import dispatcharr_dvr_user_agent
return [
"ffmpeg", "-y",
"-reconnect", "1",
"-reconnect_streamed", "1",
"-reconnect_delay_max", "5",
"-user_agent", f"Dispatcharr-DVR/recording-{recording_id}",
"-user_agent", dispatcharr_dvr_user_agent(recording_id),
# Regenerate monotonic PTS to handle erratic/discontinuous timestamps
# from IPTV sources.
"-fflags", "+genpts",

View file

@ -128,7 +128,7 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
import hashlib
import requests as http_requests
from apps.epg.tasks import SD_BASE_URL
from version import __version__ as dispatcharr_version
from core.utils import dispatcharr_http_headers
username = (source.username or '').strip()
password = (source.password or '').strip()
@ -143,7 +143,7 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
auth_response = http_requests.post(
f"{SD_BASE_URL}/token",
json={'username': username, 'password': sha1_password},
headers={'Content-Type': 'application/json', 'User-Agent': f'Dispatcharr/{dispatcharr_version}'},
headers=dispatcharr_http_headers(),
timeout=15,
)
auth_response.raise_for_status()
@ -227,6 +227,24 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
f"Lockout set until {reset_at.isoformat()}."
)
def _fetch_sd_countries(self):
"""Fetch the SD country list (token not required; User-Agent is)."""
import requests as http_requests
from apps.epg.tasks import SD_BASE_URL
from core.utils import dispatcharr_http_headers
try:
resp = http_requests.get(
f"{SD_BASE_URL}/available/countries",
headers=dispatcharr_http_headers(content_type=None),
timeout=15,
)
resp.raise_for_status()
return resp.json()
except http_requests.exceptions.RequestException as e:
logger.warning(f"Failed to fetch SD countries: {e}")
return None
@action(detail=True, methods=["get", "post", "delete"], url_path="sd-lineups")
def sd_lineups(self, request, pk=None):
"""
@ -236,7 +254,7 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
"""
import requests as http_requests
from apps.epg.tasks import SD_BASE_URL
from version import __version__ as dispatcharr_version
from core.utils import dispatcharr_http_headers
source = self.get_object()
if source.source_type != 'schedules_direct':
@ -249,13 +267,10 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
if error:
return error
headers = {
'Content-Type': 'application/json',
'User-Agent': f'Dispatcharr/{dispatcharr_version}',
'token': token,
}
headers = dispatcharr_http_headers(token=token)
if request.method == "GET":
countries = self._fetch_sd_countries()
try:
resp = http_requests.get(
f"{SD_BASE_URL}/lineups",
@ -272,6 +287,7 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
"changes_remaining": self._get_sd_changes_remaining(source),
"changes_reset_at": self._get_sd_reset_at(source),
"notice": "No lineups are currently configured on this Schedules Direct account. Use the search below to add one.",
"countries": countries,
})
resp.raise_for_status()
data = resp.json()
@ -281,6 +297,7 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
"max_lineups": 4,
"changes_remaining": self._get_sd_changes_remaining(source),
"changes_reset_at": self._get_sd_reset_at(source),
"countries": countries,
})
except http_requests.exceptions.RequestException as e:
return Response(
@ -399,7 +416,7 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
"""
import requests as http_requests
from apps.epg.tasks import SD_BASE_URL
from version import __version__ as dispatcharr_version
from core.utils import dispatcharr_http_headers
source = self.get_object()
if source.source_type != 'schedules_direct':
@ -420,11 +437,7 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
if error:
return error
headers = {
'Content-Type': 'application/json',
'User-Agent': f'Dispatcharr/{dispatcharr_version}',
'token': token,
}
headers = dispatcharr_http_headers(token=token)
try:
resp = http_requests.get(
@ -493,6 +506,7 @@ class ProgramViewSet(viewsets.ModelViewSet):
"""Proxy endpoint for SD program poster images. Nginx caches the response."""
import requests as http_requests
from apps.epg.tasks import SD_BASE_URL
from core.utils import dispatcharr_http_headers
program = self.get_object()
poster_sd_url = (program.custom_properties or {}).get('sd_icon')
@ -516,14 +530,10 @@ class ProgramViewSet(viewsets.ModelViewSet):
if not token:
sha1_password = hashlib.sha1(source.password.encode('utf-8')).hexdigest()
try:
from version import __version__ as dispatcharr_version
auth_resp = http_requests.post(
f"{SD_BASE_URL}/token",
json={'username': source.username, 'password': sha1_password},
headers={
'Content-Type': 'application/json',
'User-Agent': f'Dispatcharr/{dispatcharr_version}',
},
headers=dispatcharr_http_headers(),
timeout=10,
)
auth_data = auth_resp.json()
@ -549,7 +559,7 @@ class ProgramViewSet(viewsets.ModelViewSet):
try:
img_resp = http_requests.get(
poster_sd_url,
headers={'token': token},
headers=dispatcharr_http_headers(token=token, content_type=None),
timeout=15,
allow_redirects=True,
)

View file

@ -2242,17 +2242,10 @@ def fetch_schedules_direct(source, stations_only=False, force=False):
# SD API spec requires the User-Agent to identify the application and version.
# SergeantPanda confirmed Dispatcharr should identify itself properly.
# -------------------------------------------------------------------------
from version import __version__ as dispatcharr_version
sd_user_agent = f"Dispatcharr/{dispatcharr_version}"
from core.utils import dispatcharr_http_headers
def _sd_headers(token=None):
h = {
'Content-Type': 'application/json',
'User-Agent': sd_user_agent,
}
if token:
h['token'] = token
return h
return dispatcharr_http_headers(token=token)
def _sd_post_refresh_tasks(mapped_epg_ids, program_metadata, today):
"""Poster fetch, logo auto-apply, and pruning — runs even when schedules are unchanged."""

View file

@ -23,6 +23,7 @@ from rest_framework_simplejwt.authentication import JWTAuthentication
from apps.accounts.authentication import ApiKeyAuthentication, QueryParamJWTAuthentication
from apps.proxy.utils import check_user_stream_limits
from dispatcharr.utils import network_access_allowed
from core.utils import dispatcharr_user_agent
logger = logging.getLogger(__name__)
@ -704,7 +705,7 @@ def head_vod(request, content_type, content_id, session_id=None, profile_id=None
# Use M3U account's user agent as primary, client user agent as fallback
m3u_user_agent = m3u_account.get_user_agent().user_agent if m3u_account.get_user_agent() else None
headers = {
'User-Agent': m3u_user_agent or client_user_agent or 'Dispatcharr/1.0',
'User-Agent': m3u_user_agent or client_user_agent or dispatcharr_user_agent(),
'Accept': '*/*',
'Range': 'bytes=0-1' # Request only first 2 bytes
}