mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-09 17:32:16 +02:00
feat: add target-wide curated catalog selection (#864)
* test: characterize target curation transition * refactor: model complete curation outcomes * feat: configure target-wide curation projection * feat: apply target-wide curation selections * docs: explain target-wide Trakt curation * fix: preserve source-less Trakt policy edits * target wide curaton selection * target wide curaton selection --------- Co-authored-by: Juanjo Presa <juanjop@gmail.com> Co-authored-by: euzu <euzu@proton.me>
This commit is contained in:
co-authored by
Juanjo Presa
euzu
parent
2297b3700a
commit
8fa8a3601b
+19
-4
@@ -148,6 +148,21 @@
|
||||
to observe, `=2` to also restart the process on a confirmed stall), and exposes its state through the `/healthcheck`
|
||||
`runtime` object.
|
||||
|
||||
- **Trakt curation can now select one target-wide VOD/Series catalog and project Xtream categories independently.**
|
||||
`output[].trakt.catalog_selection` accepts `full` (the compatibility default) or `curated`;
|
||||
`include_xtream_base_categories` defaults to `true`; and each list/chart has a default-true
|
||||
`create_xtream_category`. Existing YAML therefore keeps the full catalog, normal Xtream categories, and current
|
||||
category-scoped alias IDs. Selection-only selectors may omit `category_name`, while category-producing selectors
|
||||
still require it. The Source Editor exposes the same controls in all supported locales.
|
||||
- Curation now evaluates exact surviving target UUIDs after favourites, group merge, and post-merge content
|
||||
deduplication. M3U and STRM receive selected normal entries rather than Xtream aliases, while Xtream watch behavior
|
||||
continues to observe its category view.
|
||||
- Every enabled list/chart is required for a refresh. Partial selector success, missing/invalid credentials, request
|
||||
failures, malformed responses, and incomplete pagination now fail that target before IDs, persistence, cache, or
|
||||
watch effects instead of publishing a partial/base fallback.
|
||||
- A complete empty or no-match result under `catalog_selection: curated` intentionally clears managed VOD/Series
|
||||
Xtream, M3U, and STRM state while preserving Live. Ordinary or failed empty refreshes retain previous artifacts.
|
||||
|
||||
- **`.env` file support for secrets and environment variables:** Tuliprox now automatically loads environment variables
|
||||
from a `.env` file at startup.
|
||||
- **Discovery order:** searches `--env-file <PATH>` (or `-e`), `TULIPROX_ENV_FILE`, then `<config_file_dir>/.env`
|
||||
@@ -1524,10 +1539,10 @@
|
||||
|
||||
## 🛠 Maintenance
|
||||
|
||||
- **Playlist curation now has a dedicated capability boundary**: matching, ordering, and virtual-category projection
|
||||
live in the source-neutral `tuliprox-curation` crate, while Trakt HTTP/JSON handling translates records at the edge.
|
||||
Existing `output[].trakt` configuration, category identity, matching behavior, and partial-success semantics remain
|
||||
unchanged.
|
||||
- **Playlist curation now has a dedicated capability boundary**: matching and ordered membership evaluation live in the
|
||||
source-neutral `tuliprox-curation` kernel, while Trakt HTTP/JSON handling translates records at the edge and the
|
||||
category-scoped compatibility projector remains separate from membership identity. Existing category identity and
|
||||
matching rules remain unchanged; the target-wide selection entry above documents the intentional outcome changes.
|
||||
|
||||
- **`AdmissionRequest` bundles the request-scoped admission arguments**: five functions each threaded the same ten
|
||||
positional parameters, three of them consecutive bare `bool`s (`use_session_admission`, then
|
||||
|
||||
@@ -433,6 +433,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdhomerun_keeps_its_configured_underlying_output_delegation() {
|
||||
let dto = ConfigTargetDto {
|
||||
output: vec![
|
||||
TargetOutputDto::Xtream(XtreamTargetOutputDto::default()),
|
||||
TargetOutputDto::M3u(M3uTargetOutputDto::default()),
|
||||
TargetOutputDto::HdHomeRun(HdHomeRunTargetOutputDto {
|
||||
device: "device".to_string(),
|
||||
username: "viewer".to_string(),
|
||||
use_output: Some(TargetType::Xtream),
|
||||
}),
|
||||
],
|
||||
..ConfigTargetDto::default()
|
||||
};
|
||||
|
||||
let target = ConfigTarget::from(&dto);
|
||||
|
||||
assert_eq!(target.get_hdhomerun_output().and_then(|output| output.use_output), Some(TargetType::Xtream));
|
||||
assert!(target.has_output(TargetType::Xtream));
|
||||
assert!(target.has_output(TargetType::M3u));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execution_plan_preserves_both_deduplication_passes() {
|
||||
let deduplicate = DeduplicateConfig::default();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::model::macros;
|
||||
use shared::model::{
|
||||
TraktApiConfigDto, TraktChartConfigDto, TraktChartKind, TraktChartType, TraktConfigDto, TraktContentType,
|
||||
TraktListConfigDto,
|
||||
TraktApiConfigDto, TraktCatalogSelection, TraktChartConfigDto, TraktChartKind, TraktChartType, TraktConfigDto,
|
||||
TraktContentType, TraktListConfigDto,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -39,7 +39,8 @@ impl From<&TraktApiConfig> for TraktApiConfigDto {
|
||||
pub struct TraktListConfig {
|
||||
pub user: String,
|
||||
pub list_slug: String,
|
||||
pub category_name: String,
|
||||
pub category_name: Option<String>,
|
||||
pub create_xtream_category: bool,
|
||||
pub content_type: TraktContentType,
|
||||
pub tmdb_only: bool,
|
||||
pub fuzzy_match_threshold: u8, // Percentage (0-100)
|
||||
@@ -52,6 +53,7 @@ impl From<&TraktListConfigDto> for TraktListConfig {
|
||||
user: dto.user.clone(),
|
||||
list_slug: dto.list_slug.clone(),
|
||||
category_name: dto.category_name.clone(),
|
||||
create_xtream_category: dto.create_xtream_category,
|
||||
content_type: dto.content_type,
|
||||
tmdb_only: dto.tmdb_only,
|
||||
fuzzy_match_threshold: dto.fuzzy_match_threshold,
|
||||
@@ -65,6 +67,7 @@ impl From<&TraktListConfig> for TraktListConfigDto {
|
||||
user: instance.user.clone(),
|
||||
list_slug: instance.list_slug.clone(),
|
||||
category_name: instance.category_name.clone(),
|
||||
create_xtream_category: instance.create_xtream_category,
|
||||
content_type: instance.content_type,
|
||||
tmdb_only: instance.tmdb_only,
|
||||
fuzzy_match_threshold: instance.fuzzy_match_threshold,
|
||||
@@ -76,7 +79,8 @@ impl From<&TraktListConfig> for TraktListConfigDto {
|
||||
pub struct TraktChartConfig {
|
||||
pub kind: TraktChartKind,
|
||||
pub chart: TraktChartType,
|
||||
pub category_name: String,
|
||||
pub category_name: Option<String>,
|
||||
pub create_xtream_category: bool,
|
||||
pub tmdb_only: bool,
|
||||
pub fuzzy_match_threshold: u8, // Percentage (0-100)
|
||||
}
|
||||
@@ -88,6 +92,7 @@ impl From<&TraktChartConfigDto> for TraktChartConfig {
|
||||
kind: dto.kind,
|
||||
chart: dto.chart,
|
||||
category_name: dto.category_name.clone(),
|
||||
create_xtream_category: dto.create_xtream_category,
|
||||
tmdb_only: dto.tmdb_only,
|
||||
fuzzy_match_threshold: dto.fuzzy_match_threshold,
|
||||
}
|
||||
@@ -100,6 +105,7 @@ impl From<&TraktChartConfig> for TraktChartConfigDto {
|
||||
kind: instance.kind,
|
||||
chart: instance.chart,
|
||||
category_name: instance.category_name.clone(),
|
||||
create_xtream_category: instance.create_xtream_category,
|
||||
tmdb_only: instance.tmdb_only,
|
||||
fuzzy_match_threshold: instance.fuzzy_match_threshold,
|
||||
}
|
||||
@@ -109,6 +115,8 @@ impl From<&TraktChartConfig> for TraktChartConfigDto {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TraktConfig {
|
||||
pub enabled: bool,
|
||||
pub catalog_selection: TraktCatalogSelection,
|
||||
pub include_xtream_base_categories: bool,
|
||||
pub api: TraktApiConfig,
|
||||
pub lists: Vec<TraktListConfig>,
|
||||
pub charts: Vec<TraktChartConfig>,
|
||||
@@ -119,6 +127,8 @@ impl From<&TraktConfigDto> for TraktConfig {
|
||||
fn from(dto: &TraktConfigDto) -> Self {
|
||||
Self {
|
||||
enabled: dto.enabled,
|
||||
catalog_selection: dto.catalog_selection,
|
||||
include_xtream_base_categories: dto.include_xtream_base_categories,
|
||||
api: TraktApiConfig::from(&dto.api),
|
||||
lists: dto.lists.iter().map(Into::into).collect(),
|
||||
charts: dto.charts.iter().map(Into::into).collect(),
|
||||
@@ -129,6 +139,8 @@ impl From<&TraktConfig> for TraktConfigDto {
|
||||
fn from(dto: &TraktConfig) -> Self {
|
||||
Self {
|
||||
enabled: dto.enabled,
|
||||
catalog_selection: dto.catalog_selection,
|
||||
include_xtream_base_categories: dto.include_xtream_base_categories,
|
||||
api: TraktApiConfigDto::from(&dto.api),
|
||||
lists: dto.lists.iter().map(TraktListConfigDto::from).collect(),
|
||||
charts: dto.charts.iter().map(TraktChartConfigDto::from).collect(),
|
||||
@@ -144,6 +156,8 @@ mod tests {
|
||||
fn resolved_trakt_config_round_trips_through_the_compatible_dto() {
|
||||
let dto = TraktConfigDto {
|
||||
enabled: true,
|
||||
catalog_selection: TraktCatalogSelection::Full,
|
||||
include_xtream_base_categories: true,
|
||||
api: TraktApiConfigDto {
|
||||
api_key: "client-id".to_string(),
|
||||
version: "2".to_string(),
|
||||
@@ -153,7 +167,8 @@ mod tests {
|
||||
lists: vec![TraktListConfigDto {
|
||||
user: "alice".to_string(),
|
||||
list_slug: "watchlist".to_string(),
|
||||
category_name: "Watchlist".to_string(),
|
||||
category_name: Some("Watchlist".to_string()),
|
||||
create_xtream_category: true,
|
||||
content_type: TraktContentType::Vod,
|
||||
tmdb_only: false,
|
||||
fuzzy_match_threshold: 80,
|
||||
@@ -161,7 +176,8 @@ mod tests {
|
||||
charts: vec![TraktChartConfigDto {
|
||||
kind: TraktChartKind::Shows,
|
||||
chart: TraktChartType::Popular,
|
||||
category_name: "Popular Shows".to_string(),
|
||||
category_name: Some("Popular Shows".to_string()),
|
||||
create_xtream_category: false,
|
||||
tmdb_only: true,
|
||||
fuzzy_match_threshold: 90,
|
||||
}],
|
||||
|
||||
+318
-57
@@ -17,12 +17,87 @@ use strsim::normalized_levenshtein;
|
||||
static TRAILING_TITLE_YEAR: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"\(?(\d{4})\)?$").expect("curation title-year regex must compile"));
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum CurationMediaKind {
|
||||
/// 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 {
|
||||
@@ -87,6 +162,12 @@ impl CurationMatchPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
#[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 },
|
||||
@@ -105,8 +186,7 @@ impl ProjectionIdentityStrategy<'_> {
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct CurationCategorySpec<'a> {
|
||||
pub(crate) name: &'a str,
|
||||
pub(crate) media_scope: CurationMediaScope,
|
||||
pub(crate) match_policy: CurationMatchPolicy,
|
||||
pub(crate) selector: CurationSelectorSpec,
|
||||
pub(crate) projection_identity: ProjectionIdentityStrategy<'a>,
|
||||
}
|
||||
|
||||
@@ -144,6 +224,8 @@ struct PlaylistCandidate<'a> {
|
||||
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 {
|
||||
@@ -190,7 +272,7 @@ fn calculate_year_bonus(playlist_year: Option<u32>, reference_year: Option<u32>)
|
||||
fn find_best_fuzzy_match_for_item<'playlist, 'reference>(
|
||||
candidate: &PlaylistCandidate<'playlist>,
|
||||
references: &'reference [CuratedMediaReference],
|
||||
specification: &CurationCategorySpec<'_>,
|
||||
specification: CurationSelectorSpec,
|
||||
threshold: f64,
|
||||
) -> Option<MatchResult<'playlist, 'reference>> {
|
||||
let mut best_match: Option<(&CuratedMediaReference, f64)> = None;
|
||||
@@ -221,7 +303,12 @@ fn find_best_fuzzy_match_for_item<'playlist, 'reference>(
|
||||
candidate.item.header.title,
|
||||
reference.title
|
||||
);
|
||||
return Some(MatchResult { playlist_item: candidate.item, reference });
|
||||
return Some(MatchResult {
|
||||
playlist_item: candidate.item,
|
||||
reference,
|
||||
media_kind: candidate.kind,
|
||||
candidate_order: 0,
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
@@ -230,7 +317,7 @@ fn find_best_fuzzy_match_for_item<'playlist, 'reference>(
|
||||
fn find_best_match_for_item<'playlist, 'reference>(
|
||||
candidate: &PlaylistCandidate<'playlist>,
|
||||
references: &'reference [CuratedMediaReference],
|
||||
specification: &CurationCategorySpec<'_>,
|
||||
specification: CurationSelectorSpec,
|
||||
) -> Option<MatchResult<'playlist, 'reference>> {
|
||||
if let Some(playlist_tmdb_id) = candidate.tmdb_id {
|
||||
for reference in references.iter().filter(|reference| {
|
||||
@@ -238,7 +325,12 @@ fn find_best_match_for_item<'playlist, 'reference>(
|
||||
}) {
|
||||
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 });
|
||||
return Some(MatchResult {
|
||||
playlist_item: candidate.item,
|
||||
reference,
|
||||
media_kind: candidate.kind,
|
||||
candidate_order: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -247,11 +339,11 @@ fn find_best_match_for_item<'playlist, 'reference>(
|
||||
find_best_fuzzy_match_for_item(candidate, references, specification, threshold)
|
||||
}
|
||||
|
||||
pub(crate) fn curate_category(
|
||||
references: &[CuratedMediaReference],
|
||||
playlist: &[PlaylistGroup],
|
||||
specification: &CurationCategorySpec<'_>,
|
||||
) -> Vec<PlaylistGroup> {
|
||||
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!(
|
||||
@@ -260,6 +352,7 @@ pub(crate) fn curate_category(
|
||||
);
|
||||
|
||||
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)
|
||||
@@ -275,41 +368,120 @@ pub(crate) fn curate_category(
|
||||
year: extract_year_from_title(&channel.header.title),
|
||||
tmdb_id: channel.get_tmdb_id(),
|
||||
};
|
||||
if let Some(matched) = find_best_match_for_item(&candidate, references, specification) {
|
||||
if let Some(mut matched) = find_best_match_for_item(&candidate, references, specification) {
|
||||
matched.candidate_order = candidate_order;
|
||||
matches.push(matched);
|
||||
}
|
||||
candidate_order += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let series_children = series_children_by_parent_code(playlist);
|
||||
create_category_from_matches(matches, specification, &series_children)
|
||||
matches
|
||||
}
|
||||
|
||||
fn create_category_from_matches(
|
||||
mut matches: Vec<MatchResult<'_, '_>>,
|
||||
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 matches.is_empty() {
|
||||
if items.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
matches.sort_by(|left, right| {
|
||||
(left.reference.rank.unwrap_or(9999), left.reference.title.to_lowercase())
|
||||
.cmp(&(right.reference.rank.unwrap_or(9999), right.reference.title.to_lowercase()))
|
||||
});
|
||||
|
||||
let group_title = specification.name.intern();
|
||||
let mut matched_items_by_cluster: IndexMap<XtreamCluster, Vec<PlaylistItem>> = IndexMap::new();
|
||||
|
||||
for matched in matches {
|
||||
let projected_item = clone_item_for_category(matched.playlist_item, specification, &group_title);
|
||||
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(matched.playlist_item) } else { Vec::new() };
|
||||
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);
|
||||
|
||||
@@ -418,7 +590,7 @@ mod tests {
|
||||
];
|
||||
let candidate = candidate(&playlist_item);
|
||||
|
||||
let matched = find_best_match_for_item(&candidate, &references, &specification("Featured", false))
|
||||
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));
|
||||
@@ -430,10 +602,10 @@ mod tests {
|
||||
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);
|
||||
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());
|
||||
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]
|
||||
@@ -442,10 +614,10 @@ mod tests {
|
||||
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);
|
||||
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());
|
||||
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]
|
||||
@@ -456,9 +628,12 @@ mod tests {
|
||||
reference(CurationMediaKind::Movie, "The Matrix", None, Some(222), Some(2)),
|
||||
];
|
||||
|
||||
let matched =
|
||||
find_best_match_for_item(&candidate(&playlist_item), &references, &specification("Featured", false))
|
||||
.expect("a perfect fuzzy title should match");
|
||||
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));
|
||||
}
|
||||
@@ -471,15 +646,18 @@ mod tests {
|
||||
reference(CurationMediaKind::Movie, "matrixx", None, Some(222), Some(2)),
|
||||
];
|
||||
|
||||
let matched =
|
||||
find_best_match_for_item(&candidate(&playlist_item), &references, &fuzzy_specification("Featured", 80))
|
||||
.expect("the best reference above an 80 percent threshold should match");
|
||||
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),
|
||||
fuzzy_specification("Featured", 90).selector,
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
@@ -504,6 +682,74 @@ mod tests {
|
||||
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!(memberships.is_empty());
|
||||
|
||||
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!(memberships.is_empty());
|
||||
|
||||
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![
|
||||
@@ -524,8 +770,13 @@ mod tests {
|
||||
reference(CurationMediaKind::Movie, "Movie", None, Some(1), Some(1)),
|
||||
reference(CurationMediaKind::Series, "Show", None, Some(2), Some(2)),
|
||||
];
|
||||
let specification =
|
||||
CurationCategorySpec { media_scope: CurationMediaScope::Both, ..specification("Mixed", true) };
|
||||
let specification = CurationCategorySpec {
|
||||
selector: CurationSelectorSpec {
|
||||
media_scope: CurationMediaScope::Both,
|
||||
..specification("Mixed", true).selector
|
||||
},
|
||||
..specification("Mixed", true)
|
||||
};
|
||||
|
||||
let categories = curate_category(&references, &playlist, &specification);
|
||||
|
||||
@@ -545,11 +796,11 @@ mod tests {
|
||||
reference(CurationMediaKind::Movie, "Movie Reference", None, Some(42), Some(2)),
|
||||
];
|
||||
let exact_specification =
|
||||
CurationCategorySpec { media_scope: CurationMediaScope::Both, ..specification("Mixed", true) };
|
||||
CurationSelectorSpec { media_scope: CurationMediaScope::Both, ..specification("Mixed", true).selector };
|
||||
|
||||
let movie_match = find_best_match_for_item(&candidate(&movie), &exact_references, &exact_specification)
|
||||
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)
|
||||
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);
|
||||
@@ -559,12 +810,14 @@ mod tests {
|
||||
reference(CurationMediaKind::Series, "Shared Title", None, None, Some(1)),
|
||||
reference(CurationMediaKind::Movie, "Shared Title", None, None, Some(2)),
|
||||
];
|
||||
let fuzzy_specification =
|
||||
CurationCategorySpec { media_scope: CurationMediaScope::Both, ..fuzzy_specification("Mixed", 100) };
|
||||
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)
|
||||
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)
|
||||
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);
|
||||
@@ -658,8 +911,10 @@ mod tests {
|
||||
if exact_only {
|
||||
CurationCategorySpec {
|
||||
name: category_name,
|
||||
media_scope: CurationMediaScope::Movies,
|
||||
match_policy: CurationMatchPolicy::ExactTmdbOnly,
|
||||
selector: CurationSelectorSpec {
|
||||
media_scope: CurationMediaScope::Movies,
|
||||
match_policy: CurationMatchPolicy::ExactTmdbOnly,
|
||||
},
|
||||
projection_identity: ProjectionIdentityStrategy::LegacyCategoryScoped { namespace: "legacy-category" },
|
||||
}
|
||||
} else {
|
||||
@@ -670,14 +925,20 @@ mod tests {
|
||||
fn fuzzy_specification(category_name: &str, threshold_percent: u8) -> CurationCategorySpec<'_> {
|
||||
CurationCategorySpec {
|
||||
name: category_name,
|
||||
media_scope: CurationMediaScope::Movies,
|
||||
match_policy: CurationMatchPolicy::ExactTmdbThenFuzzy { threshold_percent },
|
||||
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<'_> {
|
||||
CurationCategorySpec { media_scope: CurationMediaScope::Series, ..specification(category_name, exact_only) }
|
||||
let specification = specification(category_name, exact_only);
|
||||
CurationCategorySpec {
|
||||
selector: CurationSelectorSpec { media_scope: CurationMediaScope::Series, ..specification.selector },
|
||||
..specification
|
||||
}
|
||||
}
|
||||
|
||||
fn candidate(item: &PlaylistItem) -> PlaylistCandidate<'_> {
|
||||
|
||||
@@ -11,4 +11,8 @@
|
||||
mod kernel;
|
||||
mod trakt;
|
||||
|
||||
pub use trakt::curate_trakt_categories;
|
||||
pub use kernel::{
|
||||
CurationEvaluation, CurationFailure, CurationIncompleteReason, CurationMediaKind, CurationMembership,
|
||||
CurationRunOutcome, CurationSelectorKey, CurationSelectorSummary, CurationUnavailableReason, SelectorOutcome,
|
||||
};
|
||||
pub use trakt::{evaluate_trakt_curation, project_trakt_categories};
|
||||
|
||||
@@ -15,6 +15,32 @@ use tuliprox_core::model::{TraktApiConfig, TraktChartConfig, TraktListConfig};
|
||||
const TRAKT_PAGE_LIMIT: u32 = 100;
|
||||
const TRAKT_MAX_PAGES: u32 = 100;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum TraktFetchFailureKind {
|
||||
Interrupted,
|
||||
PaginationTruncated,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct TraktFetchFailure {
|
||||
pub(super) kind: TraktFetchFailureKind,
|
||||
error: TuliproxError,
|
||||
}
|
||||
|
||||
impl TraktFetchFailure {
|
||||
fn pagination_truncated(message: String) -> Self {
|
||||
Self { kind: TraktFetchFailureKind::PaginationTruncated, error: TuliproxError::RepositoryTrakt(message) }
|
||||
}
|
||||
|
||||
fn from_page_error(page: u32, error: TuliproxError) -> Self {
|
||||
let kind = if page > 1 { TraktFetchFailureKind::Interrupted } else { TraktFetchFailureKind::Unavailable };
|
||||
Self { kind, error }
|
||||
}
|
||||
|
||||
pub(super) fn message(&self) -> &str { self.error.message() }
|
||||
}
|
||||
|
||||
pub(super) struct TraktClient {
|
||||
client: reqwest::Client,
|
||||
api_config: TraktApiConfig,
|
||||
@@ -68,7 +94,7 @@ impl TraktClient {
|
||||
pub(super) async fn get_chart_items(
|
||||
&self,
|
||||
chart_config: &TraktChartConfig,
|
||||
) -> Result<Vec<TraktListItem>, TuliproxError> {
|
||||
) -> Result<Vec<TraktListItem>, TraktFetchFailure> {
|
||||
let id_label = format!("{}:{}", chart_config.kind, chart_config.chart);
|
||||
self.paginate_items(
|
||||
"chart",
|
||||
@@ -81,7 +107,7 @@ impl TraktClient {
|
||||
pub(super) async fn get_list_items(
|
||||
&self,
|
||||
list_config: &TraktListConfig,
|
||||
) -> Result<Vec<TraktListItem>, TuliproxError> {
|
||||
) -> Result<Vec<TraktListItem>, TraktFetchFailure> {
|
||||
let id_label = format!("{}:{}", list_config.user, list_config.list_slug);
|
||||
self.paginate_items("list", id_label, |page| async move { self.get_list_items_page(list_config, page).await })
|
||||
.await
|
||||
@@ -92,7 +118,7 @@ impl TraktClient {
|
||||
kind_label: &'static str,
|
||||
id_label: String,
|
||||
mut fetch_page: F,
|
||||
) -> Result<Vec<TraktListItem>, TuliproxError>
|
||||
) -> Result<Vec<TraktListItem>, TraktFetchFailure>
|
||||
where
|
||||
F: FnMut(u32) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<TraktListItemsPage, TuliproxError>>,
|
||||
@@ -101,29 +127,59 @@ impl TraktClient {
|
||||
|
||||
let mut page = 1;
|
||||
let mut items = Vec::new();
|
||||
let mut expected_page_count = None;
|
||||
let mut expected_item_count = None;
|
||||
loop {
|
||||
let mut page_items = fetch_page(page).await?;
|
||||
let page_count = page_items.page_count;
|
||||
let item_count = page_items.item_count;
|
||||
debug!(
|
||||
"Fetched Trakt {kind_label} {id_label} page {page}/{page_count} with {} items",
|
||||
page_items.items.len()
|
||||
);
|
||||
let is_last_page = page >= page_count || page >= TRAKT_MAX_PAGES || page_items.items.is_empty();
|
||||
let mut page_items =
|
||||
fetch_page(page).await.map_err(|error| TraktFetchFailure::from_page_error(page, error))?;
|
||||
let reported_page_count = page_items.page_count.filter(|count| *count > 0);
|
||||
let page_count = if page == 1 {
|
||||
expected_page_count = reported_page_count;
|
||||
expected_item_count = page_items.item_count;
|
||||
reported_page_count.unwrap_or(page)
|
||||
} else {
|
||||
let Some(page_count) = expected_page_count else {
|
||||
return Err(TraktFetchFailure::pagination_truncated(format!(
|
||||
"Trakt {kind_label} {id_label} pagination metadata was missing before page {page}"
|
||||
)));
|
||||
};
|
||||
if reported_page_count != Some(page_count) || page_items.item_count != expected_item_count {
|
||||
return Err(TraktFetchFailure::pagination_truncated(format!(
|
||||
"Trakt {kind_label} {id_label} pagination metadata changed or disappeared at page {page}"
|
||||
)));
|
||||
}
|
||||
page_count
|
||||
};
|
||||
if page_count < page {
|
||||
return Err(TraktFetchFailure::pagination_truncated(format!(
|
||||
"Trakt {kind_label} {id_label} reported {page_count} pages while fetching page {page}"
|
||||
)));
|
||||
}
|
||||
let fetched_count = page_items.items.len();
|
||||
debug!("Fetched Trakt {kind_label} {id_label} page {page}/{page_count} with {fetched_count} items");
|
||||
items.append(&mut page_items.items);
|
||||
if is_last_page {
|
||||
if page >= TRAKT_MAX_PAGES && page < page_count {
|
||||
debug!(
|
||||
"Stopped Trakt {kind_label} {id_label} after {TRAKT_MAX_PAGES} pages; reported page count was {page_count}"
|
||||
);
|
||||
|
||||
if page >= page_count {
|
||||
if expected_item_count.is_some_and(|count| usize::try_from(count).ok() != Some(items.len())) {
|
||||
return Err(TraktFetchFailure::pagination_truncated(format!(
|
||||
"Trakt {kind_label} {id_label} snapshot was incomplete: fetched {} items but the source reported {}",
|
||||
items.len(),
|
||||
expected_item_count.unwrap_or_default()
|
||||
)));
|
||||
}
|
||||
info!(
|
||||
"Successfully fetched {} items from Trakt {kind_label} {id_label}{}",
|
||||
items.len(),
|
||||
item_count.map(|count| format!(" (reported item count: {count})")).unwrap_or_default()
|
||||
expected_item_count.map(|count| format!(" (reported item count: {count})")).unwrap_or_default()
|
||||
);
|
||||
return Ok(items);
|
||||
}
|
||||
|
||||
if page >= TRAKT_MAX_PAGES || fetched_count == 0 {
|
||||
return Err(TraktFetchFailure::pagination_truncated(format!(
|
||||
"Trakt {kind_label} {id_label} snapshot was incomplete at page {page} of {page_count}"
|
||||
)));
|
||||
}
|
||||
page += 1;
|
||||
}
|
||||
}
|
||||
@@ -136,8 +192,7 @@ impl TraktClient {
|
||||
let url = self.build_list_url(&list_config.user, &list_config.list_slug);
|
||||
let request_url = format!("{url}?page={page}&limit={TRAKT_PAGE_LIMIT}");
|
||||
let list_id = format!("{}:{}", list_config.user, list_config.list_slug);
|
||||
let (response_text, page_count, item_count) =
|
||||
self.fetch_trakt_page(request_url, "list", &list_id, page).await?;
|
||||
let (response_text, page_count, item_count) = self.fetch_trakt_page(request_url, "list", &list_id).await?;
|
||||
let items: Vec<TraktListItem> = serde_json::from_str(&response_text).map_err(|error: serde_json::Error| {
|
||||
TuliproxError::Config(format!("Failed to parse Trakt response: {error}"))
|
||||
})?;
|
||||
@@ -153,8 +208,7 @@ impl TraktClient {
|
||||
let url = self.build_chart_url(chart_config);
|
||||
let request_url = format!("{url}?page={page}&limit={TRAKT_PAGE_LIMIT}");
|
||||
let chart_id = format!("{}:{}", chart_config.kind, chart_config.chart);
|
||||
let (response_text, page_count, item_count) =
|
||||
self.fetch_trakt_page(request_url, "chart", &chart_id, page).await?;
|
||||
let (response_text, page_count, item_count) = self.fetch_trakt_page(request_url, "chart", &chart_id).await?;
|
||||
let items = parse_chart_items(&response_text, chart_config, page)
|
||||
.map_err(|error| TuliproxError::Config(format!("Failed to parse Trakt chart response: {error}")))?;
|
||||
|
||||
@@ -166,8 +220,7 @@ impl TraktClient {
|
||||
request_url: String,
|
||||
resource_kind: &str,
|
||||
resource_id: &str,
|
||||
page: u32,
|
||||
) -> Result<(String, u32, Option<u32>), TuliproxError> {
|
||||
) -> Result<(String, Option<u32>, Option<u32>), TuliproxError> {
|
||||
let response = self.client.get(&request_url).headers(self.headers.clone()).send().await.map_err(|err| {
|
||||
TuliproxError::Config(format!("Failed to fetch Trakt {resource_kind} {request_url}: {err}"))
|
||||
})?;
|
||||
@@ -176,7 +229,7 @@ impl TraktClient {
|
||||
handle_trakt_api_error(response.status(), resource_kind, resource_id)?;
|
||||
}
|
||||
|
||||
let page_count = parse_trakt_pagination_header(response.headers(), "x-pagination-page-count").unwrap_or(page);
|
||||
let page_count = parse_trakt_pagination_header(response.headers(), "x-pagination-page-count");
|
||||
let item_count = parse_trakt_pagination_header(response.headers(), "x-pagination-item-count");
|
||||
let response_text = response.text().await.map_err(|error: reqwest::Error| {
|
||||
TuliproxError::Config(format!("Failed to read Trakt response: {error}"))
|
||||
@@ -188,7 +241,7 @@ impl TraktClient {
|
||||
|
||||
struct TraktListItemsPage {
|
||||
items: Vec<TraktListItem>,
|
||||
page_count: u32,
|
||||
page_count: Option<u32>,
|
||||
item_count: Option<u32>,
|
||||
}
|
||||
|
||||
@@ -328,7 +381,8 @@ mod tests {
|
||||
let list_config = TraktListConfig {
|
||||
user: "user".to_string(),
|
||||
list_slug: "list".to_string(),
|
||||
category_name: "category".to_string(),
|
||||
category_name: Some("category".to_string()),
|
||||
create_xtream_category: true,
|
||||
content_type: TraktContentType::Vod,
|
||||
tmdb_only: false,
|
||||
fuzzy_match_threshold: 90,
|
||||
@@ -341,6 +395,100 @@ mod tests {
|
||||
assert_eq!(requests.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pagination_at_the_safety_cap_is_reported_as_incomplete() {
|
||||
let client = TraktClient::new(reqwest::Client::new(), api_config("http://127.0.0.1:9".to_string(), "test-key"))
|
||||
.expect("client");
|
||||
|
||||
let error = client
|
||||
.paginate_items("list", "large-list".to_string(), |page| async move {
|
||||
let item = serde_json::from_str::<TraktListItem>(&trakt_movie_json(page))
|
||||
.expect("test Trakt item should parse");
|
||||
Ok(TraktListItemsPage {
|
||||
items: vec![item],
|
||||
page_count: Some(TRAKT_MAX_PAGES + 1),
|
||||
item_count: Some(TRAKT_MAX_PAGES + 1),
|
||||
})
|
||||
})
|
||||
.await
|
||||
.expect_err("a bounded prefix must not be accepted as an authoritative snapshot");
|
||||
|
||||
assert_eq!(error.kind, TraktFetchFailureKind::PaginationTruncated);
|
||||
assert!(error.message().contains("incomplete"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interrupted_later_page_is_not_reported_as_source_unavailable() {
|
||||
let client = TraktClient::new(reqwest::Client::new(), api_config("http://127.0.0.1:9".to_string(), "test-key"))
|
||||
.expect("client");
|
||||
|
||||
let error = client
|
||||
.paginate_items("list", "interrupted-list".to_string(), |page| async move {
|
||||
if page == 1 {
|
||||
let item = serde_json::from_str::<TraktListItem>(&trakt_movie_json(page))
|
||||
.expect("test Trakt item should parse");
|
||||
Ok(TraktListItemsPage { items: vec![item], page_count: Some(2), item_count: Some(2) })
|
||||
} else {
|
||||
Err(TuliproxError::RepositoryTrakt("later page failed".to_string()))
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect_err("interrupted pagination must fail");
|
||||
|
||||
assert_eq!(error.kind, TraktFetchFailureKind::Interrupted);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inconsistent_reported_item_count_is_incomplete() {
|
||||
let client = TraktClient::new(reqwest::Client::new(), api_config("http://127.0.0.1:9".to_string(), "test-key"))
|
||||
.expect("client");
|
||||
|
||||
let error = client
|
||||
.paginate_items("list", "short-list".to_string(), |page| async move {
|
||||
let item = serde_json::from_str::<TraktListItem>(&trakt_movie_json(page))
|
||||
.expect("test Trakt item should parse");
|
||||
Ok(TraktListItemsPage { items: vec![item], page_count: Some(1), item_count: Some(2) })
|
||||
})
|
||||
.await
|
||||
.expect_err("reported count mismatch must fail");
|
||||
|
||||
assert_eq!(error.kind, TraktFetchFailureKind::PaginationTruncated);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn zero_page_count_allows_an_empty_first_page_snapshot() {
|
||||
let client = TraktClient::new(reqwest::Client::new(), api_config("http://127.0.0.1:9".to_string(), "test-key"))
|
||||
.expect("client");
|
||||
|
||||
let items = client
|
||||
.paginate_items("list", "empty-list".to_string(), |_| async {
|
||||
Ok(TraktListItemsPage { items: Vec::new(), page_count: Some(0), item_count: Some(0) })
|
||||
})
|
||||
.await
|
||||
.expect("a zero page count should represent an empty snapshot");
|
||||
|
||||
assert!(items.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_pagination_metadata_on_later_page_is_incomplete() {
|
||||
let client = TraktClient::new(reqwest::Client::new(), api_config("http://127.0.0.1:9".to_string(), "test-key"))
|
||||
.expect("client");
|
||||
|
||||
let error = client
|
||||
.paginate_items("list", "missing-pagination".to_string(), |page| async move {
|
||||
let item = serde_json::from_str::<TraktListItem>(&trakt_movie_json(page))
|
||||
.expect("test Trakt item should parse");
|
||||
let (page_count, item_count) = if page == 1 { (Some(3), Some(3)) } else { (None, None) };
|
||||
Ok(TraktListItemsPage { items: vec![item], page_count, item_count })
|
||||
})
|
||||
.await
|
||||
.expect_err("missing pagination metadata must not publish a partial snapshot");
|
||||
|
||||
assert_eq!(error.kind, TraktFetchFailureKind::PaginationTruncated);
|
||||
assert!(error.message().contains("pagination metadata changed or disappeared"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_chart_items_fetches_public_trending_movies() {
|
||||
let requests = Arc::new(Mutex::new(Vec::new()));
|
||||
@@ -474,7 +622,8 @@ mod tests {
|
||||
TraktChartConfig {
|
||||
kind,
|
||||
chart,
|
||||
category_name: "category".to_string(),
|
||||
category_name: Some("category".to_string()),
|
||||
create_xtream_category: true,
|
||||
tmdb_only: false,
|
||||
fuzzy_match_threshold: 90,
|
||||
}
|
||||
|
||||
+346
-125
@@ -3,10 +3,12 @@ mod errors;
|
||||
mod model;
|
||||
|
||||
use crate::kernel::{
|
||||
curate_category, CuratedMediaReference, CurationCategorySpec, CurationMatchPolicy, CurationMediaScope,
|
||||
ProjectionIdentityStrategy,
|
||||
evaluate_selector, project_memberships, CuratedMediaReference, CurationCategorySpec, CurationEvaluation,
|
||||
CurationFailure, CurationIncompleteReason, CurationMatchPolicy, CurationMediaScope, CurationProjectionCatalog,
|
||||
CurationRunOutcome, CurationSelectorKey, CurationSelectorSpec, CurationSelectorSummary, CurationUnavailableReason,
|
||||
ProjectionIdentityStrategy, SelectorOutcome,
|
||||
};
|
||||
use client::TraktClient;
|
||||
use client::{TraktClient, TraktFetchFailureKind};
|
||||
use log::{debug, info, warn};
|
||||
use model::TraktListItem;
|
||||
use shared::model::{PlaylistGroup, TraktContentType};
|
||||
@@ -16,33 +18,160 @@ use tuliprox_core::model::{TraktChartConfig, TraktConfig, TraktListConfig};
|
||||
// deliberately supplied by the adapter rather than treated as canonical media identity.
|
||||
const LEGACY_TRAKT_CATEGORY_NAMESPACE: &str = "trakt-category";
|
||||
|
||||
/// Curate virtual playlist categories from the configured Trakt lists and charts.
|
||||
/// Evaluate every configured Trakt selector into exact target memberships.
|
||||
///
|
||||
/// Disabled or source-less configuration is a no-op. Individual list/chart
|
||||
/// failures are logged and isolated so successful sources still contribute.
|
||||
pub async fn curate_trakt_categories(
|
||||
/// No partial selector result is admitted into [`CurationRunOutcome::Complete`].
|
||||
pub async fn evaluate_trakt_curation(
|
||||
http_client: &reqwest::Client,
|
||||
playlist: &[PlaylistGroup],
|
||||
target_name: &str,
|
||||
trakt_config: &TraktConfig,
|
||||
) -> Option<Vec<PlaylistGroup>> {
|
||||
if !trakt_config.enabled {
|
||||
return None;
|
||||
}
|
||||
if trakt_config.lists.is_empty() && trakt_config.charts.is_empty() {
|
||||
debug!("No Trakt lists or charts configured for target {target_name}");
|
||||
return None;
|
||||
) -> CurationRunOutcome {
|
||||
if !trakt_config.enabled || (trakt_config.lists.is_empty() && trakt_config.charts.is_empty()) {
|
||||
return CurationRunOutcome::NotConfigured;
|
||||
}
|
||||
|
||||
let selector_count = trakt_config.lists.len() + trakt_config.charts.len();
|
||||
let processor = match TraktCategoriesProcessor::new(http_client, trakt_config) {
|
||||
Ok(processor) => processor,
|
||||
Err(error) => {
|
||||
warn!("Skipping Trakt curation for target '{target_name}': {}", error.message());
|
||||
return None;
|
||||
warn!("Trakt curation is unavailable for target '{target_name}': {}", error.message());
|
||||
return CurationRunOutcome::Failed(CurationFailure {
|
||||
selector_outcomes: (0..selector_count)
|
||||
.map(|ordinal| SelectorOutcome::Unavailable {
|
||||
key: selector_key(ordinal),
|
||||
reason: CurationUnavailableReason::Configuration,
|
||||
})
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
Some(processor.process(playlist, target_name, trakt_config).await)
|
||||
info!(
|
||||
"Evaluating {} Trakt lists and {} Trakt charts for target {target_name}",
|
||||
trakt_config.lists.len(),
|
||||
trakt_config.charts.len()
|
||||
);
|
||||
let mut selector_outcomes = Vec::with_capacity(selector_count);
|
||||
|
||||
for (ordinal, list_config) in trakt_config.lists.iter().enumerate() {
|
||||
let key = selector_key(ordinal);
|
||||
let source_label = format!("{}:{}", list_config.user, list_config.list_slug);
|
||||
let outcome = match processor.client.get_list_items(list_config).await {
|
||||
Ok(items) => {
|
||||
debug!("Evaluating Trakt list {source_label} with {} items", items.len());
|
||||
let references = translate_items(items);
|
||||
evaluate_selector(key, &references, playlist, list_selector_spec(list_config))
|
||||
}
|
||||
Err(error) => {
|
||||
warn!("Failed to fetch Trakt list {source_label}: {}", error.message());
|
||||
failed_selector_outcome(key, error.kind)
|
||||
}
|
||||
};
|
||||
selector_outcomes.push(outcome);
|
||||
}
|
||||
|
||||
for (chart_index, chart_config) in trakt_config.charts.iter().enumerate() {
|
||||
let ordinal = trakt_config.lists.len() + chart_index;
|
||||
let key = selector_key(ordinal);
|
||||
let source_label = format!("{}:{}", chart_config.kind, chart_config.chart);
|
||||
let outcome = match processor.client.get_chart_items(chart_config).await {
|
||||
Ok(items) => {
|
||||
debug!("Evaluating Trakt chart {source_label} with {} items", items.len());
|
||||
let references = translate_items(items);
|
||||
evaluate_selector(key, &references, playlist, chart_selector_spec(chart_config))
|
||||
}
|
||||
Err(error) => {
|
||||
warn!("Failed to fetch Trakt chart {source_label}: {}", error.message());
|
||||
failed_selector_outcome(key, error.kind)
|
||||
}
|
||||
};
|
||||
selector_outcomes.push(outcome);
|
||||
}
|
||||
|
||||
complete_evaluation(selector_outcomes)
|
||||
}
|
||||
|
||||
const fn selector_key(ordinal: usize) -> CurationSelectorKey { CurationSelectorKey(ordinal) }
|
||||
|
||||
fn failed_selector_outcome(key: CurationSelectorKey, failure: TraktFetchFailureKind) -> SelectorOutcome {
|
||||
match failure {
|
||||
TraktFetchFailureKind::Interrupted => {
|
||||
SelectorOutcome::Incomplete { key, reason: CurationIncompleteReason::Interrupted }
|
||||
}
|
||||
TraktFetchFailureKind::PaginationTruncated => {
|
||||
SelectorOutcome::Incomplete { key, reason: CurationIncompleteReason::PaginationTruncated }
|
||||
}
|
||||
TraktFetchFailureKind::Unavailable => {
|
||||
SelectorOutcome::Unavailable { key, reason: CurationUnavailableReason::Source }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Project a complete neutral evaluation into configured Xtream categories.
|
||||
pub fn project_trakt_categories(
|
||||
evaluation: &CurationEvaluation,
|
||||
playlist: &[PlaylistGroup],
|
||||
trakt_config: &TraktConfig,
|
||||
) -> Vec<PlaylistGroup> {
|
||||
let mut categories = Vec::new();
|
||||
let projection_catalog = CurationProjectionCatalog::new(playlist);
|
||||
for (ordinal, list_config) in trakt_config.lists.iter().enumerate() {
|
||||
append_selector_projection(
|
||||
selector_key(ordinal),
|
||||
list_config.category_name.as_deref(),
|
||||
list_config.create_xtream_category,
|
||||
evaluation,
|
||||
&projection_catalog,
|
||||
&list_category_spec(list_config),
|
||||
&mut categories,
|
||||
);
|
||||
}
|
||||
for (chart_index, chart_config) in trakt_config.charts.iter().enumerate() {
|
||||
append_selector_projection(
|
||||
selector_key(trakt_config.lists.len() + chart_index),
|
||||
chart_config.category_name.as_deref(),
|
||||
chart_config.create_xtream_category,
|
||||
evaluation,
|
||||
&projection_catalog,
|
||||
&chart_category_spec(chart_config),
|
||||
&mut categories,
|
||||
);
|
||||
}
|
||||
categories
|
||||
}
|
||||
|
||||
fn append_selector_projection(
|
||||
key: CurationSelectorKey,
|
||||
category_name: Option<&str>,
|
||||
create_xtream_category: bool,
|
||||
evaluation: &CurationEvaluation,
|
||||
projection_catalog: &CurationProjectionCatalog<'_>,
|
||||
specification: &CurationCategorySpec<'_>,
|
||||
categories: &mut Vec<PlaylistGroup>,
|
||||
) {
|
||||
if !create_xtream_category || category_name.is_none_or(|name| name.trim().is_empty()) {
|
||||
return;
|
||||
}
|
||||
let memberships = evaluation.memberships.iter().filter(|membership| membership.selector_key == key);
|
||||
categories.extend(project_memberships(memberships, projection_catalog, specification));
|
||||
}
|
||||
|
||||
fn complete_evaluation(selector_outcomes: Vec<SelectorOutcome>) -> CurationRunOutcome {
|
||||
if selector_outcomes.iter().any(|outcome| !matches!(outcome, SelectorOutcome::Complete { .. })) {
|
||||
return CurationRunOutcome::Failed(CurationFailure { selector_outcomes });
|
||||
}
|
||||
|
||||
let mut selectors = Vec::with_capacity(selector_outcomes.len());
|
||||
let mut memberships = Vec::new();
|
||||
for outcome in selector_outcomes {
|
||||
let SelectorOutcome::Complete { key, reference_count, memberships: mut selector_memberships } = outcome else {
|
||||
unreachable!("all selector outcomes were checked as complete")
|
||||
};
|
||||
selectors.push(CurationSelectorSummary { key, reference_count, membership_count: selector_memberships.len() });
|
||||
memberships.append(&mut selector_memberships);
|
||||
}
|
||||
CurationRunOutcome::Complete(CurationEvaluation { selectors, memberships })
|
||||
}
|
||||
|
||||
struct TraktCategoriesProcessor {
|
||||
@@ -54,92 +183,39 @@ impl TraktCategoriesProcessor {
|
||||
let client = TraktClient::new(http_client.clone(), trakt_config.api.clone())?;
|
||||
Ok(Self { client })
|
||||
}
|
||||
|
||||
async fn process(
|
||||
&self,
|
||||
playlist: &[PlaylistGroup],
|
||||
target_name: &str,
|
||||
trakt_config: &TraktConfig,
|
||||
) -> Vec<PlaylistGroup> {
|
||||
info!(
|
||||
"Processing {} Trakt lists and {} Trakt charts for target {target_name}",
|
||||
trakt_config.lists.len(),
|
||||
trakt_config.charts.len()
|
||||
);
|
||||
let mut new_categories = Vec::new();
|
||||
let mut total_matches = 0;
|
||||
|
||||
for list_config in &trakt_config.lists {
|
||||
let source_label = format!("{}:{}", list_config.user, list_config.list_slug);
|
||||
let specification = list_category_spec(list_config);
|
||||
|
||||
match self.client.get_list_items(list_config).await {
|
||||
Ok(items) => {
|
||||
debug!("Processing Trakt list {source_label} with {} items", items.len());
|
||||
let references = translate_items(items);
|
||||
append_categories(&references, playlist, &specification, &mut new_categories, &mut total_matches);
|
||||
}
|
||||
Err(error) => warn!("Failed to fetch Trakt list {source_label}: {}", error.message()),
|
||||
}
|
||||
}
|
||||
|
||||
for chart_config in &trakt_config.charts {
|
||||
let source_label = format!("{}:{}", chart_config.kind, chart_config.chart);
|
||||
let specification = chart_category_spec(chart_config);
|
||||
|
||||
match self.client.get_chart_items(chart_config).await {
|
||||
Ok(items) => {
|
||||
debug!("Processing Trakt chart {source_label} with {} items", items.len());
|
||||
let references = translate_items(items);
|
||||
append_categories(&references, playlist, &specification, &mut new_categories, &mut total_matches);
|
||||
}
|
||||
Err(error) => warn!("Failed to fetch Trakt chart {source_label}: {}", error.message()),
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"Trakt processing complete: created {} categories with {total_matches} total matches",
|
||||
new_categories.len()
|
||||
);
|
||||
new_categories
|
||||
}
|
||||
}
|
||||
|
||||
fn append_categories(
|
||||
references: &[CuratedMediaReference],
|
||||
playlist: &[PlaylistGroup],
|
||||
specification: &CurationCategorySpec<'_>,
|
||||
categories: &mut Vec<PlaylistGroup>,
|
||||
total_matches: &mut usize,
|
||||
) {
|
||||
for category in curate_category(references, playlist, specification) {
|
||||
if !category.channels.is_empty() {
|
||||
*total_matches += category.channels.len();
|
||||
let category_len = category.channels.len();
|
||||
categories.push(category);
|
||||
debug!("Created Trakt category '{}' with {category_len} items", specification.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn translate_items(items: Vec<TraktListItem>) -> Vec<CuratedMediaReference> {
|
||||
items.into_iter().filter_map(TraktListItem::into_curated_reference).collect()
|
||||
}
|
||||
|
||||
fn list_selector_spec(config: &TraktListConfig) -> CurationSelectorSpec {
|
||||
selector_spec(config.content_type, config.tmdb_only, config.fuzzy_match_threshold)
|
||||
}
|
||||
|
||||
fn chart_selector_spec(config: &TraktChartConfig) -> CurationSelectorSpec {
|
||||
selector_spec(config.kind.content_type(), config.tmdb_only, config.fuzzy_match_threshold)
|
||||
}
|
||||
|
||||
fn list_category_spec(config: &TraktListConfig) -> CurationCategorySpec<'_> {
|
||||
category_spec(&config.category_name, config.content_type, config.tmdb_only, config.fuzzy_match_threshold)
|
||||
category_spec(
|
||||
config.category_name.as_deref().unwrap_or_default(),
|
||||
config.content_type,
|
||||
config.tmdb_only,
|
||||
config.fuzzy_match_threshold,
|
||||
)
|
||||
}
|
||||
|
||||
fn chart_category_spec(config: &TraktChartConfig) -> CurationCategorySpec<'_> {
|
||||
category_spec(&config.category_name, config.kind.content_type(), config.tmdb_only, config.fuzzy_match_threshold)
|
||||
category_spec(
|
||||
config.category_name.as_deref().unwrap_or_default(),
|
||||
config.kind.content_type(),
|
||||
config.tmdb_only,
|
||||
config.fuzzy_match_threshold,
|
||||
)
|
||||
}
|
||||
|
||||
fn category_spec(
|
||||
category_name: &str,
|
||||
content_type: TraktContentType,
|
||||
tmdb_only: bool,
|
||||
fuzzy_match_threshold: u8,
|
||||
) -> CurationCategorySpec<'_> {
|
||||
fn selector_spec(content_type: TraktContentType, tmdb_only: bool, fuzzy_match_threshold: u8) -> CurationSelectorSpec {
|
||||
let media_scope = match content_type {
|
||||
TraktContentType::Vod => CurationMediaScope::Movies,
|
||||
TraktContentType::Series => CurationMediaScope::Series,
|
||||
@@ -150,10 +226,18 @@ fn category_spec(
|
||||
} else {
|
||||
CurationMatchPolicy::ExactTmdbThenFuzzy { threshold_percent: fuzzy_match_threshold }
|
||||
};
|
||||
CurationSelectorSpec { media_scope, match_policy }
|
||||
}
|
||||
|
||||
fn category_spec(
|
||||
category_name: &str,
|
||||
content_type: TraktContentType,
|
||||
tmdb_only: bool,
|
||||
fuzzy_match_threshold: u8,
|
||||
) -> CurationCategorySpec<'_> {
|
||||
CurationCategorySpec {
|
||||
name: category_name,
|
||||
media_scope,
|
||||
match_policy,
|
||||
selector: selector_spec(content_type, tmdb_only, fuzzy_match_threshold),
|
||||
projection_identity: ProjectionIdentityStrategy::LegacyCategoryScoped {
|
||||
namespace: LEGACY_TRAKT_CATEGORY_NAMESPACE,
|
||||
},
|
||||
@@ -167,8 +251,8 @@ mod tests {
|
||||
use shared::{
|
||||
model::{
|
||||
EpisodeStreamProperties, FieldGet, HeaderField, PlaylistItem, PlaylistItemHeader, PlaylistItemType,
|
||||
SeriesStreamProperties, StreamProperties, TraktChartKind, TraktChartType, VideoStreamProperties, VirtualId,
|
||||
XtreamCluster,
|
||||
SeriesStreamProperties, StreamProperties, TraktCatalogSelection, TraktChartKind, TraktChartType,
|
||||
VideoStreamProperties, VirtualId, XtreamCluster,
|
||||
},
|
||||
utils::{hash_string, Internable},
|
||||
};
|
||||
@@ -183,19 +267,63 @@ mod tests {
|
||||
};
|
||||
use tuliprox_core::model::TraktApiConfig;
|
||||
|
||||
#[test]
|
||||
fn complete_evaluation_preserves_selector_order_and_overlapping_subjects() {
|
||||
let subject_uuid = hash_string("overlapping-subject");
|
||||
let outcome = complete_evaluation(vec![
|
||||
SelectorOutcome::Complete {
|
||||
key: CurationSelectorKey(0),
|
||||
reference_count: 1,
|
||||
memberships: vec![crate::kernel::CurationMembership {
|
||||
selector_key: CurationSelectorKey(0),
|
||||
subject_uuid,
|
||||
media_kind: crate::kernel::CurationMediaKind::Movie,
|
||||
rank: Some(1),
|
||||
title_tiebreak: "same".to_string(),
|
||||
candidate_order: 0,
|
||||
}],
|
||||
},
|
||||
SelectorOutcome::Complete {
|
||||
key: CurationSelectorKey(1),
|
||||
reference_count: 1,
|
||||
memberships: vec![crate::kernel::CurationMembership {
|
||||
selector_key: CurationSelectorKey(1),
|
||||
subject_uuid,
|
||||
media_kind: crate::kernel::CurationMediaKind::Movie,
|
||||
rank: Some(1),
|
||||
title_tiebreak: "same".to_string(),
|
||||
candidate_order: 0,
|
||||
}],
|
||||
},
|
||||
]);
|
||||
|
||||
let CurationRunOutcome::Complete(evaluation) = outcome else { panic!("all selectors completed") };
|
||||
assert_eq!(
|
||||
evaluation.selectors.iter().map(|summary| summary.key).collect::<Vec<_>>(),
|
||||
[CurationSelectorKey(0), CurationSelectorKey(1),]
|
||||
);
|
||||
assert_eq!(evaluation.memberships.len(), 2);
|
||||
assert_eq!(evaluation.memberships[0].subject_uuid, evaluation.memberships[1].subject_uuid);
|
||||
assert_ne!(evaluation.memberships[0].selector_key, evaluation.memberships[1].selector_key);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn configured_curation_without_a_usable_client_id_makes_no_request() {
|
||||
for client_id in ["", " \t\r\n ", "sensitive-client-id\ninjected-header"] {
|
||||
let requests = Arc::new(AtomicUsize::new(0));
|
||||
let (base_url, server) = spawn_counting_trakt_server(Arc::clone(&requests)).await;
|
||||
let config = trakt_config(client_id, base_url, true, vec![remote_list_config("Missing")], Vec::new());
|
||||
async fn missing_credentials_make_every_required_selector_unavailable_without_a_request() {
|
||||
let requests = Arc::new(AtomicUsize::new(0));
|
||||
let (base_url, server) = spawn_counting_trakt_server(Arc::clone(&requests)).await;
|
||||
let config =
|
||||
trakt_config("", base_url, true, vec![remote_list_config("List")], vec![remote_chart_config("Chart")]);
|
||||
|
||||
let result = curate_trakt_categories(&reqwest::Client::new(), &[], "test-target", &config).await;
|
||||
let outcome = evaluate_trakt_curation(&reqwest::Client::new(), &[], "test-target", &config).await;
|
||||
|
||||
assert!(result.is_none());
|
||||
assert_eq!(requests.load(Ordering::SeqCst), 0);
|
||||
server.abort();
|
||||
}
|
||||
let CurationRunOutcome::Failed(failure) = outcome else { panic!("missing credentials must fail the run") };
|
||||
assert_eq!(failure.selector_outcomes.len(), 2);
|
||||
assert!(failure.selector_outcomes.iter().all(|outcome| matches!(
|
||||
outcome,
|
||||
SelectorOutcome::Unavailable { reason: CurationUnavailableReason::Configuration, .. }
|
||||
)));
|
||||
assert_eq!(requests.load(Ordering::SeqCst), 0);
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -205,21 +333,69 @@ mod tests {
|
||||
let disabled = trakt_config("", base_url.clone(), false, vec![remote_list_config("Disabled")], Vec::new());
|
||||
let source_less = trakt_config("", base_url, true, Vec::new(), Vec::new());
|
||||
|
||||
assert!(curate_trakt_categories(&reqwest::Client::new(), &[], "test-target", &disabled).await.is_none());
|
||||
assert!(curate_trakt_categories(&reqwest::Client::new(), &[], "test-target", &source_less).await.is_none());
|
||||
assert_eq!(
|
||||
evaluate_trakt_curation(&reqwest::Client::new(), &[], "test-target", &disabled).await,
|
||||
CurationRunOutcome::NotConfigured
|
||||
);
|
||||
assert_eq!(
|
||||
evaluate_trakt_curation(&reqwest::Client::new(), &[], "test-target", &source_less).await,
|
||||
CurationRunOutcome::NotConfigured
|
||||
);
|
||||
assert_eq!(requests.load(Ordering::SeqCst), 0);
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_list_does_not_suppress_successful_chart() {
|
||||
async fn complete_remote_empty_remains_distinct_from_not_configured() {
|
||||
let requests = Arc::new(AtomicUsize::new(0));
|
||||
let (base_url, server) = spawn_counting_trakt_server(Arc::clone(&requests)).await;
|
||||
let config = trakt_config("test-client-id", base_url, true, vec![remote_list_config("Empty")], Vec::new());
|
||||
|
||||
let outcome = evaluate_trakt_curation(&reqwest::Client::new(), &[], "test-target", &config).await;
|
||||
|
||||
let CurationRunOutcome::Complete(evaluation) = outcome else { panic!("empty response must complete") };
|
||||
assert_eq!(evaluation.selectors.len(), 1);
|
||||
assert_eq!(evaluation.selectors[0].reference_count, 0);
|
||||
assert_eq!(evaluation.selectors[0].membership_count, 0);
|
||||
assert!(evaluation.memberships.is_empty());
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn complete_remote_match_produces_exact_subject_membership() {
|
||||
let body = r#"[{"id":1,"rank":1,"listed_at":"2026-01-01T00:00:00.000Z","type":"movie","movie":{"title":"Movie 1","year":2026,"ids":{"trakt":1,"slug":"movie-1","tvdb":null,"imdb":null,"tmdb":11,"tvrage":null}}}]"#;
|
||||
let (base_url, server) = spawn_single_response_trakt_server(body).await;
|
||||
let config = trakt_config("test-client-id", base_url, true, vec![remote_list_config("Matched")], Vec::new());
|
||||
let mut item = video_item("Movie 1", Some(11));
|
||||
item.header.uuid = hash_string("exact-target-subject");
|
||||
let subject_uuid = item.header.uuid;
|
||||
let playlist = vec![PlaylistGroup {
|
||||
id: 1,
|
||||
title: "Original".intern(),
|
||||
channels: vec![item],
|
||||
xtream_cluster: XtreamCluster::Video,
|
||||
}];
|
||||
|
||||
let outcome = evaluate_trakt_curation(&reqwest::Client::new(), &playlist, "test-target", &config).await;
|
||||
|
||||
let CurationRunOutcome::Complete(evaluation) = outcome else { panic!("selector should complete") };
|
||||
assert_eq!(evaluation.memberships.len(), 1);
|
||||
assert_eq!(evaluation.memberships[0].subject_uuid, subject_uuid);
|
||||
server.await.expect("test server should finish");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn one_failed_selector_prevents_complete_run_even_when_another_succeeds() {
|
||||
let requests = Arc::new(Mutex::new(Vec::new()));
|
||||
let (base_url, server) = spawn_partial_success_trakt_server(Arc::clone(&requests)).await;
|
||||
let mut selection_only = remote_list_config("Preserved name");
|
||||
selection_only.create_xtream_category = false;
|
||||
selection_only.category_name = None;
|
||||
let config = trakt_config(
|
||||
"test-client-id",
|
||||
base_url,
|
||||
true,
|
||||
vec![remote_list_config("Unavailable List")],
|
||||
vec![selection_only],
|
||||
vec![remote_chart_config("Available Chart")],
|
||||
);
|
||||
let playlist = vec![PlaylistGroup {
|
||||
@@ -229,15 +405,14 @@ mod tests {
|
||||
xtream_cluster: XtreamCluster::Video,
|
||||
}];
|
||||
|
||||
let categories = curate_trakt_categories(&reqwest::Client::new(), &playlist, "test-target", &config)
|
||||
.await
|
||||
.expect("configured sources should produce a result");
|
||||
let outcome = evaluate_trakt_curation(&reqwest::Client::new(), &playlist, "test-target", &config).await;
|
||||
|
||||
assert_eq!(categories.len(), 1);
|
||||
assert_eq!(categories[0].title.as_ref(), "Available Chart");
|
||||
assert_eq!(categories[0].channels.len(), 1);
|
||||
assert_eq!(categories[0].channels[0].header.title.as_ref(), "Movie 1");
|
||||
assert_eq!(requests.lock().expect("requests").len(), 2);
|
||||
let CurationRunOutcome::Failed(failure) = outcome else { panic!("partial success must fail the run") };
|
||||
assert!(matches!(failure.selector_outcomes[0], SelectorOutcome::Unavailable { .. }));
|
||||
assert!(matches!(
|
||||
&failure.selector_outcomes[1],
|
||||
SelectorOutcome::Complete { memberships, .. } if memberships.len() == 1
|
||||
));
|
||||
server.await.expect("test server should finish");
|
||||
}
|
||||
|
||||
@@ -271,10 +446,22 @@ mod tests {
|
||||
let featured_config = remote_list_config("Featured");
|
||||
let renoir_config = remote_list_config("Renoir");
|
||||
|
||||
let featured = curate_category(&references, &playlist, &list_category_spec(&featured_config));
|
||||
let renoir = curate_category(&references, &playlist, &list_category_spec(&renoir_config));
|
||||
let featured_item = &featured[0].channels[0];
|
||||
let renoir_item = &renoir[0].channels[0];
|
||||
let CurationRunOutcome::Complete(evaluation) = complete_evaluation(vec![
|
||||
evaluate_selector(CurationSelectorKey(0), &references, &playlist, list_selector_spec(&featured_config)),
|
||||
evaluate_selector(CurationSelectorKey(1), &references, &playlist, list_selector_spec(&renoir_config)),
|
||||
]) else {
|
||||
panic!("both local selectors should complete")
|
||||
};
|
||||
let config = trakt_config(
|
||||
"test-client-id",
|
||||
"http://example.invalid".to_string(),
|
||||
true,
|
||||
vec![featured_config, renoir_config],
|
||||
Vec::new(),
|
||||
);
|
||||
let categories = project_trakt_categories(&evaluation, &playlist, &config);
|
||||
let featured_item = &categories[0].channels[0];
|
||||
let renoir_item = &categories[1].channels[0];
|
||||
|
||||
assert_eq!(featured_item.header.group.as_ref(), "Featured");
|
||||
assert_eq!(renoir_item.header.group.as_ref(), "Renoir");
|
||||
@@ -301,7 +488,7 @@ mod tests {
|
||||
let references = translate_items(vec![trakt_list_show("Slow Horses", Some(2022), Some(12345), 1)]);
|
||||
let config = TraktListConfig { content_type: TraktContentType::Series, ..remote_list_config("Trending") };
|
||||
|
||||
let categories = curate_category(&references, &playlist, &list_category_spec(&config));
|
||||
let categories = project_list_references(&references, &playlist, &config);
|
||||
let cloned_series = categories[0]
|
||||
.channels
|
||||
.iter()
|
||||
@@ -336,7 +523,7 @@ mod tests {
|
||||
}];
|
||||
let references = translate_items(vec![trakt_list_movie("Clean Title", None, Some(1), 1)]);
|
||||
|
||||
let categories = curate_category(&references, &playlist, &list_category_spec(&remote_list_config("Featured")));
|
||||
let categories = project_list_references(&references, &playlist, &remote_list_config("Featured"));
|
||||
|
||||
assert_eq!(
|
||||
categories[0].channels[0].header.get(HeaderField::Caption).expect("quality caption").as_cow(),
|
||||
@@ -344,6 +531,36 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn project_list_references(
|
||||
references: &[CuratedMediaReference],
|
||||
playlist: &[PlaylistGroup],
|
||||
list_config: &TraktListConfig,
|
||||
) -> Vec<PlaylistGroup> {
|
||||
let outcome = evaluate_selector(CurationSelectorKey(0), references, playlist, list_selector_spec(list_config));
|
||||
let CurationRunOutcome::Complete(evaluation) = complete_evaluation(vec![outcome]) else {
|
||||
panic!("local selector evaluation should complete")
|
||||
};
|
||||
let config = trakt_config(
|
||||
"test-client-id",
|
||||
"http://example.invalid".to_string(),
|
||||
true,
|
||||
vec![list_config.clone()],
|
||||
Vec::new(),
|
||||
);
|
||||
project_trakt_categories(&evaluation, playlist, &config)
|
||||
}
|
||||
|
||||
async fn spawn_single_response_trakt_server(body: &'static str) -> (String, JoinHandle<()>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind test server");
|
||||
let addr = listener.local_addr().expect("local addr");
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.expect("accept test request");
|
||||
let _ = read_request(&mut stream).await;
|
||||
write_response(&mut stream, "200 OK", body).await;
|
||||
});
|
||||
(format!("http://{addr}"), server)
|
||||
}
|
||||
|
||||
async fn spawn_counting_trakt_server(requests: Arc<AtomicUsize>) -> (String, JoinHandle<()>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind test server");
|
||||
let addr = listener.local_addr().expect("local addr");
|
||||
@@ -415,6 +632,8 @@ mod tests {
|
||||
) -> TraktConfig {
|
||||
TraktConfig {
|
||||
enabled,
|
||||
catalog_selection: TraktCatalogSelection::Full,
|
||||
include_xtream_base_categories: true,
|
||||
api: TraktApiConfig {
|
||||
api_key: client_id.to_string(),
|
||||
version: "2".to_string(),
|
||||
@@ -430,7 +649,8 @@ mod tests {
|
||||
TraktListConfig {
|
||||
user: "test-user".to_string(),
|
||||
list_slug: "test-list".to_string(),
|
||||
category_name: category_name.to_string(),
|
||||
category_name: Some(category_name.to_string()),
|
||||
create_xtream_category: true,
|
||||
content_type: TraktContentType::Vod,
|
||||
tmdb_only: true,
|
||||
fuzzy_match_threshold: 100,
|
||||
@@ -441,7 +661,8 @@ mod tests {
|
||||
TraktChartConfig {
|
||||
kind: TraktChartKind::Movies,
|
||||
chart: TraktChartType::Popular,
|
||||
category_name: category_name.to_string(),
|
||||
category_name: Some(category_name.to_string()),
|
||||
create_xtream_category: true,
|
||||
tmdb_only: true,
|
||||
fuzzy_match_threshold: 100,
|
||||
}
|
||||
|
||||
@@ -140,6 +140,25 @@ mod tests {
|
||||
assert_eq!(titles, vec!["News [FHD]", "Sports HD"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_merge_dedup_selects_the_eligible_winner_before_curation_projection() {
|
||||
let mut playlist =
|
||||
vec![make_group("Base", vec![make_item("Movie HD")]), make_group("Trending", vec![make_item("Movie 4K")])];
|
||||
let config = DeduplicateConfig {
|
||||
match_by: DeduplicateMatchBy::Caption,
|
||||
keep: DeduplicateKeep::BestQuality,
|
||||
match_as_ascii: false,
|
||||
};
|
||||
|
||||
let removed = deduplicate_playlist(config, &mut playlist);
|
||||
|
||||
// Target-wide curation evaluates only this surviving winner.
|
||||
assert_eq!(removed, 1);
|
||||
assert_eq!(playlist.len(), 1);
|
||||
assert_eq!(playlist[0].title.as_ref(), "Trending");
|
||||
assert_eq!(playlist[0].channels[0].header.title.as_ref(), "Movie 4K");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedup_keep_first_preserves_playlist_order_winner() {
|
||||
let mut playlist = vec![make_group("G", vec![make_item("News HD"), make_item("News [FHD]")])];
|
||||
|
||||
@@ -15,7 +15,7 @@ use crate::{
|
||||
};
|
||||
use futures::{FutureExt, StreamExt};
|
||||
use indexmap::IndexMap;
|
||||
use log::{debug, error, info, log_enabled, trace, warn, Level};
|
||||
use log::{debug, error, info, log_enabled, warn, Level};
|
||||
use path_clean::PathClean;
|
||||
use shared::{
|
||||
concat_string,
|
||||
@@ -26,11 +26,12 @@ use shared::{
|
||||
ClusterFlags, ConfigTargetOptions, CounterModifier, EventMessage, EventSink, FieldGet, FieldSet,
|
||||
InputRefreshOverride, InputRefreshPolicy, InputStats, InputType, InputUpdateAction, InputUpdateRequest,
|
||||
MappingStage, PersistedPlaylistUpdateClusterSnapshot, PersistedPlaylistUpdateQualityDecision,
|
||||
PersistedPlaylistUpdateQualitySnapshot, PersistedPlaylistUpdateTechnicalState, PipelineStats, PlaylistGroup,
|
||||
PlaylistItem, PlaylistItemType, PlaylistStats, PlaylistUpdateClusterDecision, PlaylistUpdateClusterTelemetry,
|
||||
PlaylistUpdateDataSource, PlaylistUpdateInputTelemetry, PlaylistUpdateProgressEvent, PlaylistUpdateRunId,
|
||||
PlaylistUpdateRunOrder, PlaylistUpdateState, PlaylistUpdateSummary, ProviderFetchFailure, SourceStats,
|
||||
StreamProperties, TargetStats, UUIDType, WatchDisabled, WatchDisabledReason, WatchUnmatched, XtreamCluster,
|
||||
PersistedPlaylistUpdateQualitySnapshot, PersistedPlaylistUpdateTechnicalState, PipelineStats, PlaylistEntry,
|
||||
PlaylistGroup, PlaylistItem, PlaylistItemType, PlaylistStats, PlaylistUpdateClusterDecision,
|
||||
PlaylistUpdateClusterTelemetry, PlaylistUpdateDataSource, PlaylistUpdateInputTelemetry,
|
||||
PlaylistUpdateProgressEvent, PlaylistUpdateRunId, PlaylistUpdateRunOrder, PlaylistUpdateState,
|
||||
PlaylistUpdateSummary, ProviderFetchFailure, SourceStats, StreamProperties, TargetStats, UUIDType,
|
||||
WatchDisabled, WatchDisabledReason, WatchUnmatched, XtreamCluster,
|
||||
},
|
||||
utils::{create_alias_uuid, interner_gc, sanitize_sensitive_info, Internable},
|
||||
};
|
||||
@@ -50,11 +51,14 @@ use tuliprox_core::{
|
||||
is_valid, retain_filtered_playlist, AppConfig, ClusterForceUpdate, ClusterUpdateRejection, CompiledMapping,
|
||||
ConfigFavourites, ConfigInput, ConfigInputFlags, ConfigInputOptions, ConfigRename, ConfigTarget, Epg,
|
||||
FilterOutcome, MappingProgram, ProcessTargets, ProviderIdType, ResolveReason, ReverseProxyDisabledHeaderConfig,
|
||||
TransformStage, UpdateGuard, UpdateTask,
|
||||
TraktConfig, TransformStage, UpdateGuard, UpdateTask,
|
||||
},
|
||||
utils::{debug_if_enabled, log_memory_snapshot, trace_if_enabled, StepMeasure, StepMeasureCallback},
|
||||
};
|
||||
use tuliprox_curation::curate_trakt_categories;
|
||||
use tuliprox_curation::{
|
||||
evaluate_trakt_curation, project_trakt_categories, CurationEvaluation, CurationFailure, CurationRunOutcome,
|
||||
SelectorOutcome,
|
||||
};
|
||||
use tuliprox_iptv::{
|
||||
epg::{CountingEpgSink, EpgFetchRequest, EpgProvider},
|
||||
error::ProviderErrorKind,
|
||||
@@ -65,8 +69,8 @@ use tuliprox_iptv::{
|
||||
xtream,
|
||||
};
|
||||
use tuliprox_repository::{
|
||||
load_input_playlist, persist_input_playlist_with_options, persist_playlist, CategoryKey,
|
||||
InputPlaylistPersistOptions, MemoryPlaylistSource, PlaylistSource, PlaylistStorageState,
|
||||
load_input_playlist, persist_input_playlist_with_options, persist_playlist_views, CategoryKey,
|
||||
InputPlaylistPersistOptions, MemoryPlaylistSource, PlaylistPublicationPlan, PlaylistSource, PlaylistStorageState,
|
||||
TargetPlaylistPersistOptions,
|
||||
};
|
||||
use tuliprox_session::ActiveProviderManager;
|
||||
|
||||
@@ -399,6 +399,70 @@ pub(crate) fn spill_epg_to_disk(sources: Vec<Epg>) -> Result<Option<Epg>, Tulipr
|
||||
}
|
||||
}
|
||||
|
||||
fn finalization_step_label(view: &str, stage: &str) -> String {
|
||||
if view.is_empty() {
|
||||
stage.to_string()
|
||||
} else {
|
||||
format!("{view} {stage}")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_eligible_catalog(
|
||||
target: &ConfigTarget,
|
||||
playlist: Vec<PlaylistGroup>,
|
||||
step: &mut StepMeasure,
|
||||
) -> Vec<PlaylistGroup> {
|
||||
let mut playlist = flatten_groups(playlist);
|
||||
step.tick("playlist merge");
|
||||
log_memory_snapshot(format!("target '{}' after_playlist_merge", target.name).as_str());
|
||||
if let Some(dedup_config) = target.execution_plan.post_merge_content_dedup.as_ref() {
|
||||
let removed = crate::processor::deduplicate::deduplicate_playlist(*dedup_config, &mut playlist);
|
||||
if removed > 0 {
|
||||
info!("Deduplicated {removed} eligible channels for target {}", target.name);
|
||||
}
|
||||
step.tick("playlist dedup");
|
||||
log_memory_snapshot(format!("target '{}' after_playlist_dedup", target.name).as_str());
|
||||
}
|
||||
playlist
|
||||
}
|
||||
|
||||
fn finalize_playlist_view(
|
||||
target: &ConfigTarget,
|
||||
playlist: Vec<PlaylistGroup>,
|
||||
step: &mut StepMeasure,
|
||||
view: &str,
|
||||
) -> Vec<PlaylistGroup> {
|
||||
let mut playlist = flatten_groups(playlist);
|
||||
step.tick(&finalization_step_label(view, "appearance merge"));
|
||||
|
||||
for stage in FINALIZATION_ORDER.into_iter().skip(2) {
|
||||
match stage {
|
||||
FinalizationStage::Merge | FinalizationStage::Deduplicate => {
|
||||
unreachable!("eligibility merge and deduplication are completed before curation")
|
||||
}
|
||||
FinalizationStage::Sort => {
|
||||
if sort_playlist(target, &mut playlist) {
|
||||
step.tick(&finalization_step_label(view, "playlist sort"));
|
||||
log_memory_snapshot(format!("target '{}' {view} after_playlist_sort", target.name).as_str());
|
||||
}
|
||||
}
|
||||
FinalizationStage::AssignChannelNumbers => {
|
||||
assign_channel_no_playlist(&mut playlist);
|
||||
step.tick(&finalization_step_label(view, "assigning channel numbers"));
|
||||
log_memory_snapshot(format!("target '{}' {view} after_assign_channel_numbers", target.name).as_str());
|
||||
}
|
||||
FinalizationStage::AssignCounters => {
|
||||
map_playlist_counter(target, &mut playlist);
|
||||
step.tick(&finalization_step_label(view, "assigning channel counter"));
|
||||
log_memory_snapshot(format!("target '{}' {view} after_assign_channel_counter", target.name).as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
apply_persist_filter(target, &mut playlist);
|
||||
playlist
|
||||
}
|
||||
|
||||
pub(crate) async fn finalize_prepared_target<E: EventSink + Clone + 'static, M: MetadataUpdateSink>(
|
||||
ctx: Arc<PlaylistProcessingContext<E, M>>,
|
||||
prepared: PreparedTarget,
|
||||
@@ -420,95 +484,83 @@ pub(crate) async fn finalize_prepared_target<E: EventSink + Clone + 'static, M:
|
||||
{
|
||||
step.stop("");
|
||||
info!("Playlist is empty: {}", target.name);
|
||||
(Ok(()), errors)
|
||||
} else {
|
||||
// Process Trakt categories
|
||||
if trakt_playlist(&ctx.client, target, &mut new_playlist).await {
|
||||
step.tick("trakt categories");
|
||||
log_memory_snapshot(format!("target '{}' after_trakt", target.name).as_str());
|
||||
}
|
||||
|
||||
let mut flat_new_playlist = flatten_groups(new_playlist);
|
||||
step.tick("playlist merge");
|
||||
log_memory_snapshot(format!("target '{}' after_playlist_merge", target.name).as_str());
|
||||
|
||||
for stage in FINALIZATION_ORDER.into_iter().skip(1) {
|
||||
match stage {
|
||||
FinalizationStage::Merge => unreachable!("merge is completed before post-merge finalization"),
|
||||
FinalizationStage::Deduplicate => {
|
||||
if let Some(dedup_config) = target.execution_plan.post_merge_content_dedup.as_ref() {
|
||||
let removed =
|
||||
crate::processor::deduplicate::deduplicate_playlist(*dedup_config, &mut flat_new_playlist);
|
||||
if removed > 0 {
|
||||
info!("Deduplicated {removed} channels for target {}", target.name);
|
||||
}
|
||||
step.tick("playlist dedup");
|
||||
log_memory_snapshot(format!("target '{}' after_playlist_dedup", target.name).as_str());
|
||||
}
|
||||
}
|
||||
FinalizationStage::Sort => {
|
||||
if sort_playlist(target, &mut flat_new_playlist) {
|
||||
step.tick("playlist sort");
|
||||
log_memory_snapshot(format!("target '{}' after_playlist_sort", target.name).as_str());
|
||||
}
|
||||
}
|
||||
FinalizationStage::AssignChannelNumbers => {
|
||||
assign_channel_no_playlist(&mut flat_new_playlist);
|
||||
step.tick("assigning channel numbers");
|
||||
log_memory_snapshot(format!("target '{}' after_assign_channel_numbers", target.name).as_str());
|
||||
}
|
||||
FinalizationStage::AssignCounters => {
|
||||
map_playlist_counter(target, &mut flat_new_playlist);
|
||||
step.tick("assigning channel counter");
|
||||
log_memory_snapshot(format!("target '{}' after_assign_channel_counter", target.name).as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
apply_persist_filter(target, &mut flat_new_playlist);
|
||||
retain_epg_referenced_by_groups(&flat_new_playlist, &mut new_epg);
|
||||
|
||||
let merged_epg = if ctx.config.config.load().disk_based_processing {
|
||||
// Per-source drain to disk, then multi-way merge. Errors are pushed
|
||||
// to `errors` rather than `?` because the function returns
|
||||
// `(Result, Vec<TuliproxError>)`, not `Result` directly. We must
|
||||
// surface tempdir / write / merge failures — the user opted in to
|
||||
// disk spilling, and silently falling back to the in-memory path
|
||||
// can OOM on large feeds. When the spill itself fails we skip the
|
||||
// persist step entirely: continuing with `merged_epg = None` would
|
||||
// overwrite the existing on-disk EPG with nothing and discard the
|
||||
// previously persisted artifact on a transient error.
|
||||
match spill_epg_to_disk(new_epg) {
|
||||
Ok(epg) => epg,
|
||||
Err(err) => {
|
||||
let result_error = TuliproxError::new(err.kind(), err.message());
|
||||
errors.push(err);
|
||||
step.stop("EPG spill failed; skipping persist to preserve existing EPG");
|
||||
log_memory_snapshot(format!("target '{}' after_persist", target.name).as_str());
|
||||
return (Err(vec![result_error]), errors);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
flatten_tvguide(new_epg)
|
||||
};
|
||||
let library_empty = library_empty.for_filtered_playlist(&flat_new_playlist);
|
||||
let result = persist_playlist(
|
||||
&ctx.config,
|
||||
&mut flat_new_playlist,
|
||||
merged_epg.as_ref(),
|
||||
target,
|
||||
ctx.playlist_state.as_ref(),
|
||||
TargetPlaylistPersistOptions { accepted_empty_clusters: prepared.accepted_empty_clusters, library_empty },
|
||||
)
|
||||
.await;
|
||||
if result.is_ok() && process_watch(&ctx.config, &ctx.events, target, &flat_new_playlist).await {
|
||||
step.tick("group watches");
|
||||
log_memory_snapshot(format!("target '{}' after_group_watches", target.name).as_str());
|
||||
}
|
||||
step.stop("Persisting playlists");
|
||||
log_memory_snapshot(format!("target '{}' after_persist", target.name).as_str());
|
||||
(result, errors)
|
||||
return (Ok(()), errors);
|
||||
}
|
||||
|
||||
let eligible_catalog = prepare_eligible_catalog(target, new_playlist, &mut step);
|
||||
let views = match prepare_target_playlist_views(&ctx.client, target, eligible_catalog).await {
|
||||
Ok(views) => views,
|
||||
Err(error) => {
|
||||
step.stop("Curation failed; skipping persist to preserve finalized artifacts");
|
||||
return (Err(vec![error]), errors);
|
||||
}
|
||||
};
|
||||
if views.base.is_empty()
|
||||
&& views.xtream.is_none()
|
||||
&& views.publication_plan == PlaylistPublicationPlan::Ordinary
|
||||
&& prepared.accepted_empty_clusters.is_empty()
|
||||
&& !library_empty.replaces_empty_target()
|
||||
{
|
||||
step.stop("");
|
||||
info!("Playlist is empty: {}", target.name);
|
||||
return (Ok(()), errors);
|
||||
}
|
||||
if views.publication_plan != PlaylistPublicationPlan::Ordinary {
|
||||
step.tick("target curation");
|
||||
log_memory_snapshot(format!("target '{}' after_curation", target.name).as_str());
|
||||
}
|
||||
|
||||
let mut finalized_base = finalize_playlist_view(target, views.base, &mut step, "base");
|
||||
let mut finalized_xtream =
|
||||
views.xtream.map(|playlist| finalize_playlist_view(target, playlist, &mut step, "Xtream"));
|
||||
retain_epg_referenced_by_groups(&finalized_base, &mut new_epg);
|
||||
|
||||
let merged_epg = if ctx.config.config.load().disk_based_processing {
|
||||
// Per-source drain to disk, then multi-way merge. Errors are pushed
|
||||
// to `errors` rather than `?` because the function returns
|
||||
// `(Result, Vec<TuliproxError>)`, not `Result` directly. We must
|
||||
// surface tempdir / write / merge failures — the user opted in to
|
||||
// disk spilling, and silently falling back to the in-memory path
|
||||
// can OOM on large feeds. When the spill itself fails we skip the
|
||||
// persist step entirely: continuing with `merged_epg = None` would
|
||||
// overwrite the existing on-disk EPG with nothing and discard the
|
||||
// previously persisted artifact on a transient error.
|
||||
match spill_epg_to_disk(new_epg) {
|
||||
Ok(epg) => epg,
|
||||
Err(err) => {
|
||||
let result_error = TuliproxError::new(err.kind(), err.message());
|
||||
errors.push(err);
|
||||
step.stop("EPG spill failed; skipping persist to preserve existing EPG");
|
||||
log_memory_snapshot(format!("target '{}' after_persist", target.name).as_str());
|
||||
return (Err(vec![result_error]), errors);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
flatten_tvguide(new_epg)
|
||||
};
|
||||
let library_empty = library_empty.for_filtered_playlist(&finalized_base);
|
||||
let result = persist_playlist_views(
|
||||
&ctx.config,
|
||||
&mut finalized_base,
|
||||
finalized_xtream.as_deref_mut(),
|
||||
merged_epg.as_ref(),
|
||||
target,
|
||||
ctx.playlist_state.as_ref(),
|
||||
TargetPlaylistPersistOptions {
|
||||
accepted_empty_clusters: prepared.accepted_empty_clusters,
|
||||
library_empty,
|
||||
publication_plan: views.publication_plan,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let watch_playlist = target_watch_view(&finalized_base, finalized_xtream.as_deref());
|
||||
if result.is_ok() && process_watch(&ctx.config, &ctx.events, target, watch_playlist).await {
|
||||
step.tick("group watches");
|
||||
log_memory_snapshot(format!("target '{}' after_group_watches", target.name).as_str());
|
||||
}
|
||||
step.stop("Persisting playlists");
|
||||
log_memory_snapshot(format!("target '{}' after_persist", target.name).as_str());
|
||||
(result, errors)
|
||||
}
|
||||
|
||||
pub(crate) async fn playlist_resolve<E: EventSink + Clone + 'static, M: MetadataUpdateSink>(
|
||||
@@ -802,23 +854,165 @@ pub fn process_favourites(playlist: &mut Vec<PlaylistGroup>, favourites_cfg: Opt
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn trakt_playlist(
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct TargetPlaylistViews {
|
||||
pub(crate) base: Vec<PlaylistGroup>,
|
||||
pub(crate) xtream: Option<Vec<PlaylistGroup>>,
|
||||
pub(crate) publication_plan: PlaylistPublicationPlan,
|
||||
}
|
||||
|
||||
pub(crate) fn build_curated_playlist_views(
|
||||
playlist: Vec<PlaylistGroup>,
|
||||
evaluation: &CurationEvaluation,
|
||||
trakt_config: &TraktConfig,
|
||||
appearance_filter_configured: bool,
|
||||
) -> TargetPlaylistViews {
|
||||
let curated_catalog = trakt_config.catalog_selection.is_curated();
|
||||
let mut categories = project_trakt_categories(evaluation, &playlist, trakt_config);
|
||||
let base = select_target_catalog(playlist, evaluation, curated_catalog);
|
||||
let mut xtream = if trakt_config.include_xtream_base_categories { base.clone() } else { live_only(&base) };
|
||||
xtream.append(&mut categories);
|
||||
TargetPlaylistViews {
|
||||
base,
|
||||
xtream: Some(xtream),
|
||||
publication_plan: PlaylistPublicationPlan::complete_curation_with_filter(
|
||||
curated_catalog,
|
||||
!trakt_config.include_xtream_base_categories,
|
||||
appearance_filter_configured,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn prepare_target_playlist_views(
|
||||
client: &reqwest::Client,
|
||||
target: &ConfigTarget,
|
||||
playlist: &mut Vec<PlaylistGroup>,
|
||||
) -> bool {
|
||||
let Some(trakt_config) = target.get_xtream_output().and_then(|output| output.trakt.as_ref()) else {
|
||||
trace!("No Trakt configuration found for target {}", target.name);
|
||||
return false;
|
||||
playlist: Vec<PlaylistGroup>,
|
||||
) -> Result<TargetPlaylistViews, TuliproxError> {
|
||||
let Some(trakt_config) = target.get_xtream_output().and_then(|xtream| xtream.trakt.as_ref()) else {
|
||||
return Ok(TargetPlaylistViews {
|
||||
base: playlist,
|
||||
xtream: None,
|
||||
publication_plan: PlaylistPublicationPlan::Ordinary,
|
||||
});
|
||||
};
|
||||
let Some(trakt_categories) = curate_trakt_categories(client, playlist, &target.name, trakt_config).await else {
|
||||
return false;
|
||||
};
|
||||
if !trakt_categories.is_empty() {
|
||||
info!("Adding {} Trakt categories to playlist", trakt_categories.len());
|
||||
playlist.extend(trakt_categories);
|
||||
|
||||
match evaluate_trakt_curation(client, &playlist, &target.name, trakt_config).await {
|
||||
CurationRunOutcome::NotConfigured => Ok(TargetPlaylistViews {
|
||||
base: playlist,
|
||||
xtream: None,
|
||||
publication_plan: PlaylistPublicationPlan::Ordinary,
|
||||
}),
|
||||
CurationRunOutcome::Failed(failure) => Err(curation_failure_error(&target.name, &failure)),
|
||||
CurationRunOutcome::Complete(evaluation) => {
|
||||
let views =
|
||||
build_curated_playlist_views(playlist, &evaluation, trakt_config, target.filter.persist.is_some());
|
||||
info!(
|
||||
"Target '{}' curation completed with {} memberships and {} Xtream groups",
|
||||
target.name,
|
||||
evaluation.memberships.len(),
|
||||
views.xtream.as_deref().map_or(0, <[PlaylistGroup]>::len)
|
||||
);
|
||||
Ok(views)
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn curation_failure_error(target_name: &str, failure: &CurationFailure) -> TuliproxError {
|
||||
let mut complete = 0usize;
|
||||
let mut incomplete = 0usize;
|
||||
let mut unavailable = 0usize;
|
||||
for outcome in &failure.selector_outcomes {
|
||||
match outcome {
|
||||
SelectorOutcome::Complete { .. } => complete += 1,
|
||||
SelectorOutcome::Incomplete { .. } => incomplete += 1,
|
||||
SelectorOutcome::Unavailable { .. } => unavailable += 1,
|
||||
}
|
||||
}
|
||||
TuliproxError::RepositoryPlaylist(format!(
|
||||
"Target '{target_name}' curation refresh failed (complete selectors: {complete}, incomplete: {incomplete}, unavailable: {unavailable}); existing finalized artifacts were retained"
|
||||
))
|
||||
}
|
||||
|
||||
fn item_subject_uuid(item: &PlaylistItem) -> UUIDType {
|
||||
if item.header.uuid == UUIDType::default() {
|
||||
item.get_uuid()
|
||||
} else {
|
||||
item.header.uuid
|
||||
}
|
||||
}
|
||||
|
||||
fn selected_series_parent_keys(playlist: &[PlaylistGroup], selected_subjects: &HashSet<UUIDType>) -> HashSet<Arc<str>> {
|
||||
let mut parent_keys = HashSet::new();
|
||||
for item in playlist.iter().flat_map(|group| &group.channels) {
|
||||
if !matches!(item.header.item_type, PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo)
|
||||
|| !selected_subjects.contains(&item_subject_uuid(item))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
parent_keys.insert(item.get_uuid().intern());
|
||||
if item.header.uuid != UUIDType::default() {
|
||||
parent_keys.insert(item.header.uuid.intern());
|
||||
}
|
||||
if item.header.virtual_id.get() != 0 {
|
||||
parent_keys.insert(item.header.virtual_id.get().to_string().intern());
|
||||
}
|
||||
if item.header.item_type == PlaylistItemType::LocalSeriesInfo && !item.header.id.is_empty() {
|
||||
parent_keys.insert(Arc::clone(&item.header.id));
|
||||
}
|
||||
}
|
||||
parent_keys
|
||||
}
|
||||
|
||||
pub(crate) fn select_target_catalog(
|
||||
mut playlist: Vec<PlaylistGroup>,
|
||||
evaluation: &CurationEvaluation,
|
||||
enabled: bool,
|
||||
) -> Vec<PlaylistGroup> {
|
||||
if !enabled {
|
||||
return playlist;
|
||||
}
|
||||
|
||||
let selected_subjects =
|
||||
evaluation.memberships.iter().map(|membership| membership.subject_uuid).collect::<HashSet<_>>();
|
||||
let selected_parent_keys = selected_series_parent_keys(&playlist, &selected_subjects);
|
||||
|
||||
for group in &mut playlist {
|
||||
group.channels.retain(|item| {
|
||||
if item.header.xtream_cluster == XtreamCluster::Live {
|
||||
return true;
|
||||
}
|
||||
match item.header.item_type {
|
||||
PlaylistItemType::Video
|
||||
| PlaylistItemType::LocalVideo
|
||||
| PlaylistItemType::SeriesInfo
|
||||
| PlaylistItemType::LocalSeriesInfo => selected_subjects.contains(&item_subject_uuid(item)),
|
||||
PlaylistItemType::Series | PlaylistItemType::LocalSeries => {
|
||||
selected_parent_keys.contains(&item.header.parent_code)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
});
|
||||
}
|
||||
playlist.retain(|group| group.xtream_cluster == XtreamCluster::Live || !group.channels.is_empty());
|
||||
playlist
|
||||
}
|
||||
|
||||
fn live_only(playlist: &[PlaylistGroup]) -> Vec<PlaylistGroup> {
|
||||
playlist
|
||||
.iter()
|
||||
.filter_map(|group| {
|
||||
let mut live_group = group.clone();
|
||||
live_group.channels.retain(|item| item.header.xtream_cluster == XtreamCluster::Live);
|
||||
(group.xtream_cluster == XtreamCluster::Live || !live_group.channels.is_empty()).then_some(live_group)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn target_watch_view<'playlist>(
|
||||
base: &'playlist [PlaylistGroup],
|
||||
xtream: Option<&'playlist [PlaylistGroup]>,
|
||||
) -> &'playlist [PlaylistGroup] {
|
||||
xtream.unwrap_or(base)
|
||||
}
|
||||
|
||||
pub(crate) async fn process_watch<E: EventSink>(
|
||||
|
||||
@@ -4,12 +4,17 @@ use shared::{
|
||||
foundation::{get_filter, MapperScript, ValueProvider},
|
||||
model::{
|
||||
ClusterFlags, ConfigInputDto, ConfigRenameDto, ConfigTargetDto, ConfigTargetOptions, FieldSetAccessor,
|
||||
ItemField, M3uPlaylistItem, MappingStage, PlaylistEntry, PlaylistItem, PlaylistItemHeader, PlaylistItemType,
|
||||
PlaylistUpdateRunOrder, XtreamCluster, XtreamPlaylistItem,
|
||||
ItemField, M3uPlaylistItem, M3uTargetOutputDto, MappingStage, PlaylistEntry, PlaylistItem, PlaylistItemHeader,
|
||||
PlaylistItemType, PlaylistUpdateRunOrder, TargetOutputDto, TraktApiConfigDto, TraktCatalogSelection,
|
||||
TraktConfigDto, TraktContentType, TraktListConfigDto, UUIDType, XtreamCluster, XtreamPlaylistItem,
|
||||
XtreamTargetOutputDto,
|
||||
},
|
||||
utils::Internable,
|
||||
};
|
||||
use tuliprox_core::model::{CompiledMappingRule, CompiledTargetMappings, Config, ConfigInputAlias};
|
||||
use tuliprox_core::model::{CompiledMappingRule, CompiledTargetMappings, Config, ConfigInputAlias, TraktConfig};
|
||||
use tuliprox_curation::{
|
||||
CurationEvaluation, CurationMediaKind, CurationMembership, CurationSelectorKey, CurationSelectorSummary,
|
||||
};
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct PlaylistRunCollectSink(Arc<std::sync::Mutex<Vec<EventMessage>>>);
|
||||
@@ -1774,15 +1779,822 @@ match {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_target_finalization_order_merges_before_dedup_and_presentation() {
|
||||
assert_eq!(
|
||||
FINALIZATION_ORDER,
|
||||
[
|
||||
FinalizationStage::Merge,
|
||||
FinalizationStage::Deduplicate,
|
||||
FinalizationStage::Sort,
|
||||
FinalizationStage::AssignChannelNumbers,
|
||||
FinalizationStage::AssignCounters,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curation_eligible_catalog_is_merged_and_deduplicated_before_matching() {
|
||||
let mut target = ConfigTarget::from(&ConfigTargetDto::default());
|
||||
target.execution_plan.post_merge_content_dedup = Some(shared::model::DeduplicateConfig::default());
|
||||
let losing_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000051");
|
||||
let winning_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000052");
|
||||
let playlist = vec![
|
||||
PlaylistGroup {
|
||||
id: 1,
|
||||
title: "Movies".intern(),
|
||||
channels: vec![catalog_test_item(
|
||||
"Movie HD",
|
||||
losing_uuid,
|
||||
PlaylistItemType::Video,
|
||||
XtreamCluster::Video,
|
||||
None,
|
||||
)],
|
||||
xtream_cluster: XtreamCluster::Video,
|
||||
},
|
||||
PlaylistGroup {
|
||||
id: 2,
|
||||
title: "movies".intern(),
|
||||
channels: vec![catalog_test_item(
|
||||
"Movie 4K",
|
||||
winning_uuid,
|
||||
PlaylistItemType::Video,
|
||||
XtreamCluster::Video,
|
||||
None,
|
||||
)],
|
||||
xtream_cluster: XtreamCluster::Video,
|
||||
},
|
||||
];
|
||||
let mut step = StepMeasure::new("test", |_, _| {});
|
||||
|
||||
let eligible = prepare_eligible_catalog(&target, playlist, &mut step);
|
||||
|
||||
assert_eq!(eligible.len(), 1);
|
||||
assert_eq!(eligible[0].channels.len(), 1);
|
||||
assert_eq!(eligible[0].channels[0].header.uuid, winning_uuid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_filter_can_select_a_generated_curation_group() {
|
||||
let mut target = ConfigTarget::from(&ConfigTargetDto::default());
|
||||
target.filter.persist = Some(get_filter(r#"Group = "Trending""#, None).expect("persist filter"));
|
||||
let mut playlist = vec![
|
||||
PlaylistGroup {
|
||||
id: 1,
|
||||
title: "Base".intern(),
|
||||
channels: vec![PlaylistItem {
|
||||
header: PlaylistItemHeader { group: "Base".intern(), ..Default::default() },
|
||||
}],
|
||||
xtream_cluster: XtreamCluster::Video,
|
||||
},
|
||||
PlaylistGroup {
|
||||
id: 2,
|
||||
title: "Trending".intern(),
|
||||
channels: vec![PlaylistItem {
|
||||
header: PlaylistItemHeader { group: "Trending".intern(), ..Default::default() },
|
||||
}],
|
||||
xtream_cluster: XtreamCluster::Video,
|
||||
},
|
||||
];
|
||||
|
||||
apply_persist_filter(&target, &mut playlist);
|
||||
|
||||
assert_eq!(playlist.len(), 1);
|
||||
assert_eq!(playlist[0].title.as_ref(), "Trending");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_filter_can_select_a_base_group() {
|
||||
let mut target = ConfigTarget::from(&ConfigTargetDto::default());
|
||||
target.filter.persist = Some(get_filter(r#"Group = "Base""#, None).expect("persist filter"));
|
||||
let mut playlist = vec![
|
||||
PlaylistGroup {
|
||||
id: 1,
|
||||
title: "Base".intern(),
|
||||
channels: vec![PlaylistItem {
|
||||
header: PlaylistItemHeader { group: "Base".intern(), ..Default::default() },
|
||||
}],
|
||||
xtream_cluster: XtreamCluster::Video,
|
||||
},
|
||||
PlaylistGroup {
|
||||
id: 2,
|
||||
title: "Curated".intern(),
|
||||
channels: vec![PlaylistItem {
|
||||
header: PlaylistItemHeader { group: "Curated".intern(), ..Default::default() },
|
||||
}],
|
||||
xtream_cluster: XtreamCluster::Video,
|
||||
},
|
||||
];
|
||||
|
||||
apply_persist_filter(&target, &mut playlist);
|
||||
|
||||
assert_eq!(playlist.len(), 1);
|
||||
assert_eq!(playlist[0].title.as_ref(), "Base");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trakt_finalization_stage_is_a_noop_without_xtream_configuration() {
|
||||
async fn trakt_target_curation_is_a_noop_without_xtream_configuration() {
|
||||
let target = ConfigTarget::from(&ConfigTargetDto::default());
|
||||
let mut playlist = Vec::new();
|
||||
|
||||
let applied = trakt_playlist(&reqwest::Client::new(), &target, &mut playlist).await;
|
||||
let views = prepare_target_playlist_views(&reqwest::Client::new(), &target, Vec::new())
|
||||
.await
|
||||
.expect("unconfigured curation should not fail");
|
||||
|
||||
assert!(!applied);
|
||||
assert!(playlist.is_empty());
|
||||
assert!(views.base.is_empty());
|
||||
assert!(views.xtream.is_none());
|
||||
assert_eq!(views.publication_plan, PlaylistPublicationPlan::Ordinary);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unavailable_required_selector_returns_target_failure_instead_of_base_fallback() {
|
||||
let target = ConfigTarget::from(&ConfigTargetDto {
|
||||
name: "curation-failure".to_string(),
|
||||
output: vec![TargetOutputDto::Xtream(XtreamTargetOutputDto {
|
||||
trakt: Some(TraktConfigDto {
|
||||
lists: vec![TraktListConfigDto {
|
||||
user: "alice".to_string(),
|
||||
list_slug: "watchlist".to_string(),
|
||||
category_name: Some("Watchlist".to_string()),
|
||||
create_xtream_category: true,
|
||||
content_type: TraktContentType::Vod,
|
||||
tmdb_only: true,
|
||||
fuzzy_match_threshold: 100,
|
||||
}],
|
||||
..TraktConfigDto::default()
|
||||
}),
|
||||
..XtreamTargetOutputDto::default()
|
||||
})],
|
||||
..ConfigTargetDto::default()
|
||||
});
|
||||
let base = vec![PlaylistGroup {
|
||||
id: 1,
|
||||
title: "Movies".intern(),
|
||||
channels: vec![catalog_test_item(
|
||||
"Base movie",
|
||||
UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000061"),
|
||||
PlaylistItemType::Video,
|
||||
XtreamCluster::Video,
|
||||
None,
|
||||
)],
|
||||
xtream_cluster: XtreamCluster::Video,
|
||||
}];
|
||||
|
||||
let error = prepare_target_playlist_views(&reqwest::Client::new(), &target, base)
|
||||
.await
|
||||
.expect_err("missing credentials must stop target publication");
|
||||
|
||||
assert!(error.message().contains("existing finalized artifacts were retained"));
|
||||
}
|
||||
|
||||
mod curation_effect_gate {
|
||||
use super::*;
|
||||
use arc_swap::{ArcSwap, ArcSwapOption};
|
||||
use shared::model::{ConfigPaths, NoopSink};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
path::Path,
|
||||
};
|
||||
use tempfile::tempdir;
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::TcpListener,
|
||||
task::JoinHandle,
|
||||
};
|
||||
use tuliprox_core::{
|
||||
model::{ApiProxyConfig, CustomStreamResponse, HdHomeRunConfig, MediaToolCapabilities, SourcesConfig},
|
||||
utils::FileLockManager,
|
||||
};
|
||||
|
||||
fn app_config(storage_dir: &Path) -> Arc<AppConfig> {
|
||||
Arc::new(AppConfig {
|
||||
config: Arc::new(ArcSwap::from_pointee(Config {
|
||||
storage_dir: storage_dir.to_string_lossy().into_owned(),
|
||||
..Config::default()
|
||||
})),
|
||||
sources: Arc::new(ArcSwap::from_pointee(SourcesConfig::default())),
|
||||
hdhomerun: Arc::new(ArcSwapOption::<HdHomeRunConfig>::default()),
|
||||
api_proxy: Arc::new(ArcSwapOption::<ApiProxyConfig>::default()),
|
||||
file_locks: Arc::new(FileLockManager::default()),
|
||||
paths: Arc::new(ArcSwap::from_pointee(ConfigPaths {
|
||||
home_path: String::new(),
|
||||
config_path: String::new(),
|
||||
storage_path: String::new(),
|
||||
config_file_path: String::new(),
|
||||
sources_file_path: String::new(),
|
||||
mapping_file_path: None,
|
||||
mapping_files_used: None,
|
||||
template_file_path: None,
|
||||
template_files_used: None,
|
||||
api_proxy_file_path: String::new(),
|
||||
custom_stream_response_path: None,
|
||||
})),
|
||||
custom_stream_response: Arc::new(ArcSwapOption::<CustomStreamResponse>::default()),
|
||||
access_token_secret: [0; 32],
|
||||
encrypt_secret: [0; 16],
|
||||
media_tools: Arc::new(MediaToolCapabilities::new()),
|
||||
})
|
||||
}
|
||||
|
||||
fn processing_context(
|
||||
app_config: Arc<AppConfig>,
|
||||
playlist_state: Option<Arc<PlaylistStorageState>>,
|
||||
) -> PlaylistProcessingContext<NoopSink> {
|
||||
PlaylistProcessingContext {
|
||||
client: reqwest::Client::new(),
|
||||
run_id: "curation-effect-gate-run".into(),
|
||||
execution_order: PlaylistUpdateRunOrder::from(1),
|
||||
config: app_config,
|
||||
user_targets: Arc::new(ProcessTargets {
|
||||
enabled: false,
|
||||
inputs: Vec::new(),
|
||||
targets: Vec::new(),
|
||||
target_names: Vec::new(),
|
||||
}),
|
||||
events: NoopSink,
|
||||
playlist_state,
|
||||
disabled_headers: None,
|
||||
processed_inputs: Arc::new(Mutex::new(HashSet::new())),
|
||||
input_completions: Arc::new(Mutex::new(HashMap::new())),
|
||||
input_locks: Arc::new(Mutex::new(HashMap::new())),
|
||||
provider_manager: None,
|
||||
metadata_manager: None,
|
||||
pre_processed_inputs: None,
|
||||
stalker_refresh_mode: StalkerRefreshMode::Complete,
|
||||
partial_refresh: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
had_quality_rejections: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
input_refresh: None,
|
||||
library_update_mode: LibraryUpdateMode::ExistingCatalog,
|
||||
}
|
||||
}
|
||||
|
||||
fn curation_persist_options(publication_plan: PlaylistPublicationPlan) -> TargetPlaylistPersistOptions {
|
||||
TargetPlaylistPersistOptions { publication_plan, ..TargetPlaylistPersistOptions::default() }
|
||||
}
|
||||
|
||||
fn file_snapshot(root: &Path) -> BTreeMap<std::path::PathBuf, Vec<u8>> {
|
||||
fn collect(root: &Path, path: &Path, snapshot: &mut BTreeMap<std::path::PathBuf, Vec<u8>>) {
|
||||
let Ok(entries) = std::fs::read_dir(path) else { return };
|
||||
for entry in entries.flatten() {
|
||||
let entry_path = entry.path();
|
||||
if entry_path.is_dir() {
|
||||
collect(root, &entry_path, snapshot);
|
||||
} else {
|
||||
snapshot.insert(
|
||||
entry_path.strip_prefix(root).expect("snapshot path under root").to_path_buf(),
|
||||
std::fs::read(&entry_path).expect("snapshot file"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut snapshot = BTreeMap::new();
|
||||
collect(root, root, &mut snapshot);
|
||||
snapshot
|
||||
}
|
||||
|
||||
async fn empty_trakt_server() -> (String, JoinHandle<()>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind Trakt test server");
|
||||
let address = listener.local_addr().expect("Trakt test address");
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.expect("accept Trakt request");
|
||||
let mut request = Vec::new();
|
||||
loop {
|
||||
let mut buffer = [0u8; 1024];
|
||||
let read = stream.read(&mut buffer).await.expect("read Trakt request");
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
request.extend_from_slice(&buffer[..read]);
|
||||
if request.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
stream
|
||||
.write_all(b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 2\r\nconnection: close\r\n\r\n[]")
|
||||
.await
|
||||
.expect("write Trakt response");
|
||||
});
|
||||
(format!("http://{address}"), server)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unavailable_curation_retains_seeded_artifacts_cache_and_watch_state() {
|
||||
let directory = tempdir().expect("tempdir");
|
||||
let app_config = app_config(directory.path());
|
||||
let playlist_state = Arc::new(PlaylistStorageState::new());
|
||||
let target = ConfigTarget::from(&ConfigTargetDto {
|
||||
name: "curation-effect-gate".to_string(),
|
||||
output: vec![
|
||||
TargetOutputDto::Xtream(XtreamTargetOutputDto {
|
||||
trakt: Some(TraktConfigDto {
|
||||
lists: vec![TraktListConfigDto {
|
||||
user: "alice".to_string(),
|
||||
list_slug: "watchlist".to_string(),
|
||||
category_name: Some("Watchlist".to_string()),
|
||||
create_xtream_category: true,
|
||||
content_type: TraktContentType::Vod,
|
||||
tmdb_only: true,
|
||||
fuzzy_match_threshold: 100,
|
||||
}],
|
||||
..TraktConfigDto::default()
|
||||
}),
|
||||
..XtreamTargetOutputDto::default()
|
||||
}),
|
||||
TargetOutputDto::M3u(M3uTargetOutputDto {
|
||||
filename: Some("curation-effect-gate.m3u".to_string()),
|
||||
..M3uTargetOutputDto::default()
|
||||
}),
|
||||
],
|
||||
watch: Some(vec![".*".to_string()]),
|
||||
use_memory_cache: true,
|
||||
..ConfigTargetDto::default()
|
||||
});
|
||||
let mut seeded = vec![PlaylistGroup {
|
||||
id: 1,
|
||||
title: "Movies".intern(),
|
||||
channels: vec![catalog_test_item(
|
||||
"Seeded movie",
|
||||
UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000071"),
|
||||
PlaylistItemType::Video,
|
||||
XtreamCluster::Video,
|
||||
None,
|
||||
)],
|
||||
xtream_cluster: XtreamCluster::Video,
|
||||
}];
|
||||
let seeded_result = persist_playlist_views(
|
||||
&app_config,
|
||||
&mut seeded,
|
||||
None,
|
||||
None,
|
||||
&target,
|
||||
Some(&playlist_state),
|
||||
curation_persist_options(PlaylistPublicationPlan::Ordinary),
|
||||
)
|
||||
.await;
|
||||
assert!(seeded_result.is_ok(), "seed persist failed: {seeded_result:?}");
|
||||
assert!(process_watch(&app_config, &NoopSink, &target, &seeded).await);
|
||||
let before_files = file_snapshot(directory.path());
|
||||
let before_cache_len = playlist_state
|
||||
.data
|
||||
.read()
|
||||
.await
|
||||
.get(&target.name)
|
||||
.and_then(|storage| storage.xtream.as_ref())
|
||||
.map_or(0, |storage| storage.vod.len());
|
||||
|
||||
let context = processing_context(Arc::clone(&app_config), Some(Arc::clone(&playlist_state)));
|
||||
let prepared = PreparedTarget {
|
||||
target,
|
||||
playlist: Vec::new(),
|
||||
epg: Vec::new(),
|
||||
processing: PipelineStats::default(),
|
||||
accepted_empty_clusters: ClusterFlags::Vod,
|
||||
library_empty: tuliprox_repository::LibraryEmptyPublication::None,
|
||||
};
|
||||
|
||||
let (result, errors) = finalize_prepared_target(Arc::new(context), prepared).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(errors.is_empty());
|
||||
assert_eq!(file_snapshot(directory.path()), before_files);
|
||||
let after_cache_len = playlist_state
|
||||
.data
|
||||
.read()
|
||||
.await
|
||||
.get("curation-effect-gate")
|
||||
.and_then(|storage| storage.xtream.as_ref())
|
||||
.map_or(0, |storage| storage.vod.len());
|
||||
assert_eq!(after_cache_len, before_cache_len);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn complete_empty_curation_publishes_empty_watch_group_state() {
|
||||
let directory = tempdir().expect("tempdir");
|
||||
let app_config = app_config(directory.path());
|
||||
let (base_url, server) = empty_trakt_server().await;
|
||||
let target_name = "curation-empty-watch";
|
||||
let target = ConfigTarget::from(&ConfigTargetDto {
|
||||
name: target_name.to_string(),
|
||||
output: vec![TargetOutputDto::Xtream(XtreamTargetOutputDto {
|
||||
trakt: Some(TraktConfigDto {
|
||||
catalog_selection: TraktCatalogSelection::Curated,
|
||||
api: TraktApiConfigDto {
|
||||
api_key: "test-client-id".to_string(),
|
||||
version: "2".to_string(),
|
||||
url: base_url,
|
||||
user_agent: "tuliprox-test".to_string(),
|
||||
},
|
||||
lists: vec![TraktListConfigDto {
|
||||
user: "alice".to_string(),
|
||||
list_slug: "watchlist".to_string(),
|
||||
category_name: Some("Watchlist".to_string()),
|
||||
create_xtream_category: true,
|
||||
content_type: TraktContentType::Vod,
|
||||
tmdb_only: true,
|
||||
fuzzy_match_threshold: 100,
|
||||
}],
|
||||
..TraktConfigDto::default()
|
||||
}),
|
||||
..XtreamTargetOutputDto::default()
|
||||
})],
|
||||
watch: Some(vec![".*".to_string()]),
|
||||
..ConfigTargetDto::default()
|
||||
});
|
||||
let mut seeded = vec![PlaylistGroup {
|
||||
id: 1,
|
||||
title: "Movies".intern(),
|
||||
channels: vec![catalog_test_item(
|
||||
"Seeded movie",
|
||||
UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000072"),
|
||||
PlaylistItemType::Video,
|
||||
XtreamCluster::Video,
|
||||
None,
|
||||
)],
|
||||
xtream_cluster: XtreamCluster::Video,
|
||||
}];
|
||||
let seed_result = persist_playlist_views(
|
||||
&app_config,
|
||||
&mut seeded,
|
||||
None,
|
||||
None,
|
||||
&target,
|
||||
None,
|
||||
curation_persist_options(PlaylistPublicationPlan::Ordinary),
|
||||
)
|
||||
.await;
|
||||
assert!(seed_result.is_ok(), "seed persist failed: {seed_result:?}");
|
||||
assert!(process_watch(&app_config, &NoopSink, &target, &seeded).await);
|
||||
let watch_index = directory.path().join(format!("{target_name}.groups.bin"));
|
||||
let before: BTreeSet<Arc<str>> =
|
||||
tuliprox_core::utils::binary_deserialize(&std::fs::read(&watch_index).expect("seeded watch index"))
|
||||
.expect("decode seeded watch index");
|
||||
assert_eq!(before.len(), 1);
|
||||
|
||||
let context = processing_context(Arc::clone(&app_config), None);
|
||||
let prepared = PreparedTarget {
|
||||
target,
|
||||
playlist: seeded,
|
||||
epg: Vec::new(),
|
||||
processing: PipelineStats::default(),
|
||||
accepted_empty_clusters: ClusterFlags::empty(),
|
||||
library_empty: tuliprox_repository::LibraryEmptyPublication::None,
|
||||
};
|
||||
let (result, errors) = finalize_prepared_target(Arc::new(context), prepared).await;
|
||||
server.await.expect("Trakt server should finish");
|
||||
|
||||
assert!(result.is_ok(), "complete empty finalization failed: {result:?}");
|
||||
assert!(errors.is_empty());
|
||||
let after: BTreeSet<Arc<str>> =
|
||||
tuliprox_core::utils::binary_deserialize(&std::fs::read(watch_index).expect("empty watch index"))
|
||||
.expect("decode empty watch index");
|
||||
assert!(after.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
fn catalog_test_item(
|
||||
title: &str,
|
||||
uuid: UUIDType,
|
||||
item_type: PlaylistItemType,
|
||||
cluster: XtreamCluster,
|
||||
parent_code: Option<&str>,
|
||||
) -> PlaylistItem {
|
||||
PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
id: title.intern(),
|
||||
name: title.intern(),
|
||||
title: title.intern(),
|
||||
group: match cluster {
|
||||
XtreamCluster::Live => "Live".intern(),
|
||||
XtreamCluster::Video => "Movies".intern(),
|
||||
XtreamCluster::Series => "Series".intern(),
|
||||
},
|
||||
uuid,
|
||||
item_type,
|
||||
xtream_cluster: cluster,
|
||||
parent_code: parent_code.unwrap_or_default().intern(),
|
||||
..PlaylistItemHeader::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn complete_catalog_evaluation(memberships: Vec<CurationMembership>) -> CurationEvaluation {
|
||||
CurationEvaluation {
|
||||
selectors: vec![CurationSelectorSummary {
|
||||
key: CurationSelectorKey(0),
|
||||
reference_count: memberships.len(),
|
||||
membership_count: memberships.len(),
|
||||
}],
|
||||
memberships,
|
||||
}
|
||||
}
|
||||
|
||||
fn catalog_membership(uuid: UUIDType, media_kind: CurationMediaKind, order: usize) -> CurationMembership {
|
||||
CurationMembership {
|
||||
selector_key: CurationSelectorKey(0),
|
||||
subject_uuid: uuid,
|
||||
media_kind,
|
||||
rank: Some(u32::try_from(order + 1).expect("test rank")),
|
||||
title_tiebreak: format!("item-{order}"),
|
||||
candidate_order: order,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_selection_preserves_live_and_selected_series_children() {
|
||||
let live_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000001");
|
||||
let selected_movie_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000002");
|
||||
let rejected_movie_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000003");
|
||||
let series_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000004");
|
||||
let episode_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000005");
|
||||
let rejected_series_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000006");
|
||||
let rejected_episode_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000007");
|
||||
let playlist = vec![
|
||||
PlaylistGroup {
|
||||
id: 1,
|
||||
title: "Live".intern(),
|
||||
channels: vec![catalog_test_item(
|
||||
"Live channel",
|
||||
live_uuid,
|
||||
PlaylistItemType::Live,
|
||||
XtreamCluster::Live,
|
||||
None,
|
||||
)],
|
||||
xtream_cluster: XtreamCluster::Live,
|
||||
},
|
||||
PlaylistGroup {
|
||||
id: 2,
|
||||
title: "Movies".intern(),
|
||||
channels: vec![
|
||||
catalog_test_item(
|
||||
"Selected movie",
|
||||
selected_movie_uuid,
|
||||
PlaylistItemType::Video,
|
||||
XtreamCluster::Video,
|
||||
None,
|
||||
),
|
||||
catalog_test_item(
|
||||
"Rejected movie",
|
||||
rejected_movie_uuid,
|
||||
PlaylistItemType::Video,
|
||||
XtreamCluster::Video,
|
||||
None,
|
||||
),
|
||||
],
|
||||
xtream_cluster: XtreamCluster::Video,
|
||||
},
|
||||
PlaylistGroup {
|
||||
id: 3,
|
||||
title: "Series".intern(),
|
||||
channels: vec![
|
||||
catalog_test_item(
|
||||
"Selected series",
|
||||
series_uuid,
|
||||
PlaylistItemType::SeriesInfo,
|
||||
XtreamCluster::Series,
|
||||
None,
|
||||
),
|
||||
catalog_test_item(
|
||||
"Selected episode",
|
||||
episode_uuid,
|
||||
PlaylistItemType::Series,
|
||||
XtreamCluster::Series,
|
||||
Some(&series_uuid.to_string()),
|
||||
),
|
||||
catalog_test_item(
|
||||
"Rejected series",
|
||||
rejected_series_uuid,
|
||||
PlaylistItemType::SeriesInfo,
|
||||
XtreamCluster::Series,
|
||||
None,
|
||||
),
|
||||
catalog_test_item(
|
||||
"Rejected episode",
|
||||
rejected_episode_uuid,
|
||||
PlaylistItemType::Series,
|
||||
XtreamCluster::Series,
|
||||
Some(&rejected_series_uuid.to_string()),
|
||||
),
|
||||
],
|
||||
xtream_cluster: XtreamCluster::Series,
|
||||
},
|
||||
];
|
||||
let evaluation = complete_catalog_evaluation(vec![
|
||||
catalog_membership(selected_movie_uuid, CurationMediaKind::Movie, 0),
|
||||
catalog_membership(series_uuid, CurationMediaKind::Series, 1),
|
||||
]);
|
||||
|
||||
let selected = select_target_catalog(playlist.clone(), &evaluation, true);
|
||||
let titles =
|
||||
selected.iter().flat_map(|group| &group.channels).map(|item| item.header.title.as_ref()).collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(titles, ["Live channel", "Selected movie", "Selected series", "Selected episode"]);
|
||||
assert_eq!(
|
||||
select_target_catalog(playlist.clone(), &evaluation, false).iter().flat_map(|group| &group.channels).count(),
|
||||
7
|
||||
);
|
||||
assert_eq!(selected[0].channels[0].header.uuid, live_uuid, "target-wide selection must not rewrite Live");
|
||||
|
||||
let remote_empty = select_target_catalog(playlist, &complete_catalog_evaluation(Vec::new()), true);
|
||||
assert_eq!(remote_empty.len(), 1);
|
||||
assert_eq!(remote_empty[0].xtream_cluster, XtreamCluster::Live);
|
||||
assert_eq!(remote_empty[0].channels[0].header.uuid, live_uuid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xtream_base_and_selector_category_projection_are_independent() {
|
||||
let selected_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000011");
|
||||
let rejected_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000012");
|
||||
let playlist = vec![PlaylistGroup {
|
||||
id: 1,
|
||||
title: "Movies".intern(),
|
||||
channels: vec![
|
||||
catalog_test_item("Selected", selected_uuid, PlaylistItemType::Video, XtreamCluster::Video, None),
|
||||
catalog_test_item("Rejected", rejected_uuid, PlaylistItemType::Video, XtreamCluster::Video, None),
|
||||
],
|
||||
xtream_cluster: XtreamCluster::Video,
|
||||
}];
|
||||
let evaluation = complete_catalog_evaluation(vec![catalog_membership(selected_uuid, CurationMediaKind::Movie, 0)]);
|
||||
let dto = TraktConfigDto {
|
||||
enabled: true,
|
||||
catalog_selection: TraktCatalogSelection::Curated,
|
||||
include_xtream_base_categories: false,
|
||||
api: TraktApiConfigDto::default(),
|
||||
lists: vec![TraktListConfigDto {
|
||||
user: "alice".to_string(),
|
||||
list_slug: "watchlist".to_string(),
|
||||
category_name: Some("Curated".to_string()),
|
||||
create_xtream_category: true,
|
||||
content_type: TraktContentType::Vod,
|
||||
tmdb_only: true,
|
||||
fuzzy_match_threshold: 100,
|
||||
}],
|
||||
charts: Vec::new(),
|
||||
};
|
||||
let config = TraktConfig::from(&dto);
|
||||
|
||||
let views = build_curated_playlist_views(playlist.clone(), &evaluation, &config, false);
|
||||
|
||||
assert_eq!(views.base.len(), 1);
|
||||
assert_eq!(views.base[0].channels.len(), 1);
|
||||
assert!(
|
||||
target_watch_view(&views.base, views.xtream.as_deref()).iter().any(|group| group.title.as_ref() == "Curated"),
|
||||
"configured Trakt watches observe the Xtream category appearance"
|
||||
);
|
||||
let xtream = views.xtream.expect("complete curation has an Xtream view");
|
||||
assert_eq!(xtream.len(), 1);
|
||||
assert_eq!(xtream[0].title.as_ref(), "Curated");
|
||||
assert_eq!(xtream[0].channels.len(), 1);
|
||||
assert_ne!(xtream[0].channels[0].header.uuid, selected_uuid);
|
||||
|
||||
let mut compatible_dto = dto;
|
||||
compatible_dto.catalog_selection = TraktCatalogSelection::Full;
|
||||
compatible_dto.include_xtream_base_categories = true;
|
||||
compatible_dto.lists[0].create_xtream_category = false;
|
||||
compatible_dto.lists[0].category_name = None;
|
||||
let compatible = build_curated_playlist_views(playlist, &evaluation, &TraktConfig::from(&compatible_dto), false);
|
||||
assert_eq!(compatible.base[0].channels.len(), 2);
|
||||
assert_eq!(compatible.xtream.expect("Xtream view").len(), 1, "selection-only selector creates no category");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curation_policy_truth_table_covers_a_through_h() {
|
||||
let selected_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000041");
|
||||
let rejected_uuid = UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000042");
|
||||
let playlist = vec![PlaylistGroup {
|
||||
id: 1,
|
||||
title: "Movies".intern(),
|
||||
channels: vec![
|
||||
catalog_test_item("Selected", selected_uuid, PlaylistItemType::Video, XtreamCluster::Video, None),
|
||||
catalog_test_item("Rejected", rejected_uuid, PlaylistItemType::Video, XtreamCluster::Video, None),
|
||||
],
|
||||
xtream_cluster: XtreamCluster::Video,
|
||||
}];
|
||||
let evaluation = complete_catalog_evaluation(vec![catalog_membership(selected_uuid, CurationMediaKind::Movie, 0)]);
|
||||
|
||||
let cases = [
|
||||
("A", TraktCatalogSelection::Full, true, true),
|
||||
("B", TraktCatalogSelection::Curated, true, true),
|
||||
("C", TraktCatalogSelection::Curated, true, false),
|
||||
("D", TraktCatalogSelection::Curated, false, true),
|
||||
("E", TraktCatalogSelection::Full, true, false),
|
||||
("F", TraktCatalogSelection::Full, false, true),
|
||||
("G", TraktCatalogSelection::Full, false, false),
|
||||
("H", TraktCatalogSelection::Curated, false, false),
|
||||
];
|
||||
|
||||
for (case, catalog_selection, include_base, create_category) in cases {
|
||||
let config = TraktConfig::from(&TraktConfigDto {
|
||||
enabled: true,
|
||||
catalog_selection,
|
||||
include_xtream_base_categories: include_base,
|
||||
api: TraktApiConfigDto::default(),
|
||||
lists: vec![TraktListConfigDto {
|
||||
user: "alice".to_string(),
|
||||
list_slug: "watchlist".to_string(),
|
||||
category_name: Some("Curated".to_string()),
|
||||
create_xtream_category: create_category,
|
||||
content_type: TraktContentType::Vod,
|
||||
tmdb_only: true,
|
||||
fuzzy_match_threshold: 100,
|
||||
}],
|
||||
charts: Vec::new(),
|
||||
});
|
||||
|
||||
let views = build_curated_playlist_views(playlist.clone(), &evaluation, &config, false);
|
||||
let base_items = views.base.iter().flat_map(|group| &group.channels).collect::<Vec<_>>();
|
||||
let expected_base_count = if catalog_selection == TraktCatalogSelection::Full { 2 } else { 1 };
|
||||
assert_eq!(base_items.len(), expected_base_count, "case {case} selected catalog");
|
||||
assert!(base_items.iter().any(|item| item.header.uuid == selected_uuid), "case {case} selected subject");
|
||||
|
||||
let xtream = views.xtream.expect("complete curation Xtream view");
|
||||
let base_groups = xtream.iter().filter(|group| group.title.as_ref() == "Movies").count();
|
||||
let category_groups = xtream.iter().filter(|group| group.title.as_ref() == "Curated").count();
|
||||
assert_eq!(base_groups, usize::from(include_base), "case {case} base appearance");
|
||||
assert_eq!(category_groups, usize::from(create_category), "case {case} category appearance");
|
||||
let expected_unfiltered_items = usize::from(include_base) * expected_base_count + usize::from(create_category);
|
||||
assert_eq!(
|
||||
xtream.iter().map(|group| group.channels.len()).sum::<usize>(),
|
||||
expected_unfiltered_items,
|
||||
"case {case} unfiltered Xtream catalog"
|
||||
);
|
||||
if let Some(alias) =
|
||||
xtream.iter().find(|group| group.title.as_ref() == "Curated").and_then(|group| group.channels.first())
|
||||
{
|
||||
assert_ne!(alias.header.uuid, selected_uuid, "case {case} alias identity");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_catalog_projection_smoke_keeps_two_explicit_views_bounded() {
|
||||
const CATALOG_SIZE: usize = 10_000;
|
||||
const SELECTOR_COUNT: usize = 4;
|
||||
const MEMBERSHIP_STRIDE: usize = 10;
|
||||
|
||||
let uuid_for = |index: usize| {
|
||||
let mut bytes = [0u8; 32];
|
||||
bytes[..8].copy_from_slice(&u64::try_from(index + 1).expect("test index").to_be_bytes());
|
||||
UUIDType(bytes)
|
||||
};
|
||||
let playlist = vec![PlaylistGroup {
|
||||
id: 1,
|
||||
title: "Movies".intern(),
|
||||
channels: (0..CATALOG_SIZE)
|
||||
.map(|index| {
|
||||
catalog_test_item(
|
||||
&format!("Movie {index}"),
|
||||
uuid_for(index),
|
||||
PlaylistItemType::Video,
|
||||
XtreamCluster::Video,
|
||||
None,
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
xtream_cluster: XtreamCluster::Video,
|
||||
}];
|
||||
let mut memberships = Vec::with_capacity(CATALOG_SIZE / MEMBERSHIP_STRIDE * SELECTOR_COUNT);
|
||||
let mut selectors = Vec::with_capacity(SELECTOR_COUNT);
|
||||
for selector in 0..SELECTOR_COUNT {
|
||||
let key = CurationSelectorKey(selector);
|
||||
let start = memberships.len();
|
||||
for index in (selector..CATALOG_SIZE).step_by(MEMBERSHIP_STRIDE) {
|
||||
memberships.push(CurationMembership {
|
||||
selector_key: key,
|
||||
subject_uuid: uuid_for(index),
|
||||
media_kind: CurationMediaKind::Movie,
|
||||
rank: Some(u32::try_from(index).expect("test rank")),
|
||||
title_tiebreak: format!("movie-{index}"),
|
||||
candidate_order: index,
|
||||
});
|
||||
}
|
||||
selectors.push(CurationSelectorSummary {
|
||||
key,
|
||||
reference_count: memberships.len() - start,
|
||||
membership_count: memberships.len() - start,
|
||||
});
|
||||
}
|
||||
let evaluation = CurationEvaluation { selectors, memberships };
|
||||
let config = TraktConfig::from(&TraktConfigDto {
|
||||
lists: (0..SELECTOR_COUNT)
|
||||
.map(|selector| TraktListConfigDto {
|
||||
user: "alice".to_string(),
|
||||
list_slug: format!("list-{selector}"),
|
||||
category_name: Some(format!("Curated {selector}")),
|
||||
create_xtream_category: true,
|
||||
content_type: TraktContentType::Vod,
|
||||
tmdb_only: true,
|
||||
fuzzy_match_threshold: 100,
|
||||
})
|
||||
.collect(),
|
||||
..TraktConfigDto::default()
|
||||
});
|
||||
|
||||
let views = build_curated_playlist_views(playlist, &evaluation, &config, false);
|
||||
|
||||
assert_eq!(views.base[0].channels.len(), CATALOG_SIZE);
|
||||
let xtream = views.xtream.expect("Xtream view");
|
||||
assert_eq!(xtream.len(), SELECTOR_COUNT + 1);
|
||||
assert_eq!(xtream.iter().map(|group| group.channels.len()).sum::<usize>(), CATALOG_SIZE + 4_000);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -264,9 +264,9 @@ pub async fn m3u_write_playlist(
|
||||
target_output: &M3uTargetOutput,
|
||||
target_path: &Path,
|
||||
new_playlist: &[PlaylistGroup],
|
||||
library_empty: crate::LibraryEmptyPublication,
|
||||
allow_empty_publication: bool,
|
||||
) -> Result<(), TuliproxError> {
|
||||
if new_playlist.is_empty() && !library_empty.replaces_empty_target() {
|
||||
if new_playlist.is_empty() && !allow_empty_publication {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
|
||||
@@ -103,16 +103,108 @@ impl LibraryEmptyPublication {
|
||||
}
|
||||
}
|
||||
|
||||
/// Target persistence behavior authorized by successful input results, not by Quality inference.
|
||||
/// Output replacement authority derived only from a complete curation run.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PlaylistPublicationPlan {
|
||||
Ordinary,
|
||||
CompleteCuration {
|
||||
intentionally_empty_base_vod: bool,
|
||||
intentionally_empty_base_series: bool,
|
||||
intentionally_empty_xtream_vod: bool,
|
||||
intentionally_empty_xtream_series: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl PlaylistPublicationPlan {
|
||||
#[must_use]
|
||||
pub const fn complete_curation(curated_catalog: bool, xtream_base_suppressed: bool) -> Self {
|
||||
Self::complete_curation_with_filter(curated_catalog, xtream_base_suppressed, false)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn complete_curation_with_filter(
|
||||
curated_catalog: bool,
|
||||
xtream_base_suppressed: bool,
|
||||
appearance_filter_configured: bool,
|
||||
) -> Self {
|
||||
Self::CompleteCuration {
|
||||
intentionally_empty_base_vod: curated_catalog || appearance_filter_configured,
|
||||
intentionally_empty_base_series: curated_catalog || appearance_filter_configured,
|
||||
intentionally_empty_xtream_vod: curated_catalog || xtream_base_suppressed || appearance_filter_configured,
|
||||
intentionally_empty_xtream_series: curated_catalog
|
||||
|| xtream_base_suppressed
|
||||
|| appearance_filter_configured,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn with_output_filter(self, output_filter_configured: bool) -> Self {
|
||||
if !output_filter_configured {
|
||||
return self;
|
||||
}
|
||||
match self {
|
||||
Self::Ordinary => Self::Ordinary,
|
||||
Self::CompleteCuration { .. } => Self::CompleteCuration {
|
||||
intentionally_empty_base_vod: true,
|
||||
intentionally_empty_base_series: true,
|
||||
intentionally_empty_xtream_vod: true,
|
||||
intentionally_empty_xtream_series: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn allows_empty_xtream_cluster(self, cluster: XtreamCluster) -> bool {
|
||||
matches!(
|
||||
(self, cluster),
|
||||
(Self::CompleteCuration { intentionally_empty_xtream_vod: true, .. }, XtreamCluster::Video)
|
||||
| (Self::CompleteCuration { intentionally_empty_xtream_series: true, .. }, XtreamCluster::Series)
|
||||
)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn allows_empty_base_output(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::CompleteCuration { intentionally_empty_base_vod: true, .. }
|
||||
| Self::CompleteCuration { intentionally_empty_base_series: true, .. }
|
||||
)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn allows_any_empty_output(self) -> bool {
|
||||
self.allows_empty_base_output()
|
||||
|| self.allows_empty_xtream_cluster(XtreamCluster::Video)
|
||||
|| self.allows_empty_xtream_cluster(XtreamCluster::Series)
|
||||
}
|
||||
|
||||
fn replacement_clusters(self) -> ClusterFlags {
|
||||
let mut clusters = ClusterFlags::empty();
|
||||
if self.allows_empty_xtream_cluster(XtreamCluster::Video) {
|
||||
clusters |= ClusterFlags::Vod;
|
||||
}
|
||||
if self.allows_empty_xtream_cluster(XtreamCluster::Series) {
|
||||
clusters |= ClusterFlags::Series;
|
||||
}
|
||||
clusters
|
||||
}
|
||||
}
|
||||
|
||||
/// Target persistence behavior authorized by successful input and curation results, not by Quality inference.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct TargetPlaylistPersistOptions {
|
||||
pub accepted_empty_clusters: ClusterFlags,
|
||||
pub library_empty: LibraryEmptyPublication,
|
||||
pub publication_plan: PlaylistPublicationPlan,
|
||||
}
|
||||
|
||||
impl Default for TargetPlaylistPersistOptions {
|
||||
fn default() -> Self {
|
||||
Self { accepted_empty_clusters: ClusterFlags::empty(), library_empty: LibraryEmptyPublication::None }
|
||||
Self {
|
||||
accepted_empty_clusters: ClusterFlags::empty(),
|
||||
library_empty: LibraryEmptyPublication::None,
|
||||
publication_plan: PlaylistPublicationPlan::Ordinary,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +213,10 @@ fn validate_target_playlist_persistence(
|
||||
playlist_is_empty: bool,
|
||||
options: TargetPlaylistPersistOptions,
|
||||
) -> Result<(), TuliproxError> {
|
||||
if !playlist_is_empty || options.library_empty.replaces_empty_target() {
|
||||
if !playlist_is_empty
|
||||
|| options.library_empty.replaces_empty_target()
|
||||
|| options.publication_plan.allows_any_empty_output()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if options.accepted_empty_clusters.is_empty() {
|
||||
@@ -141,12 +236,19 @@ fn validate_target_playlist_persistence(
|
||||
|
||||
fn prepare_target_output_playlists(
|
||||
target: &ConfigTarget,
|
||||
playlist: &[PlaylistGroup],
|
||||
base_playlist: &[PlaylistGroup],
|
||||
xtream_playlist: Option<&[PlaylistGroup]>,
|
||||
) -> Vec<Option<Vec<PlaylistGroup>>> {
|
||||
target
|
||||
.output
|
||||
.iter()
|
||||
.map(|output| output.filter().map(|filter| apply_filter_to_playlist(playlist, filter)))
|
||||
.map(|output| {
|
||||
let source_playlist = match output {
|
||||
TargetOutput::Xtream(_) => xtream_playlist.unwrap_or(base_playlist),
|
||||
_ => base_playlist,
|
||||
};
|
||||
output.filter().map(|filter| apply_filter_to_playlist(source_playlist, filter))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -169,7 +271,9 @@ fn validate_force_empty_output_filters(
|
||||
let Some(filtered_playlist) = prepared_output else {
|
||||
continue;
|
||||
};
|
||||
if !playlist_has_items(filtered_playlist) {
|
||||
let curation_allows_empty =
|
||||
options.publication_plan.with_output_filter(output.filter().is_some()).allows_empty_base_output();
|
||||
if !playlist_has_items(filtered_playlist) && !curation_allows_empty {
|
||||
return Err(TuliproxError::RepositoryPlaylist(format!(
|
||||
"Refusing to publish force-empty {output_name} output for target '{}' after its output filter; existing data was retained",
|
||||
target.name
|
||||
@@ -228,60 +332,12 @@ fn normalize_target_playlist_epg_ids(playlist: &mut [PlaylistGroup], target_opti
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub async fn persist_playlist(
|
||||
app_config: &Arc<AppConfig>,
|
||||
fn prepare_target_playlist_for_persistence(
|
||||
playlist: &mut [PlaylistGroup],
|
||||
epg: Option<&Epg>,
|
||||
target: &ConfigTarget,
|
||||
playlist_state: Option<&Arc<PlaylistStorageState>>,
|
||||
options: TargetPlaylistPersistOptions,
|
||||
) -> Result<(), Vec<TuliproxError>> {
|
||||
persist_playlist_with_mode(
|
||||
app_config,
|
||||
playlist,
|
||||
epg,
|
||||
target,
|
||||
playlist_state,
|
||||
options,
|
||||
TargetPersistenceMode::Persist,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
async fn persist_playlist_with_mode(
|
||||
app_config: &Arc<AppConfig>,
|
||||
playlist: &mut [PlaylistGroup],
|
||||
epg: Option<&Epg>,
|
||||
target: &ConfigTarget,
|
||||
playlist_state: Option<&Arc<PlaylistStorageState>>,
|
||||
options: TargetPlaylistPersistOptions,
|
||||
persistence_mode: TargetPersistenceMode,
|
||||
) -> Result<(), Vec<TuliproxError>> {
|
||||
let playlist_is_empty = !playlist_has_items(playlist);
|
||||
if let Err(error) = validate_target_playlist_persistence(target, playlist_is_empty, options) {
|
||||
return Err(vec![error]);
|
||||
}
|
||||
let mut errors = vec![];
|
||||
let config = &app_config.config.load();
|
||||
let target_path = match ensure_target_storage_path(config, &target.name).await {
|
||||
Ok(path) => path,
|
||||
Err(err) => return Err(vec![err]),
|
||||
};
|
||||
|
||||
let (mut target_id_mapping, file_lock) =
|
||||
match get_target_id_mapping(app_config, &target_path, target.use_memory_cache).await {
|
||||
Ok(result) => result,
|
||||
Err(err) => return Err(vec![err]),
|
||||
};
|
||||
|
||||
let mut local_library_series = HashMap::<Arc<str>, Vec<LocalEpisodeKey>>::new();
|
||||
let mut provider_series = HashMap::<Arc<str>, Vec<ProviderEpisodeKey>>::new();
|
||||
let mut media_server_series = HashMap::<Arc<str>, Vec<SeriesStreamDetailEpisodeProperties>>::new();
|
||||
|
||||
target_id_mapping: &mut TargetIdMapping,
|
||||
) {
|
||||
let mut source_ordinal: u32 = 0;
|
||||
// Virtual IDs assignment
|
||||
for group in playlist.iter_mut() {
|
||||
for channel in &mut group.channels {
|
||||
let header = &mut channel.header;
|
||||
@@ -314,8 +370,11 @@ async fn persist_playlist_with_mode(
|
||||
}
|
||||
}
|
||||
|
||||
rewrite_series_episode_parent_virtual_ids(playlist, &mut target_id_mapping);
|
||||
rewrite_series_episode_parent_virtual_ids(playlist, target_id_mapping);
|
||||
|
||||
let mut local_library_series = HashMap::<Arc<str>, Vec<LocalEpisodeKey>>::new();
|
||||
let mut provider_series = HashMap::<Arc<str>, Vec<ProviderEpisodeKey>>::new();
|
||||
let mut media_server_series = HashMap::<Arc<str>, Vec<SeriesStreamDetailEpisodeProperties>>::new();
|
||||
for group in playlist.iter_mut() {
|
||||
for channel in &mut group.channels {
|
||||
let header = &mut channel.header;
|
||||
@@ -332,13 +391,102 @@ async fn persist_playlist_with_mode(
|
||||
|
||||
materialize_media_server_series_info_episodes(playlist, &media_server_series);
|
||||
rewrite_series_info_episode_virtual_id(playlist, &local_library_series, &provider_series);
|
||||
drop(local_library_series);
|
||||
drop(provider_series);
|
||||
drop(media_server_series);
|
||||
|
||||
normalize_target_playlist_epg_ids(playlist, target.options.as_ref());
|
||||
}
|
||||
|
||||
let mut prepared_outputs = prepare_target_output_playlists(target, playlist);
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub async fn persist_playlist(
|
||||
app_config: &Arc<AppConfig>,
|
||||
playlist: &mut [PlaylistGroup],
|
||||
epg: Option<&Epg>,
|
||||
target: &ConfigTarget,
|
||||
playlist_state: Option<&Arc<PlaylistStorageState>>,
|
||||
options: TargetPlaylistPersistOptions,
|
||||
) -> Result<(), Vec<TuliproxError>> {
|
||||
persist_playlist_with_mode(
|
||||
app_config,
|
||||
playlist,
|
||||
epg,
|
||||
target,
|
||||
playlist_state,
|
||||
options,
|
||||
TargetPersistenceMode::Persist,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub async fn persist_playlist_views(
|
||||
app_config: &Arc<AppConfig>,
|
||||
base_playlist: &mut [PlaylistGroup],
|
||||
xtream_playlist: Option<&mut [PlaylistGroup]>,
|
||||
epg: Option<&Epg>,
|
||||
target: &ConfigTarget,
|
||||
playlist_state: Option<&Arc<PlaylistStorageState>>,
|
||||
options: TargetPlaylistPersistOptions,
|
||||
) -> Result<(), Vec<TuliproxError>> {
|
||||
persist_playlist_views_with_mode(
|
||||
app_config,
|
||||
base_playlist,
|
||||
xtream_playlist,
|
||||
epg,
|
||||
target,
|
||||
playlist_state,
|
||||
options,
|
||||
TargetPersistenceMode::Persist,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
async fn persist_playlist_with_mode(
|
||||
app_config: &Arc<AppConfig>,
|
||||
playlist: &mut [PlaylistGroup],
|
||||
epg: Option<&Epg>,
|
||||
target: &ConfigTarget,
|
||||
playlist_state: Option<&Arc<PlaylistStorageState>>,
|
||||
options: TargetPlaylistPersistOptions,
|
||||
persistence_mode: TargetPersistenceMode,
|
||||
) -> Result<(), Vec<TuliproxError>> {
|
||||
persist_playlist_views_with_mode(app_config, playlist, None, epg, target, playlist_state, options, persistence_mode)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
|
||||
async fn persist_playlist_views_with_mode(
|
||||
app_config: &Arc<AppConfig>,
|
||||
base_playlist: &mut [PlaylistGroup],
|
||||
mut xtream_playlist: Option<&mut [PlaylistGroup]>,
|
||||
epg: Option<&Epg>,
|
||||
target: &ConfigTarget,
|
||||
playlist_state: Option<&Arc<PlaylistStorageState>>,
|
||||
options: TargetPlaylistPersistOptions,
|
||||
persistence_mode: TargetPersistenceMode,
|
||||
) -> Result<(), Vec<TuliproxError>> {
|
||||
let playlist_is_empty = !playlist_has_items(base_playlist)
|
||||
&& xtream_playlist.as_deref().is_none_or(|playlist| !playlist_has_items(playlist));
|
||||
if let Err(error) = validate_target_playlist_persistence(target, playlist_is_empty, options) {
|
||||
return Err(vec![error]);
|
||||
}
|
||||
let mut errors = vec![];
|
||||
let config = &app_config.config.load();
|
||||
let target_path = match ensure_target_storage_path(config, &target.name).await {
|
||||
Ok(path) => path,
|
||||
Err(err) => return Err(vec![err]),
|
||||
};
|
||||
|
||||
let (mut target_id_mapping, file_lock) =
|
||||
match get_target_id_mapping(app_config, &target_path, target.use_memory_cache).await {
|
||||
Ok(result) => result,
|
||||
Err(err) => return Err(vec![err]),
|
||||
};
|
||||
|
||||
prepare_target_playlist_for_persistence(base_playlist, target, &mut target_id_mapping);
|
||||
if let Some(xtream_view) = xtream_playlist.as_deref_mut() {
|
||||
prepare_target_playlist_for_persistence(xtream_view, target, &mut target_id_mapping);
|
||||
}
|
||||
|
||||
let mut prepared_outputs = prepare_target_output_playlists(target, base_playlist, xtream_playlist.as_deref());
|
||||
if let Err(error) = validate_force_empty_output_filters(target, &prepared_outputs, options) {
|
||||
target_id_mapping.discard_unpersisted_changes();
|
||||
drop(target_id_mapping);
|
||||
@@ -347,12 +495,20 @@ async fn persist_playlist_with_mode(
|
||||
}
|
||||
|
||||
for (output, prepared_output) in target.output.iter().zip(&mut prepared_outputs) {
|
||||
let output_publication_plan = options.publication_plan.with_output_filter(output.filter().is_some());
|
||||
let source_playlist = match output {
|
||||
TargetOutput::Xtream(_) => xtream_playlist.as_deref_mut().unwrap_or(base_playlist),
|
||||
_ => &mut *base_playlist,
|
||||
};
|
||||
let pl: &mut [PlaylistGroup] = if let Some(filtered_playlist) = prepared_output.as_mut() {
|
||||
filtered_playlist.as_mut_slice()
|
||||
} else {
|
||||
&mut *playlist
|
||||
source_playlist
|
||||
};
|
||||
let library_empty = options.library_empty.for_filtered_playlist(pl);
|
||||
let curation_empty_clusters = output_publication_plan.replacement_clusters();
|
||||
let allows_empty_base =
|
||||
library_empty.replaces_empty_target() || output_publication_plan.allows_empty_base_output();
|
||||
|
||||
let result = match output {
|
||||
TargetOutput::Xtream(_xtream_output) => {
|
||||
@@ -360,23 +516,31 @@ async fn persist_playlist_with_mode(
|
||||
app_config,
|
||||
target,
|
||||
pl,
|
||||
options.accepted_empty_clusters | library_empty.replacement_clusters(),
|
||||
options.accepted_empty_clusters | library_empty.replacement_clusters() | curation_empty_clusters,
|
||||
persistence_mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
TargetOutput::M3u(m3u_output) => {
|
||||
m3u_write_playlist(app_config, target, m3u_output, &target_path, pl, library_empty).await
|
||||
m3u_write_playlist(app_config, target, m3u_output, &target_path, pl, allows_empty_base).await
|
||||
}
|
||||
TargetOutput::Strm(strm_output) => {
|
||||
write_strm_playlist(app_config, target, strm_output, pl, library_empty).await
|
||||
write_strm_playlist(app_config, target, strm_output, pl, allows_empty_base).await
|
||||
}
|
||||
TargetOutput::HdHomeRun(_hdhomerun_output) => Ok(()),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
if !pl.is_empty() {
|
||||
let allows_empty_output = match output {
|
||||
TargetOutput::Xtream(_) => {
|
||||
!curation_empty_clusters.is_empty()
|
||||
|| !library_empty.replacement_clusters().is_empty()
|
||||
|| !options.accepted_empty_clusters.is_empty()
|
||||
}
|
||||
_ => allows_empty_base,
|
||||
};
|
||||
if !pl.is_empty() || allows_empty_output {
|
||||
let epg_pl: &[PlaylistGroup] = pl;
|
||||
if let Err(err) =
|
||||
epg_write_for_target(config, target, &target_path, epg, output, Some(epg_pl)).await
|
||||
@@ -1205,24 +1369,29 @@ mod tests {
|
||||
use super::{
|
||||
assign_local_series_info_episode_key, assign_media_server_series_info_episode,
|
||||
get_input_media_server_playlist_file_path, materialize_media_server_series_info_episodes,
|
||||
normalize_target_playlist_epg_ids, persist_playlist_with_mode, playlist_has_items,
|
||||
normalize_target_playlist_epg_ids, persist_playlist_views, persist_playlist_with_mode, playlist_has_items,
|
||||
rewrite_local_series_info_episode_virtual_id, rewrite_series_episode_parent_virtual_ids,
|
||||
rewrite_series_info_episode_virtual_id, skipped_clusters, validate_target_playlist_persistence,
|
||||
LocalEpisodeKey, ProviderEpisodeKey, TargetCacheReloadStage, TargetPersistenceMode,
|
||||
LocalEpisodeKey, PlaylistPublicationPlan, ProviderEpisodeKey, TargetCacheReloadStage, TargetPersistenceMode,
|
||||
TargetPlaylistPersistOptions,
|
||||
};
|
||||
use crate::{load_xtream_target_storage, BPlusTreeQuery, PlaylistStorageState, TargetIdMapping, VirtualIdRecord};
|
||||
use crate::{
|
||||
get_series_cat_collection_path, get_target_storage_path, get_vod_cat_collection_path, load_m3u_target_storage,
|
||||
load_xtream_target_storage, strm_get_file_paths, xtream_get_storage_path, BPlusTreeQuery, PlaylistStorageState,
|
||||
TargetIdMapping, VirtualIdRecord,
|
||||
};
|
||||
use arc_swap::{ArcSwap, ArcSwapOption};
|
||||
use shared::{
|
||||
foundation::get_filter,
|
||||
model::{
|
||||
ClusterFlags, ConfigPaths, ConfigTargetOptions, EpgOutputOptions, EpisodeStreamProperties, M3uPlaylistItem,
|
||||
PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, ProcessingOrder,
|
||||
SeriesStreamDetailEpisodeProperties, SeriesStreamDetailProperties, SeriesStreamDetailSeasonProperties,
|
||||
SeriesStreamProperties, StreamProperties, StrmExportStyle, UUIDType, VirtualId, XtreamCluster,
|
||||
XtreamPlaylistItem,
|
||||
ClusterFlags, ConfigPaths, ConfigTargetDto, ConfigTargetOptions, EpgOutputOptions, EpisodeStreamProperties,
|
||||
M3uPlaylistItem, M3uTargetOutputDto, PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistItemHeader,
|
||||
PlaylistItemType, ProcessingOrder, SeriesStreamDetailEpisodeProperties, SeriesStreamDetailProperties,
|
||||
SeriesStreamDetailSeasonProperties, SeriesStreamProperties, StreamProperties, StrmExportStyle,
|
||||
StrmTargetOutputDto, TargetOutputDto, UUIDType, VirtualId, XtreamCluster, XtreamPlaylistItem,
|
||||
XtreamTargetOutputDto,
|
||||
},
|
||||
utils::Internable,
|
||||
utils::{hash_string_as_hex, Internable},
|
||||
};
|
||||
use std::{collections::HashMap, path::Path, sync::Arc};
|
||||
use tuliprox_core::{
|
||||
@@ -1231,7 +1400,7 @@ mod tests {
|
||||
MediaToolCapabilities, SourcesConfig, StagedFilter, StrmTargetFlagsSet, StrmTargetOutput,
|
||||
TargetExecutionPlan, TargetOutput, XtreamTargetFlagsSet, XtreamTargetOutput,
|
||||
},
|
||||
utils::FileLockManager,
|
||||
utils::{normalize_string_path, FileLockManager},
|
||||
};
|
||||
|
||||
fn target_with_outputs(output: Vec<TargetOutput>) -> ConfigTarget {
|
||||
@@ -1621,6 +1790,372 @@ mod tests {
|
||||
}
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn complete_curation_empty_authorization_is_projection_and_cluster_scoped() {
|
||||
let compatibility = PlaylistPublicationPlan::complete_curation(false, false);
|
||||
assert!(!compatibility.allows_any_empty_output());
|
||||
|
||||
let suppressed_xtream_base = PlaylistPublicationPlan::complete_curation(false, true);
|
||||
assert!(!suppressed_xtream_base.allows_empty_base_output());
|
||||
assert!(!suppressed_xtream_base.allows_empty_xtream_cluster(XtreamCluster::Live));
|
||||
assert!(suppressed_xtream_base.allows_empty_xtream_cluster(XtreamCluster::Video));
|
||||
assert!(suppressed_xtream_base.allows_empty_xtream_cluster(XtreamCluster::Series));
|
||||
|
||||
let persist_filtered = PlaylistPublicationPlan::complete_curation_with_filter(false, false, true);
|
||||
assert!(persist_filtered.allows_empty_base_output());
|
||||
assert!(persist_filtered.allows_empty_xtream_cluster(XtreamCluster::Video));
|
||||
let output_filtered = compatibility.with_output_filter(true);
|
||||
assert!(output_filtered.allows_empty_base_output());
|
||||
assert_eq!(PlaylistPublicationPlan::Ordinary.with_output_filter(true), PlaylistPublicationPlan::Ordinary);
|
||||
}
|
||||
|
||||
fn curation_persist_options(publication_plan: PlaylistPublicationPlan) -> TargetPlaylistPersistOptions {
|
||||
TargetPlaylistPersistOptions { publication_plan, ..TargetPlaylistPersistOptions::default() }
|
||||
}
|
||||
|
||||
fn target_test_app_config(storage_dir: &Path) -> Arc<AppConfig> {
|
||||
Arc::new(AppConfig {
|
||||
config: Arc::new(ArcSwap::from_pointee(Config {
|
||||
storage_dir: storage_dir.to_string_lossy().into_owned(),
|
||||
..Config::default()
|
||||
})),
|
||||
sources: Arc::new(ArcSwap::from_pointee(SourcesConfig::default())),
|
||||
hdhomerun: Arc::new(ArcSwapOption::<HdHomeRunConfig>::default()),
|
||||
api_proxy: Arc::new(ArcSwapOption::<ApiProxyConfig>::default()),
|
||||
file_locks: Arc::new(FileLockManager::default()),
|
||||
paths: Arc::new(ArcSwap::from_pointee(ConfigPaths {
|
||||
home_path: String::new(),
|
||||
config_path: String::new(),
|
||||
storage_path: String::new(),
|
||||
config_file_path: String::new(),
|
||||
sources_file_path: String::new(),
|
||||
mapping_file_path: None,
|
||||
mapping_files_used: None,
|
||||
template_file_path: None,
|
||||
template_files_used: None,
|
||||
api_proxy_file_path: String::new(),
|
||||
custom_stream_response_path: None,
|
||||
})),
|
||||
custom_stream_response: Arc::new(ArcSwapOption::<CustomStreamResponse>::default()),
|
||||
access_token_secret: [0; 32],
|
||||
encrypt_secret: [0; 16],
|
||||
media_tools: Arc::new(MediaToolCapabilities::new()),
|
||||
})
|
||||
}
|
||||
|
||||
fn mixed_output_target() -> ConfigTarget {
|
||||
let mut xtream = XtreamTargetOutputDto::default();
|
||||
xtream.t_filter =
|
||||
Some(shared::foundation::get_filter(r#"Group = "Curated alias""#, None).expect("Xtream output filter"));
|
||||
let mut m3u = M3uTargetOutputDto { filename: Some("curated.m3u".to_string()), ..Default::default() };
|
||||
m3u.t_filter =
|
||||
Some(shared::foundation::get_filter(r#"Group = "Base movie""#, None).expect("M3U output filter"));
|
||||
ConfigTarget::from(&ConfigTargetDto {
|
||||
name: "curated-output-test".to_string(),
|
||||
output: vec![
|
||||
TargetOutputDto::Xtream(xtream),
|
||||
TargetOutputDto::M3u(m3u),
|
||||
TargetOutputDto::Strm(StrmTargetOutputDto {
|
||||
directory: "strm".to_string(),
|
||||
flat: true,
|
||||
cleanup: true,
|
||||
..StrmTargetOutputDto::default()
|
||||
}),
|
||||
],
|
||||
use_memory_cache: true,
|
||||
..ConfigTargetDto::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn target_video_group(title: &str, uuid: UUIDType) -> PlaylistGroup {
|
||||
PlaylistGroup {
|
||||
id: 1,
|
||||
title: title.intern(),
|
||||
channels: vec![PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
id: "1".intern(),
|
||||
name: title.intern(),
|
||||
title: title.intern(),
|
||||
group: title.intern(),
|
||||
url: format!("http://example.invalid/{title}").intern(),
|
||||
uuid,
|
||||
item_type: PlaylistItemType::Video,
|
||||
xtream_cluster: XtreamCluster::Video,
|
||||
..PlaylistItemHeader::default()
|
||||
},
|
||||
}],
|
||||
xtream_cluster: XtreamCluster::Video,
|
||||
}
|
||||
}
|
||||
|
||||
fn strm_files_below(path: &Path) -> Vec<std::path::PathBuf> {
|
||||
let mut files = Vec::new();
|
||||
let Ok(entries) = std::fs::read_dir(path) else { return files };
|
||||
for entry in entries.flatten() {
|
||||
let entry_path = entry.path();
|
||||
if entry_path.is_dir() {
|
||||
files.extend(strm_files_below(&entry_path));
|
||||
} else if entry_path.extension().is_some_and(|extension| extension == "strm") {
|
||||
files.push(entry_path);
|
||||
}
|
||||
}
|
||||
files
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn intentional_empty_output_views_clear_managed_artifacts_without_alias_leakage() {
|
||||
let directory = tempdir().expect("tempdir");
|
||||
let app_config = target_test_app_config(directory.path());
|
||||
let target = mixed_output_target();
|
||||
let playlist_state = Arc::new(crate::PlaylistStorageState::new());
|
||||
let mut standard =
|
||||
vec![target_video_group("Base movie", UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000021"))];
|
||||
let mut xtream = vec![target_video_group(
|
||||
"Curated alias",
|
||||
UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000022"),
|
||||
)];
|
||||
|
||||
let first = persist_playlist_views(
|
||||
&app_config,
|
||||
&mut standard,
|
||||
Some(&mut xtream),
|
||||
None,
|
||||
&target,
|
||||
Some(&playlist_state),
|
||||
curation_persist_options(PlaylistPublicationPlan::complete_curation(true, false)),
|
||||
)
|
||||
.await;
|
||||
assert!(first.is_ok(), "initial mixed-output persist failed: {first:?}");
|
||||
|
||||
let m3u = load_m3u_target_storage(&app_config, &target).await.expect("M3U storage");
|
||||
let xtream = load_xtream_target_storage(&app_config, &target).await.expect("Xtream storage");
|
||||
assert_eq!(m3u.len(), 1);
|
||||
assert_eq!(m3u.iter().next().expect("M3U item").1.title.as_ref(), "Base movie");
|
||||
assert_eq!(xtream.vod.len(), 1);
|
||||
let xtream_item = xtream.vod.iter().next().expect("Xtream item").1;
|
||||
assert_eq!(xtream_item.title.as_ref(), "Curated alias");
|
||||
assert_ne!(xtream_item.category_id, 0, "base-suppressed unfiltered rows remain category-backed aliases");
|
||||
{
|
||||
let cache = playlist_state.data.read().await;
|
||||
let cached = cache.get(&target.name).expect("target cache");
|
||||
assert_eq!(cached.m3u.as_ref().expect("M3U cache").len(), 1);
|
||||
assert_eq!(cached.xtream.as_ref().expect("Xtream cache").vod.len(), 1);
|
||||
}
|
||||
let m3u_text_path = directory.path().join("curated.m3u");
|
||||
assert!(std::fs::read_to_string(&m3u_text_path).expect("M3U text").contains("Base movie"));
|
||||
let strm_root = directory.path().join("strm");
|
||||
let strm_files = strm_files_below(&strm_root);
|
||||
assert_eq!(strm_files.len(), 1);
|
||||
assert!(std::fs::read_to_string(&strm_files[0]).expect("STRM content").contains("Base movie"));
|
||||
|
||||
let mut empty_standard = Vec::new();
|
||||
let mut empty_xtream = Vec::new();
|
||||
let retained = persist_playlist_views(
|
||||
&app_config,
|
||||
&mut empty_standard,
|
||||
Some(&mut empty_xtream),
|
||||
None,
|
||||
&target,
|
||||
Some(&playlist_state),
|
||||
curation_persist_options(PlaylistPublicationPlan::Ordinary),
|
||||
)
|
||||
.await;
|
||||
assert!(retained.is_err(), "untrusted empty input must retain the published snapshot");
|
||||
assert_eq!(load_m3u_target_storage(&app_config, &target).await.expect("retained M3U").len(), 1);
|
||||
assert_eq!(load_xtream_target_storage(&app_config, &target).await.expect("retained Xtream").vod.len(), 1);
|
||||
{
|
||||
let cache = playlist_state.data.read().await;
|
||||
let cached = cache.get(&target.name).expect("retained target cache");
|
||||
assert_eq!(cached.m3u.as_ref().expect("retained M3U cache").len(), 1);
|
||||
assert_eq!(cached.xtream.as_ref().expect("retained Xtream cache").vod.len(), 1);
|
||||
}
|
||||
assert_eq!(strm_files_below(&strm_root).len(), 1, "untrusted empty refresh must retain STRM files");
|
||||
|
||||
let published = persist_playlist_views(
|
||||
&app_config,
|
||||
&mut empty_standard,
|
||||
Some(&mut empty_xtream),
|
||||
None,
|
||||
&target,
|
||||
Some(&playlist_state),
|
||||
curation_persist_options(PlaylistPublicationPlan::complete_curation(true, false)),
|
||||
)
|
||||
.await;
|
||||
assert!(published.is_ok(), "trusted empty snapshot failed: {published:?}");
|
||||
assert!(load_m3u_target_storage(&app_config, &target).await.expect("empty M3U").is_empty());
|
||||
assert_eq!(std::fs::read_to_string(&m3u_text_path).expect("empty M3U text"), "#EXTM3U\n");
|
||||
let empty_xtream = load_xtream_target_storage(&app_config, &target).await.expect("empty Xtream");
|
||||
assert!(empty_xtream.live.is_empty());
|
||||
assert!(empty_xtream.vod.is_empty());
|
||||
assert!(empty_xtream.series.is_empty());
|
||||
let target_storage = {
|
||||
let config = app_config.config.load();
|
||||
get_target_storage_path(&config, &target.name).expect("target storage")
|
||||
};
|
||||
let xtream_storage = {
|
||||
let config = app_config.config.load();
|
||||
xtream_get_storage_path(&config, &target.name).expect("Xtream storage path")
|
||||
};
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(get_vod_cat_collection_path(&xtream_storage)).expect("empty VOD categories"),
|
||||
"[]"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(get_series_cat_collection_path(&xtream_storage)).expect("empty series categories"),
|
||||
"[]"
|
||||
);
|
||||
let strm_index = strm_get_file_paths(&hash_string_as_hex(&normalize_string_path("strm")), &target_storage);
|
||||
assert!(std::fs::read_to_string(strm_index).expect("empty STRM index").is_empty());
|
||||
{
|
||||
let cache = playlist_state.data.read().await;
|
||||
let cached = cache.get(&target.name).expect("empty target cache");
|
||||
assert!(cached.m3u.as_ref().expect("empty M3U cache").is_empty());
|
||||
assert!(cached.xtream.as_ref().expect("empty Xtream cache").vod.is_empty());
|
||||
}
|
||||
assert!(strm_files_below(&strm_root).is_empty(), "trusted empty refresh must clean stale STRM files");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn intentional_empty_strm_cleanup_false_removes_indexed_files_but_keeps_unmanaged_files() {
|
||||
let directory = tempdir().expect("tempdir");
|
||||
let app_config = target_test_app_config(directory.path());
|
||||
let target = ConfigTarget::from(&ConfigTargetDto {
|
||||
name: "curated-strm-retention-test".to_string(),
|
||||
output: vec![TargetOutputDto::Strm(StrmTargetOutputDto {
|
||||
directory: "strm-retained".to_string(),
|
||||
flat: true,
|
||||
cleanup: false,
|
||||
..StrmTargetOutputDto::default()
|
||||
})],
|
||||
..ConfigTargetDto::default()
|
||||
});
|
||||
let mut seeded = vec![target_video_group(
|
||||
"Retained movie",
|
||||
UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000029"),
|
||||
)];
|
||||
let initial = persist_playlist_views(
|
||||
&app_config,
|
||||
&mut seeded,
|
||||
None,
|
||||
None,
|
||||
&target,
|
||||
None,
|
||||
curation_persist_options(PlaylistPublicationPlan::Ordinary),
|
||||
)
|
||||
.await;
|
||||
assert!(initial.is_ok(), "initial STRM persist failed: {initial:?}");
|
||||
let strm_root = directory.path().join("strm-retained");
|
||||
let managed_files = strm_files_below(&strm_root);
|
||||
assert_eq!(managed_files.len(), 1);
|
||||
let managed_file = managed_files[0].clone();
|
||||
let unmanaged_file = strm_root.join("unmanaged.strm");
|
||||
std::fs::write(&unmanaged_file, "unmanaged").expect("unmanaged STRM fixture");
|
||||
|
||||
let mut empty = Vec::new();
|
||||
let published = persist_playlist_views(
|
||||
&app_config,
|
||||
&mut empty,
|
||||
None,
|
||||
None,
|
||||
&target,
|
||||
None,
|
||||
curation_persist_options(PlaylistPublicationPlan::complete_curation(true, false)),
|
||||
)
|
||||
.await;
|
||||
assert!(published.is_ok(), "empty STRM persist failed: {published:?}");
|
||||
|
||||
assert!(!managed_file.exists(), "cleanup=false removes files tracked by the managed index");
|
||||
assert!(unmanaged_file.exists(), "cleanup=false does not scan and remove unmanaged files");
|
||||
let target_storage = {
|
||||
let config = app_config.config.load();
|
||||
get_target_storage_path(&config, &target.name).expect("target storage")
|
||||
};
|
||||
let index = strm_get_file_paths(&hash_string_as_hex(&normalize_string_path("strm-retained")), &target_storage);
|
||||
assert!(std::fs::read_to_string(index).expect("empty STRM index").is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn intentional_empty_curation_never_authorizes_empty_live_cluster_replacement() {
|
||||
let directory = tempdir().expect("tempdir");
|
||||
let app_config = target_test_app_config(directory.path());
|
||||
let target = ConfigTarget::from(&ConfigTargetDto {
|
||||
name: "curated-live-retention-test".to_string(),
|
||||
output: vec![TargetOutputDto::Xtream(XtreamTargetOutputDto::default())],
|
||||
..ConfigTargetDto::default()
|
||||
});
|
||||
let mut live = vec![
|
||||
PlaylistGroup {
|
||||
id: 1,
|
||||
title: "Live".intern(),
|
||||
channels: vec![PlaylistItem {
|
||||
header: PlaylistItemHeader {
|
||||
id: "1".intern(),
|
||||
name: "Live channel".intern(),
|
||||
title: "Live channel".intern(),
|
||||
group: "Live".intern(),
|
||||
url: "http://example.invalid/live".intern(),
|
||||
uuid: UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000031"),
|
||||
item_type: PlaylistItemType::Live,
|
||||
xtream_cluster: XtreamCluster::Live,
|
||||
..PlaylistItemHeader::default()
|
||||
},
|
||||
}],
|
||||
xtream_cluster: XtreamCluster::Live,
|
||||
},
|
||||
target_video_group(
|
||||
"Previously published movie",
|
||||
UUIDType::from_valid_uuid("00000000-0000-4000-8000-000000000032"),
|
||||
),
|
||||
];
|
||||
|
||||
let initial = persist_playlist_views(
|
||||
&app_config,
|
||||
&mut live,
|
||||
None,
|
||||
None,
|
||||
&target,
|
||||
None,
|
||||
curation_persist_options(PlaylistPublicationPlan::Ordinary),
|
||||
)
|
||||
.await;
|
||||
assert!(initial.is_ok(), "initial Live persist failed: {initial:?}");
|
||||
|
||||
let mut ordinary_live_only = vec![live[0].clone()];
|
||||
let ordinary = persist_playlist_views(
|
||||
&app_config,
|
||||
&mut ordinary_live_only,
|
||||
None,
|
||||
None,
|
||||
&target,
|
||||
None,
|
||||
curation_persist_options(PlaylistPublicationPlan::Ordinary),
|
||||
)
|
||||
.await;
|
||||
assert!(ordinary.is_ok(), "ordinary Live-only persist failed: {ordinary:?}");
|
||||
let ordinary_storage = load_xtream_target_storage(&app_config, &target).await.expect("ordinary storage");
|
||||
assert_eq!(ordinary_storage.live.len(), 1);
|
||||
assert_eq!(ordinary_storage.vod.len(), 1, "ordinary refresh retains an absent cluster");
|
||||
|
||||
let mut empty_standard = Vec::new();
|
||||
let mut empty_xtream = Vec::new();
|
||||
let curated_empty = persist_playlist_views(
|
||||
&app_config,
|
||||
&mut empty_standard,
|
||||
Some(&mut empty_xtream),
|
||||
None,
|
||||
&target,
|
||||
None,
|
||||
curation_persist_options(PlaylistPublicationPlan::complete_curation(true, false)),
|
||||
)
|
||||
.await;
|
||||
assert!(curated_empty.is_ok(), "curated empty persist failed: {curated_empty:?}");
|
||||
|
||||
let storage = load_xtream_target_storage(&app_config, &target).await.expect("Xtream storage");
|
||||
assert_eq!(storage.live.len(), 1);
|
||||
assert!(storage.vod.is_empty());
|
||||
assert!(storage.series.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_server_playlist_file_path_uses_separate_prefix() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
|
||||
@@ -807,9 +807,9 @@ pub async fn write_strm_playlist(
|
||||
target: &ConfigTarget,
|
||||
target_output: &StrmTargetOutput,
|
||||
new_playlist: &mut [PlaylistGroup],
|
||||
library_empty: crate::LibraryEmptyPublication,
|
||||
allow_empty_publication: bool,
|
||||
) -> Result<(), TuliproxError> {
|
||||
if new_playlist.is_empty() && !library_empty.replaces_empty_target() {
|
||||
if new_playlist.is_empty() && !allow_empty_publication {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
|
||||
+6
-1
@@ -45,12 +45,16 @@ sources:
|
||||
# Set enabled to true after configuring api.api_key with your Trakt Client ID.
|
||||
# See the documentation for the TRAKT_CLIENT_ID environment-variable example.
|
||||
enabled: false
|
||||
# "full" preserves the entire catalog (default); "curated" keeps selected VOD/Series plus all Live.
|
||||
catalog_selection: curated
|
||||
include_xtream_base_categories: true
|
||||
api:
|
||||
api_key: ""
|
||||
lists:
|
||||
- user: "linaspurinis"
|
||||
list_slug: "top-watched-movies-of-the-week"
|
||||
category_name: "📈 Top Weekly Movies"
|
||||
create_xtream_category: true
|
||||
content_type: "vod"
|
||||
fuzzy_match_threshold: 80
|
||||
- user: "garycrawfordgc"
|
||||
@@ -60,7 +64,8 @@ sources:
|
||||
fuzzy_match_threshold: 75
|
||||
- user: "trakt"
|
||||
list_slug: "trending"
|
||||
category_name: "🔥 Trending"
|
||||
# Selection-only selectors contribute memberships without creating an Xtream alias category.
|
||||
create_xtream_category: false
|
||||
content_type: "both"
|
||||
fuzzy_match_threshold: 80
|
||||
- user: "mmozeiko"
|
||||
|
||||
@@ -105,15 +105,19 @@ Mapper blocks with `stage: after_epg`
|
||||
Collect all processed inputs for the target
|
||||
|
|
||||
v
|
||||
Favourites and Trakt categories
|
||||
Configured favourite aliases -> Merge groups -> Post-merge content deduplication
|
||||
|
|
||||
v
|
||||
Merge groups -> Sort -> Assign channel numbers -> Mapping counters -> Watches -> Persistence
|
||||
Evaluate required Trakt selectors -> Select catalog -> Derive base and Xtream appearances
|
||||
|
|
||||
v
|
||||
Sort -> Assign channel numbers -> Mapping counters -> Persist/output filters -> Persistence -> Watches
|
||||
```
|
||||
|
||||
`processing_order` controls only the order of filter (`F`), rename (`R`), and the default mapping position (`M`).
|
||||
`after_epg` is a separate stage outside that configurable pipe. Mapping counters are also outside the pipe and always
|
||||
run on the consolidated target playlist after merging, sorting, and initial channel-number assignment.
|
||||
`after_epg` is a separate stage outside that configurable pipe. Mapping counters are also outside the pipe and run on
|
||||
each finalized appearance after the target has merged and deduplicated the curation-eligible catalog, then sorted and
|
||||
assigned initial channel numbers. Trakt curation never changes which mapper stage runs.
|
||||
|
||||
The configured order and mapping-stage indexes are compiled when configuration is loaded. During a refresh, the F/R/M
|
||||
pipe materializes each input into one item buffer, preserves the historical grouping order between stages, and rebuilds
|
||||
|
||||
@@ -1392,8 +1392,9 @@ filter:
|
||||
persist: 'EpgId IS NOT EMPTY'
|
||||
```
|
||||
|
||||
`processing` runs at the normal `F` position. `persist` runs after EPG matching, smart matching, all mappings, merge,
|
||||
favourites/Trakt, deduplication, sorting, channel numbering, and counters, immediately before watch evaluation and target
|
||||
`processing` runs at the normal `F` position. The target then applies favourites, merges groups, performs post-merge
|
||||
content deduplication, evaluates any configured Trakt selectors, and derives the base and Xtream appearance views.
|
||||
`persist` runs after each view is sorted, numbered, and counted, immediately before output filters, watch evaluation, and
|
||||
persistence. Output-level filters remain plain strings and have no configurable stage.
|
||||
|
||||
You can define complex strings or regex patterns exactly once in [template.yml](./template.md)
|
||||
@@ -1797,6 +1798,8 @@ output:
|
||||
update_strategy: instant
|
||||
trakt:
|
||||
enabled: true
|
||||
catalog_selection: curated
|
||||
include_xtream_base_categories: true
|
||||
api:
|
||||
# Despite the compatible field name, this value is the Trakt Client ID.
|
||||
api_key: "${env:TRAKT_CLIENT_ID}"
|
||||
@@ -1807,6 +1810,7 @@ output:
|
||||
- user: "gary"
|
||||
list_slug: "latest-tv"
|
||||
category_name: "Trending TV"
|
||||
create_xtream_category: true
|
||||
content_type: series
|
||||
fuzzy_match_threshold: 80
|
||||
```
|
||||
@@ -1820,7 +1824,7 @@ output:
|
||||
| `skip_video_direct_source` | Bool | No | `true` | If `true`, Tuliprox ignores provider `direct_source` values for movies/VOD. This improves consistency across clients that otherwise may bypass Tuliprox for video playback. |
|
||||
| `skip_series_direct_source` | Bool | No | `true` | If `true`, Tuliprox ignores provider `direct_source` values for series entries. This ensures Tuliprox stays in control of series playback URL generation and proxy behavior. |
|
||||
| `update_strategy` | Enum | No | `instant` | `instant` writes changes immediately, while `bundled` batches write operations. This directly trades off freshness versus disk I/O load during background metadata enrichment and output maintenance. |
|
||||
| `trakt` | Object | No | | Trakt.tv integration block. Tuliprox can fetch Trakt lists, fuzzy-match them against playlist entries, and inject matched VOD or series entries into generated virtual categories. |
|
||||
| `trakt` | Object | No | | Trakt.tv target-wide catalog selection and Xtream category-projection block. |
|
||||
| `filter` | String | No | | Optional output-level filter for the Xtream export only. Useful when the same target should expose different subsets to different output formats. |
|
||||
|
||||
> **Note:** IPTV players vary in how they resolve streams: some use the direct-source attribute, while others
|
||||
@@ -1835,9 +1839,9 @@ output:
|
||||
|
||||
#### `trakt` Object in Xtream Output
|
||||
|
||||
Trakt.tv is an online platform for tracking, organizing, and discovering movies and TV shows.
|
||||
Tuliprox can query Trakt lists and match playlist entries using Jaro-Winkler-style fuzzy matching.
|
||||
Matching entries are then added to new virtual categories inside the Xtream output.
|
||||
Trakt.tv is an online platform for tracking, organizing, and discovering movies and TV shows. Tuliprox evaluates every
|
||||
configured list and chart against the target's merged, deduplicated VOD and series catalog. The resulting exact-entry
|
||||
memberships can select the target-wide catalog and independently create compatibility alias categories in Xtream.
|
||||
|
||||
You can define a `Trakt` config like
|
||||
|
||||
@@ -1862,6 +1866,8 @@ sources:
|
||||
skip_series_direct_source: true
|
||||
trakt:
|
||||
enabled: true
|
||||
catalog_selection: curated
|
||||
include_xtream_base_categories: true
|
||||
api:
|
||||
# Despite the compatible field name, this value is the Trakt Client ID.
|
||||
api_key: "${env:TRAKT_CLIENT_ID}"
|
||||
@@ -1872,17 +1878,19 @@ sources:
|
||||
- user: "linaspurinis"
|
||||
list_slug: "top-watched-movies-of-the-week"
|
||||
category_name: "📈 Top Weekly Movies"
|
||||
create_xtream_category: true
|
||||
content_type: vod
|
||||
fuzzy_match_threshold: 80
|
||||
- user: "garycrawfordgc"
|
||||
list_slug: "latest-tv-shows"
|
||||
category_name: "📺 Latest TV Shows"
|
||||
create_xtream_category: false
|
||||
content_type: series
|
||||
fuzzy_match_threshold: 80
|
||||
charts:
|
||||
- kind: movies
|
||||
chart: trending
|
||||
category_name: "🔥 Trending Movies"
|
||||
create_xtream_category: true
|
||||
tmdb_only: true
|
||||
- kind: shows
|
||||
chart: popular
|
||||
@@ -1890,9 +1898,10 @@ sources:
|
||||
tmdb_only: true
|
||||
```
|
||||
|
||||
This configuration creates additional virtual categories populated with matched entries from the configured Trakt user
|
||||
lists and public Trakt charts. Define `TRAKT_CLIENT_ID` in the environment of the Tuliprox process before enabling the
|
||||
block.
|
||||
This example selects one catalog for the target: all Live entries plus VOD/series roots selected by at least one Trakt
|
||||
selector, including the selected series' episode closure. The first list and both charts also create named Xtream alias
|
||||
categories. The second list is selection-only and therefore needs no `category_name`. M3U and STRM outputs on the same
|
||||
target receive selected normal entries, never those Xtream aliases.
|
||||
|
||||
The serialized field remains `api.api_key` for configuration compatibility, but its value is the Client ID of your
|
||||
Trakt API application and is sent in the `trakt-api-key` header. Tuliprox does not bundle a Client ID and never falls
|
||||
@@ -1900,31 +1909,46 @@ back to another identity. Creating Trakt API applications currently requires act
|
||||
response only means that Trakt denied the request; check both the configured Client ID and access to the requested
|
||||
resource rather than assuming that every `403` proves a particular account state.
|
||||
|
||||
If lists or charts are configured while the Client ID is blank or cannot be used as an HTTP header, Tuliprox makes no
|
||||
Trakt request, logs one target-scoped warning, and skips only optional Trakt curation. The rest of target processing
|
||||
continues. A disabled block, or a block with no lists or charts, remains a silent no-op.
|
||||
Every enabled list and chart is required for one refresh. A missing or invalid Client ID makes no request; credential,
|
||||
transport, status, JSON, interrupted-pagination, or truncated-pagination failures stop that target before IDs, files,
|
||||
caches, or watches are changed. A complete response with zero references, or with no local matches, remains successful.
|
||||
With `catalog_selection: curated`, that complete result clears VOD/Series while preserving Live. A disabled block, or a
|
||||
source-less block whose policies all use their defaults, remains a no-op. Input quality/force decisions are resolved
|
||||
before this boundary: curation cannot make an unauthorized empty input publishable, and even force-authorized emptiness
|
||||
waits for every required selector to complete. After a complete curation result enters publication, independent writer
|
||||
failures retain the existing best-effort cross-output behavior and are reported as writer failures rather than as Trakt
|
||||
source failures.
|
||||
|
||||
##### Trakt Parameters
|
||||
|
||||
| Parameter | Type | Required | Default | Technical Impact & Background |
|
||||
| :------------------------------- | :------ | :------: | :--------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `enabled` | Bool | No | `true` | Enables Trakt curation. Keep it `false` until an explicit Client ID is configured. |
|
||||
| `api.api_key` | String | Yes | | Compatible field that stores the Trakt Client ID. There is no bundled fallback; use an explicit value such as `${env:TRAKT_CLIENT_ID}`. |
|
||||
| `api.version` | String | No | `"2"` | API version header value. This ensures Tuliprox formats requests against the correct Trakt API version. |
|
||||
| `api.url` | String | No | `https://api.trakt.tv` | Base API URL for Trakt requests. This defines the remote endpoint Tuliprox queries for list data. |
|
||||
| `api.user_agent` | String | No | | Optional `User-Agent` used for Trakt API requests. This can help satisfy API gateway expectations or deployment-specific request policies. |
|
||||
| `lists[].user` | String | Yes | | Trakt username owning the list. This identifies which account namespace Tuliprox fetches list data from. |
|
||||
| `lists[].list_slug` | String | Yes | | Trakt list slug. Combined with `user`, this uniquely identifies the remote list to load. |
|
||||
| `lists[].category_name` | String | Yes | | Name of the generated virtual category inside Tuliprox's Xtream output. This controls where matched entries appear to clients. |
|
||||
| `lists[].content_type` | Enum | Yes | | `vod` or `series`. This determines which class of playlist entries Tuliprox will attempt to match and inject into the generated category. |
|
||||
| `lists[].tmdb_only` | Bool | No | `false` | If `true`, only exact TMDB-id matches are accepted for this list, disabling title/year fuzzy fallback and reducing false positives. |
|
||||
| `lists[].fuzzy_match_threshold` | Integer | No | | Fuzzy matching threshold for title matching. Higher values reduce false positives but may miss loosely matching items. |
|
||||
| `charts[]` | List | No | `[]` | Public Trakt chart definitions. Unlike `lists[]`, these are system charts and do not have a user/list owner. |
|
||||
| `charts[].kind` | Enum | Yes | | `movies` or `shows`. Aliases such as `movie`, `vod`, `show`, `series`, and `tvshows` are accepted. |
|
||||
| `charts[].chart` | Enum | Yes | | Public chart to fetch. MVP supports `trending` and `popular`. |
|
||||
| `charts[].category_name` | String | Yes | | Name of the generated virtual category inside Tuliprox's Xtream output. |
|
||||
| `charts[].tmdb_only` | Bool | No | `false` | If `true`, only exact TMDB-id matches are accepted. This is recommended for dynamic charts to avoid fuzzy false positives. |
|
||||
| `charts[].fuzzy_match_threshold` | Integer | No | | Fuzzy matching threshold for chart title matching when `tmdb_only` is not enabled. |
|
||||
| Parameter | Type | Required | Default | Technical Impact & Background |
|
||||
| :-------------------------------- | :------ | :---------: | :--------------------- | :-------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `enabled` | Bool | No | `true` | Enables Trakt curation. Disabled blocks may retain incomplete editor values without running selectors. |
|
||||
| `catalog_selection` | Enum | No | `full` | `full` keeps the complete eligible target catalog; `curated` keeps all Live plus selected VOD/Series and series children. |
|
||||
| `include_xtream_base_categories` | Bool | No | `true` | Includes normal VOD/Series categories in Xtream independently of catalog selection and alias-category creation. |
|
||||
| `api.api_key` | String | Yes | | Compatible field containing the explicit Trakt Client ID, for example `${env:TRAKT_CLIENT_ID}`. |
|
||||
| `api.version` | String | No | `"2"` | API version header value. |
|
||||
| `api.url` | String | No | `https://api.trakt.tv` | Base API URL for Trakt requests. |
|
||||
| `api.user_agent` | String | No | | Optional `User-Agent` for Trakt requests. |
|
||||
| `lists[].user` | String | Yes | | Trakt username owning the list. |
|
||||
| `lists[].list_slug` | String | Yes | | Trakt list slug within the user's namespace. |
|
||||
| `lists[].create_xtream_category` | Bool | No | `true` | Creates a category-scoped Xtream alias projection. `false` keeps the list as a required selection-only selector. |
|
||||
| `lists[].category_name` | String | Conditional | | Required and non-blank only when `create_xtream_category` is `true`; a saved value is preserved while creation is disabled. |
|
||||
| `lists[].content_type` | Enum | Yes | | `vod`, `series`, or `both`; direct matching uses VOD/series roots, never episode titles. |
|
||||
| `lists[].tmdb_only` | Bool | No | `false` | Accepts only exact TMDB-id matches, disabling title/year fuzzy fallback. |
|
||||
| `lists[].fuzzy_match_threshold` | Integer | No | | Fuzzy title-match threshold when `tmdb_only` is not enabled. |
|
||||
| `charts[]` | List | No | `[]` | Public, non-OAuth Trakt chart definitions. |
|
||||
| `charts[].kind` | Enum | Yes | | `movies` or `shows`; the documented singular/VOD/series aliases remain accepted. |
|
||||
| `charts[].chart` | Enum | Yes | | Public chart; currently `trending` or `popular`. |
|
||||
| `charts[].create_xtream_category` | Bool | No | `true` | Creates the chart's named Xtream alias; `false` leaves it as a required selection-only selector. |
|
||||
| `charts[].category_name` | String | Conditional | | Required and non-blank only when `create_xtream_category` is `true`. |
|
||||
| `charts[].tmdb_only` | Bool | No | `false` | Accepts only exact TMDB-id matches. |
|
||||
| `charts[].fuzzy_match_threshold` | Integer | No | | Fuzzy title-match threshold when `tmdb_only` is not enabled. |
|
||||
|
||||
Existing configurations omit all three new policy controls and therefore retain Case A behavior: `full` catalog,
|
||||
Xtream base categories, and one category for every configured selector. Filters at `filter.persist` and inside output
|
||||
blocks run on their concrete post-projection appearance; they can hide an appearance but cannot reintroduce a subject
|
||||
that was absent from the eligible catalog.
|
||||
|
||||
The `charts[]` MVP intentionally supports only public, non-OAuth Trakt charts. User-specific recommendations and
|
||||
account-scoped history feeds are not fetched by this block.
|
||||
|
||||
@@ -1612,6 +1612,12 @@
|
||||
"TOKEN": "رمز",
|
||||
"TOKEN_TTL_MINS": "TTL الرمز (دقائق)",
|
||||
"TRAKT": "Trakt",
|
||||
"TRAKT_CATALOG_SELECTION": "كتالوج VOD والمسلسلات",
|
||||
"TRAKT_CATALOG_SELECTION_FULL": "الكتالوج الكامل",
|
||||
"TRAKT_CATALOG_SELECTION_CURATED": "الكتالوج المنسق",
|
||||
"TRAKT_INCLUDE_XTREAM_BASE_CATEGORIES": "تضمين فئات Xtream الأساسية",
|
||||
"TRAKT_CREATE_XTREAM_CATEGORY": "إنشاء فئة Xtream",
|
||||
"TRAKT_SELECTION_ONLY": "اختيار فقط",
|
||||
"TRAKT_CATEGORY_NAME": "اسم الفئة",
|
||||
"TRAKT_CHARTS": "الرسوم البيانية",
|
||||
"TRAKT_CHART_KIND": "نوع الرسم البياني",
|
||||
|
||||
@@ -1696,6 +1696,12 @@
|
||||
"TOKEN": "Token",
|
||||
"TOKEN_TTL_MINS": "Token ttl (mins)",
|
||||
"TRAKT": "Trakt",
|
||||
"TRAKT_CATALOG_SELECTION": "VOD and series catalog",
|
||||
"TRAKT_CATALOG_SELECTION_FULL": "Full catalog",
|
||||
"TRAKT_CATALOG_SELECTION_CURATED": "Curated catalog",
|
||||
"TRAKT_INCLUDE_XTREAM_BASE_CATEGORIES": "Include Xtream base categories",
|
||||
"TRAKT_CREATE_XTREAM_CATEGORY": "Create Xtream category",
|
||||
"TRAKT_SELECTION_ONLY": "Selection only",
|
||||
"TRAKT_CATEGORY_NAME": "Category name",
|
||||
"TRAKT_CHARTS": "Charts",
|
||||
"TRAKT_CHART_KIND": "Chart kind",
|
||||
|
||||
@@ -1520,6 +1520,12 @@
|
||||
"TOKEN": "Токен",
|
||||
"TOKEN_TTL_MINS": "TTL токена (мин)",
|
||||
"TRAKT": "Trakt",
|
||||
"TRAKT_CATALOG_SELECTION": "Каталог VOD и сериалов",
|
||||
"TRAKT_CATALOG_SELECTION_FULL": "Полный каталог",
|
||||
"TRAKT_CATALOG_SELECTION_CURATED": "Курируемый каталог",
|
||||
"TRAKT_INCLUDE_XTREAM_BASE_CATEGORIES": "Включать базовые категории Xtream",
|
||||
"TRAKT_CREATE_XTREAM_CATEGORY": "Создавать категорию Xtream",
|
||||
"TRAKT_SELECTION_ONLY": "Только отбор",
|
||||
"TRAKT_CATEGORY_NAME": "Имя категории",
|
||||
"TRAKT_FUZZY_MATCH_THRESHOLD": "Порог нечеткого сопоставления",
|
||||
"TRAKT_LISTS": "Списки",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use crate::{
|
||||
app::components::{
|
||||
config::HasFormData, BlockId, BlockInstance, Card, EditMode, FilterInput, IconButton, Panel,
|
||||
SourceEditorContext, TextButton, TitledCard, TraktChartItemForm, TraktListItemForm,
|
||||
build_options, config::HasFormData, select::Select, selection_parse_first, BlockId, BlockInstance, Card,
|
||||
DropDownSelection, EditMode, FilterInput, IconButton, Panel, SourceEditorContext, TextButton, TitledCard,
|
||||
TraktChartItemForm, TraktListItemForm,
|
||||
},
|
||||
config_field, config_field_bool, config_field_child, config_field_custom, edit_field_bool, edit_field_text,
|
||||
generate_form_reducer,
|
||||
@@ -11,15 +12,16 @@ use shared::{
|
||||
concat_string,
|
||||
error::TuliproxError,
|
||||
model::{
|
||||
TargetOutputDto, TraktApiConfigDto, TraktChartConfigDto, TraktConfigDto, TraktListConfigDto,
|
||||
XtreamTargetOutputDto,
|
||||
TargetOutputDto, TraktApiConfigDto, TraktCatalogSelection, TraktChartConfigDto, TraktConfigDto,
|
||||
TraktListConfigDto, XtreamTargetOutputDto,
|
||||
},
|
||||
utils::Internable,
|
||||
};
|
||||
use std::{fmt::Display, rc::Rc, str::FromStr, sync::Arc};
|
||||
use web_sys::MouseEvent;
|
||||
use yew::{
|
||||
component, html, use_context, use_effect_with, use_reducer, use_state, Callback, Html, Properties, UseReducerHandle,
|
||||
component, html, use_context, use_effect_with, use_memo, use_reducer, use_state, Callback, Html, Properties,
|
||||
UseReducerHandle,
|
||||
};
|
||||
|
||||
const LABEL_SKIP_DIRECT_SOURCE: &str = "LABEL.SKIP_DIRECT_SOURCE";
|
||||
@@ -39,6 +41,11 @@ const LABEL_USER_AGENT: &str = "LABEL.API_USER_AGENT";
|
||||
const LABEL_MAIN: &str = "LABEL.MAIN_CONFIG";
|
||||
const LABEL_TRAKT: &str = "LABEL.TRAKT";
|
||||
const LABEL_ENABLED: &str = "LABEL.ENABLED";
|
||||
const LABEL_TRAKT_CATALOG_SELECTION: &str = "LABEL.TRAKT_CATALOG_SELECTION";
|
||||
const LABEL_TRAKT_CATALOG_SELECTION_FULL: &str = "LABEL.TRAKT_CATALOG_SELECTION_FULL";
|
||||
const LABEL_TRAKT_CATALOG_SELECTION_CURATED: &str = "LABEL.TRAKT_CATALOG_SELECTION_CURATED";
|
||||
const LABEL_TRAKT_INCLUDE_XTREAM_BASE_CATEGORIES: &str = "LABEL.TRAKT_INCLUDE_XTREAM_BASE_CATEGORIES";
|
||||
const LABEL_TRAKT_SELECTION_ONLY: &str = "LABEL.TRAKT_SELECTION_ONLY";
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq)]
|
||||
enum XtreamOutputFormPage {
|
||||
@@ -91,6 +98,8 @@ generate_form_reducer!(
|
||||
action_name: TraktConfigFormAction,
|
||||
fields {
|
||||
Enabled => enabled: bool,
|
||||
CatalogSelection => catalog_selection: TraktCatalogSelection,
|
||||
IncludeXtreamBaseCategories => include_xtream_base_categories: bool,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -126,14 +135,24 @@ pub struct XtreamTargetOutputViewProps {
|
||||
|
||||
fn build_trakt_output_config(
|
||||
enabled: bool,
|
||||
catalog_selection: TraktCatalogSelection,
|
||||
include_xtream_base_categories: bool,
|
||||
api: TraktApiConfigDto,
|
||||
lists: Vec<TraktListConfigDto>,
|
||||
charts: Vec<TraktChartConfigDto>,
|
||||
) -> Option<TraktConfigDto> {
|
||||
if lists.is_empty() && charts.is_empty() {
|
||||
let candidate = TraktConfigDto { enabled, catalog_selection, include_xtream_base_categories, api, lists, charts };
|
||||
if candidate.is_source_less_noop() {
|
||||
None
|
||||
} else {
|
||||
Some(TraktConfigDto { enabled, api, lists, charts })
|
||||
Some(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
fn trakt_catalog_selection_label_key(selection: TraktCatalogSelection) -> &'static str {
|
||||
match selection {
|
||||
TraktCatalogSelection::Full => LABEL_TRAKT_CATALOG_SELECTION_FULL,
|
||||
TraktCatalogSelection::Curated => LABEL_TRAKT_CATALOG_SELECTION_CURATED,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,19 +163,29 @@ fn append_trakt_matching_summary_suffix(mut summary: String, tmdb_only: bool) ->
|
||||
summary
|
||||
}
|
||||
|
||||
fn trakt_list_summary(item: &TraktListConfigDto) -> String {
|
||||
fn trakt_list_summary(item: &TraktListConfigDto, selection_only: &str) -> String {
|
||||
append_trakt_matching_summary_suffix(
|
||||
format!(
|
||||
"{} / {} - {} ({}, {}%)",
|
||||
item.user, item.list_slug, item.category_name, item.content_type, item.fuzzy_match_threshold
|
||||
item.user,
|
||||
item.list_slug,
|
||||
if item.create_xtream_category { item.category_name.as_deref().unwrap_or("-") } else { selection_only },
|
||||
item.content_type,
|
||||
item.fuzzy_match_threshold
|
||||
),
|
||||
item.tmdb_only,
|
||||
)
|
||||
}
|
||||
|
||||
fn trakt_chart_summary(item: &TraktChartConfigDto) -> String {
|
||||
fn trakt_chart_summary(item: &TraktChartConfigDto, selection_only: &str) -> String {
|
||||
append_trakt_matching_summary_suffix(
|
||||
format!("{}/{} - {} ({}%)", item.kind, item.chart, item.category_name, item.fuzzy_match_threshold),
|
||||
format!(
|
||||
"{}/{} - {} ({}%)",
|
||||
item.kind,
|
||||
item.chart,
|
||||
if item.create_xtream_category { item.category_name.as_deref().unwrap_or("-") } else { selection_only },
|
||||
item.fuzzy_match_threshold
|
||||
),
|
||||
item.tmdb_only,
|
||||
)
|
||||
}
|
||||
@@ -193,6 +222,16 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html {
|
||||
|
||||
let trakt_lists_state = use_state(Vec::<TraktListConfigDto>::new);
|
||||
let trakt_charts_state = use_state(Vec::<TraktChartConfigDto>::new);
|
||||
let catalog_selection_options = use_memo(trakt_state.form.catalog_selection, {
|
||||
let translate = translate.clone();
|
||||
move |selection| {
|
||||
build_options(
|
||||
[TraktCatalogSelection::Full, TraktCatalogSelection::Curated],
|
||||
selection,
|
||||
|value| html! { translate.t(trakt_catalog_selection_label_key(*value)) },
|
||||
)
|
||||
}
|
||||
});
|
||||
let show_trakt_list_form_state = use_state(|| false);
|
||||
let show_trakt_chart_form_state = use_state(|| false);
|
||||
let editing_trakt_list_index_state = use_state(|| None::<usize>);
|
||||
@@ -431,6 +470,7 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html {
|
||||
on_cancel={handle_close_trakt_list_form}
|
||||
initial={initial_trakt_list}
|
||||
readonly={!props.allow_write}
|
||||
validate_category_name={trakt_form.form.enabled}
|
||||
/>
|
||||
} else if *show_trakt_chart_form {
|
||||
<TraktChartItemForm
|
||||
@@ -438,12 +478,42 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html {
|
||||
on_cancel={handle_close_trakt_chart_form}
|
||||
initial={initial_trakt_chart}
|
||||
readonly={!props.allow_write}
|
||||
validate_category_name={trakt_form.form.enabled}
|
||||
/>
|
||||
} else {
|
||||
{ if props.allow_write {
|
||||
html! { { edit_field_bool!(trakt_form, translate.t(LABEL_ENABLED), enabled, TraktConfigFormAction::Enabled) } }
|
||||
html! {
|
||||
<>
|
||||
{ edit_field_bool!(trakt_form, translate.t(LABEL_ENABLED), enabled, TraktConfigFormAction::Enabled) }
|
||||
{ config_field_child!(translate.t(LABEL_TRAKT_CATALOG_SELECTION), "OUTPUT_XTREAM_FORM.TRAKT_CATALOG_SELECTION", {
|
||||
let selection_form = trakt_form.clone();
|
||||
html! {
|
||||
<Select
|
||||
name={"trakt_catalog_selection"}
|
||||
multi_select={false}
|
||||
on_select={Callback::from(move |(_, selections):(String, DropDownSelection)| {
|
||||
if let Some(selection) = selection_parse_first::<TraktCatalogSelection>(&selections) {
|
||||
selection_form.dispatch(TraktConfigFormAction::CatalogSelection(selection));
|
||||
}
|
||||
})}
|
||||
options={catalog_selection_options.clone()}
|
||||
/>
|
||||
}
|
||||
})}
|
||||
{ edit_field_bool!(trakt_form, translate.t(LABEL_TRAKT_INCLUDE_XTREAM_BASE_CATEGORIES), include_xtream_base_categories, TraktConfigFormAction::IncludeXtreamBaseCategories) }
|
||||
</>
|
||||
}
|
||||
} else {
|
||||
html! { { config_field_bool!(trakt_form.form, translate.t(LABEL_ENABLED), enabled) } }
|
||||
html! {
|
||||
<>
|
||||
{ config_field_bool!(trakt_form.form, translate.t(LABEL_ENABLED), enabled) }
|
||||
{ config_field_custom!(
|
||||
translate.t(LABEL_TRAKT_CATALOG_SELECTION),
|
||||
translate.t(trakt_catalog_selection_label_key(trakt_form.form.catalog_selection))
|
||||
) }
|
||||
{ config_field_bool!(trakt_form.form, translate.t(LABEL_TRAKT_INCLUDE_XTREAM_BASE_CATEGORIES), include_xtream_base_categories) }
|
||||
</>
|
||||
}
|
||||
}}
|
||||
<div class="tp__form-section">
|
||||
<h3>{translate.t(LABEL_API_CONFIGURATION)}</h3>
|
||||
@@ -486,7 +556,7 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html {
|
||||
</div>
|
||||
}
|
||||
<div class="tp__form-list__item-content">
|
||||
<span>{trakt_list_summary(item.1)}</span>
|
||||
<span>{trakt_list_summary(item.1, &translate.t(LABEL_TRAKT_SELECTION_ONLY))}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -525,7 +595,7 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html {
|
||||
</div>
|
||||
}
|
||||
<div class="tp__form-list__item-content">
|
||||
<span>{trakt_chart_summary(item.1)}</span>
|
||||
<span>{trakt_chart_summary(item.1, &translate.t(LABEL_TRAKT_SELECTION_ONLY))}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -599,6 +669,8 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html {
|
||||
let trakt_charts = (*trakt_charts_state).clone();
|
||||
output.trakt = build_trakt_output_config(
|
||||
trakt_state.data().enabled,
|
||||
trakt_state.data().catalog_selection,
|
||||
trakt_state.data().include_xtream_base_categories,
|
||||
trakt_api_state.data().clone(),
|
||||
trakt_lists,
|
||||
trakt_charts,
|
||||
@@ -645,9 +717,120 @@ mod tests {
|
||||
use shared::model::{TraktChartKind, TraktChartType};
|
||||
|
||||
#[test]
|
||||
fn build_trakt_output_config_returns_none_when_empty() {
|
||||
let result = build_trakt_output_config(true, TraktApiConfigDto::default(), Vec::new(), Vec::new());
|
||||
assert!(result.is_none());
|
||||
fn build_trakt_output_config_returns_none_for_source_less_default_policy() {
|
||||
let api = TraktApiConfigDto {
|
||||
api_key: "draft-client-id".to_string(),
|
||||
version: "draft-version".to_string(),
|
||||
url: "draft-url".to_string(),
|
||||
user_agent: "draft-agent".to_string(),
|
||||
};
|
||||
|
||||
for enabled in [true, false] {
|
||||
let result = build_trakt_output_config(
|
||||
enabled,
|
||||
TraktCatalogSelection::Full,
|
||||
true,
|
||||
api.clone(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_trakt_output_config_preserves_source_less_curated_policy() {
|
||||
let api = TraktApiConfigDto {
|
||||
api_key: "draft-client-id".to_string(),
|
||||
version: "draft-version".to_string(),
|
||||
url: "draft-url".to_string(),
|
||||
user_agent: "draft-agent".to_string(),
|
||||
};
|
||||
|
||||
let result =
|
||||
build_trakt_output_config(true, TraktCatalogSelection::Curated, true, api.clone(), Vec::new(), Vec::new())
|
||||
.expect("source-less curated policy should be preserved");
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
TraktConfigDto {
|
||||
enabled: true,
|
||||
catalog_selection: TraktCatalogSelection::Curated,
|
||||
include_xtream_base_categories: true,
|
||||
api,
|
||||
lists: Vec::new(),
|
||||
charts: Vec::new(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_trakt_output_config_preserves_source_less_base_category_policy() {
|
||||
let result = build_trakt_output_config(
|
||||
true,
|
||||
TraktCatalogSelection::Full,
|
||||
false,
|
||||
TraktApiConfigDto::default(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
)
|
||||
.expect("source-less base-category policy should be preserved");
|
||||
|
||||
assert!(!result.include_xtream_base_categories);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserved_enabled_source_less_trakt_policy_reaches_preparation() {
|
||||
let mut result = build_trakt_output_config(
|
||||
true,
|
||||
TraktCatalogSelection::Curated,
|
||||
true,
|
||||
TraktApiConfigDto::default(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
)
|
||||
.expect("source-less curated policy should be preserved");
|
||||
|
||||
assert!(result.prepare().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserved_disabled_source_less_trakt_policy_prepares_successfully() {
|
||||
let mut result = build_trakt_output_config(
|
||||
false,
|
||||
TraktCatalogSelection::Curated,
|
||||
true,
|
||||
TraktApiConfigDto::default(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
)
|
||||
.expect("disabled source-less policy should be preserved");
|
||||
|
||||
assert!(result.prepare().is_ok());
|
||||
assert_eq!(result.catalog_selection, TraktCatalogSelection::Curated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_trakt_output_config_keeps_lists_without_charts() {
|
||||
let lists = vec![TraktListConfigDto {
|
||||
user: "alice".to_string(),
|
||||
list_slug: "watchlist".to_string(),
|
||||
create_xtream_category: false,
|
||||
..TraktListConfigDto::default()
|
||||
}];
|
||||
|
||||
let result = build_trakt_output_config(
|
||||
true,
|
||||
TraktCatalogSelection::Full,
|
||||
true,
|
||||
TraktApiConfigDto::default(),
|
||||
lists.clone(),
|
||||
Vec::new(),
|
||||
)
|
||||
.expect("lists-only trakt config");
|
||||
|
||||
assert_eq!(result.lists, lists);
|
||||
assert!(result.charts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -655,18 +838,46 @@ mod tests {
|
||||
let charts = vec![TraktChartConfigDto {
|
||||
kind: TraktChartKind::Movies,
|
||||
chart: TraktChartType::Trending,
|
||||
category_name: "Trending Movies".to_string(),
|
||||
category_name: Some("Trending Movies".to_string()),
|
||||
create_xtream_category: false,
|
||||
tmdb_only: true,
|
||||
fuzzy_match_threshold: 90,
|
||||
}];
|
||||
|
||||
let result = build_trakt_output_config(true, TraktApiConfigDto::default(), Vec::new(), charts.clone())
|
||||
.expect("charts-only trakt config");
|
||||
let result = build_trakt_output_config(
|
||||
true,
|
||||
TraktCatalogSelection::Curated,
|
||||
false,
|
||||
TraktApiConfigDto::default(),
|
||||
Vec::new(),
|
||||
charts.clone(),
|
||||
)
|
||||
.expect("charts-only trakt config");
|
||||
|
||||
assert_eq!(result.catalog_selection, TraktCatalogSelection::Curated);
|
||||
assert!(!result.include_xtream_base_categories);
|
||||
assert!(result.lists.is_empty());
|
||||
assert_eq!(result.charts, charts);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_summary_and_defaults_expose_independent_policy() {
|
||||
let chart = TraktChartConfigDto {
|
||||
category_name: Some("Preserved category".to_string()),
|
||||
create_xtream_category: false,
|
||||
..TraktChartConfigDto::default()
|
||||
};
|
||||
|
||||
let summary = trakt_chart_summary(&chart, "Selection only");
|
||||
assert!(summary.contains("Selection only"));
|
||||
assert!(!summary.contains("Preserved category"));
|
||||
|
||||
let defaults = TraktConfigDto::default();
|
||||
assert_eq!(defaults.catalog_selection, TraktCatalogSelection::Full);
|
||||
assert!(defaults.include_xtream_base_categories);
|
||||
assert!(TraktChartConfigDto::default().create_xtream_category);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_vec_item_replaces_existing_entry() {
|
||||
let mut items = vec!["a".to_string(), "b".to_string()];
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
use crate::{
|
||||
app::components::{build_options, select::Select, selection_parse_first, Card, DropDownSelection, TextButton},
|
||||
config_field_bool, config_field_child, config_field_custom, edit_field_bool, edit_field_number_u8, edit_field_text,
|
||||
generate_form_reducer,
|
||||
config_field_bool, config_field_child, config_field_custom, config_field_optional, edit_field_bool,
|
||||
edit_field_number_u8, edit_field_text_option, generate_form_reducer,
|
||||
i18n::use_translation,
|
||||
};
|
||||
use shared::model::{TraktChartConfigDto, TraktChartKind, TraktChartType};
|
||||
use yew::{component, html, use_memo, use_reducer, Callback, Html, Properties, UseReducerHandle};
|
||||
|
||||
const LABEL_TRAKT_CATEGORY_NAME: &str = "LABEL.TRAKT_CATEGORY_NAME";
|
||||
const LABEL_TRAKT_CREATE_XTREAM_CATEGORY: &str = "LABEL.TRAKT_CREATE_XTREAM_CATEGORY";
|
||||
const LABEL_TRAKT_TMDB_ONLY: &str = "LABEL.TRAKT_TMDB_ONLY";
|
||||
const LABEL_TRAKT_FUZZY_MATCH_THRESHOLD: &str = "LABEL.TRAKT_FUZZY_MATCH_THRESHOLD";
|
||||
const LABEL_TRAKT_CHART_KIND: &str = "LABEL.TRAKT_CHART_KIND";
|
||||
@@ -37,7 +38,8 @@ generate_form_reducer!(
|
||||
fields {
|
||||
Kind => kind: TraktChartKind,
|
||||
Chart => chart: TraktChartType,
|
||||
CategoryName => category_name: String,
|
||||
CategoryName => category_name: Option<String>,
|
||||
CreateXtreamCategory => create_xtream_category: bool,
|
||||
TmdbOnly => tmdb_only: bool,
|
||||
FuzzyMatchThreshold => fuzzy_match_threshold: u8,
|
||||
}
|
||||
@@ -51,6 +53,14 @@ pub struct TraktChartItemFormProps {
|
||||
pub initial: Option<TraktChartConfigDto>,
|
||||
#[prop_or(false)]
|
||||
pub readonly: bool,
|
||||
#[prop_or(true)]
|
||||
pub validate_category_name: bool,
|
||||
}
|
||||
|
||||
fn can_submit_chart(data: &TraktChartConfigDto, validate_category_name: bool) -> bool {
|
||||
!validate_category_name
|
||||
|| !data.create_xtream_category
|
||||
|| data.category_name.as_deref().is_some_and(|name| !name.trim().is_empty())
|
||||
}
|
||||
|
||||
#[component]
|
||||
@@ -80,9 +90,10 @@ pub fn TraktChartItemForm(props: &TraktChartItemFormProps) -> Html {
|
||||
let handle_submit = {
|
||||
let form_state = form_state.clone();
|
||||
let on_submit = props.on_submit.clone();
|
||||
let validate_category_name = props.validate_category_name;
|
||||
Callback::from(move |_| {
|
||||
let data = form_state.form.clone();
|
||||
if !data.category_name.trim().is_empty() {
|
||||
if can_submit_chart(&data, validate_category_name) {
|
||||
on_submit.emit(data);
|
||||
}
|
||||
})
|
||||
@@ -97,7 +108,10 @@ pub fn TraktChartItemForm(props: &TraktChartItemFormProps) -> Html {
|
||||
if props.readonly {
|
||||
{ config_field_custom!(translate.t(LABEL_TRAKT_CHART_KIND), translate.t(trakt_chart_kind_label_key(form_state.form.kind))) }
|
||||
{ config_field_custom!(translate.t(LABEL_TRAKT_CHART_TYPE), translate.t(trakt_chart_type_label_key(form_state.form.chart))) }
|
||||
{ config_field_custom!(translate.t(LABEL_TRAKT_CATEGORY_NAME), form_state.form.category_name.clone()) }
|
||||
{ config_field_bool!(form_state.form, translate.t(LABEL_TRAKT_CREATE_XTREAM_CATEGORY), create_xtream_category) }
|
||||
if form_state.form.create_xtream_category {
|
||||
{ config_field_optional!(form_state.form, translate.t(LABEL_TRAKT_CATEGORY_NAME), category_name) }
|
||||
}
|
||||
{ config_field_bool!(form_state.form, translate.t(LABEL_TRAKT_TMDB_ONLY), tmdb_only) }
|
||||
{ config_field_custom!(translate.t(LABEL_TRAKT_FUZZY_MATCH_THRESHOLD), form_state.form.fuzzy_match_threshold.to_string()) }
|
||||
} else {
|
||||
@@ -131,7 +145,10 @@ pub fn TraktChartItemForm(props: &TraktChartItemFormProps) -> Html {
|
||||
/>
|
||||
}
|
||||
})}
|
||||
{ edit_field_text!(form_state, translate.t(LABEL_TRAKT_CATEGORY_NAME), category_name, TraktChartFormAction::CategoryName) }
|
||||
{ edit_field_bool!(form_state, translate.t(LABEL_TRAKT_CREATE_XTREAM_CATEGORY), create_xtream_category, TraktChartFormAction::CreateXtreamCategory) }
|
||||
if form_state.form.create_xtream_category {
|
||||
{ edit_field_text_option!(form_state, translate.t(LABEL_TRAKT_CATEGORY_NAME), category_name, TraktChartFormAction::CategoryName) }
|
||||
}
|
||||
{ edit_field_bool!(form_state, translate.t(LABEL_TRAKT_TMDB_ONLY), tmdb_only, TraktChartFormAction::TmdbOnly) }
|
||||
{ edit_field_number_u8!(form_state, translate.t(LABEL_TRAKT_FUZZY_MATCH_THRESHOLD), fuzzy_match_threshold, TraktChartFormAction::FuzzyMatchThreshold) }
|
||||
}
|
||||
@@ -157,3 +174,24 @@ pub fn TraktChartItemForm(props: &TraktChartItemFormProps) -> Html {
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn chart_submit_validation_matches_conditional_category_contract() {
|
||||
let mut chart = TraktChartConfigDto::default();
|
||||
|
||||
assert!(chart.create_xtream_category);
|
||||
assert!(!can_submit_chart(&chart, true));
|
||||
chart.category_name = Some("Trending".to_string());
|
||||
assert!(can_submit_chart(&chart, true));
|
||||
chart.category_name = None;
|
||||
chart.create_xtream_category = false;
|
||||
assert!(can_submit_chart(&chart, true));
|
||||
|
||||
chart.create_xtream_category = true;
|
||||
assert!(can_submit_chart(&chart, false), "disabled Trakt keeps incomplete edits");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::{
|
||||
app::components::{build_options, select::Select, selection_parse_first, Card, DropDownSelection, TextButton},
|
||||
config_field, config_field_bool, config_field_child, config_field_custom, edit_field_bool, edit_field_number_u8,
|
||||
edit_field_text, generate_form_reducer,
|
||||
config_field, config_field_bool, config_field_child, config_field_custom, config_field_optional, edit_field_bool,
|
||||
edit_field_number_u8, edit_field_text, edit_field_text_option, generate_form_reducer,
|
||||
i18n::use_translation,
|
||||
};
|
||||
use shared::model::{TraktContentType, TraktListConfigDto};
|
||||
@@ -10,6 +10,7 @@ use yew::{component, html, use_memo, use_reducer, Callback, Html, Properties, Us
|
||||
const LABEL_TRAKT_USER: &str = "LABEL.TRAKT_USER";
|
||||
const LABEL_TRAKT_LIST_SLUG: &str = "LABEL.TRAKT_LIST_SLUG";
|
||||
const LABEL_TRAKT_CATEGORY_NAME: &str = "LABEL.TRAKT_CATEGORY_NAME";
|
||||
const LABEL_TRAKT_CREATE_XTREAM_CATEGORY: &str = "LABEL.TRAKT_CREATE_XTREAM_CATEGORY";
|
||||
const LABEL_TRAKT_CONTENT_TYPE: &str = "LABEL.TRAKT_CONTENT_TYPE";
|
||||
const LABEL_TRAKT_TMDB_ONLY: &str = "LABEL.TRAKT_TMDB_ONLY";
|
||||
const LABEL_TRAKT_FUZZY_MATCH_THRESHOLD: &str = "LABEL.TRAKT_FUZZY_MATCH_THRESHOLD";
|
||||
@@ -31,7 +32,8 @@ generate_form_reducer!(
|
||||
fields {
|
||||
User => user: String,
|
||||
ListSlug => list_slug: String,
|
||||
CategoryName => category_name: String,
|
||||
CategoryName => category_name: Option<String>,
|
||||
CreateXtreamCategory => create_xtream_category: bool,
|
||||
ContentType => content_type: TraktContentType,
|
||||
TmdbOnly => tmdb_only: bool,
|
||||
FuzzyMatchThreshold => fuzzy_match_threshold: u8,
|
||||
@@ -46,6 +48,15 @@ pub struct TraktListItemFormProps {
|
||||
pub initial: Option<TraktListConfigDto>,
|
||||
#[prop_or(false)]
|
||||
pub readonly: bool,
|
||||
#[prop_or(true)]
|
||||
pub validate_category_name: bool,
|
||||
}
|
||||
|
||||
fn can_submit_list(data: &TraktListConfigDto, validate_category_name: bool) -> bool {
|
||||
let category_is_valid = !validate_category_name
|
||||
|| !data.create_xtream_category
|
||||
|| data.category_name.as_deref().is_some_and(|name| !name.trim().is_empty());
|
||||
!data.user.trim().is_empty() && !data.list_slug.trim().is_empty() && category_is_valid
|
||||
}
|
||||
|
||||
#[component]
|
||||
@@ -56,7 +67,8 @@ pub fn TraktListItemForm(props: &TraktListItemFormProps) -> Html {
|
||||
form: props.initial.clone().unwrap_or_else(|| TraktListConfigDto {
|
||||
user: String::new(),
|
||||
list_slug: String::new(),
|
||||
category_name: String::new(),
|
||||
category_name: None,
|
||||
create_xtream_category: true,
|
||||
content_type: TraktContentType::Both,
|
||||
tmdb_only: false,
|
||||
fuzzy_match_threshold: 80,
|
||||
@@ -78,12 +90,10 @@ pub fn TraktListItemForm(props: &TraktListItemFormProps) -> Html {
|
||||
let handle_submit = {
|
||||
let form_state = form_state.clone();
|
||||
let on_submit = props.on_submit.clone();
|
||||
let validate_category_name = props.validate_category_name;
|
||||
Callback::from(move |_| {
|
||||
let data = form_state.form.clone();
|
||||
if !data.user.trim().is_empty()
|
||||
&& !data.list_slug.trim().is_empty()
|
||||
&& !data.category_name.trim().is_empty()
|
||||
{
|
||||
if can_submit_list(&data, validate_category_name) {
|
||||
on_submit.emit(data);
|
||||
}
|
||||
})
|
||||
@@ -101,12 +111,18 @@ pub fn TraktListItemForm(props: &TraktListItemFormProps) -> Html {
|
||||
if props.readonly {
|
||||
{ config_field!(form_state.form, translate.t(LABEL_TRAKT_USER), user) }
|
||||
{ config_field!(form_state.form, translate.t(LABEL_TRAKT_LIST_SLUG), list_slug) }
|
||||
{ config_field!(form_state.form, translate.t(LABEL_TRAKT_CATEGORY_NAME), category_name) }
|
||||
{ config_field_bool!(form_state.form, translate.t(LABEL_TRAKT_CREATE_XTREAM_CATEGORY), create_xtream_category) }
|
||||
if form_state.form.create_xtream_category {
|
||||
{ config_field_optional!(form_state.form, translate.t(LABEL_TRAKT_CATEGORY_NAME), category_name) }
|
||||
}
|
||||
} else {
|
||||
<>
|
||||
{ edit_field_text!(form_state, translate.t(LABEL_TRAKT_USER), user, TraktListFormAction::User) }
|
||||
{ edit_field_text!(form_state, translate.t(LABEL_TRAKT_LIST_SLUG), list_slug, TraktListFormAction::ListSlug) }
|
||||
{ edit_field_text!(form_state, translate.t(LABEL_TRAKT_CATEGORY_NAME), category_name, TraktListFormAction::CategoryName) }
|
||||
{ edit_field_bool!(form_state, translate.t(LABEL_TRAKT_CREATE_XTREAM_CATEGORY), create_xtream_category, TraktListFormAction::CreateXtreamCategory) }
|
||||
if form_state.form.create_xtream_category {
|
||||
{ edit_field_text_option!(form_state, translate.t(LABEL_TRAKT_CATEGORY_NAME), category_name, TraktListFormAction::CategoryName) }
|
||||
}
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -166,3 +182,28 @@ pub fn TraktListItemForm(props: &TraktListItemFormProps) -> Html {
|
||||
</Card>
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn list_submit_validation_matches_conditional_category_contract() {
|
||||
let mut list = TraktListConfigDto {
|
||||
user: "alice".to_string(),
|
||||
list_slug: "watchlist".to_string(),
|
||||
..TraktListConfigDto::default()
|
||||
};
|
||||
|
||||
assert!(list.create_xtream_category);
|
||||
assert!(!can_submit_list(&list, true));
|
||||
list.category_name = Some("Watchlist".to_string());
|
||||
assert!(can_submit_list(&list, true));
|
||||
list.category_name = None;
|
||||
list.create_xtream_category = false;
|
||||
assert!(can_submit_list(&list, true));
|
||||
|
||||
list.create_xtream_category = true;
|
||||
assert!(can_submit_list(&list, false), "disabled Trakt keeps incomplete edits");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,7 +282,7 @@ impl Prepare for XtreamTargetOutputDto {
|
||||
self.t_filter = Some(get_filter(raw_filter, templates)?);
|
||||
}
|
||||
if let Some(trakt) = &mut self.trakt {
|
||||
trakt.prepare();
|
||||
trakt.prepare()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use crate::defaults::{
|
||||
default_as_true, default_trakt_fuzzy_threshold, is_false, is_true, DEFAULT_USER_AGENT, TRAKT_API_URL,
|
||||
TRAKT_API_VERSION,
|
||||
use crate::{
|
||||
defaults::{
|
||||
default_as_true, default_trakt_fuzzy_threshold, is_false, is_true, DEFAULT_USER_AGENT, TRAKT_API_URL,
|
||||
TRAKT_API_VERSION,
|
||||
},
|
||||
error::TuliproxError,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use strum_macros::{Display, EnumString};
|
||||
@@ -30,6 +33,21 @@ pub struct TraktApiConfigDto {
|
||||
pub user_agent: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Copy, Clone, Serialize, Deserialize, PartialEq, Eq, Display, EnumString)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[strum(serialize_all = "lowercase", ascii_case_insensitive)]
|
||||
pub enum TraktCatalogSelection {
|
||||
#[default]
|
||||
Full,
|
||||
Curated,
|
||||
}
|
||||
|
||||
impl TraktCatalogSelection {
|
||||
pub const fn is_full(&self) -> bool { matches!(self, Self::Full) }
|
||||
|
||||
pub const fn is_curated(self) -> bool { matches!(self, Self::Curated) }
|
||||
}
|
||||
|
||||
impl TraktApiConfigDto {
|
||||
pub fn prepare(&mut self) {
|
||||
self.api_key = self.api_key.trim().to_string();
|
||||
@@ -47,7 +65,10 @@ impl TraktApiConfigDto {
|
||||
pub struct TraktListConfigDto {
|
||||
pub user: String,
|
||||
pub list_slug: String,
|
||||
pub category_name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub category_name: Option<String>,
|
||||
#[serde(default = "default_as_true", skip_serializing_if = "is_true")]
|
||||
pub create_xtream_category: bool,
|
||||
pub content_type: TraktContentType,
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub tmdb_only: bool,
|
||||
@@ -92,7 +113,10 @@ pub enum TraktChartType {
|
||||
pub struct TraktChartConfigDto {
|
||||
pub kind: TraktChartKind,
|
||||
pub chart: TraktChartType,
|
||||
pub category_name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub category_name: Option<String>,
|
||||
#[serde(default = "default_as_true", skip_serializing_if = "is_true")]
|
||||
pub create_xtream_category: bool,
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub tmdb_only: bool,
|
||||
#[serde(default = "default_trakt_fuzzy_threshold")]
|
||||
@@ -104,7 +128,8 @@ impl Default for TraktChartConfigDto {
|
||||
Self {
|
||||
kind: TraktChartKind::default(),
|
||||
chart: TraktChartType::default(),
|
||||
category_name: String::new(),
|
||||
category_name: None,
|
||||
create_xtream_category: true,
|
||||
tmdb_only: false,
|
||||
fuzzy_match_threshold: default_trakt_fuzzy_threshold(),
|
||||
}
|
||||
@@ -116,7 +141,8 @@ impl Default for TraktListConfigDto {
|
||||
TraktListConfigDto {
|
||||
user: String::new(),
|
||||
list_slug: String::new(),
|
||||
category_name: String::new(),
|
||||
category_name: None,
|
||||
create_xtream_category: true,
|
||||
content_type: TraktContentType::default(),
|
||||
tmdb_only: false,
|
||||
fuzzy_match_threshold: default_trakt_fuzzy_threshold(),
|
||||
@@ -129,6 +155,10 @@ impl Default for TraktListConfigDto {
|
||||
pub struct TraktConfigDto {
|
||||
#[serde(default = "default_as_true", skip_serializing_if = "is_true")]
|
||||
pub enabled: bool,
|
||||
#[serde(default, skip_serializing_if = "TraktCatalogSelection::is_full")]
|
||||
pub catalog_selection: TraktCatalogSelection,
|
||||
#[serde(default = "default_as_true", skip_serializing_if = "is_true")]
|
||||
pub include_xtream_base_categories: bool,
|
||||
#[serde(default)]
|
||||
pub api: TraktApiConfigDto,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
@@ -139,12 +169,69 @@ pub struct TraktConfigDto {
|
||||
|
||||
impl Default for TraktConfigDto {
|
||||
fn default() -> Self {
|
||||
Self { enabled: true, api: TraktApiConfigDto::default(), lists: Vec::new(), charts: Vec::new() }
|
||||
Self {
|
||||
enabled: true,
|
||||
catalog_selection: TraktCatalogSelection::Full,
|
||||
include_xtream_base_categories: true,
|
||||
api: TraktApiConfigDto::default(),
|
||||
lists: Vec::new(),
|
||||
charts: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_selector_category(
|
||||
category_name: &mut Option<String>,
|
||||
create_xtream_category: bool,
|
||||
validate: bool,
|
||||
selector_name: &str,
|
||||
) -> Result<(), TuliproxError> {
|
||||
*category_name = category_name.take().map(|name| name.trim().to_string()).filter(|name| !name.is_empty());
|
||||
if validate && create_xtream_category && category_name.is_none() {
|
||||
return Err(TuliproxError::Config(format!(
|
||||
"Trakt {selector_name} category_name is required when create_xtream_category is true"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl TraktConfigDto {
|
||||
pub fn prepare(&mut self) { self.api.prepare(); }
|
||||
pub fn has_selectors(&self) -> bool { !self.lists.is_empty() || !self.charts.is_empty() }
|
||||
|
||||
/// Returns whether this is the compatible all-default, source-less no-op.
|
||||
///
|
||||
/// Enabled and API edit fields do not affect this classification.
|
||||
pub fn is_source_less_noop(&self) -> bool {
|
||||
!self.has_selectors()
|
||||
&& self.catalog_selection == TraktCatalogSelection::Full
|
||||
&& self.include_xtream_base_categories
|
||||
}
|
||||
|
||||
pub fn prepare(&mut self) -> Result<(), TuliproxError> {
|
||||
self.api.prepare();
|
||||
for selector in &mut self.lists {
|
||||
prepare_selector_category(
|
||||
&mut selector.category_name,
|
||||
selector.create_xtream_category,
|
||||
self.enabled,
|
||||
"list",
|
||||
)?;
|
||||
}
|
||||
for selector in &mut self.charts {
|
||||
prepare_selector_category(
|
||||
&mut selector.category_name,
|
||||
selector.create_xtream_category,
|
||||
self.enabled,
|
||||
"chart",
|
||||
)?;
|
||||
}
|
||||
if self.enabled && !self.has_selectors() && !self.is_source_less_noop() {
|
||||
return Err(TuliproxError::Config(
|
||||
"Enabled Trakt curation with non-default policy requires at least one list or chart".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -210,14 +297,116 @@ mod tests {
|
||||
)
|
||||
.expect("charts-only Trakt config should deserialize");
|
||||
|
||||
assert_eq!(config.catalog_selection, TraktCatalogSelection::Full);
|
||||
assert!(config.include_xtream_base_categories);
|
||||
assert!(config.lists.is_empty());
|
||||
assert_eq!(config.charts.len(), 1);
|
||||
assert_eq!(config.charts[0].kind, TraktChartKind::Movies);
|
||||
assert_eq!(config.charts[0].kind.content_type(), TraktContentType::Vod);
|
||||
assert_eq!(config.charts[0].chart, TraktChartType::Trending);
|
||||
assert_eq!(config.charts[0].category_name.as_deref(), Some("Trending Movies"));
|
||||
assert!(config.charts[0].create_xtream_category);
|
||||
assert_eq!(config.charts[0].fuzzy_match_threshold, default_trakt_fuzzy_threshold());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_category_is_optional_and_projection_switches_are_independent() {
|
||||
let mut config = serde_json::from_str::<TraktConfigDto>(
|
||||
r#"{"catalog_selection":"curated","include_xtream_base_categories":false,"lists":[{"user":"alice","list_slug":"watchlist","content_type":"both","create_xtream_category":false}]}"#,
|
||||
)
|
||||
.expect("selection-only Trakt selector should deserialize");
|
||||
|
||||
config.prepare().expect("selection-only selector should prepare");
|
||||
assert_eq!(config.catalog_selection, TraktCatalogSelection::Curated);
|
||||
assert!(!config.include_xtream_base_categories);
|
||||
assert_eq!(config.lists.len(), 1);
|
||||
assert!(!config.lists[0].create_xtream_category);
|
||||
assert!(config.lists[0].category_name.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn category_name_is_conditionally_required_during_preparation() {
|
||||
let mut required = serde_json::from_str::<TraktConfigDto>(
|
||||
r#"{"lists":[{"user":"alice","list_slug":"watchlist","content_type":"both"}]}"#,
|
||||
)
|
||||
.expect("selector DTO");
|
||||
assert!(required.prepare().is_err());
|
||||
|
||||
required.lists[0].create_xtream_category = false;
|
||||
assert!(required.prepare().is_ok());
|
||||
|
||||
let mut chart = serde_json::from_str::<TraktConfigDto>(r#"{"charts":[{"kind":"movies","chart":"trending"}]}"#)
|
||||
.expect("chart selector DTO");
|
||||
assert!(chart.prepare().is_err());
|
||||
chart.charts[0].create_xtream_category = false;
|
||||
assert!(chart.prepare().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_config_can_retain_incomplete_selector_edits() {
|
||||
let mut config = serde_json::from_str::<TraktConfigDto>(
|
||||
r#"{"enabled":false,"lists":[{"user":"alice","list_slug":"watchlist","content_type":"both"}]}"#,
|
||||
)
|
||||
.expect("disabled selector DTO");
|
||||
|
||||
assert!(config.prepare().is_ok());
|
||||
assert!(config.lists[0].category_name.is_none());
|
||||
assert!(config.lists[0].create_xtream_category);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_less_non_default_trakt_policy_axes_are_rejected_only_when_enabled() {
|
||||
let configs = [
|
||||
TraktConfigDto { catalog_selection: TraktCatalogSelection::Curated, ..TraktConfigDto::default() },
|
||||
TraktConfigDto { include_xtream_base_categories: false, ..TraktConfigDto::default() },
|
||||
];
|
||||
|
||||
for mut enabled in configs {
|
||||
assert!(enabled.prepare().is_err());
|
||||
|
||||
enabled.enabled = false;
|
||||
assert!(enabled.prepare().is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_yaml_shape_keeps_case_a_defaults_without_serializing_new_fields() {
|
||||
let config = serde_json::from_str::<TraktConfigDto>(
|
||||
r#"{"charts":[{"kind":"movies","chart":"trending","category_name":"Trending"}]}"#,
|
||||
)
|
||||
.expect("old config shape");
|
||||
let serialized = serde_json::to_value(&config).expect("serialize compatible config");
|
||||
|
||||
assert_eq!(config.catalog_selection, TraktCatalogSelection::Full);
|
||||
assert!(config.include_xtream_base_categories);
|
||||
assert!(config.charts[0].create_xtream_category);
|
||||
assert!(serialized.get("catalog_selection").is_none());
|
||||
assert!(serialized.get("include_xtream_base_categories").is_none());
|
||||
assert!(serialized["charts"][0].get("create_xtream_category").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yaml_accepts_explicit_a_through_d_policy_controls() {
|
||||
let cases = [
|
||||
("A", "full", true, true),
|
||||
("B", "curated", true, true),
|
||||
("C", "curated", true, false),
|
||||
("D", "curated", false, true),
|
||||
];
|
||||
|
||||
for (case, selection, include_base, create_category) in cases {
|
||||
let yaml = format!(
|
||||
"catalog_selection: {selection}\ninclude_xtream_base_categories: {include_base}\nlists:\n - user: alice\n list_slug: watchlist\n category_name: Watchlist\n create_xtream_category: {create_category}\n content_type: vod\n"
|
||||
);
|
||||
let mut config = serde_saphyr::from_str::<TraktConfigDto>(&yaml).expect("A-D YAML should deserialize");
|
||||
config.prepare().expect("A-D YAML should prepare");
|
||||
|
||||
assert_eq!(config.catalog_selection.to_string(), selection, "case {case}");
|
||||
assert_eq!(config.include_xtream_base_categories, include_base, "case {case}");
|
||||
assert_eq!(config.lists[0].create_xtream_category, create_category, "case {case}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trakt_chart_kind_accepts_show_aliases() {
|
||||
let config = serde_json::from_str::<TraktConfigDto>(
|
||||
|
||||
Reference in New Issue
Block a user