Files
tuliprox/backend/curation/src/kernel.rs
T
knylbyteandGitHub 5ae547f5e5 FIX: stabilize shared HLS playback, recovery and account handovers (#925)
* fix (hls): preserve playback reserve across cache eviction

keep a stable session media clock and completed segment timings so cache
eviction and origin rollovers cannot reset playback reserve.

update segment fixtures and cover reserve stability after collection.

* fix (hls): prevent playback stalls during origin manifest retries

mark the origin as degraded on the first retryable manifest failure, cap
retry-after delays, and reevaluate reserve independently of client polling.

use a bounded state commit after an expired splice deadline instead of
leaving the lease live when terminal media cannot be published safely.

* fix (hls): serve custom responses after the live splice deadline

publish a finite lease-bound channel_unavailable playlist on the existing
manifest route when a safe live splice is no longer possible.

renew lease and session activity on successful custom-segment requests
and retain concrete preparation failure diagnostics.

* fix(hls): persist origin qualification for short playlists

Share qualification and retry receipts across leases, preserve them while media publication is pending, and allow controlled recovery after expired acceptance episodes. Prefer eligible regular candidates before short fallbacks.

Add startup, revalidation, replay, cookie and endpoint regression coverage, and document short-window behavior.

Validation during implementation: Linux make test (7009 passed, 23 ignored), focused HLS and endpoint suites, format checks, Clippy and Markdown lint. Offline production library and backend checks passed after the import fix.

* fix(hls): reconcile cache write budgets and revision reclamation

Transfer reserved bytes atomically through staging and publication, preserve cleanup ownership across cancellation, and retire unpinned revisions during local and global reclamation.

Version temp registrations during inventory scans, remove production panic calls, and isolate debug diagnostics from admission results.

Validation: 1,162 HLS tests and 7,038 workspace tests passed; fmt-check, lint, test, markdown-lint and isolated production Clippy passed with dev-slim.

* feat (ai): add local codex files

* fix (hls): replace invalid origin accounts in shared HLS sessions

* fix (hls): synchronize origin account handovers across WebUI viewers

* fix (hls): preserve same-account resume work and backpressure

* fix (hls): resume reduced retries without crossing generations
Allow eligible exhausted acceptance episodes to select reduced retry candidates
while preserving generation, candidate, workload and timing guards. Keep
recovery chains bound to their generation, including changes between selection
and commit.

Add regressions for repaired and persistent resource conflicts, generation
isolation and media delivery through the app's local HTTP origin. Keep shared
manifest fixtures under test/fixtures/hls/reduced-retry.
2026-10-19 17:42:50 +02:00

1033 lines
40 KiB
Rust

use indexmap::IndexMap;
use log::{debug, trace};
use regex::Regex;
use shared::{
model::{
FieldGet, FieldSet, HeaderField, PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistItemType, UUIDType,
XtreamCluster,
},
utils::{deunicode_string, hash_string, Internable, CONSTANTS},
};
use std::{
collections::HashMap,
sync::{Arc, LazyLock},
};
use strsim::normalized_levenshtein;
static TRAILING_TITLE_YEAR: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\(?(\d{4})\)?$").expect("curation title-year regex must compile"));
/// Media kind understood by the source-neutral curation kernel.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum CurationMediaKind {
Movie,
Series,
}
/// Stable selector identity within one target curation run.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct CurationSelectorKey(pub usize);
/// Stable reason for a selector that returned only a partial snapshot.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CurationIncompleteReason {
Interrupted,
PaginationTruncated,
}
/// Stable reason for a selector that could not produce a snapshot.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CurationUnavailableReason {
Configuration,
Source,
}
/// One exact target subject selected by one source selector.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CurationMembership {
pub selector_key: CurationSelectorKey,
pub subject_uuid: UUIDType,
pub media_kind: CurationMediaKind,
pub rank: Option<u32>,
pub title_tiebreak: String,
pub candidate_order: usize,
}
/// Outcome of evaluating one required selector.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SelectorOutcome {
Complete { key: CurationSelectorKey, reference_count: usize, memberships: Vec<CurationMembership> },
Incomplete { key: CurationSelectorKey, reason: CurationIncompleteReason },
Unavailable { key: CurationSelectorKey, reason: CurationUnavailableReason },
}
impl SelectorOutcome {
pub const fn key(&self) -> CurationSelectorKey {
match self {
Self::Complete { key, .. } | Self::Incomplete { key, .. } | Self::Unavailable { key, .. } => *key,
}
}
}
/// Complete selector summary without duplicating membership storage.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CurationSelectorSummary {
pub key: CurationSelectorKey,
pub reference_count: usize,
pub membership_count: usize,
}
/// Trusted result produced only when every required selector completed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CurationEvaluation {
pub selectors: Vec<CurationSelectorSummary>,
pub memberships: Vec<CurationMembership>,
}
/// Diagnostic selector outcomes for a run that cannot be published.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CurationFailure {
pub selector_outcomes: Vec<SelectorOutcome>,
}
/// Target-facing curation run outcome.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CurationRunOutcome {
NotConfigured,
Complete(CurationEvaluation),
Failed(CurationFailure),
}
impl CurationMediaKind {
const fn from_playlist_item_type(item_type: PlaylistItemType) -> Option<Self> {
match item_type {
PlaylistItemType::Video | PlaylistItemType::LocalVideo => Some(Self::Movie),
PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo => Some(Self::Series),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CurationMediaScope {
Movies,
Series,
Both,
}
impl CurationMediaScope {
const fn includes_reference(self, kind: CurationMediaKind) -> bool {
match self {
Self::Movies => matches!(kind, CurationMediaKind::Movie),
Self::Series => matches!(kind, CurationMediaKind::Series),
Self::Both => true,
}
}
const fn includes_cluster(self, cluster: XtreamCluster) -> bool {
match self {
Self::Movies => matches!(cluster, XtreamCluster::Video),
Self::Series => matches!(cluster, XtreamCluster::Series),
Self::Both => matches!(cluster, XtreamCluster::Video | XtreamCluster::Series),
}
}
fn includes_playlist_item(self, item_type: PlaylistItemType) -> bool {
match self {
Self::Movies => item_type.is_video(),
Self::Series => matches!(item_type, PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo),
Self::Both => matches!(
item_type,
PlaylistItemType::Video
| PlaylistItemType::LocalVideo
| PlaylistItemType::SeriesInfo
| PlaylistItemType::LocalSeriesInfo
),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CurationMatchPolicy {
ExactTmdbOnly,
ExactTmdbThenFuzzy { threshold_percent: u8 },
}
impl CurationMatchPolicy {
fn fuzzy_threshold(self) -> Option<f64> {
match self {
Self::ExactTmdbOnly => None,
Self::ExactTmdbThenFuzzy { threshold_percent } => Some(f64::from(threshold_percent) / 100.0),
}
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct CurationSelectorSpec {
pub(crate) media_scope: CurationMediaScope,
pub(crate) match_policy: CurationMatchPolicy,
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum ProjectionIdentityStrategy<'a> {
LegacyCategoryScoped { namespace: &'a str },
}
impl ProjectionIdentityStrategy<'_> {
fn projected_uuid(self, category_name: &str, source_uuid: UUIDType) -> UUIDType {
match self {
Self::LegacyCategoryScoped { namespace } => {
hash_string(&format!("{namespace}:{category_name}:{source_uuid}"))
}
}
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct CurationCategorySpec<'a> {
pub(crate) name: &'a str,
pub(crate) selector: CurationSelectorSpec,
pub(crate) projection_identity: ProjectionIdentityStrategy<'a>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CuratedMediaReference {
pub(crate) kind: CurationMediaKind,
pub(crate) title: String,
normalized_title: String,
pub(crate) year: Option<u32>,
pub(crate) tmdb_id: Option<u32>,
pub(crate) rank: Option<u32>,
}
impl CuratedMediaReference {
pub(crate) fn new(
kind: CurationMediaKind,
title: String,
year: Option<u32>,
tmdb_id: Option<u32>,
rank: Option<u32>,
) -> Self {
let normalized_title = normalize_title_for_matching(&title);
Self { kind, title, normalized_title, year, tmdb_id, rank }
}
}
struct PlaylistCandidate<'a> {
item: &'a PlaylistItem,
kind: CurationMediaKind,
normalized_title: String,
year: Option<u32>,
tmdb_id: Option<u32>,
}
struct MatchResult<'playlist, 'reference> {
playlist_item: &'playlist PlaylistItem,
reference: &'reference CuratedMediaReference,
media_kind: CurationMediaKind,
candidate_order: usize,
}
pub(crate) fn normalize_title_for_matching(title: &str) -> String {
let normalized = deunicode_string(title.trim());
let mut result = String::with_capacity(normalized.len());
for ch in normalized.chars() {
if ch.is_alphanumeric() {
result.push(ch.to_ascii_lowercase());
}
}
if TRAILING_TITLE_YEAR.is_match(&result) {
TRAILING_TITLE_YEAR.replace(&result, "").into_owned()
} else {
result
}
}
pub(crate) fn extract_year_from_title(title: &str) -> Option<u32> {
if let Some(captures) = TRAILING_TITLE_YEAR.captures(title) {
if let Some(year_str) = captures.get(1) {
if let Ok(year) = year_str.as_str().parse::<u32>() {
if (1900..=2100).contains(&year) {
return Some(year);
}
}
}
}
None
}
fn calculate_year_bonus(playlist_year: Option<u32>, reference_year: Option<u32>) -> f64 {
if let (Some(playlist_year), Some(reference_year)) = (playlist_year, reference_year) {
if playlist_year == reference_year {
return 0.5;
}
return -0.5;
}
0.0
}
fn find_best_fuzzy_match_for_item<'playlist, 'reference>(
candidate: &PlaylistCandidate<'playlist>,
references: &'reference [CuratedMediaReference],
specification: CurationSelectorSpec,
threshold: f64,
) -> Option<MatchResult<'playlist, 'reference>> {
let mut best_match: Option<(&CuratedMediaReference, f64)> = None;
for reference in references.iter().filter(|reference| {
specification.media_scope.includes_reference(reference.kind) && reference.kind == candidate.kind
}) {
let title_score = normalized_levenshtein(&candidate.normalized_title, &reference.normalized_title);
if title_score >= threshold {
let year_bonus = calculate_year_bonus(candidate.year, reference.year);
let combined_score = (title_score + year_bonus).clamp(0.0, 1.0);
if combined_score >= threshold {
if best_match.is_none_or(|(_, current_best_score)| combined_score > current_best_score) {
best_match = Some((reference, combined_score));
}
if combined_score >= 0.99 {
break;
}
}
}
}
if let Some((reference, combined_score)) = best_match {
trace!(
"Fuzzy curation match: '{}' -> '{}' (final: {combined_score:.3})",
candidate.item.header.title,
reference.title
);
return Some(MatchResult {
playlist_item: candidate.item,
reference,
media_kind: candidate.kind,
candidate_order: 0,
});
}
None
}
fn find_best_match_for_item<'playlist, 'reference>(
candidate: &PlaylistCandidate<'playlist>,
references: &'reference [CuratedMediaReference],
specification: CurationSelectorSpec,
) -> Option<MatchResult<'playlist, 'reference>> {
if let Some(playlist_tmdb_id) = candidate.tmdb_id {
for reference in references.iter().filter(|reference| {
specification.media_scope.includes_reference(reference.kind) && reference.kind == candidate.kind
}) {
if Some(playlist_tmdb_id) == reference.tmdb_id {
trace!("TMDB exact curation match: '{}' (TMDB: {})", candidate.item.header.title, playlist_tmdb_id);
return Some(MatchResult {
playlist_item: candidate.item,
reference,
media_kind: candidate.kind,
candidate_order: 0,
});
}
}
}
let threshold = specification.match_policy.fuzzy_threshold()?;
find_best_fuzzy_match_for_item(candidate, references, specification, threshold)
}
fn find_matches<'playlist, 'reference>(
references: &'reference [CuratedMediaReference],
playlist: &'playlist [PlaylistGroup],
specification: CurationSelectorSpec,
) -> Vec<MatchResult<'playlist, 'reference>> {
let reference_count =
references.iter().filter(|reference| specification.media_scope.includes_reference(reference.kind)).count();
debug!(
"Matching {reference_count} curated media references against playlist for media scope {:?}",
specification.media_scope
);
let mut matches = Vec::new();
let mut candidate_order = 0usize;
for playlist_group in playlist {
for channel in &playlist_group.channels {
if specification.media_scope.includes_cluster(channel.header.xtream_cluster)
&& specification.media_scope.includes_playlist_item(channel.header.item_type)
{
let Some(kind) = CurationMediaKind::from_playlist_item_type(channel.header.item_type) else {
continue;
};
let candidate = PlaylistCandidate {
item: channel,
kind,
normalized_title: normalize_title_for_matching(&channel.header.title),
year: extract_year_from_title(&channel.header.title),
tmdb_id: channel.get_tmdb_id(),
};
if let Some(mut matched) = find_best_match_for_item(&candidate, references, specification) {
matched.candidate_order = candidate_order;
matches.push(matched);
}
candidate_order += 1;
}
}
}
matches
}
fn sort_matches(matches: &mut [MatchResult<'_, '_>]) {
matches.sort_by(|left, right| {
(left.reference.rank.unwrap_or(9999), left.reference.title.to_lowercase(), left.candidate_order).cmp(&(
right.reference.rank.unwrap_or(9999),
right.reference.title.to_lowercase(),
right.candidate_order,
))
});
}
fn subject_uuid(item: &PlaylistItem) -> UUIDType {
if item.header.uuid == UUIDType::default() {
item.get_uuid()
} else {
item.header.uuid
}
}
pub(crate) fn evaluate_selector(
key: CurationSelectorKey,
references: &[CuratedMediaReference],
playlist: &[PlaylistGroup],
specification: CurationSelectorSpec,
) -> SelectorOutcome {
let reference_count =
references.iter().filter(|reference| specification.media_scope.includes_reference(reference.kind)).count();
let mut matches = find_matches(references, playlist, specification);
sort_matches(&mut matches);
let memberships = matches
.into_iter()
.map(|matched| CurationMembership {
selector_key: key,
subject_uuid: subject_uuid(matched.playlist_item),
media_kind: matched.media_kind,
rank: matched.reference.rank,
title_tiebreak: matched.reference.title.to_lowercase(),
candidate_order: matched.candidate_order,
})
.collect();
SelectorOutcome::Complete { key, reference_count, memberships }
}
pub(crate) struct CurationProjectionCatalog<'playlist> {
items_by_uuid: HashMap<UUIDType, &'playlist PlaylistItem>,
series_children_by_parent_code: HashMap<Arc<str>, Vec<&'playlist PlaylistItem>>,
}
impl<'playlist> CurationProjectionCatalog<'playlist> {
pub(crate) fn new(playlist: &'playlist [PlaylistGroup]) -> Self {
let items_by_uuid = playlist
.iter()
.flat_map(|group| &group.channels)
.filter(|item| CurationMediaKind::from_playlist_item_type(item.header.item_type).is_some())
.map(|item| (subject_uuid(item), item))
.collect();
Self { items_by_uuid, series_children_by_parent_code: series_children_by_parent_code(playlist) }
}
}
#[cfg(test)]
pub(crate) fn curate_category(
references: &[CuratedMediaReference],
playlist: &[PlaylistGroup],
specification: &CurationCategorySpec<'_>,
) -> Vec<PlaylistGroup> {
let mut matches = find_matches(references, playlist, specification.selector);
sort_matches(&mut matches);
let items = matches.into_iter().map(|matched| matched.playlist_item).collect();
let projection_catalog = CurationProjectionCatalog::new(playlist);
create_category_from_items(items, specification, &projection_catalog.series_children_by_parent_code)
}
pub(crate) fn project_memberships<'membership>(
memberships: impl IntoIterator<Item = &'membership CurationMembership>,
projection_catalog: &CurationProjectionCatalog<'_>,
specification: &CurationCategorySpec<'_>,
) -> Vec<PlaylistGroup> {
let items = memberships
.into_iter()
.filter(|membership| specification.selector.media_scope.includes_reference(membership.media_kind))
.filter_map(|membership| projection_catalog.items_by_uuid.get(&membership.subject_uuid).copied())
.collect();
create_category_from_items(items, specification, &projection_catalog.series_children_by_parent_code)
}
fn create_category_from_items(
items: Vec<&PlaylistItem>,
specification: &CurationCategorySpec<'_>,
series_children_by_parent_code: &HashMap<Arc<str>, Vec<&PlaylistItem>>,
) -> Vec<PlaylistGroup> {
if items.is_empty() {
return Vec::new();
}
let group_title = specification.name.intern();
let mut matched_items_by_cluster: IndexMap<XtreamCluster, Vec<PlaylistItem>> = IndexMap::new();
for item in items {
let projected_item = clone_item_for_category(item, specification, &group_title);
let parent_uuid = projected_item.header.uuid.intern();
let is_series_info =
matches!(projected_item.header.item_type, PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo);
let child_lookup_keys = if is_series_info { series_info_child_lookup_keys(item) } else { Vec::new() };
let cluster = projected_item.header.xtream_cluster;
matched_items_by_cluster.entry(cluster).or_default().push(projected_item);
if let Some(children) = child_lookup_keys.iter().find_map(|key| series_children_by_parent_code.get(key)) {
for child in children {
let mut projected_child = clone_item_for_category(child, specification, &group_title);
projected_child.header.parent_code = parent_uuid.clone();
matched_items_by_cluster
.entry(projected_child.header.xtream_cluster)
.or_default()
.push(projected_child);
}
}
}
matched_items_by_cluster
.into_iter()
.map(|(cluster, channels)| PlaylistGroup {
id: 0,
title: group_title.clone(),
channels,
xtream_cluster: cluster,
})
.collect()
}
fn clone_item_for_category(
item: &PlaylistItem,
specification: &CurationCategorySpec<'_>,
group_title: &Arc<str>,
) -> PlaylistItem {
let mut projected_item = item.clone();
let source_uuid = if projected_item.header.uuid == UUIDType::default() {
projected_item.get_uuid()
} else {
projected_item.header.uuid
};
let header = &mut projected_item.header;
let title = header.get(HeaderField::Caption).map_or_else(|| Arc::clone(&header.title), |value| value.to_arc());
if extract_quality(&title).is_none() {
if let Some(quality) = extract_quality(&header.group) {
let mut caption = String::with_capacity(title.len() + 6);
caption.push('[');
caption.push_str(quality);
caption.push_str("] ");
caption.push_str(&title);
header.set(HeaderField::Caption, &caption);
}
}
header.group = group_title.clone();
header.uuid = specification.projection_identity.projected_uuid(specification.name, source_uuid);
projected_item
}
fn extract_quality(value: &str) -> Option<&str> {
CONSTANTS.re_quality.captures(value).and_then(|captures| captures.get(0)).map(|value| value.as_str())
}
fn series_info_child_lookup_keys(series_info: &PlaylistItem) -> Vec<Arc<str>> {
match series_info.header.item_type {
PlaylistItemType::LocalSeriesInfo => vec![series_info.header.id.clone(), series_info.header.uuid.intern()],
PlaylistItemType::SeriesInfo => vec![series_info.get_uuid().intern(), series_info.header.uuid.intern()],
_ => Vec::new(),
}
}
fn series_children_by_parent_code(playlist: &[PlaylistGroup]) -> HashMap<Arc<str>, Vec<&PlaylistItem>> {
let mut children = HashMap::<Arc<str>, Vec<&PlaylistItem>>::new();
for playlist_group in playlist {
for channel in &playlist_group.channels {
if channel.header.item_type.is_series() && !channel.header.parent_code.is_empty() {
children.entry(channel.header.parent_code.clone()).or_default().push(channel);
}
}
}
children
}
#[cfg(test)]
mod tests {
use super::*;
use shared::model::{
EpisodeStreamProperties, PlaylistItemHeader, SeriesStreamProperties, StreamProperties, VideoStreamProperties,
VirtualId,
};
#[test]
fn title_normalization_and_year_extraction_preserve_existing_rules() {
assert_eq!(normalize_title_for_matching("The Matrix"), "thematrix");
assert_eq!(normalize_title_for_matching("Spider-Man: No Way Home"), "spidermannowayhome");
assert_eq!(normalize_title_for_matching("Élite"), "elite");
assert_eq!(normalize_title_for_matching("The Matrix (1999)"), "thematrix");
assert_eq!(extract_year_from_title("The Matrix (1999)"), Some(1999));
assert_eq!(extract_year_from_title("Avengers Endgame 2019"), Some(2019));
assert_eq!(extract_year_from_title("Just a Title"), None);
}
#[test]
fn exact_tmdb_match_precedes_a_perfect_fuzzy_title_match() {
let playlist_item = video_item("Same Title", Some(222));
let references = vec![
reference(CurationMediaKind::Movie, "Same Title", None, Some(111), Some(1)),
reference(CurationMediaKind::Movie, "Different Title", None, Some(222), Some(2)),
];
let candidate = candidate(&playlist_item);
let matched = find_best_match_for_item(&candidate, &references, specification("Featured", false).selector)
.expect("TMDB identity should take precedence");
assert_eq!(matched.reference.tmdb_id, Some(222));
assert_eq!(matched.reference.title, "Different Title");
}
#[test]
fn exact_only_policy_forbids_fuzzy_fallback_but_keeps_tmdb_matches() {
let without_tmdb = video_item("The Captive", None);
let matching_tmdb = video_item("Cautivos", Some(456));
let references = vec![reference(CurationMediaKind::Movie, "The Captive", Some(1915), Some(456), Some(1))];
let exact_only = specification("Featured", true).selector;
assert!(find_best_match_for_item(&candidate(&without_tmdb), &references, exact_only).is_none());
assert!(find_best_match_for_item(&candidate(&matching_tmdb), &references, exact_only).is_some());
}
#[test]
fn fuzzy_matching_preserves_year_bonus_and_penalty() {
let playlist_item = video_item("The Matrix 1999", None);
let candidate = candidate(&playlist_item);
let matching_year = vec![reference(CurationMediaKind::Movie, "The Matrix", Some(1999), None, Some(1))];
let different_year = vec![reference(CurationMediaKind::Movie, "The Matrix", Some(2000), None, Some(2))];
let specification = specification("Featured", false).selector;
assert!(find_best_match_for_item(&candidate, &matching_year, specification).is_some());
assert!(find_best_match_for_item(&candidate, &different_year, specification).is_none());
}
#[test]
fn fuzzy_matching_keeps_the_first_perfect_reference() {
let playlist_item = video_item("The Matrix", None);
let references = vec![
reference(CurationMediaKind::Movie, "The Matrix", None, Some(111), Some(1)),
reference(CurationMediaKind::Movie, "The Matrix", None, Some(222), Some(2)),
];
let matched = find_best_match_for_item(
&candidate(&playlist_item),
&references,
specification("Featured", false).selector,
)
.expect("a perfect fuzzy title should match");
assert_eq!(matched.reference.tmdb_id, Some(111));
}
#[test]
fn fuzzy_threshold_and_best_match_selection_remain_percentage_based() {
let playlist_item = video_item("matrix", None);
let references = vec![
reference(CurationMediaKind::Movie, "matri", None, Some(111), Some(1)),
reference(CurationMediaKind::Movie, "matrixx", None, Some(222), Some(2)),
];
let matched = find_best_match_for_item(
&candidate(&playlist_item),
&references,
fuzzy_specification("Featured", 80).selector,
)
.expect("the best reference above an 80 percent threshold should match");
assert_eq!(matched.reference.tmdb_id, Some(222));
assert!(find_best_match_for_item(
&candidate(&playlist_item),
&references,
fuzzy_specification("Featured", 90).selector,
)
.is_none());
}
#[test]
fn projected_items_are_ordered_by_rank_then_lowercase_reference_title() {
let playlist = vec![PlaylistGroup {
id: 1,
title: "Original".intern(),
channels: vec![video_item("Gamma", Some(3)), video_item("zebra", Some(2)), video_item("Alpha", Some(1))],
xtream_cluster: XtreamCluster::Video,
}];
let references = vec![
reference(CurationMediaKind::Movie, "Gamma", None, Some(3), Some(2)),
reference(CurationMediaKind::Movie, "zebra", None, Some(2), Some(1)),
reference(CurationMediaKind::Movie, "Alpha", None, Some(1), Some(1)),
];
let categories = curate_category(&references, &playlist, &specification("Ranked", true));
let titles = categories[0].channels.iter().map(|item| item.header.title.as_ref()).collect::<Vec<_>>();
assert_eq!(titles, ["Alpha", "zebra", "Gamma"]);
}
#[test]
fn selector_outcomes_distinguish_remote_empty_no_local_match_and_matches() {
let playlist = vec![PlaylistGroup {
id: 1,
title: "Movies".intern(),
channels: vec![video_item("Matched", Some(7))],
xtream_cluster: XtreamCluster::Video,
}];
let key = CurationSelectorKey(3);
let specification = specification("Ignored by membership evaluation", true).selector;
let SelectorOutcome::Complete { reference_count, memberships, .. } =
evaluate_selector(key, &[], &playlist, specification)
else {
panic!("an empty source is complete")
};
assert_eq!(reference_count, 0);
assert_eq!(memberships.len(), 0);
let no_match = vec![reference(CurationMediaKind::Movie, "Missing", None, Some(8), Some(1))];
let SelectorOutcome::Complete { reference_count, memberships, .. } =
evaluate_selector(key, &no_match, &playlist, specification)
else {
panic!("a complete source with no local match is complete")
};
assert_eq!(reference_count, 1);
assert_eq!(memberships.len(), 0);
let matched = vec![reference(CurationMediaKind::Movie, "Matched", None, Some(7), Some(1))];
let SelectorOutcome::Complete { reference_count, memberships, .. } =
evaluate_selector(key, &matched, &playlist, specification)
else {
panic!("matching source is complete")
};
assert_eq!(reference_count, 1);
assert_eq!(memberships.len(), 1);
assert_eq!(memberships[0].selector_key, key);
assert_eq!(memberships[0].media_kind, CurationMediaKind::Movie);
}
#[test]
fn membership_identity_keeps_distinct_exact_alias_subjects() {
let mut base = video_item("Same", Some(7));
base.header.uuid = hash_string("base-subject");
let mut favourite = video_item("Same", Some(7));
favourite.header.uuid = hash_string("favourite-subject");
let playlist = vec![PlaylistGroup {
id: 1,
title: "Movies".intern(),
channels: vec![base, favourite],
xtream_cluster: XtreamCluster::Video,
}];
let references = vec![reference(CurationMediaKind::Movie, "Same", None, Some(7), Some(1))];
let SelectorOutcome::Complete { memberships, .. } = evaluate_selector(
CurationSelectorKey(0),
&references,
&playlist,
specification("Membership", true).selector,
) else {
panic!("selector should complete")
};
assert_eq!(memberships.len(), 2);
assert_eq!(memberships.iter().map(|membership| membership.candidate_order).collect::<Vec<_>>(), [0, 1]);
assert_ne!(memberships[0].subject_uuid, memberships[1].subject_uuid);
}
#[test]
fn projected_categories_remain_grouped_by_playlist_cluster() {
let playlist = vec![
PlaylistGroup {
id: 1,
title: "Movies".intern(),
channels: vec![video_item("Movie", Some(1))],
xtream_cluster: XtreamCluster::Video,
},
PlaylistGroup {
id: 2,
title: "Series".intern(),
channels: vec![series_item("Show", Some(2))],
xtream_cluster: XtreamCluster::Series,
},
];
let references = vec![
reference(CurationMediaKind::Movie, "Movie", None, Some(1), Some(1)),
reference(CurationMediaKind::Series, "Show", None, Some(2), Some(2)),
];
let specification = CurationCategorySpec {
selector: CurationSelectorSpec {
media_scope: CurationMediaScope::Both,
..specification("Mixed", true).selector
},
..specification("Mixed", true)
};
let categories = curate_category(&references, &playlist, &specification);
assert_eq!(categories.len(), 2);
assert_eq!(categories[0].xtream_cluster, XtreamCluster::Video);
assert_eq!(categories[0].channels[0].header.title.as_ref(), "Movie");
assert_eq!(categories[1].xtream_cluster, XtreamCluster::Series);
assert_eq!(categories[1].channels[0].header.title.as_ref(), "Show");
}
#[test]
fn both_scope_matches_references_only_to_the_same_media_kind() {
let movie = video_item("Shared Title", Some(42));
let series = series_item("Shared Title", Some(42));
let exact_references = vec![
reference(CurationMediaKind::Series, "Series Reference", None, Some(42), Some(1)),
reference(CurationMediaKind::Movie, "Movie Reference", None, Some(42), Some(2)),
];
let exact_specification =
CurationSelectorSpec { media_scope: CurationMediaScope::Both, ..specification("Mixed", true).selector };
let movie_match = find_best_match_for_item(&candidate(&movie), &exact_references, exact_specification)
.expect("movie candidate should match the movie reference");
let series_match = find_best_match_for_item(&candidate(&series), &exact_references, exact_specification)
.expect("series candidate should match the series reference");
assert_eq!(movie_match.reference.kind, CurationMediaKind::Movie);
assert_eq!(series_match.reference.kind, CurationMediaKind::Series);
let fuzzy_references = vec![
reference(CurationMediaKind::Series, "Shared Title", None, None, Some(1)),
reference(CurationMediaKind::Movie, "Shared Title", None, None, Some(2)),
];
let fuzzy_specification = CurationSelectorSpec {
media_scope: CurationMediaScope::Both,
..fuzzy_specification("Mixed", 100).selector
};
let movie_match = find_best_match_for_item(&candidate(&movie), &fuzzy_references, fuzzy_specification)
.expect("movie candidate should fuzzy-match the movie reference");
let series_match = find_best_match_for_item(&candidate(&series), &fuzzy_references, fuzzy_specification)
.expect("series candidate should fuzzy-match the series reference");
assert_eq!(movie_match.reference.kind, CurationMediaKind::Movie);
assert_eq!(series_match.reference.kind, CurationMediaKind::Series);
}
#[test]
fn movie_scope_does_not_match_series_roots_and_series_scope_ignores_episodes() {
let series = series_item("Shared Title", Some(42));
let episode = episode_item("Shared Title", &"parent".intern(), 7001);
let playlist = vec![PlaylistGroup {
id: 1,
title: "Series".intern(),
channels: vec![series, episode],
xtream_cluster: XtreamCluster::Series,
}];
let movie_references = vec![reference(CurationMediaKind::Movie, "Shared Title", None, Some(42), Some(1))];
let series_references = vec![reference(CurationMediaKind::Series, "Shared Title", None, None, Some(1))];
assert!(curate_category(&movie_references, &playlist, &specification("Movies", true)).is_empty());
assert!(curate_category(
&series_references,
&[PlaylistGroup {
id: 1,
title: "Episodes".intern(),
channels: vec![playlist[0].channels[1].clone()],
xtream_cluster: XtreamCluster::Series,
}],
&series_specification("Series", false),
)
.is_empty());
}
#[test]
fn projection_propagates_group_quality_only_when_caption_has_none() {
let mut without_quality = video_item("Clean Title", Some(1));
without_quality.header.group = "Provider UHD".intern();
let mut with_quality = video_item("Already 4K", Some(2));
with_quality.header.group = "Provider UHD".intern();
let playlist = vec![PlaylistGroup {
id: 1,
title: "Original".intern(),
channels: vec![without_quality, with_quality],
xtream_cluster: XtreamCluster::Video,
}];
let references = vec![
reference(CurationMediaKind::Movie, "Clean Title", None, Some(1), Some(1)),
reference(CurationMediaKind::Movie, "Already 4K", None, Some(2), Some(2)),
];
let categories = curate_category(&references, &playlist, &specification("Featured", true));
let clean = &categories[0].channels[0];
let already_tagged = &categories[0].channels[1];
assert_eq!(clean.header.get(HeaderField::Caption).expect("quality caption").as_cow(), "[UHD] Clean Title");
assert_eq!(already_tagged.header.get(HeaderField::Caption).expect("existing caption").as_cow(), "Already 4K");
}
#[test]
fn series_children_are_cloned_and_reparented_to_the_projected_root() {
let mut series = series_item("Slow Horses", Some(12345));
series.header.uuid = hash_string("series-source-item");
let source_parent_code = series.header.uuid.intern();
let mut episode = episode_item("Old Scores", &source_parent_code, 7001);
episode.header.uuid = hash_string("episode-source-item");
let playlist = vec![PlaylistGroup {
id: 1,
title: "Series".intern(),
channels: vec![series, episode],
xtream_cluster: XtreamCluster::Series,
}];
let references = vec![reference(CurationMediaKind::Series, "Slow Horses", Some(2022), Some(12345), Some(1))];
let categories = curate_category(&references, &playlist, &series_specification("Trending", true));
let cloned_series = categories[0]
.channels
.iter()
.find(|item| item.header.item_type == PlaylistItemType::SeriesInfo)
.expect("series info clone");
let cloned_episode = categories[0]
.channels
.iter()
.find(|item| item.header.item_type == PlaylistItemType::Series)
.expect("episode clone");
assert_eq!(cloned_episode.header.parent_code, cloned_series.header.uuid.intern());
assert_ne!(cloned_episode.header.uuid, playlist[0].channels[1].header.uuid);
}
fn specification(category_name: &str, exact_only: bool) -> CurationCategorySpec<'_> {
if exact_only {
CurationCategorySpec {
name: category_name,
selector: CurationSelectorSpec {
media_scope: CurationMediaScope::Movies,
match_policy: CurationMatchPolicy::ExactTmdbOnly,
},
projection_identity: ProjectionIdentityStrategy::LegacyCategoryScoped { namespace: "legacy-category" },
}
} else {
fuzzy_specification(category_name, 100)
}
}
fn fuzzy_specification(category_name: &str, threshold_percent: u8) -> CurationCategorySpec<'_> {
CurationCategorySpec {
name: category_name,
selector: CurationSelectorSpec {
media_scope: CurationMediaScope::Movies,
match_policy: CurationMatchPolicy::ExactTmdbThenFuzzy { threshold_percent },
},
projection_identity: ProjectionIdentityStrategy::LegacyCategoryScoped { namespace: "legacy-category" },
}
}
fn series_specification(category_name: &str, exact_only: bool) -> CurationCategorySpec<'_> {
let specification = specification(category_name, exact_only);
CurationCategorySpec {
selector: CurationSelectorSpec { media_scope: CurationMediaScope::Series, ..specification.selector },
..specification
}
}
fn candidate(item: &PlaylistItem) -> PlaylistCandidate<'_> {
PlaylistCandidate {
item,
kind: CurationMediaKind::from_playlist_item_type(item.header.item_type)
.expect("test candidate must be a movie or series root"),
normalized_title: normalize_title_for_matching(&item.header.title),
year: extract_year_from_title(&item.header.title),
tmdb_id: item.get_tmdb_id(),
}
}
fn reference(
kind: CurationMediaKind,
title: &str,
year: Option<u32>,
tmdb_id: Option<u32>,
rank: Option<u32>,
) -> CuratedMediaReference {
CuratedMediaReference::new(kind, title.to_string(), year, tmdb_id, rank)
}
fn video_item(title: &str, tmdb: Option<u32>) -> PlaylistItem {
PlaylistItem {
header: PlaylistItemHeader {
title: title.intern(),
xtream_cluster: XtreamCluster::Video,
item_type: PlaylistItemType::Video,
additional_properties: Some(StreamProperties::Video(Box::new(VideoStreamProperties {
name: title.intern(),
tmdb,
..VideoStreamProperties::default()
}))),
..PlaylistItemHeader::default()
},
}
}
fn series_item(title: &str, tmdb: Option<u32>) -> PlaylistItem {
PlaylistItem {
header: PlaylistItemHeader {
id: format!("series-{title}").intern(),
input_name: "input".intern(),
title: title.intern(),
name: title.intern(),
url: format!("media-server://unavailable/server/shows/{title}").intern(),
xtream_cluster: XtreamCluster::Series,
item_type: PlaylistItemType::SeriesInfo,
additional_properties: Some(StreamProperties::Series(Box::new(SeriesStreamProperties {
name: title.intern(),
tmdb,
..SeriesStreamProperties::default()
}))),
..PlaylistItemHeader::default()
},
}
}
fn episode_item(title: &str, parent_code: &Arc<str>, virtual_id: u32) -> PlaylistItem {
PlaylistItem {
header: PlaylistItemHeader {
uuid: hash_string(&format!("episode:{title}:{virtual_id}")),
id: format!("episode-{virtual_id}").intern(),
input_name: "input".intern(),
parent_code: parent_code.clone(),
title: title.intern(),
name: title.intern(),
url: format!("media-server://plex/server/{virtual_id}?part_key=%2Flibrary%2Fparts%2Fredacted").intern(),
virtual_id: VirtualId::new(virtual_id),
xtream_cluster: XtreamCluster::Series,
item_type: PlaylistItemType::Series,
additional_properties: Some(StreamProperties::Episode(Box::new(EpisodeStreamProperties {
episode_id: virtual_id,
episode: 1,
season: 1,
added: None,
release_date: None,
series_release_date: None,
tmdb: None,
movie_image: "".intern(),
container_extension: "mkv".intern(),
video: None,
audio: None,
plot: None,
}))),
..PlaylistItemHeader::default()
},
}
}
}