mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/Goldenfreddy0703/1254
This commit is contained in:
commit
0f0e855507
167 changed files with 22718 additions and 4539 deletions
|
|
@ -429,7 +429,8 @@ class ChannelStatus:
|
|||
info['stream_name'] = stream_name
|
||||
|
||||
# Add data throughput information to basic info
|
||||
total_bytes_bytes = proxy_server.redis_client.hget(metadata_key, ChannelMetadataField.TOTAL_BYTES)
|
||||
# TOTAL_BYTES is already present in the hgetall result — avoid a redundant round-trip
|
||||
total_bytes_bytes = metadata.get(ChannelMetadataField.TOTAL_BYTES)
|
||||
if total_bytes_bytes:
|
||||
total_bytes = int(total_bytes_bytes)
|
||||
info['total_bytes'] = total_bytes
|
||||
|
|
@ -470,29 +471,31 @@ class ChannelStatus:
|
|||
|
||||
client_key = RedisKeys.client_metadata(channel_id, client_id)
|
||||
|
||||
# Fetch only the fields we need in one round-trip (hmget returns a list
|
||||
# in the same order as the requested keys; values are None if absent)
|
||||
ua, ip, connected_at, user_id, output_format, raw_profile_id = (
|
||||
proxy_server.redis_client.hmget(
|
||||
client_key,
|
||||
'user_agent', 'ip_address', 'connected_at', 'user_id',
|
||||
'output_format', 'output_profile_id',
|
||||
)
|
||||
)
|
||||
|
||||
client_info = {
|
||||
'client_id': client_id,
|
||||
'user_agent': ua,
|
||||
'output_format': output_format or 'mpegts',
|
||||
}
|
||||
|
||||
user_agent_bytes = proxy_server.redis_client.hget(client_key, 'user_agent')
|
||||
client_info['user_agent'] = user_agent_bytes
|
||||
if ip:
|
||||
client_info['ip_address'] = ip
|
||||
|
||||
ip_address_bytes = proxy_server.redis_client.hget(client_key, 'ip_address')
|
||||
if ip_address_bytes:
|
||||
client_info['ip_address'] = ip_address_bytes
|
||||
if connected_at:
|
||||
client_info['connected_at'] = float(connected_at)
|
||||
|
||||
connected_at_bytes = proxy_server.redis_client.hget(client_key, 'connected_at')
|
||||
if connected_at_bytes:
|
||||
client_info['connected_at'] = float(connected_at_bytes)
|
||||
if user_id:
|
||||
client_info['user_id'] = user_id
|
||||
|
||||
user_id_bytes = proxy_server.redis_client.hget(client_key, 'user_id')
|
||||
if user_id_bytes:
|
||||
client_info['user_id'] = user_id_bytes
|
||||
|
||||
output_format = proxy_server.redis_client.hget(client_key, 'output_format')
|
||||
client_info['output_format'] = output_format or 'mpegts'
|
||||
|
||||
raw_profile_id = proxy_server.redis_client.hget(client_key, 'output_profile_id')
|
||||
if raw_profile_id and raw_profile_id not in ('None', '0', ''):
|
||||
client_info['output_profile_id'] = int(raw_profile_id)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -140,11 +140,8 @@ class StreamGenerator:
|
|||
self._cleanup()
|
||||
|
||||
def _wait_for_initialization(self):
|
||||
"""Wait for channel initialization to complete, sending keepalive packets."""
|
||||
initialization_start = time.time()
|
||||
max_init_wait = ConfigHelper.client_wait_timeout()
|
||||
keepalive_interval = 0.5
|
||||
last_keepalive = 0
|
||||
proxy_server = ProxyServer.get_instance()
|
||||
|
||||
while time.time() - initialization_start < max_init_wait:
|
||||
|
|
@ -169,16 +166,7 @@ class StreamGenerator:
|
|||
yield create_ts_packet('error', "Error: Channel is stopping")
|
||||
return False
|
||||
|
||||
# Send PAT+PMT+null so clients recognise a valid TS program and keep
|
||||
# buffering instead of timing out from missing program info.
|
||||
if time.time() - last_keepalive >= keepalive_interval:
|
||||
logger.debug(f"[{self.client_id}] Sending keepalive during initialization")
|
||||
keepalive_data = create_ts_packet('null')
|
||||
yield keepalive_data
|
||||
self.bytes_sent += len(keepalive_data)
|
||||
last_keepalive = time.time()
|
||||
else:
|
||||
gevent.sleep(0.1)
|
||||
gevent.sleep(0.1)
|
||||
|
||||
logger.warning(f"[{self.client_id}] Timed out waiting for initialization")
|
||||
yield create_ts_packet('error', "Error: Initialization timeout")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import logging
|
||||
import re
|
||||
import struct
|
||||
from urllib.parse import urlparse
|
||||
import inspect
|
||||
|
||||
|
|
@ -58,53 +57,6 @@ def get_client_ip(request):
|
|||
ip = request.META.get('REMOTE_ADDR')
|
||||
return ip
|
||||
|
||||
def _mpeg_crc32(data):
|
||||
crc = 0xFFFFFFFF
|
||||
for b in data:
|
||||
crc ^= b << 24
|
||||
for _ in range(8):
|
||||
if crc & 0x80000000:
|
||||
crc = ((crc << 1) ^ 0x04C11DB7) & 0xFFFFFFFF
|
||||
else:
|
||||
crc = (crc << 1) & 0xFFFFFFFF
|
||||
return crc
|
||||
|
||||
|
||||
def create_ts_pat_pmt_packets():
|
||||
"""
|
||||
Return two valid TS packets: PAT (PID 0x0000) and PMT (PID 0x0100).
|
||||
|
||||
Declares program 1 with an H.264 video track at PID 0x0101.
|
||||
TS clients like VLC need PAT/PMT to recognise a stream as valid; without
|
||||
them they time out waiting for program info even while receiving null packets.
|
||||
Returns exactly 376 bytes (2 x 188-byte TS packets).
|
||||
"""
|
||||
# PAT section: program 1 mapped to PMT at PID 0x0100
|
||||
pat_body = bytes([
|
||||
0x00, 0xB0, 0x0D, # table_id=PAT, section_length=13
|
||||
0x00, 0x01, # transport_stream_id=1
|
||||
0xC1, 0x00, 0x00, # version=0, current=1, section 0/0
|
||||
0x00, 0x01, # program_number=1
|
||||
0xE1, 0x00, # PMT PID=0x0100 (reserved 0b111 | PID)
|
||||
])
|
||||
pat_body += struct.pack('>I', _mpeg_crc32(pat_body))
|
||||
pat_packet = bytes([0x47, 0x40, 0x00, 0x10, 0x00]) + pat_body + bytes([0xFF] * (183 - len(pat_body)))
|
||||
|
||||
# PMT section: program 1, H.264 video at PID 0x0101
|
||||
pmt_body = bytes([
|
||||
0x02, 0xB0, 0x12, # table_id=PMT, section_length=18
|
||||
0x00, 0x01, # program_number=1
|
||||
0xC1, 0x00, 0x00, # version=0, current=1, section 0/0
|
||||
0xE1, 0x01, # PCR_PID=0x0101
|
||||
0xF0, 0x00, # program_info_length=0
|
||||
0x1B, 0xE1, 0x01, 0xF0, 0x00, # stream_type=H.264, PID=0x0101
|
||||
])
|
||||
pmt_body += struct.pack('>I', _mpeg_crc32(pmt_body))
|
||||
pmt_packet = bytes([0x47, 0x41, 0x00, 0x10, 0x00]) + pmt_body + bytes([0xFF] * (183 - len(pmt_body)))
|
||||
|
||||
return pat_packet + pmt_packet
|
||||
|
||||
|
||||
def create_ts_packet(packet_type='null', message=None):
|
||||
"""
|
||||
Create a Transport Stream (TS) packet for various purposes.
|
||||
|
|
|
|||
|
|
@ -467,14 +467,11 @@ def stream_ts(request, channel_id, user=None, force_output_format=None):
|
|||
|
||||
if proxy_server.redis_client:
|
||||
metadata_key = RedisKeys.channel_metadata(channel_id)
|
||||
url_bytes = proxy_server.redis_client.hget(
|
||||
metadata_key, ChannelMetadataField.URL
|
||||
)
|
||||
ua_bytes = proxy_server.redis_client.hget(
|
||||
metadata_key, ChannelMetadataField.USER_AGENT
|
||||
)
|
||||
profile_bytes = proxy_server.redis_client.hget(
|
||||
metadata_key, ChannelMetadataField.STREAM_PROFILE
|
||||
url_bytes, ua_bytes, profile_bytes = proxy_server.redis_client.hmget(
|
||||
metadata_key,
|
||||
ChannelMetadataField.URL,
|
||||
ChannelMetadataField.USER_AGENT,
|
||||
ChannelMetadataField.STREAM_PROFILE,
|
||||
)
|
||||
|
||||
if url_bytes:
|
||||
|
|
@ -629,7 +626,12 @@ def stream_xc(request, username, password, channel_id):
|
|||
else:
|
||||
channel = get_object_or_404(Channel, id=channel_id)
|
||||
|
||||
force_format = 'fmp4' if extension.lower() == '.mp4' else 'mpegts'
|
||||
if extension.lower() == '.mp4':
|
||||
force_format = 'fmp4'
|
||||
elif extension.lower() == '.ts':
|
||||
force_format = 'mpegts'
|
||||
else:
|
||||
force_format = None
|
||||
return stream_ts(request._request, str(channel.uuid), user, force_output_format=force_format)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -98,8 +98,7 @@ def get_user_active_connections(user_id):
|
|||
channel_id = parts[2]
|
||||
client_id = parts[4]
|
||||
|
||||
client_user_id = redis_client.hget(key, 'user_id')
|
||||
connected_at = redis_client.hget(key, 'connected_at')
|
||||
client_user_id, connected_at = redis_client.hmget(key, 'user_id', 'connected_at')
|
||||
|
||||
logger.debug(f"[stream limits] user_id = {user_id}")
|
||||
logger.debug(f"[stream limits] channel_id = {channel_id}")
|
||||
|
|
@ -124,9 +123,9 @@ def get_user_active_connections(user_id):
|
|||
if len(parts) >= 2:
|
||||
client_id = parts[1]
|
||||
|
||||
client_user_id = redis_client.hget(key, 'user_id')
|
||||
connected_at = redis_client.hget(key, 'created_at')
|
||||
content_uuid = redis_client.hget(key, 'content_uuid')
|
||||
client_user_id, connected_at, content_uuid = redis_client.hmget(
|
||||
key, 'user_id', 'created_at', 'content_uuid'
|
||||
)
|
||||
|
||||
logger.debug(f"[stream limits] user_id = {user_id}")
|
||||
logger.debug(f"[stream limits] client_id = {client_id}")
|
||||
|
|
|
|||
225
apps/proxy/vod_proxy/tests/test_streamid_fallback.py
Normal file
225
apps/proxy/vod_proxy/tests/test_streamid_fallback.py
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
"""
|
||||
Tests for `_get_content_and_relation`'s graceful stream_id fallback when the
|
||||
VOD content UUID has been orphaned by an import-time refresh.
|
||||
|
||||
Context (see #961 / closed #973): `process_movie_batch` and `process_series_batch`
|
||||
can create duplicate `vod_movie` / `vod_episode` records during a refresh and
|
||||
repoint existing `M3U*Relation` rows at the new records. The old UUIDs that
|
||||
external players (Emby / Jellyfin / ChannelsDVR) cached in `.strm` URLs are
|
||||
left orphaned, and the proxy then 404s — even though the same request carries
|
||||
a stable `stream_id` that uniquely identifies a live relation.
|
||||
|
||||
These tests cover the read-side fallback that resolves content via that
|
||||
stream_id when the UUID lookup misses, leaving the existing UUID-first path
|
||||
unchanged for the happy case. Both branches (movie + episode) exercise:
|
||||
|
||||
* UUID hit (no fallback fires) — happy path unchanged
|
||||
* UUID miss + stream_id present → resolved via stream_id, [STREAMID-FALLBACK]
|
||||
logged at WARNING
|
||||
* UUID miss + stream_id present + preferred_m3u_account_id → strictest-first
|
||||
account match preferred
|
||||
* UUID miss + stream_id present but no relation matches → Http404 with both
|
||||
identifiers in the message
|
||||
* UUID miss + no stream_id → Http404 (no fallback attempt)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from unittest.mock import MagicMock, patch
|
||||
from django.test import SimpleTestCase
|
||||
from django.http import Http404
|
||||
|
||||
|
||||
# ---------- Movie branch --------------------------------------------------
|
||||
|
||||
class TestStreamIdFallbackMovie(SimpleTestCase):
|
||||
"""Movie UUID dead -> fall back to M3UMovieRelation.stream_id."""
|
||||
|
||||
def _call(self, **kwargs):
|
||||
# Imported inside each test so the module-level Movie / Episode /
|
||||
# M3U*Relation references can be patched per test without leaking.
|
||||
from apps.proxy.vod_proxy.views import _get_content_and_relation
|
||||
return _get_content_and_relation(
|
||||
kwargs.pop('content_type', 'movie'),
|
||||
kwargs.pop('content_id', 'dead-uuid'),
|
||||
preferred_m3u_account_id=kwargs.pop('preferred_m3u_account_id', None),
|
||||
preferred_stream_id=kwargs.pop('preferred_stream_id', None),
|
||||
)
|
||||
|
||||
def test_uuid_hit_no_fallback_attempted(self):
|
||||
"""When the UUID resolves, the M3UMovieRelation table is never queried
|
||||
for fallback purposes — the existing happy-path behaviour is preserved
|
||||
and only the stream_id-specific relation selection runs."""
|
||||
live_movie = MagicMock(name='Movie', uuid='live-uuid', id=42)
|
||||
live_movie.name = 'Live Movie'
|
||||
# The existing relation-selection logic walks
|
||||
# content_obj.m3u_relations; give it a relation matching the requested
|
||||
# stream_id so we exit cleanly.
|
||||
live_relation = MagicMock(stream_id='S1')
|
||||
live_relation.m3u_account.name = 'AcmeProvider'
|
||||
live_movie.m3u_relations.filter.return_value.filter.return_value.first.return_value = live_relation
|
||||
|
||||
with patch('apps.proxy.vod_proxy.views.Movie') as MovieMock, \
|
||||
patch('apps.proxy.vod_proxy.views.M3UMovieRelation') as RelMock:
|
||||
MovieMock.objects.filter.return_value.first.return_value = live_movie
|
||||
content, relation = self._call(
|
||||
content_type='movie', content_id='live-uuid', preferred_stream_id='S1',
|
||||
)
|
||||
self.assertIs(content, live_movie)
|
||||
self.assertIs(relation, live_relation)
|
||||
# Fallback path must not have queried the relation table directly
|
||||
# — happy path is unchanged.
|
||||
RelMock.objects.filter.assert_not_called()
|
||||
|
||||
def test_uuid_miss_resolves_via_stream_id(self):
|
||||
"""UUID lookup returns None; stream_id finds an active relation; the
|
||||
recovered movie is returned and the fallback line is logged."""
|
||||
recovered_movie = MagicMock(name='Movie', uuid='new-uuid', id=99)
|
||||
recovered_movie.name = 'Recovered Movie'
|
||||
fallback_rel = MagicMock(movie=recovered_movie)
|
||||
fallback_rel.m3u_account.name = 'AcmeProvider'
|
||||
# The fallback only sets content_obj; the existing relation-selection
|
||||
# logic then re-discovers the same relation via the reverse FK.
|
||||
recovered_movie.m3u_relations.filter.return_value.filter.return_value.first.return_value = fallback_rel
|
||||
|
||||
with patch('apps.proxy.vod_proxy.views.Movie') as MovieMock, \
|
||||
patch('apps.proxy.vod_proxy.views.M3UMovieRelation') as RelMock, \
|
||||
self.assertLogs('apps.proxy.vod_proxy.views', level='WARNING') as logs:
|
||||
MovieMock.objects.filter.return_value.first.return_value = None
|
||||
# The non-account-scoped fallback chain returns our rel.
|
||||
RelMock.objects.filter.return_value.select_related.return_value.order_by.return_value.first.return_value = fallback_rel
|
||||
content, _ = self._call(
|
||||
content_type='movie', content_id='dead-uuid', preferred_stream_id='S1',
|
||||
)
|
||||
self.assertIs(content, recovered_movie)
|
||||
self.assertTrue(
|
||||
any('[STREAMID-FALLBACK]' in m for m in logs.output),
|
||||
f"expected [STREAMID-FALLBACK] in warnings, got: {logs.output}",
|
||||
)
|
||||
|
||||
def test_uuid_miss_prefers_requested_account_first(self):
|
||||
"""When preferred_m3u_account_id is set AND a matching relation exists
|
||||
on that account, it must be chosen ahead of the unrestricted ordered
|
||||
fallback. This is the strictest-match-first contract."""
|
||||
preferred_movie = MagicMock(name='PreferredMovie', uuid='preferred-uuid', id=1)
|
||||
preferred_movie.name = 'Preferred'
|
||||
preferred_rel = MagicMock(movie=preferred_movie)
|
||||
preferred_rel.m3u_account.name = 'Preferred'
|
||||
preferred_movie.m3u_relations.filter.return_value.filter.return_value.first.return_value = preferred_rel
|
||||
|
||||
with patch('apps.proxy.vod_proxy.views.Movie') as MovieMock, \
|
||||
patch('apps.proxy.vod_proxy.views.M3UMovieRelation') as RelMock:
|
||||
MovieMock.objects.filter.return_value.first.return_value = None
|
||||
|
||||
# Two distinct fallback chains share the same RelMock — we
|
||||
# distinguish them by which `.filter(...)` call they emerged from.
|
||||
# The account-scoped query is the FIRST .filter() call (with the
|
||||
# m3u_account_id kw); the unrestricted ordered query is the SECOND.
|
||||
unrestricted_movie = MagicMock(uuid='other-uuid', id=2)
|
||||
unrestricted_movie.name = 'OtherMovie'
|
||||
unrestricted_rel = MagicMock(movie=unrestricted_movie)
|
||||
|
||||
def filter_router(**kwargs):
|
||||
# First chain: scoped to m3u_account_id -> returns preferred_rel
|
||||
if 'm3u_account_id' in kwargs:
|
||||
chain = MagicMock()
|
||||
chain.select_related.return_value.first.return_value = preferred_rel
|
||||
return chain
|
||||
# Second chain: no account scope -> returns unrestricted_rel
|
||||
chain = MagicMock()
|
||||
chain.select_related.return_value.order_by.return_value.first.return_value = unrestricted_rel
|
||||
return chain
|
||||
RelMock.objects.filter.side_effect = filter_router
|
||||
|
||||
content, _ = self._call(
|
||||
content_type='movie',
|
||||
content_id='dead-uuid',
|
||||
preferred_stream_id='S1',
|
||||
preferred_m3u_account_id=7,
|
||||
)
|
||||
# The account-scoped relation wins; the unrestricted-ordered one
|
||||
# is never consulted because the strict match succeeded.
|
||||
self.assertIs(content, preferred_movie)
|
||||
|
||||
def test_uuid_miss_with_no_stream_id_raises_404(self):
|
||||
with patch('apps.proxy.vod_proxy.views.Movie') as MovieMock, \
|
||||
patch('apps.proxy.vod_proxy.views.M3UMovieRelation') as RelMock:
|
||||
MovieMock.objects.filter.return_value.first.return_value = None
|
||||
content, relation = self._call(
|
||||
content_type='movie', content_id='dead-uuid', preferred_stream_id=None,
|
||||
)
|
||||
# _get_content_and_relation swallows exceptions and returns
|
||||
# (None, None) for any error including Http404 — caller checks for
|
||||
# that. Verify the fallback was NEVER attempted.
|
||||
self.assertIsNone(content)
|
||||
self.assertIsNone(relation)
|
||||
RelMock.objects.filter.assert_not_called()
|
||||
|
||||
def test_uuid_miss_with_no_matching_relation_raises_404(self):
|
||||
with patch('apps.proxy.vod_proxy.views.Movie') as MovieMock, \
|
||||
patch('apps.proxy.vod_proxy.views.M3UMovieRelation') as RelMock:
|
||||
MovieMock.objects.filter.return_value.first.return_value = None
|
||||
# Both the account-scoped and unrestricted chains return None.
|
||||
RelMock.objects.filter.return_value.select_related.return_value.order_by.return_value.first.return_value = None
|
||||
RelMock.objects.filter.return_value.select_related.return_value.first.return_value = None
|
||||
content, relation = self._call(
|
||||
content_type='movie',
|
||||
content_id='dead-uuid',
|
||||
preferred_stream_id='ghost-stream',
|
||||
)
|
||||
self.assertIsNone(content)
|
||||
self.assertIsNone(relation)
|
||||
|
||||
|
||||
# ---------- Episode branch ------------------------------------------------
|
||||
|
||||
class TestStreamIdFallbackEpisode(SimpleTestCase):
|
||||
"""Episode UUID dead -> fall back to M3UEpisodeRelation.stream_id.
|
||||
|
||||
Same contract as the movie branch; less duplication of edge cases since
|
||||
the code paths are intentionally symmetric.
|
||||
"""
|
||||
|
||||
def _call(self, **kwargs):
|
||||
from apps.proxy.vod_proxy.views import _get_content_and_relation
|
||||
return _get_content_and_relation(
|
||||
'episode',
|
||||
kwargs.pop('content_id', 'dead-uuid'),
|
||||
preferred_m3u_account_id=kwargs.pop('preferred_m3u_account_id', None),
|
||||
preferred_stream_id=kwargs.pop('preferred_stream_id', None),
|
||||
)
|
||||
|
||||
def test_uuid_miss_resolves_via_stream_id(self):
|
||||
recovered_episode = MagicMock(uuid='new-uuid', id=77)
|
||||
recovered_episode.name = 'Recovered S01E01'
|
||||
recovered_episode.series.name = 'Recovered Show'
|
||||
fallback_rel = MagicMock(episode=recovered_episode)
|
||||
fallback_rel.m3u_account.name = 'AcmeProvider'
|
||||
recovered_episode.m3u_relations.filter.return_value.filter.return_value.first.return_value = fallback_rel
|
||||
|
||||
with patch('apps.proxy.vod_proxy.views.Episode') as EpisodeMock, \
|
||||
patch('apps.proxy.vod_proxy.views.M3UEpisodeRelation') as RelMock, \
|
||||
self.assertLogs('apps.proxy.vod_proxy.views', level='WARNING') as logs:
|
||||
EpisodeMock.objects.filter.return_value.first.return_value = None
|
||||
RelMock.objects.filter.return_value.select_related.return_value.order_by.return_value.first.return_value = fallback_rel
|
||||
content, _ = self._call(content_id='dead-uuid', preferred_stream_id='S99')
|
||||
self.assertIs(content, recovered_episode)
|
||||
self.assertTrue(
|
||||
any('[STREAMID-FALLBACK]' in m and 'Episode' in m for m in logs.output),
|
||||
f"expected episode-flavoured [STREAMID-FALLBACK] warning, got: {logs.output}",
|
||||
)
|
||||
|
||||
def test_uuid_hit_no_fallback_attempted(self):
|
||||
live_episode = MagicMock(uuid='live-uuid', id=42)
|
||||
live_episode.name = 'Live S01E02'
|
||||
live_episode.series.name = 'Live Show'
|
||||
live_relation = MagicMock(stream_id='S2')
|
||||
live_relation.m3u_account.name = 'AcmeProvider'
|
||||
live_episode.m3u_relations.filter.return_value.filter.return_value.first.return_value = live_relation
|
||||
|
||||
with patch('apps.proxy.vod_proxy.views.Episode') as EpisodeMock, \
|
||||
patch('apps.proxy.vod_proxy.views.M3UEpisodeRelation') as RelMock:
|
||||
EpisodeMock.objects.filter.return_value.first.return_value = live_episode
|
||||
content, relation = self._call(content_id='live-uuid', preferred_stream_id='S2')
|
||||
self.assertIs(content, live_episode)
|
||||
self.assertIs(relation, live_relation)
|
||||
RelMock.objects.filter.assert_not_called()
|
||||
|
|
@ -7,17 +7,20 @@ import time
|
|||
import random
|
||||
import logging
|
||||
import requests
|
||||
from urllib.parse import urlencode
|
||||
from django.http import JsonResponse, Http404, HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from apps.vod.models import Movie, Series, Episode
|
||||
from apps.vod.models import Movie, Series, Episode, M3UMovieRelation, M3UEpisodeRelation
|
||||
from apps.m3u.models import M3UAccountProfile
|
||||
from apps.proxy.vod_proxy.multi_worker_connection_manager import MultiWorkerVODConnectionManager, infer_content_type_from_url, get_vod_client_stop_key
|
||||
from .utils import get_client_info
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.decorators import api_view, authentication_classes, permission_classes
|
||||
from rest_framework.permissions import AllowAny
|
||||
from apps.accounts.models import User
|
||||
from apps.accounts.permissions import IsAdmin
|
||||
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
|
||||
|
||||
|
|
@ -36,7 +39,49 @@ def _get_content_and_relation(content_type, content_id, preferred_m3u_account_id
|
|||
logger.info(f"[CONTENT-LOOKUP] Preferred stream ID: {preferred_stream_id}")
|
||||
|
||||
if content_type == 'movie':
|
||||
content_obj = get_object_or_404(Movie, uuid=content_id)
|
||||
content_obj = Movie.objects.filter(uuid=content_id).first()
|
||||
if content_obj is None and preferred_stream_id:
|
||||
# UUIDs are regenerated when process_movie_batch
|
||||
# (apps/vod/tasks.py) creates duplicate vod_movie records
|
||||
# during refresh — see #961 / #973. stream_id is stable
|
||||
# (unique per (m3u_account, stream_id)) so it's a safe
|
||||
# fallback for previously-cached external player URLs.
|
||||
# Strictest-match first: prefer the requested account, then
|
||||
# any active account by priority (matches the existing
|
||||
# relation-selection ordering below).
|
||||
rel = None
|
||||
if preferred_m3u_account_id:
|
||||
rel = (
|
||||
M3UMovieRelation.objects
|
||||
.filter(stream_id=preferred_stream_id,
|
||||
m3u_account_id=preferred_m3u_account_id,
|
||||
m3u_account__is_active=True)
|
||||
.select_related('movie', 'm3u_account')
|
||||
.first()
|
||||
)
|
||||
if rel is None:
|
||||
rel = (
|
||||
M3UMovieRelation.objects
|
||||
.filter(stream_id=preferred_stream_id,
|
||||
m3u_account__is_active=True)
|
||||
.select_related('movie', 'm3u_account')
|
||||
.order_by('-m3u_account__priority', 'id')
|
||||
.first()
|
||||
)
|
||||
if rel is not None:
|
||||
content_obj = rel.movie
|
||||
logger.warning(
|
||||
f"[STREAMID-FALLBACK] Movie UUID {content_id} not "
|
||||
f"found; resolved via stream_id "
|
||||
f"{preferred_stream_id} -> movie uuid "
|
||||
f"{content_obj.uuid} (provider: "
|
||||
f"{rel.m3u_account.name})"
|
||||
)
|
||||
if content_obj is None:
|
||||
raise Http404(
|
||||
f"Movie not found by uuid {content_id} "
|
||||
f"or stream_id {preferred_stream_id}"
|
||||
)
|
||||
logger.info(f"[CONTENT-FOUND] Movie: {content_obj.name} (ID: {content_obj.id})")
|
||||
|
||||
# Filter by preferred stream ID first (most specific)
|
||||
|
|
@ -67,7 +112,44 @@ def _get_content_and_relation(content_type, content_id, preferred_m3u_account_id
|
|||
return content_obj, relation
|
||||
|
||||
elif content_type == 'episode':
|
||||
content_obj = get_object_or_404(Episode, uuid=content_id)
|
||||
content_obj = Episode.objects.filter(uuid=content_id).first()
|
||||
if content_obj is None and preferred_stream_id:
|
||||
# Same rationale as the movie branch above — episode UUIDs
|
||||
# are regenerated when process_series_batch creates
|
||||
# duplicate vod_episode records during refresh.
|
||||
rel = None
|
||||
if preferred_m3u_account_id:
|
||||
rel = (
|
||||
M3UEpisodeRelation.objects
|
||||
.filter(stream_id=preferred_stream_id,
|
||||
m3u_account_id=preferred_m3u_account_id,
|
||||
m3u_account__is_active=True)
|
||||
.select_related('episode', 'm3u_account')
|
||||
.first()
|
||||
)
|
||||
if rel is None:
|
||||
rel = (
|
||||
M3UEpisodeRelation.objects
|
||||
.filter(stream_id=preferred_stream_id,
|
||||
m3u_account__is_active=True)
|
||||
.select_related('episode', 'm3u_account')
|
||||
.order_by('-m3u_account__priority', 'id')
|
||||
.first()
|
||||
)
|
||||
if rel is not None:
|
||||
content_obj = rel.episode
|
||||
logger.warning(
|
||||
f"[STREAMID-FALLBACK] Episode UUID {content_id} not "
|
||||
f"found; resolved via stream_id "
|
||||
f"{preferred_stream_id} -> episode uuid "
|
||||
f"{content_obj.uuid} (provider: "
|
||||
f"{rel.m3u_account.name})"
|
||||
)
|
||||
if content_obj is None:
|
||||
raise Http404(
|
||||
f"Episode not found by uuid {content_id} "
|
||||
f"or stream_id {preferred_stream_id}"
|
||||
)
|
||||
logger.info(f"[CONTENT-FOUND] Episode: {content_obj.name} (ID: {content_obj.id}, Series: {content_obj.series.name})")
|
||||
|
||||
# Filter by preferred stream ID first (most specific)
|
||||
|
|
@ -298,6 +380,7 @@ def _transform_url(original_url, m3u_profile):
|
|||
return original_url
|
||||
|
||||
@api_view(["GET"])
|
||||
@authentication_classes([JWTAuthentication, ApiKeyAuthentication, QueryParamJWTAuthentication])
|
||||
@permission_classes([AllowAny])
|
||||
def stream_vod(request, content_type, content_id, session_id=None, profile_id=None, user=None):
|
||||
"""
|
||||
|
|
@ -311,7 +394,8 @@ def stream_vod(request, content_type, content_id, session_id=None, profile_id=No
|
|||
"""
|
||||
if not network_access_allowed(request, "STREAMS"):
|
||||
return JsonResponse({"error": "Forbidden"}, status=403)
|
||||
|
||||
if user is None and hasattr(request, "user") and request.user.is_authenticated:
|
||||
user = request.user
|
||||
logger.info(f"[VOD-REQUEST] Starting VOD stream request: {content_type}/{content_id}, session: {session_id}, profile: {profile_id}")
|
||||
logger.info(f"[VOD-REQUEST] Full request path: {request.get_full_path()}")
|
||||
logger.info(f"[VOD-REQUEST] Request method: {request.method}")
|
||||
|
|
@ -389,39 +473,66 @@ def stream_vod(request, content_type, content_id, session_id=None, profile_id=No
|
|||
new_session_id = f"vod_{int(time.time() * 1000)}_{random.randint(1000, 9999)}"
|
||||
logger.info(f"[VOD-SESSION] Creating new session: {new_session_id}")
|
||||
|
||||
# Preserve any query parameters (except session_id)
|
||||
# Preserve any query parameters (except session_id and token)
|
||||
query_params = dict(request.GET)
|
||||
query_params.pop('session_id', None) # Remove if present
|
||||
query_params.pop('session_id', None)
|
||||
query_params.pop('token', None) # Token not needed after session is established
|
||||
|
||||
if user:
|
||||
redirect_url = f"{request.path}?session_id={new_session_id}"
|
||||
if query_params:
|
||||
query_string = urlencode(query_params, doseq=True)
|
||||
redirect_url = f"{redirect_url}&{query_string}"
|
||||
else:
|
||||
# Build redirect URL with session ID in path, preserve query parameters
|
||||
# The VOD proxy URL patterns accept session_id in the path, so we redirect
|
||||
# to a path-based URL. XC endpoints (/movie/<user>/<pass>/<id>.<ext>) have
|
||||
# a fixed shape and instead read session_id from a query parameter.
|
||||
is_vod_proxy_path = request.path.startswith('/proxy/vod/')
|
||||
|
||||
if is_vod_proxy_path:
|
||||
path_parts = request.path.rstrip('/').split('/')
|
||||
|
||||
# Construct new path: /vod/movie/UUID/SESSION_ID or /vod/movie/UUID/SESSION_ID/PROFILE_ID/
|
||||
if profile_id:
|
||||
new_path = f"{'/'.join(path_parts)}/{new_session_id}/{profile_id}/"
|
||||
else:
|
||||
new_path = f"{'/'.join(path_parts)}/{new_session_id}"
|
||||
|
||||
if query_params:
|
||||
from urllib.parse import urlencode
|
||||
query_string = urlencode(query_params, doseq=True)
|
||||
redirect_url = f"{new_path}?{query_string}"
|
||||
else:
|
||||
redirect_url = new_path
|
||||
else:
|
||||
# XC path: keep the original path, put session_id in the query string
|
||||
query_params['session_id'] = new_session_id
|
||||
query_string = urlencode(query_params, doseq=True)
|
||||
redirect_url = f"{request.path}?{query_string}"
|
||||
|
||||
logger.info(f"[VOD-SESSION] Redirecting to path-based URL: {redirect_url}")
|
||||
|
||||
# Persist the authenticated user to Redis so the streaming request
|
||||
# (which arrives without the token after the redirect) can resolve it.
|
||||
if user:
|
||||
try:
|
||||
from core.utils import RedisClient
|
||||
_r = RedisClient.get_client()
|
||||
if _r:
|
||||
_r.set(f"vod_session_user:{new_session_id}", user.id, ex=300)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return HttpResponse(
|
||||
status=301,
|
||||
headers={'Location': redirect_url}
|
||||
)
|
||||
|
||||
# Resolve user from Redis session mapping when the streaming request
|
||||
# arrives without auth credentials (token was stripped from redirect URL).
|
||||
# Only needed on the first streaming request - skip if connection already exists.
|
||||
if user is None:
|
||||
try:
|
||||
from core.utils import RedisClient
|
||||
_r = RedisClient.get_client()
|
||||
if _r and not _r.exists(f"vod_persistent_connection:{session_id}"):
|
||||
stored_uid = _r.get(f"vod_session_user:{session_id}")
|
||||
if stored_uid:
|
||||
user = User.objects.filter(id=int(stored_uid)).first()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if user:
|
||||
if not check_user_stream_limits(user, session_id, media_id=content_id):
|
||||
return JsonResponse(
|
||||
|
|
@ -511,6 +622,7 @@ def stream_vod(request, content_type, content_id, session_id=None, profile_id=No
|
|||
return HttpResponse(f"Streaming error: {str(e)}", status=500)
|
||||
|
||||
@api_view(["HEAD"])
|
||||
@authentication_classes([JWTAuthentication, ApiKeyAuthentication, QueryParamJWTAuthentication])
|
||||
@permission_classes([AllowAny])
|
||||
def head_vod(request, content_type, content_id, session_id=None, profile_id=None):
|
||||
"""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue