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.
This commit is contained in:
None 2026-06-07 22:02:23 -05:00
parent b928de7d50
commit 57cae0e4be
4 changed files with 305 additions and 53 deletions

View file

@ -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:

View file

@ -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,
)

View file

@ -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.

View file

@ -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