mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 17:16:26 +00:00
Issue: When `process_movie_batch` / `process_series_batch` (apps/vod/tasks.py) creates duplicate vod_movie / vod_episode records during a refresh, existing M3U*Relation rows get repointed to the new records. The old UUIDs that external players (Emby / Jellyfin / ChannelsDVR) cached in `.strm` URLs are left orphaned, and the proxy 404s on every subsequent request — even though the same request already carries a stable `stream_id` that uniquely identifies a live relation. This patch makes `_get_content_and_relation` use a soft UUID lookup and, if it misses, fall back to resolving content via `M3UMovieRelation.stream_id` / `M3UEpisodeRelation.stream_id`. The fallback respects the strictest match first: when `preferred_m3u_account_id` is also present, that account is queried first; otherwise the highest-priority active relation matching the stream_id wins (same ordering the existing relation-selection logic uses). This is read-side graceful degradation, complementary to the write-side root fix proposed in closed #973 (re-use the relation's existing movie/series during refresh rather than rematching by TMDB ID). The two PRs together would fully resolve #961: #973 prevents new orphaning, this PR makes any URL that ships a stream_id survive past orphaning. URLs without a stream_id (e.g. XC-compat `/movie/<user>/<pass>/<id>.mkv`) are NOT covered by this patch — they need the root fix. Series-UUID lookups (`/proxy/vod/series/<UUID>`) are left UUID-only by design — players cache episode and movie URLs, not series URLs. Same pattern can be added as a follow-up if needed. Tests: 7 new mock-based regression tests covering both branches, verified against `:dev`. UUID-first happy path is unchanged (M3U*Relation table never queried when the UUID resolves). Related: #961 (open), closed #973 (root fix abandoned for inactivity).
225 lines
12 KiB
Python
225 lines
12 KiB
Python
"""
|
|
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()
|