diff --git a/apps/m3u/tests/test_sync_correctness.py b/apps/m3u/tests/test_sync_correctness.py index 3cc24942..8ae09fa8 100644 --- a/apps/m3u/tests/test_sync_correctness.py +++ b/apps/m3u/tests/test_sync_correctness.py @@ -644,6 +644,34 @@ class NumbersInRangeLookupTests(TestCase): ) self.assertFalse(occupant["has_channel_number_override"]) + def test_group_override_channel_reports_target_group(self): + # When auto-sync routes channels into a different group via + # group_override, the occupant's channel_group_id is the override + # target, not the source group being configured. The frontend relies + # on this to recognize override-routed channels as the config's own + # output (effectiveSyncGroupId), so the warning does not flag them. + account = _make_account() + source = _make_group(name="SourceGrp") + target = _make_group(name="TargetGrp") + Channel.objects.create( + name="Routed", + channel_number=3210, + channel_group=target, + auto_created=True, + auto_created_by=account, + ) + client = self._client() + + response = client.get( + "/api/channels/channels/numbers-in-range/?start=3210&end=3210" + ) + + occupant = response.data["occupants"][0] + self.assertEqual(occupant["channel_group_id"], target.id) + self.assertNotEqual(occupant["channel_group_id"], source.id) + self.assertTrue(occupant["auto_created"]) + self.assertEqual(occupant["auto_created_by_account_id"], account.id) + def test_manual_channel_exposed_with_auto_created_false(self): # Manual channels are always a real collision worth surfacing. # The response must flag them with auto_created=False and a null diff --git a/frontend/src/components/forms/LiveGroupFilter.jsx b/frontend/src/components/forms/LiveGroupFilter.jsx index 626ec55e..5ed2be94 100644 --- a/frontend/src/components/forms/LiveGroupFilter.jsx +++ b/frontend/src/components/forms/LiveGroupFilter.jsx @@ -34,6 +34,7 @@ import { getRegexOptions, getStreamsRegexPreview, isExpectedOccupantForGroup, + effectiveSyncGroupId, isGroupVisible, rangeFor, } from '../../utils/forms/LiveGroupFilterUtils.js'; @@ -131,7 +132,12 @@ const LiveGroupFilter = ({ // (in-memory range overlap with sibling groups) sources so the sweep // can refresh form-overlap synchronously without firing HTTP for // groups that did not change. - const scheduleConflictScan = (groupId, rawStart, rawEnd) => { + const scheduleConflictScan = ( + groupId, + rawStart, + rawEnd, + expectedGroupId = groupId + ) => { if (conflictTimersRef.current[groupId]) { clearTimeout(conflictTimersRef.current[groupId]); } @@ -156,7 +162,7 @@ const LiveGroupFilter = ({ ? result.occupants : []; const unexpected = occupants.filter( - (o) => !isExpectedOccupantForGroup(o, groupId, playlist) + (o) => !isExpectedOccupantForGroup(o, expectedGroupId, playlist) ); setConflictSource(groupId, 'occupant', unexpected.length > 0); } catch (e) { @@ -221,7 +227,8 @@ const LiveGroupFilter = ({ scheduleConflictScan( g.channel_group, range.startRaw, - g.auto_sync_channel_end + g.auto_sync_channel_end, + effectiveSyncGroupId(g) ); } } diff --git a/frontend/src/utils/forms/LiveGroupFilterUtils.js b/frontend/src/utils/forms/LiveGroupFilterUtils.js index a1372887..3cb62bf5 100644 --- a/frontend/src/utils/forms/LiveGroupFilterUtils.js +++ b/frontend/src/utils/forms/LiveGroupFilterUtils.js @@ -57,6 +57,18 @@ export const isExpectedOccupantForGroup = ( ); }; +// The group the sync's own channels actually land in. A group_override +// routes auto-created channels into a different ChannelGroup, so the +// conflict check must recognize occupants of that target group as this +// config's own output rather than flagging them against the source group. +export const effectiveSyncGroupId = (group) => { + const override = group?.custom_properties?.group_override; + if (override !== undefined && override !== null && override !== '') { + return Number(override); + } + return group?.channel_group; +}; + export const rangeFor = (g) => { if (!g.enabled || !g.auto_channel_sync) return null; const mode = g.custom_properties?.channel_numbering_mode || 'fixed'; diff --git a/frontend/src/utils/forms/__tests__/LiveGroupFilterUtils.test.js b/frontend/src/utils/forms/__tests__/LiveGroupFilterUtils.test.js index feb878e1..86874f23 100644 --- a/frontend/src/utils/forms/__tests__/LiveGroupFilterUtils.test.js +++ b/frontend/src/utils/forms/__tests__/LiveGroupFilterUtils.test.js @@ -4,6 +4,7 @@ import { getChannelsInRange, getStreamsRegexPreview, isExpectedOccupantForGroup, + effectiveSyncGroupId, rangeFor, abortTimers, getRegexOptions, @@ -227,6 +228,116 @@ describe('LiveGroupFilterUtils', () => { }); }); + // ── effectiveSyncGroupId ─────────────────────────────────────────────────── + describe('effectiveSyncGroupId', () => { + it('returns the source channel_group when there is no override', () => { + expect(effectiveSyncGroupId(makeGroup({ channel_group: 7 }))).toBe(7); + }); + + it('returns the group_override target when set', () => { + const group = makeGroup({ + channel_group: 7, + custom_properties: { group_override: 9 }, + }); + expect(effectiveSyncGroupId(group)).toBe(9); + }); + + it('coerces a string-stored group_override to a number', () => { + const group = makeGroup({ + channel_group: 7, + custom_properties: { group_override: '9' }, + }); + expect(effectiveSyncGroupId(group)).toBe(9); + }); + + it('falls back to the source group when group_override is blank', () => { + const group = makeGroup({ + channel_group: 7, + custom_properties: { group_override: '' }, + }); + expect(effectiveSyncGroupId(group)).toBe(7); + }); + + // Regression guard for the group-override range-conflict false positive: + // the auto-sync's own channels land in the override target group, so + // comparing against the source group (pre-fix) flags them as a conflict, + // while comparing against the effective target recognizes them as this + // config's own output. + it("makes group-override occupants count as this group's own", () => { + const group = makeGroup({ + channel_group: 7, + custom_properties: { group_override: 9 }, + }); + const occupant = makeOccupant({ channel_group_id: 9 }); + // Pre-fix comparison (source group) treats own channels as a conflict. + expect( + isExpectedOccupantForGroup( + occupant, + group.channel_group, + makePlaylist() + ) + ).toBe(false); + // Comparing against the effective target recognizes them as expected. + expect( + isExpectedOccupantForGroup( + occupant, + effectiveSyncGroupId(group), + makePlaylist() + ) + ).toBe(true); + }); + + // Guards against over-suppression: resolving the effective target group + // must still surface genuine collisions in an override config's range. + // Only the config's own output (auto-created, this account, in the + // target group, unpinned) is excluded. + it('still flags genuine collisions in a group-override config', () => { + const group = makeGroup({ + channel_group: 7, + custom_properties: { group_override: 9 }, + }); + const target = effectiveSyncGroupId(group); + // Manual channel sitting in the range. + expect( + isExpectedOccupantForGroup( + makeOccupant({ channel_group_id: 9, auto_created: false }), + target, + makePlaylist() + ) + ).toBe(false); + // Auto-created by a different account. + expect( + isExpectedOccupantForGroup( + makeOccupant({ + channel_group_id: 9, + auto_created_by_account_id: 999, + }), + target, + makePlaylist() + ) + ).toBe(false); + // A channel in a different group than the override target. + expect( + isExpectedOccupantForGroup( + makeOccupant({ channel_group_id: 123 }), + target, + makePlaylist() + ) + ).toBe(false); + // A user-pinned channel number. + expect( + isExpectedOccupantForGroup( + makeOccupant({ + channel_group_id: 9, + has_channel_number_override: true, + }), + target, + makePlaylist() + ) + ).toBe(false); + }); + }); + // ── rangeFor ────────────────────────────────────────────────────────────── describe('rangeFor', () => { it('returns null when group is disabled', () => {