From 57cae0e4be9023e3d7a670107e6097ba153434eb Mon Sep 17 00:00:00 2001 From: None <190783071+CodeBormen@users.noreply.github.com> Date: Sun, 7 Jun 2026 22:02:23 -0500 Subject: [PATCH] fix(m3u): correct auto-sync numbering field interactions from the auto-sync overhaul The auto-sync overhaul added a [start, end] range and a shared numbering picker, but each mode's UI exposes only a subset of the persisted fields (Provider's "Start #" writes channel_numbering_fallback; Next Available exposes no Start/End) while the backend read auto_sync_channel_start and auto_sync_channel_end in every mode. Switching modes never resets the others, so a stale or auto-computed value silently changed numbering. - Provider mode honors the provider-supplied number (stream_chno) verbatim; the group's Start (channel_numbering_fallback) and End bound only the fallback for streams the provider did not number. auto_sync_channel_start is no longer applied in provider mode. - Next Available ignores End, since its UI exposes no range. - Range enforcement (the overflow-delete) runs in fixed mode only, the one mode with a user-set [start, end] range. - Provider mode gains the Start>End guard fixed mode already had, so an inverted fallback range cannot fail every numberless stream. Includes backend and frontend regression tests. --- apps/m3u/tasks.py | 64 ++--- apps/m3u/tests/test_sync_correctness.py | 260 +++++++++++++++++- .../src/components/forms/AutoSyncBasic.jsx | 15 +- .../forms/__tests__/AutoSyncBasic.test.jsx | 19 ++ 4 files changed, 305 insertions(+), 53 deletions(-) diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 3b504ebf..a4d863e9 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -1716,42 +1716,27 @@ def _pick_target_number( fixed_cursor, fallback_start, end_number=None, - range_start=None, ): """ - Return the channel number a given stream should claim under the group's - numbering mode, or None if the configured range is exhausted. + Return the channel number a stream should claim under the group's numbering + mode, or None if the range is exhausted. Shared by the renumber and create + passes. Each mode reads only the fields its UI exposes: - Shared by the existing-channel renumber pass and the new-channel create - pass so both honor identical mode semantics: provider-supplied number - when available and free, otherwise fall back; or always next-available; - or fixed-cursor sequential. - - `range_start`, when provided, is the inclusive lower bound for the - group's configured numbering range. Provider-supplied numbers below - this bound fall back to the next-available picker so freshly-created - channels never land outside the configured range. + - provider: the provider number is authoritative and used as-is when free. + Start (`channel_numbering_fallback`) and End bound only the fallback for + streams with no provider number; `auto_sync_channel_start` does not apply. + - next_available: lowest free number from 1; End does not apply (its UI has + no range, so a stale End must not cap it). + - fixed: sequential from the cursor, bounded by End. """ if mode == "provider": chno = stream.stream_chno - if ( - chno is not None - and chno not in used_numbers - and (range_start is None or chno >= range_start) - and (end_number is None or chno <= end_number) - ): + if chno is not None and chno not in used_numbers: return chno - # No usable provider number: walk from fallback_start, bumped up - # to range_start when set so the fallback never lands below the - # configured range. - effective_start = ( - max(fallback_start, range_start) - if range_start is not None - else fallback_start - ) - return _next_available_number(used_numbers, effective_start, end=end_number) + # No usable provider number: fall back into the configured range. + return _next_available_number(used_numbers, fallback_start, end=end_number) if mode == "next_available": - return _next_available_number(used_numbers, 1, end=end_number) + return _next_available_number(used_numbers, 1) return _next_available_number(used_numbers, fixed_cursor, end=end_number) @@ -2253,7 +2238,6 @@ def sync_auto_channels(account_id, scan_start_time=None): temp_channel_number, channel_numbering_fallback, end_number=end_number, - range_start=start_number, ) # Range exhausted: leave the channel at its existing @@ -2290,17 +2274,16 @@ def sync_auto_channels(account_id, scan_start_time=None): f"Renumbered {len(channels_to_renumber)} channels to maintain sort order" ) - # When the group's configured range is narrower than its existing - # channels span, any non-hidden auto-created channel whose number - # falls outside [start, end] gets deleted. The new-channel - # creation loop below picks up the freed stream and re-creates - # the channel at a slot inside the new range, so the net user - # outcome is a renumber, not a failure. Counted in - # channels_deleted; the replacement counts in channels_created. - # Hidden channels are preserved. Runs BEFORE new-channel creation - # so slots freed by the deletions are available to incoming - # streams. - if end_number is not None: + # Range enforcement runs in fixed mode only: it is the one mode with + # a user-set [start, end]. Provider numbers are authoritative and + # next_available has no range, so their channels are never deleted + # for falling outside start/end. + # + # Channels outside the range are deleted (hidden ones preserved); + # the creation loop below re-adds the freed streams inside the range, + # so the net effect is a renumber, not a failure. Runs first so the + # freed slots are available. + if end_number is not None and channel_numbering_mode == "fixed": overflow_delete_ids = [] for stream_id, ch in list(existing_channel_map.items()): if ch.hidden_from_output: @@ -2461,7 +2444,6 @@ def sync_auto_channels(account_id, scan_start_time=None): current_channel_number, channel_numbering_fallback, end_number=end_number, - range_start=start_number, ) if target_number is None: diff --git a/apps/m3u/tests/test_sync_correctness.py b/apps/m3u/tests/test_sync_correctness.py index 9f8b87a2..4a2986fb 100644 --- a/apps/m3u/tests/test_sync_correctness.py +++ b/apps/m3u/tests/test_sync_correctness.py @@ -46,13 +46,14 @@ def _attach_group_to_account(account, group, custom_properties=None): ) -def _make_stream(account, group, name="ESPN", tvg_id="espn"): +def _make_stream(account, group, name="ESPN", tvg_id="espn", stream_chno=None): return Stream.objects.create( name=name, url=f"http://example.com/{name.lower()}.m3u8", m3u_account=account, channel_group=group, tvg_id=tvg_id, + stream_chno=stream_chno, last_seen=timezone.now(), ) @@ -413,21 +414,21 @@ class RangeEnforcementTests(TestCase): "new auto-channel duplicates A's effective channel number.", ) - def test_provider_mode_fallback_respects_range_start(self): - # Provider-mode streams without a usable stream_chno fall back to - # the next-available picker. The fallback must honor the group's - # configured `auto_sync_channel_start` so freshly-created channels - # never land below the user's chosen range. Without this, a group - # configured for [100, 200] silently spawned channels at #1 when - # the provider omitted channel-number metadata. + def test_provider_mode_numberless_fallback_uses_visible_start(self): + # In provider mode the visible "Start #" is channel_numbering_fallback, + # so a numberless stream's fallback walks from there, not from the + # hidden auto_sync_channel_start (set far above the range here to prove + # it is ignored). + # Fail signature: 0 channels created, or a channel below 100 = fallback + # seeded from the wrong field. account = _make_account() group = _make_group(name="Sports") rel = _attach_group_to_account(account, group) - rel.auto_sync_channel_start = 100 + rel.auto_sync_channel_start = 5000 # hidden; must be ignored rel.auto_sync_channel_end = 200 rel.custom_properties = { "channel_numbering_mode": "provider", - "channel_numbering_fallback": 1, + "channel_numbering_fallback": 100, # the visible "Start #" } rel.save() @@ -2300,3 +2301,242 @@ class CompactNumberingIdempotencyTests(TransactionTestCase): "pass with no hide or override change. _repack_inner is following " "physical row order instead of id order.", ) + + +class ProviderNumberingHonorsProviderNumberTests(TestCase): + """ + Provider numbering uses a stream's provider number (stream_chno) verbatim. + The group start is auto-populated by the UI and is not editable in provider + mode (the UI binds "Start #" to channel_numbering_fallback), so treating it + as a lower bound silently discarded valid provider numbers: on a lineup + topping out near 5000, provider numbers 100-150 landed at ~5000. + + The start and end bound only the fallback for numberless streams. + """ + + def test_provider_number_below_high_auto_start_is_honored(self): + # Provider numbers 100-104 with an auto-set start of 5000 must land at + # their provider numbers. + # Fail signature: channels at 5000-5004 = start used as a hard floor. + account = _make_account() + group = _make_group(name="PPV") + rel = _attach_group_to_account(account, group) + rel.auto_sync_channel_start = 5000 + rel.auto_sync_channel_end = None + rel.custom_properties = { + "channel_numbering_mode": "provider", + "channel_numbering_fallback": 1, + } + rel.save() + for i in range(5): + _make_stream( + account, group, name=f"PPV {i}", tvg_id=f"ppv{i}", + stream_chno=100 + i, + ) + + result = _sync(account) + + self.assertEqual(result["status"], "ok") + numbers = sorted( + Channel.objects.filter( + auto_created=True, auto_created_by=account + ).values_list("channel_number", flat=True) + ) + self.assertEqual(numbers, [100.0, 101.0, 102.0, 103.0, 104.0]) + + def test_provider_number_honored_when_start_unset(self): + # start blank -> defaults to 1.0; provider numbers still honored. + account = _make_account() + group = _make_group(name="PPV") + rel = _attach_group_to_account(account, group) + rel.auto_sync_channel_start = None + rel.auto_sync_channel_end = None + rel.custom_properties = {"channel_numbering_mode": "provider"} + rel.save() + _make_stream(account, group, name="PPV", tvg_id="ppv", stream_chno=100) + + result = _sync(account) + + self.assertEqual(result["status"], "ok") + created = Channel.objects.get(auto_created=True, auto_created_by=account) + self.assertEqual(created.channel_number, 100.0) + + def test_numberless_stream_uses_fallback_not_hidden_start(self): + # In provider mode without a range, a stream lacking a provider + # number falls back to channel_numbering_fallback (the visible + # "Start #"), not the hidden auto_sync_channel_start. + # Fail signature: channel at 5000 = fallback bumped to hidden start. + account = _make_account() + group = _make_group(name="PPV") + rel = _attach_group_to_account(account, group) + rel.auto_sync_channel_start = 5000 + rel.auto_sync_channel_end = None + rel.custom_properties = { + "channel_numbering_mode": "provider", + "channel_numbering_fallback": 300, + } + rel.save() + _make_stream(account, group, name="NoChno", tvg_id="nc") + + result = _sync(account) + + self.assertEqual(result["status"], "ok") + created = Channel.objects.get(auto_created=True, auto_created_by=account) + self.assertEqual(created.channel_number, 300.0) + + def test_provider_number_below_range_is_honored_verbatim(self): + # A provider number below the group's Start/End is used as-is, not + # coerced into the range. + # Fail signature: channel pulled to >= 100 = range coercing a provider + # number. + account = _make_account() + group = _make_group(name="PPV") + rel = _attach_group_to_account(account, group) + rel.auto_sync_channel_end = 200 + rel.custom_properties = { + "channel_numbering_mode": "provider", + "channel_numbering_fallback": 100, + } + rel.save() + _make_stream(account, group, name="Low", tvg_id="low", stream_chno=50) + + result = _sync(account) + + self.assertEqual(result["status"], "ok") + created = Channel.objects.get(auto_created=True, auto_created_by=account) + self.assertEqual(created.channel_number, 50.0) + + def test_provider_number_above_end_is_honored_verbatim(self): + # Provider numbers above the configured End are also honored as-is; + # the End caps only the fallback for numberless streams. + account = _make_account() + group = _make_group(name="PPV") + rel = _attach_group_to_account(account, group) + rel.auto_sync_channel_end = 200 + rel.custom_properties = { + "channel_numbering_mode": "provider", + "channel_numbering_fallback": 1, + } + rel.save() + _make_stream(account, group, name="High", tvg_id="high", stream_chno=5000) + + result = _sync(account) + + self.assertEqual(result["status"], "ok") + created = Channel.objects.get(auto_created=True, auto_created_by=account) + self.assertEqual(created.channel_number, 5000.0) + + def test_provider_number_within_range_is_honored(self): + # An in-range provider number is used as-is. + account = _make_account() + group = _make_group(name="PPV") + rel = _attach_group_to_account(account, group) + rel.auto_sync_channel_end = 200 + rel.custom_properties = { + "channel_numbering_mode": "provider", + "channel_numbering_fallback": 1, + } + rel.save() + _make_stream(account, group, name="Mid", tvg_id="mid", stream_chno=150) + + result = _sync(account) + + self.assertEqual(result["status"], "ok") + created = Channel.objects.get(auto_created=True, auto_created_by=account) + self.assertEqual(created.channel_number, 150.0) + + +class CrossModeNumberingFieldTests(TestCase): + """ + Each numbering mode's UI exposes only a subset of the persisted fields, + and switching modes does not reset the others. The backend must therefore + read only the fields a mode actually owns, so a stale/hidden value left by + another mode cannot silently change numbering. These guard the two + remaining facets of that family (the provider-floor facet is covered by + ProviderNumberingHonorsProviderNumberTests). + """ + + def _restamp(self, account): + Stream.objects.filter(m3u_account=account).update( + last_seen=timezone.now() + ) + + def test_next_available_ignores_configured_end(self): + # next_available exposes no Start/End in its UI, so a stale End left + # over from a prior mode must not cap it. Every stream gets the lowest + # free number from 1 regardless of the End. + # Fail signature: streams beyond the End fail = next_available honoring + # a hidden cap. + account = _make_account() + group = _make_group(name="Sports") + rel = _attach_group_to_account(account, group) + rel.auto_sync_channel_start = 1 + rel.auto_sync_channel_end = 3 # stale cap from a prior mode + rel.custom_properties = {"channel_numbering_mode": "next_available"} + rel.save() + for i in range(5): + _make_stream(account, group, name=f"S{i}", tvg_id=f"s{i}") + + result = _sync(account) + + self.assertEqual(result["status"], "ok") + self.assertEqual(result["channels_created"], 5) + self.assertEqual(result["channels_failed"], 0) + + def test_provider_channels_outside_range_are_not_deleted(self): + # Range enforcement (the overflow-delete) is fixed-mode only. A provider + # channel whose number is outside [start, end] is authoritative and must + # survive sync, not be deleted and churned into a new row. + # Fail signature: channels_deleted > 0 on the second sync = overflow + # delete firing in provider mode. + account = _make_account() + group = _make_group(name="PPV") + rel = _attach_group_to_account(account, group) + rel.auto_sync_channel_end = 200 + rel.custom_properties = { + "channel_numbering_mode": "provider", + "channel_numbering_fallback": 1, + } + rel.save() + _make_stream(account, group, name="High", tvg_id="high", stream_chno=5000) + + first = _sync(account) + self.assertEqual(first["channels_created"], 1) + original = Channel.objects.get(auto_created=True, auto_created_by=account) + self.assertEqual(original.channel_number, 5000.0) + + self._restamp(account) + second = _sync(account) + + self.assertEqual(second["channels_deleted"], 0) + survivor = Channel.objects.get(auto_created=True, auto_created_by=account) + self.assertEqual(survivor.id, original.id) + self.assertEqual(survivor.channel_number, 5000.0) + + def test_next_available_channels_outside_stale_range_not_deleted(self): + # Same gate for next_available: tightening a stale End must not delete + # already-assigned channels (range enforcement is fixed-mode only). + account = _make_account() + group = _make_group(name="Sports") + rel = _attach_group_to_account(account, group) + rel.auto_sync_channel_start = 1 + rel.auto_sync_channel_end = None + rel.custom_properties = {"channel_numbering_mode": "next_available"} + rel.save() + for i in range(5): + _make_stream(account, group, name=f"S{i}", tvg_id=f"s{i}") + + self.assertEqual(_sync(account)["channels_created"], 5) + + rel.auto_sync_channel_end = 3 # stale cap appears + rel.save() + self._restamp(account) + second = _sync(account) + + self.assertEqual(second["channels_deleted"], 0) + self.assertEqual( + Channel.objects.filter( + auto_created=True, auto_created_by=account + ).count(), + 5, + ) diff --git a/frontend/src/components/forms/AutoSyncBasic.jsx b/frontend/src/components/forms/AutoSyncBasic.jsx index 6cab8ea7..a3a0b5ba 100644 --- a/frontend/src/components/forms/AutoSyncBasic.jsx +++ b/frontend/src/components/forms/AutoSyncBasic.jsx @@ -37,13 +37,24 @@ const AutoSyncBasic = ({ ? 1 : clampChannelNumber(value); if (mode === 'provider') { - onApplyGroupChange({ + // Provider's Start # seeds the fallback for numberless streams. Mirror + // Fixed mode: if it exceeds End, drop End so the fallback range is never + // inverted (an inverted range fails every numberless stream). + const next = { ...group, custom_properties: { ...(group.custom_properties || {}), channel_numbering_fallback: normalized, }, - }); + }; + if ( + endValue !== null && + endValue !== undefined && + normalized > endValue + ) { + next.auto_sync_channel_end = null; + } + onApplyGroupChange(next); } else { // If End is set and the new Start exceeds it, drop End so the user // is not left holding an invalid range silently. diff --git a/frontend/src/components/forms/__tests__/AutoSyncBasic.test.jsx b/frontend/src/components/forms/__tests__/AutoSyncBasic.test.jsx index 67055a4a..8392171f 100644 --- a/frontend/src/components/forms/__tests__/AutoSyncBasic.test.jsx +++ b/frontend/src/components/forms/__tests__/AutoSyncBasic.test.jsx @@ -289,6 +289,25 @@ describe('AutoSyncBasic', () => { const call = onApplyGroupChange.mock.calls[0][0]; expect(call.custom_properties.channel_numbering_fallback).toBe(1); }); + + it('drops End when the fallback start exceeds End in provider mode', () => { + // Mirrors fixed mode: an inverted fallback range would fail every + // numberless stream, so End is dropped rather than silently kept. + const { onApplyGroupChange } = renderComponent({ + custom_properties: { + channel_numbering_mode: 'provider', + channel_numbering_fallback: 1, + }, + auto_sync_channel_end: 200, + }); + vi.mocked(clampChannelNumber).mockReturnValueOnce(500); + fireEvent.change(screen.getByTestId(/start/i), { + target: { value: '500' }, + }); + const call = onApplyGroupChange.mock.calls[0][0]; + expect(call.custom_properties.channel_numbering_fallback).toBe(500); + expect(call.auto_sync_channel_end).toBeFalsy(); + }); }); // ── updateEnd ──────────────────────────────────────────────────────────────