Fixed: tmdb error stops pipeline processing (#886)

* **Bug Fixes**
  * Playlist publication now continues when optional content curation is unavailable or encounters service, credential, request-limit, or certificate issues.
  * In these cases, regular catalog content can still be published to supported outputs and saved to the cache. Curation-specific views are omitted when they cannot be prepared.
  * Empty playlists continue to be rejected by the regular publication process.
This commit is contained in:
euzu
2026-09-30 09:02:32 +02:00
committed by GitHub
parent 67515b120e
commit 92346301c1
6 changed files with 128 additions and 121 deletions
@@ -491,7 +491,7 @@ pub(crate) async fn finalize_prepared_target<E: EventSink + Clone + 'static, M:
ctx: Arc<PlaylistProcessingContext<E, M>>,
prepared: PreparedTarget,
) -> (Result<(), Vec<TuliproxError>>, Vec<TuliproxError>) {
// No generic-client fallback: unavailable transport participates in the same admission gate.
// Keep TMDB transport settings independent of the generic client.
let tmdb_client = build_target_tmdb_client(
&prepared.target,
|| tuliprox_core::utils::network::request::create_tmdb_client(&ctx.config),
@@ -528,13 +528,7 @@ pub(super) async fn finalize_prepared_target_with_tmdb<E: EventSink + Clone + 's
}
let eligible_catalog = prepare_eligible_catalog(target, new_playlist, &mut step);
let views = match prepare_target_playlist_views(&ctx.client, tmdb_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);
}
};
let views = prepare_target_playlist_views(&ctx.client, tmdb_client, target, eligible_catalog).await;
if views.base.is_empty()
&& views.xtream.is_none()
&& views.publication_plan == PlaylistPublicationPlan::Ordinary
@@ -932,13 +926,13 @@ pub(crate) async fn prepare_target_playlist_views(
tmdb_client: Option<&reqwest::Client>,
target: &ConfigTarget,
playlist: Vec<PlaylistGroup>,
) -> Result<TargetPlaylistViews, TuliproxError> {
) -> TargetPlaylistViews {
let Some(config) = target.effective_curation() else {
return Ok(TargetPlaylistViews {
return TargetPlaylistViews {
base: playlist,
xtream: None,
publication_plan: PlaylistPublicationPlan::Ordinary,
});
};
};
let outcome = evaluate_curation(client, tmdb_client, &playlist, &target.name, &config).await;
@@ -950,14 +944,16 @@ pub(super) fn curation_playlist_views(
playlist: Vec<PlaylistGroup>,
config: &CurationConfig,
outcome: CurationRunOutcome,
) -> Result<TargetPlaylistViews, TuliproxError> {
) -> TargetPlaylistViews {
match outcome {
CurationRunOutcome::NotConfigured => Ok(TargetPlaylistViews {
base: playlist,
xtream: None,
publication_plan: PlaylistPublicationPlan::Ordinary,
}),
CurationRunOutcome::Failed(failure) => Err(curation_failure_error(&target.name, &failure)),
CurationRunOutcome::NotConfigured => {
TargetPlaylistViews { base: playlist, xtream: None, publication_plan: PlaylistPublicationPlan::Ordinary }
}
CurationRunOutcome::Failed(failure) => {
warn_curation_failure(&target.name, &failure);
// Discovery is optional; unavailable selections must not suppress the source catalog or EPG.
TargetPlaylistViews { base: playlist, xtream: None, publication_plan: PlaylistPublicationPlan::Ordinary }
}
CurationRunOutcome::Complete(evaluation) => {
let views = build_curated_playlist_views(
playlist,
@@ -972,11 +968,11 @@ pub(super) fn curation_playlist_views(
evaluation.memberships.len(),
views.xtream.as_deref().map_or(0, <[PlaylistGroup]>::len)
);
Ok(views)
views
}
}
}
fn curation_failure_error(target_name: &str, failure: &CurationFailure) -> TuliproxError {
fn warn_curation_failure(target_name: &str, failure: &CurationFailure) {
let mut complete = 0usize;
let mut incomplete = 0usize;
let mut unavailable = 0usize;
@@ -987,9 +983,9 @@ fn curation_failure_error(target_name: &str, failure: &CurationFailure) -> Tulip
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"
))
warn!(
"Target '{target_name}' curation refresh failed (complete selectors: {complete}, incomplete: {incomplete}, unavailable: {unavailable}); continuing with the regular catalog"
);
}
fn item_subject_uuid(item: &PlaylistItem) -> UUIDType {
@@ -2751,9 +2751,7 @@ fn persist_filter_can_select_a_base_group() {
async fn trakt_target_curation_is_a_noop_without_xtream_configuration() {
let target = ConfigTarget::from(&ConfigTargetDto::default());
let views = prepare_target_playlist_views(&reqwest::Client::new(), None, &target, Vec::new())
.await
.expect("unconfigured curation should not fail");
let views = prepare_target_playlist_views(&reqwest::Client::new(), None, &target, Vec::new()).await;
assert!(views.base.is_empty());
assert!(views.xtream.is_none());
@@ -2761,7 +2759,7 @@ async fn trakt_target_curation_is_a_noop_without_xtream_configuration() {
}
#[tokio::test]
async fn unavailable_required_selector_returns_target_failure_instead_of_base_fallback() {
async fn unavailable_required_selector_continues_with_the_base_catalog() {
let target = ConfigTarget::from(&ConfigTargetDto {
name: "curation-failure".to_string(),
output: vec![TargetOutputDto::Xtream(XtreamTargetOutputDto {
@@ -2794,11 +2792,11 @@ async fn unavailable_required_selector_returns_target_failure_instead_of_base_fa
xtream_cluster: XtreamCluster::Video,
}];
let error = prepare_target_playlist_views(&reqwest::Client::new(), None, &target, base)
.await
.expect_err("missing credentials must stop target publication");
assert!(error.message().contains("existing finalized artifacts were retained"));
let views = prepare_target_playlist_views(&reqwest::Client::new(), None, &target, base).await;
assert_eq!(views.base.len(), 1);
assert_eq!(views.base[0].channels[0].header.title.as_ref(), "Base movie");
assert!(views.xtream.is_none());
assert_eq!(views.publication_plan, PlaylistPublicationPlan::Ordinary);
}
mod curation_effect_gate {
@@ -2933,12 +2931,12 @@ mod curation_effect_gate {
}
#[tokio::test]
async fn unavailable_curation_retains_seeded_artifacts_cache_and_watch_state() {
assert_curation_retains_seeded_state(None).await;
async fn unavailable_curation_preserves_the_empty_mixed_output_guard() {
assert_curation_preserves_empty_mixed_output_guard(None).await;
}
#[tokio::test]
async fn tmdb_and_mixed_failures_preserve_files_ids_cache_and_watches_under_full_and_curated() {
async fn tmdb_and_mixed_failures_preserve_the_empty_mixed_output_guard() {
for policy in ["full", "curated"] {
for mixed in [false, true] {
let (url, server) = empty_trakt_server().await;
@@ -2948,7 +2946,7 @@ mod curation_effect_gate {
}
let mut dto: shared::model::CurationConfigDto = serde_json::from_value(value).unwrap();
dto.prepare(&[TargetOutputDto::Xtream(XtreamTargetOutputDto::default())]).unwrap();
assert_curation_retains_seeded_state(Some(dto)).await;
assert_curation_preserves_empty_mixed_output_guard(Some(dto)).await;
if mixed {
server.await.unwrap();
} else {
@@ -2958,7 +2956,7 @@ mod curation_effect_gate {
}
}
async fn assert_curation_retains_seeded_state(canonical: Option<shared::model::CurationConfigDto>) {
async fn assert_curation_preserves_empty_mixed_output_guard(canonical: Option<shared::model::CurationConfigDto>) {
let directory = tempdir().expect("tempdir");
let app_config = app_config(directory.path());
let playlist_state = Arc::new(PlaylistStorageState::new());
@@ -3040,7 +3038,9 @@ mod curation_effect_gate {
let (result, errors) = finalize_prepared_target(Arc::new(context), prepared).await;
assert!(result.is_err());
assert!(result.is_err_and(|errors| errors.iter().any(|error| {
error.message().contains("non-Xtream outputs that cannot safely publish a fully empty forced result")
})));
assert!(errors.is_empty());
assert_eq!(file_snapshot(directory.path()), before_files);
let after_cache_len = playlist_state
@@ -3050,6 +3050,7 @@ mod curation_effect_gate {
.get("curation-effect-gate")
.and_then(|storage| storage.xtream.as_ref())
.map_or(0, |storage| storage.vod.len());
assert!(before_cache_len > 0);
assert_eq!(after_cache_len, before_cache_len);
}
@@ -3179,7 +3180,7 @@ fn tmdb_complete_target_selection_preserves_live_and_keeps_xtream_aliases_out_of
CurationMediaKind::Movie,
0,
)]));
let views = target::curation_playlist_views(&target, playlist.clone(), &config, outcome).unwrap();
let views = target::curation_playlist_views(&target, playlist.clone(), &config, outcome);
assert_eq!(
views.base.iter().flat_map(|g| &g.channels).map(|i| i.header.uuid).collect::<Vec<_>>(),
[live_id, id]
@@ -3192,7 +3193,7 @@ fn tmdb_complete_target_selection_preserves_live_and_keeps_xtream_aliases_out_of
assert!(views.xtream.is_none());
}
let empty = CurationRunOutcome::Complete(complete_catalog_evaluation(Vec::new()));
let empty_views = target::curation_playlist_views(&target, playlist, &config, empty).unwrap();
let empty_views = target::curation_playlist_views(&target, playlist, &config, empty);
assert_eq!(empty_views.base.len(), 1);
assert_eq!(empty_views.base[0].xtream_cluster, XtreamCluster::Live);
assert_ne!(empty_views.publication_plan, PlaylistPublicationPlan::Ordinary);
@@ -326,6 +326,35 @@ impl Publication {
values
}
async fn assert_regular_publication(&self, expected: &[PlaylistGroup]) {
if self.target.get_xtream_output().is_some() {
let rows = self.xtream_rows().await;
for item in expected.iter().flat_map(|group| &group.channels) {
assert!(rows.iter().any(|row| row.title == item.header.title && row.url == item.header.url));
}
assert!(rows.iter().all(|row| !row.group.starts_with("TMDB") && !row.group.starts_with("Trakt")));
}
assert!(std::fs::read_to_string(self.directory.path().join("published.m3u")).is_ok_and(|text| {
expected
.iter()
.flat_map(|group| &group.channels)
.filter(|item| item.header.item_type != PlaylistItemType::SeriesInfo)
.all(|item| text.contains(item.header.title.as_ref()) && text.contains(item.header.url.as_ref()))
}));
let strm = self.strm_contents();
assert!(!strm.is_empty());
let files = file_snapshot(self.directory.path());
let live_title = expected[0].channels[0].header.title.as_bytes();
assert!(
files.iter().any(|(path, contents)| {
path.to_string_lossy().contains("epg")
&& contents.windows(live_title.len()).any(|part| part == live_title)
}),
"new EPG programme must be published"
);
assert!(!self.cache_signature().await.is_empty());
}
async fn identity_signature(&self) -> BTreeMap<String, (u32, u32)> {
let cache = self.context.playlist_state.as_ref().unwrap().data.read().await;
cache[&self.target.name]
@@ -464,7 +493,7 @@ async fn tmdb_https_empty_and_no_match_clear_published_vod_series_but_keep_live(
}
#[tokio::test]
async fn either_https_failure_retains_every_published_file_id_cache_and_watch_in_full_and_curated() {
async fn either_https_failure_continues_regular_publication_in_full_and_curated() {
for policy in ["full", "curated"] {
for (route, status, body) in [
(MOVIES, 503, "{}"),
@@ -473,20 +502,30 @@ async fn either_https_failure_retains_every_published_file_id_cache_and_watch_in
] {
let server = DiscoveryServer::start().await;
let run = Publication::new(&server, &target(policy, true, true));
let initial = file_snapshot(run.directory.path());
server.reply(route, status, body);
assert!(run.publish().await.is_err(), "first run must not publish a successful sibling");
assert_eq!(file_snapshot(run.directory.path()), initial);
assert!(run.cache_signature().await.is_empty());
assert!(run.publish().await.is_ok());
run.assert_regular_publication(&catalog()).await;
server.reply(route, 200, if route == TRAKT { TRAKT_PAGE } else { MOVIE_PAGE });
run.publish().await.unwrap();
let files = file_snapshot(run.directory.path());
let cache = run.cache_signature().await;
assert!(!files.is_empty() && !cache.is_empty());
assert!(run.publish().await.is_ok());
server.reply(route, status, body);
assert!(run.publish().await.is_err());
assert_eq!(file_snapshot(run.directory.path()), files);
assert_eq!(run.cache_signature().await, cache);
assert!(run.publish_catalog(item_limit::changed_catalog()).await.is_ok());
run.assert_regular_publication(&item_limit::changed_catalog()).await;
}
}
}
#[tokio::test]
async fn missing_tmdb_credentials_publish_playlists_and_epg_with_and_without_xtream() {
for policy in ["full", "curated"] {
for xtream in [false, true] {
let server = DiscoveryServer::start().await;
let mut run = Publication::new(&server, &target(policy, false, xtream));
if let Some(source) = run.target.curation.as_mut().and_then(|config| config.tmdb.as_mut()) {
source.api.access_token.clear();
}
assert!(run.publish().await.is_ok());
run.assert_regular_publication(&catalog()).await;
assert!(server.requests.lock().is_ok_and(|requests| requests.is_empty()));
}
}
}
@@ -3,7 +3,7 @@ use target::build_target_tmdb_client;
use tuliprox_core::{model::ProxyConfig, utils::network::request::create_tmdb_client};
#[tokio::test]
async fn curation_tmdb_client_construction_reports_only_safe_phases_and_keeps_publication_closed() {
async fn curation_tmdb_client_construction_reports_safe_phases_and_continues_publication() {
for policy in ["full", "curated"] {
for phase in ["phase=profile_configuration", "phase=client_build"] {
let server = DiscoveryServer::start().await;
@@ -12,7 +12,6 @@ async fn curation_tmdb_client_construction_reports_only_safe_phases_and_keeps_pu
if previously_published {
run.publish().await.unwrap();
}
let files = file_snapshot(run.directory.path());
let cache = run.cache_signature().await;
assert_eq!(!cache.is_empty(), previously_published);
let before = server.requests.lock().unwrap().len();
@@ -43,7 +42,7 @@ async fn curation_tmdb_client_construction_reports_only_safe_phases_and_keeps_pu
assert_eq!(diagnostics, [phase]);
assert!(!diagnostics[0].contains("synthetic-"));
let mut changed = catalog();
changed[0].channels[0].header.title = "Must not publish".intern();
changed[0].channels[0].header.title = "Updated Live".intern();
let prepared = PreparedTarget {
target: run.target.clone(),
playlist: changed,
@@ -62,12 +61,12 @@ async fn curation_tmdb_client_construction_reports_only_safe_phases_and_keeps_pu
)
.await
.unwrap();
assert!(result.is_err());
assert!(result.is_ok());
assert!(errors.is_empty());
assert_eq!(file_snapshot(run.directory.path()), files);
assert_eq!(run.cache_signature().await, cache);
assert!(run.xtream_rows().await.iter().any(|row| row.title.as_ref() == "Updated Live"));
assert_ne!(run.cache_signature().await, cache);
let requests = server.requests.lock().unwrap();
assert_eq!(requests.len(), before + 1, "complete Trakt sibling cannot authorize publication");
assert_eq!(requests.len(), before + 1, "failed TMDB transport must not use the generic client");
assert!(requests[before].starts_with(&format!("GET {TRAKT} ")));
}
}
@@ -1,6 +1,6 @@
use super::*;
fn changed_catalog() -> Vec<PlaylistGroup> {
pub(super) fn changed_catalog() -> Vec<PlaylistGroup> {
let mut playlist = catalog();
for group in &mut playlist {
for item in &mut group.channels {
@@ -63,7 +63,7 @@ fn fail_late(server: &DiscoveryServer, failure: &str) {
}
#[tokio::test]
async fn curation_late_failure_keeps_all_files_epg_cache_mapping_and_watches_in_both_policies() {
async fn curation_late_failure_continues_playlists_epg_cache_and_watches_in_both_policies() {
for policy in ["full", "curated"] {
for failure in [
"status",
@@ -79,52 +79,30 @@ async fn curation_late_failure_keeps_all_files_epg_cache_mapping_and_watches_in_
] {
let server = DiscoveryServer::start().await;
let mut dto = target(policy, true, true);
// A selection-only selector is just as required as its successful projecting siblings.
dto.curation.as_mut().unwrap().tmdb.as_mut().unwrap().trending[0].create_xtream_category = false;
if let Some(source) = dto.curation.as_mut().and_then(|config| config.tmdb.as_mut()) {
source.trending[0].create_xtream_category = false;
}
let run = Publication::new(&server, &dto);
let successful_replies = server.replies.lock().unwrap().clone();
let initial = file_snapshot(run.directory.path());
fail_late(&server, failure);
assert!(run.publish_catalog(changed_catalog()).await.is_err(), "{policy}/{failure}: first refresh");
assert_eq!(file_snapshot(run.directory.path()), initial);
assert!(run.context.playlist_state.as_ref().unwrap().data.read().await.is_empty());
assert!(run.cache_signature().await.is_empty());
let count = server.requests.lock().unwrap().len();
assert_eq!(
count,
if failure == "requests" { 34 } else { 4 },
"Trakt and TV are evaluated too, but not published"
);
assert!(run.publish_catalog(changed_catalog()).await.is_ok(), "{policy}/{failure}");
run.assert_regular_publication(&changed_catalog()).await;
assert_eq!(server.requests.lock().unwrap().len(), if failure == "requests" { 34 } else { 4 });
*server.replies.lock().unwrap() = successful_replies;
run.publish().await.unwrap();
let files = file_snapshot(run.directory.path());
assert!(
files.keys().any(|path| path.to_string_lossy().contains("epg")),
"EPG must be in the retention inventory: {:?}",
files.keys().collect::<Vec<_>>()
);
let cache = run.cache_signature().await;
assert!(cache.iter().any(|entry| entry.starts_with("mapping:")));
let watch_path = run.directory.path().join("publication.groups.bin");
let watch_before: std::collections::BTreeSet<Arc<str>> =
tuliprox_core::utils::binary_deserialize(&std::fs::read(&watch_path).unwrap()).unwrap();
assert!(!watch_before.is_empty());
assert!(run.publish().await.is_ok());
fail_late(&server, failure);
assert!(
run.publish_catalog(changed_catalog()).await.is_err(),
"{policy}/{failure}: refresh with prior state"
);
assert_eq!(file_snapshot(run.directory.path()), files, "inventory and bytes, not just file sizes");
assert_eq!(run.cache_signature().await, cache, "all cached rows and mapping records, including timestamps");
let watch_after: std::collections::BTreeSet<Arc<str>> =
tuliprox_core::utils::binary_deserialize(&std::fs::read(&watch_path).unwrap()).unwrap();
assert_eq!(watch_before, watch_after);
assert!(run.publish_catalog(changed_catalog()).await.is_ok());
run.assert_regular_publication(&changed_catalog()).await;
assert!(std::fs::read(run.directory.path().join("publication.groups.bin")).is_ok_and(|bytes| {
tuliprox_core::utils::binary_deserialize::<std::collections::BTreeSet<Arc<str>>>(&bytes)
.is_ok_and(|groups| groups.iter().any(|group| group.as_ref() == "Movies"))
}));
}
}
}
#[tokio::test]
async fn curation_shared_batch_exhaustion_marks_pending_selectors_required_without_129th_get() {
async fn curation_shared_batch_exhaustion_continues_publication_without_129th_get() {
for policy in ["full", "curated"] {
let server = DiscoveryServer::start().await;
let dto = target(policy, false, true);
@@ -136,21 +114,16 @@ async fn curation_shared_batch_exhaustion_marks_pending_selectors_required_witho
selector.create_xtream_category = false;
source.trending = vec![selector; 129];
let failing = run.target.clone();
let initial = file_snapshot(run.directory.path());
assert!(run.publish().await.is_err());
assert!(run.publish().await.is_ok());
assert_eq!(server.requests.lock().unwrap().len(), 128);
assert_eq!(file_snapshot(run.directory.path()), initial);
assert!(run.cache_signature().await.is_empty());
run.assert_regular_publication(&catalog()).await;
run.target = ordinary;
run.publish().await.unwrap();
let before = file_snapshot(run.directory.path());
let cache = run.cache_signature().await;
assert!(run.publish().await.is_ok());
let requests = server.requests.lock().unwrap().len();
run.target = failing;
assert!(run.publish_catalog(changed_catalog()).await.is_err());
assert!(run.publish_catalog(changed_catalog()).await.is_ok());
assert_eq!(server.requests.lock().unwrap().len() - requests, 128);
assert_eq!(file_snapshot(run.directory.path()), before);
assert_eq!(run.cache_signature().await, cache);
run.assert_regular_publication(&changed_catalog()).await;
}
}
@@ -235,7 +208,7 @@ async fn curation_full_empty_or_no_match_removes_projections_not_ordinary_catalo
}
#[tokio::test]
async fn curation_profile_requires_the_fixture_ca_and_has_no_unconfigured_fallback() {
async fn curation_profile_requires_the_fixture_ca_and_continues_regular_publication() {
let server = DiscoveryServer::start().await;
let mut run = Publication::new(&server, &target("full", false, true));
run.tmdb_client = tuliprox_core::utils::network::request::create_tmdb_client(&run.context.config)
@@ -244,9 +217,9 @@ async fn curation_profile_requires_the_fixture_ca_and_has_no_unconfigured_fallba
.resolve("api.themoviedb.org", server.address)
.build()
.unwrap();
assert!(run.publish().await.is_err(), "untrusted CA fails rather than disabling TLS verification");
assert!(run.publish().await.is_ok());
run.assert_regular_publication(&catalog()).await;
assert!(server.requests.lock().unwrap().is_empty());
assert!(file_snapshot(run.directory.path()).is_empty());
}
#[tokio::test]
@@ -266,17 +239,17 @@ async fn curation_production_wiring_does_not_fall_back_to_the_generic_client() {
library_empty: tuliprox_repository::LibraryEmptyPublication::None,
};
let (result, _) = finalize_prepared_target(Arc::clone(&run.context), prepared).await;
assert!(result.is_err());
assert!(result.is_ok());
assert!(
server.requests.lock().unwrap().is_empty(),
"generic client has the fixture CA and could succeed, but is not used"
);
assert!(run.cache_signature().await.is_empty());
assert!(file_snapshot(run.directory.path()).is_empty());
assert!(!run.cache_signature().await.is_empty());
assert!(!file_snapshot(run.directory.path()).is_empty());
}
#[tokio::test]
async fn curation_does_not_authorize_empty_inputs_and_authorized_empty_still_waits_for_all_selectors() {
async fn curation_failure_respects_empty_input_authorization() {
for authorized in [false, true] {
let server = DiscoveryServer::start().await;
let run = Publication::new(&server, &target("curated", false, true));
@@ -292,7 +265,7 @@ async fn curation_does_not_authorize_empty_inputs_and_authorized_empty_still_wai
let (result, _) =
target::finalize_prepared_target_with_tmdb(Arc::clone(&run.context), prepared, Some(&run.tmdb_client))
.await;
assert_eq!(result.is_err(), authorized);
assert_eq!(result.is_err(), authorized, "ordinary M3U persistence still rejects an empty playlist");
assert_eq!(server.requests.lock().unwrap().len(), if authorized { 3 } else { 0 });
assert!(file_snapshot(run.directory.path()).is_empty());
assert!(run.cache_signature().await.is_empty());
@@ -72,7 +72,7 @@ async fn tmdb_profile_tls_does_not_change_generic_clients_global_insecure_policy
}
#[tokio::test]
async fn tmdb_profile_tls_failure_blocks_publication_and_retains_artifacts_even_with_complete_trakt() {
async fn tmdb_profile_tls_failure_continues_publication_without_bypassing_verification() {
for policy in ["full", "curated"] {
let server = DiscoveryServer::start().await;
let mut run = Publication::new(&server, &target(policy, true, true));
@@ -88,16 +88,15 @@ async fn tmdb_profile_tls_failure_blocks_publication_and_retains_artifacts_even_
run.tmdb_client = trusted.clone();
run.publish().await.unwrap();
}
let files = file_snapshot(run.directory.path());
let cache = run.cache_signature().await;
assert_eq!(!cache.is_empty(), previously_published);
let before = server.requests.lock().unwrap().len();
run.tmdb_client = rejected.clone();
let mut changed = catalog();
changed[0].channels[0].header.title = "Must not publish".intern();
assert!(run.publish_catalog(changed).await.is_err());
assert_eq!(file_snapshot(run.directory.path()), files, "all files, IDs and watches retained");
assert_eq!(run.cache_signature().await, cache, "no partial cache publication");
changed[0].channels[0].header.title = "Updated Live".intern();
assert!(run.publish_catalog(changed.clone()).await.is_ok());
run.assert_regular_publication(&changed).await;
assert_ne!(run.cache_signature().await, cache);
let requests = server.requests.lock().unwrap();
assert_eq!(requests.len(), before + 1, "only the required Trakt sibling reached HTTP; no fallback");
assert!(requests[before].starts_with(&format!("GET {TRAKT} ")));