mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-19 17:47:40 +00:00
When an Xtream Codes provider returns no live streams on a routine refresh (a transient upstream failure, a fetch exception, or no enabled category matching), collect_xc_streams() returns an empty list. The refresh then fell through to stale-marking and sync_auto_channels, which -- with nothing seen this refresh -- deletes the account's entire auto-created channel lineup via its per-group "no streams remaining" branch. Guard the XC branch of _refresh_single_m3u_account_impl: on an empty result, set the account to ERROR and return before stale-marking and auto-sync, mirroring the standard-path empty/failed-download guards already in the function. A transient empty fetch can no longer destroy channels. Adds apps/m3u/tests/test_xc_empty_fetch_guard.py covering both the abort (sync not called, channels preserved, streams not marked stale, status ERROR) and the healthy path (non-empty fetch still runs sync). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
131 lines
5.1 KiB
Python
131 lines
5.1 KiB
Python
"""
|
|
Regression test for the Xtream Codes empty-fetch channel wipe.
|
|
|
|
Bug: when an XC provider returns no live streams on a routine refresh (a
|
|
transient upstream failure, a fetch exception, or no enabled category
|
|
matching), ``collect_xc_streams`` returns ``[]`` and the refresh used to fall
|
|
through to stale-marking and ``sync_auto_channels``. With nothing "seen" this
|
|
refresh, auto-sync deletes the account's entire auto-created channel lineup.
|
|
|
|
Fix: ``_refresh_single_m3u_account_impl`` aborts the XC branch when
|
|
``collect_xc_streams`` returns empty, setting the account to ERROR before any
|
|
stale-marking or auto-sync runs, mirroring the standard-path empty guards.
|
|
"""
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from django.test import TransactionTestCase
|
|
from django.utils import timezone
|
|
|
|
from apps.channels.models import (
|
|
Channel,
|
|
ChannelGroup,
|
|
ChannelGroupM3UAccount,
|
|
Stream,
|
|
)
|
|
from apps.m3u.models import M3UAccount
|
|
from apps.m3u.tasks import _refresh_single_m3u_account_impl
|
|
|
|
|
|
class XCEmptyFetchGuardTests(TransactionTestCase):
|
|
def _setup_xc_account_with_auto_channel(self):
|
|
account = M3UAccount.objects.create(
|
|
name="Test XC Provider",
|
|
server_url="http://example.com",
|
|
username="user",
|
|
password="pass",
|
|
account_type=M3UAccount.Types.XC,
|
|
is_active=True,
|
|
)
|
|
group = ChannelGroup.objects.create(name="Sports")
|
|
ChannelGroupM3UAccount.objects.create(
|
|
m3u_account=account,
|
|
channel_group=group,
|
|
enabled=True,
|
|
auto_channel_sync=True,
|
|
auto_sync_channel_start=100,
|
|
custom_properties={"xc_id": "123"},
|
|
)
|
|
# A pre-existing stream and the auto-created channel built from it on a
|
|
# prior healthy refresh -- this is exactly what the bug deletes.
|
|
stream = Stream.objects.create(
|
|
name="ESPN",
|
|
url="http://example.com/espn.m3u8",
|
|
m3u_account=account,
|
|
channel_group=group,
|
|
last_seen=timezone.now(),
|
|
is_stale=False,
|
|
)
|
|
channel = Channel.objects.create(
|
|
channel_number=100,
|
|
name="ESPN",
|
|
channel_group=group,
|
|
auto_created=True,
|
|
auto_created_by=account,
|
|
)
|
|
return account, group, stream, channel
|
|
|
|
@patch("apps.m3u.tasks.sync_auto_channels")
|
|
@patch("apps.m3u.tasks.collect_xc_streams", return_value=[])
|
|
@patch("apps.m3u.tasks.refresh_m3u_groups")
|
|
def test_empty_xc_fetch_aborts_before_sync_and_preserves_channels(
|
|
self, mock_refresh_groups, _mock_collect, mock_sync
|
|
):
|
|
account, group, stream, channel = self._setup_xc_account_with_auto_channel()
|
|
# XC refresh: empty extinf_data is normal, groups must be present.
|
|
mock_refresh_groups.return_value = ([], {"Sports": group.id})
|
|
|
|
result = _refresh_single_m3u_account_impl(account.id)
|
|
|
|
# The refresh aborts, so auto channel sync never runs.
|
|
mock_sync.assert_not_called()
|
|
# The auto-created channel survives the empty fetch.
|
|
self.assertTrue(Channel.objects.filter(pk=channel.pk).exists())
|
|
# The stream is not marked stale (stale-marking is skipped on abort).
|
|
stream.refresh_from_db()
|
|
self.assertFalse(stream.is_stale)
|
|
# The account is surfaced as errored, not silently "successful".
|
|
account.refresh_from_db()
|
|
self.assertEqual(account.status, M3UAccount.Status.ERROR)
|
|
self.assertIn("no streams returned from provider", result)
|
|
|
|
@patch("apps.m3u.tasks.log_system_event")
|
|
@patch("apps.m3u.tasks.send_m3u_update")
|
|
@patch("apps.m3u.tasks.cleanup_stale_group_relationships")
|
|
@patch("apps.m3u.tasks.cleanup_streams", return_value=0)
|
|
@patch("apps.m3u.tasks.process_m3u_batch_direct", return_value="1 created, 0 updated")
|
|
@patch("apps.m3u.tasks.sync_auto_channels")
|
|
@patch("apps.m3u.tasks.refresh_m3u_groups")
|
|
def test_non_empty_xc_fetch_still_runs_sync(
|
|
self,
|
|
mock_refresh_groups,
|
|
mock_sync,
|
|
_mock_process,
|
|
_mock_cleanup_streams,
|
|
_mock_cleanup_groups,
|
|
_mock_ws,
|
|
_mock_log,
|
|
):
|
|
# The guard must not fire on a healthy refresh: a non-empty fetch
|
|
# proceeds to auto channel sync as before.
|
|
account, group, _stream, _channel = self._setup_xc_account_with_auto_channel()
|
|
mock_refresh_groups.return_value = ([], {"Sports": group.id})
|
|
mock_sync.return_value = {
|
|
"status": "ok",
|
|
"channels_created": 1,
|
|
"channels_updated": 0,
|
|
"channels_deleted": 0,
|
|
"channels_failed": 0,
|
|
"failed_stream_details": [],
|
|
}
|
|
xc_stream = {
|
|
"name": "ESPN",
|
|
"url": "http://example.com/espn.m3u8",
|
|
"attributes": {"group-title": "Sports", "stream_id": "1"},
|
|
}
|
|
|
|
with patch("apps.m3u.tasks.collect_xc_streams", return_value=[xc_stream]):
|
|
_refresh_single_m3u_account_impl(account.id)
|
|
|
|
mock_sync.assert_called_once()
|
|
account.refresh_from_db()
|
|
self.assertEqual(account.status, M3UAccount.Status.SUCCESS)
|