Fixed max user connection screen when grace granted (#880)

* **Bug Fixes**
  * Improved live-stream session handling during instant grace periods, including when a session has not yet been created.
  * Corrected connection-limit and eviction tracking so active streams remain eligible for eviction even when their session record is unavailable.
  * Improved reliability when switching between live channels under connection limits.
* **Improvements**
  * Updated staged M3U overlays for Xtream providers: staged groups determine selected categories and channels, while unmatched categories remain available. Provider categories with no usable staged channels are retained.
* **Documentation**
  * Clarified how staged Xtream and M3U playlists affect provider categories and channels.
This commit is contained in:
euzu
2026-09-25 08:22:11 +02:00
committed by GitHub
parent 0d60a54d3e
commit 2c07d52b93
14 changed files with 496 additions and 80 deletions
@@ -542,8 +542,8 @@ fn prepare_staged_xtream_group(
/// names may differ, the ids do not. Staged groups are therefore resolved against the provider groups
/// by id, never by name ([`match_staged_groups`]). An overlaid group keeps the provider category id;
/// a staged group that introduces a new category keeps its own id only when that id is still free. A
/// staged group whose channels carry no numeric stream id leaves the provider category untouched, so
/// unusable overlay rows cannot remove provider content.
/// staged group whose channels carry no numeric stream id leaves the provider category untouched for
/// Xtream staged input, while an M3U staged input remains authoritative for the selected clusters.
pub(crate) fn apply_staged_overlay_groups(
provider: &ConfigInput,
staged_type: StagedInputType,
@@ -619,6 +619,10 @@ pub(crate) fn apply_staged_overlay_groups(
let mut used_ids = GroupCategoryIds::from_groups(&provider_groups);
let assignment = match_staged_groups(&provider_groups, &staged_selected, staged_type, clusters);
if staged_type == StagedInputType::M3u {
return apply_staged_m3u_template(provider_groups, staged_selected, &assignment, clusters, used_ids);
}
let mut result: Vec<PlaylistGroup> = Vec::with_capacity(provider_groups.len() + staged_selected.len());
for (provider_idx, provider_group) in provider_groups.into_iter().enumerate() {
@@ -629,8 +633,6 @@ pub(crate) fn apply_staged_overlay_groups(
let matched = assignment.staged_of(provider_idx).and_then(|staged_idx| staged_selected[staged_idx].take());
let Some(mut staged) = matched.filter(|staged| !staged.channels.is_empty()) else {
// A staged group whose channels carried no numeric stream id keeps the provider category,
// so the overlaid cluster is never emptied by unusable overlay rows.
result.push(provider_group);
continue;
};
@@ -645,17 +647,7 @@ pub(crate) fn apply_staged_overlay_groups(
// Append newly introduced staged groups that had no corresponding provider group.
for mut staged in staged_selected.into_iter().flatten().filter(|staged| !staged.channels.is_empty()) {
if staged.id == 0 {
// A staged group without a category id introduces a new category, so it only needs a free id.
staged.id = used_ids.allocate(staged.xtream_cluster);
} else if !used_ids.claim(staged.xtream_cluster, staged.id) {
let allocated = used_ids.allocate(staged.xtream_cluster);
warn!(
"Staged group '{}' uses category id {} which is already taken in {}; renumbered to {allocated}",
staged.title, staged.id, staged.xtream_cluster
);
staged.id = allocated;
}
used_ids.assign_new_group(&mut staged);
sync_channel_category_id(&mut staged);
result.push(staged);
}
@@ -663,6 +655,35 @@ pub(crate) fn apply_staged_overlay_groups(
result
}
fn apply_staged_m3u_template(
provider_groups: Vec<PlaylistGroup>,
staged_groups: Vec<Option<PlaylistGroup>>,
assignment: &StagedOverlayAssignment,
clusters: ClusterFlags,
mut used_ids: GroupCategoryIds,
) -> Vec<PlaylistGroup> {
let mut result = Vec::with_capacity(provider_groups.len() + staged_groups.len());
for (staged_idx, staged) in staged_groups.into_iter().enumerate() {
let Some(mut staged) = staged.filter(|group| !group.channels.is_empty()) else {
continue;
};
if let Some(provider_idx) = assignment.provider_by_staged[staged_idx] {
let provider_group = &provider_groups[provider_idx];
staged.id = if provider_group.id == 0 {
used_ids.allocate(provider_group.xtream_cluster)
} else {
provider_group.id
};
} else {
used_ids.assign_new_group(&mut staged);
}
sync_channel_category_id(&mut staged);
result.push(staged);
}
result.extend(provider_groups.into_iter().filter(|group| !cluster_selected(group.xtream_cluster, clusters)));
result
}
/// Keeps the channels of a group consistent with its final category id.
///
/// Persistence re-syncs this through [`PlaylistGroup::on_load`], but the overlay result is consumed as
@@ -898,6 +919,19 @@ impl GroupCategoryIds {
id != 0 && self.used.entry(cluster).or_default().insert(id)
}
fn assign_new_group(&mut self, group: &mut PlaylistGroup) {
if group.id == 0 {
group.id = self.allocate(group.xtream_cluster);
} else if !self.claim(group.xtream_cluster, group.id) {
let allocated = self.allocate(group.xtream_cluster);
warn!(
"Staged group '{}' uses category id {} which is already taken in {}; renumbered to {allocated}",
group.title, group.id, group.xtream_cluster
);
group.id = allocated;
}
}
/// Reserves and returns the next free id of `cluster`.
fn allocate(&mut self, cluster: XtreamCluster) -> u32 {
let used = self.used.entry(cluster).or_default();
@@ -1443,14 +1443,56 @@ fn staged_m3u_unknown_streams_do_not_match_positional_category_id() {
vec![staged_new],
);
assert_eq!(groups.len(), 2);
assert_eq!(groups[0].title.as_ref(), "Sports");
assert_eq!(groups[0].channels[0].header.id.as_ref(), "100");
assert_eq!(groups[1].title.as_ref(), "New");
assert_eq!(groups[1].channels[0].header.id.as_ref(), "300");
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].title.as_ref(), "New");
assert_eq!(groups[0].channels[0].header.id.as_ref(), "300");
assert_unique_category_ids(&groups);
}
#[test]
fn staged_m3u_replaces_selected_xtream_groups_and_keeps_other_clusters() {
let provider = ConfigInput {
name: "provider".intern(),
input_type: InputType::Xtream,
url: "http://provider.example".to_string(),
username: Some("real-user".to_string()),
password: Some("real-pass".to_string()),
..Default::default()
};
let mut provider_news = test_group(XtreamCluster::Live, "Provider News", "provider");
provider_news.id = 5;
provider_news.channels[0].header.id = "100".intern();
let mut provider_sports = test_group(XtreamCluster::Live, "Provider Sports", "provider");
provider_sports.id = 7;
provider_sports.channels[0].header.id = "200".intern();
provider_sports.channels[0].header.url = "http://provider.example/direct/200.ts".intern();
let mut provider_kids = test_group(XtreamCluster::Live, "Provider Kids", "provider");
provider_kids.id = 9;
provider_kids.channels[0].header.id = "300".intern();
let provider_vod = test_group(XtreamCluster::Video, "Provider VOD", "provider");
let mut staged_custom = test_group(XtreamCluster::Live, "My Channels", "staged");
staged_custom.channels[0].header.id = "200".intern();
let mut staged_news = test_group(XtreamCluster::Live, "My News", "staged");
staged_news.channels[0].header.id = "100".intern();
let groups = super::apply_staged_overlay_groups(
&provider,
StagedInputType::M3u,
ClusterFlags::Live,
vec![provider_news, provider_sports, provider_kids, provider_vod],
vec![staged_custom, staged_news],
);
assert_eq!(groups.len(), 3);
assert_eq!(groups[0].title.as_ref(), "My Channels");
assert_eq!(groups[0].channels[0].header.id.as_ref(), "200");
assert_eq!(groups[0].channels[0].header.input_name.as_ref(), "provider");
assert_eq!(groups[0].channels[0].header.url.as_ref(), "http://provider.example/direct/200.ts");
assert_eq!(groups[1].title.as_ref(), "My News");
assert_eq!(groups[1].channels[0].header.id.as_ref(), "100");
assert_eq!(groups[2].title.as_ref(), "Provider VOD");
}
#[test]
fn staged_xtream_overlay_falls_back_to_the_secondary_stream_overlap() {
let provider = ConfigInput {