mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
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:
parent
b928de7d50
commit
57cae0e4be
4 changed files with 305 additions and 53 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue