diff --git a/Cargo.lock b/Cargo.lock index 8859745a..125ca258 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2324,6 +2324,17 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "ferrex-player-intelligence" +version = "0.1.2-alpha" +dependencies = [ + "chrono", + "ferrex-player-api", + "serde", + "serde_json", + "uuid", +] + [[package]] name = "ferrex-player-library" version = "0.1.2-alpha" @@ -2472,6 +2483,7 @@ dependencies = [ "ferrex-player-api", "ferrex-player-auth", "ferrex-player-foundation", + "ferrex-player-intelligence", "ferrex-player-library", "ferrex-player-media", "ferrex-player-metadata", diff --git a/Cargo.toml b/Cargo.toml index c0a59e51..79e4d29f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/ferrex-player-api", "crates/ferrex-player-auth", "crates/ferrex-player-foundation", + "crates/ferrex-player-intelligence", "crates/ferrex-player-repository", "crates/ferrex-player-library", "crates/ferrex-player-media", @@ -82,6 +83,10 @@ version = "0.1.2-alpha" path = "crates/ferrex-player-foundation" version = "0.1.2-alpha" +[workspace.dependencies.ferrex-player-intelligence] +path = "crates/ferrex-player-intelligence" +version = "0.1.2-alpha" + [workspace.dependencies.ferrex-player-repository] path = "crates/ferrex-player-repository" version = "0.1.2-alpha" diff --git a/crates/ferrex-core/src/api/routes.rs b/crates/ferrex-core/src/api/routes.rs index 3369fc6f..4aa000da 100644 --- a/crates/ferrex-core/src/api/routes.rs +++ b/crates/ferrex-core/src/api/routes.rs @@ -135,6 +135,12 @@ pub mod v1 { v1_path!("/intelligence/drafts/{artifact_id}"); pub const PROVIDER_STATUS: &str = v1_path!("/intelligence/provider/status"); + pub const SMART_SHELF_START: &str = + v1_path!("/intelligence/smart-shelves/runs"); + pub const SMART_SHELF_DRAFT_DETAIL: &str = + v1_path!("/intelligence/smart-shelves/drafts/{artifact_id}"); + pub const SMART_SHELF_SAVE: &str = + v1_path!("/intelligence/smart-shelves/drafts/{artifact_id}/save"); } pub mod watch { diff --git a/crates/ferrex-core/src/api/types/mod.rs b/crates/ferrex-core/src/api/types/mod.rs index e2497d43..f14204b0 100644 --- a/crates/ferrex-core/src/api/types/mod.rs +++ b/crates/ferrex-core/src/api/types/mod.rs @@ -15,6 +15,7 @@ pub mod media_repo_sync; pub mod responses; pub mod scan; pub mod setup; +pub mod smart_shelves; pub mod system_collections; pub mod users_admin; @@ -64,6 +65,7 @@ pub use scan::{ ScanCommandRequest, ScanLifecycleStatus, ScanRunMode, ScanSnapshotDto, ScanStartDisposition, StartScanRequest, }; +pub use smart_shelves::*; pub use system_collections::*; pub use users_admin::{AdminUserInfo, CreateUserRequest, UpdateUserRequest}; @@ -116,6 +118,7 @@ pub mod player { ConfirmClaimRequest, ConfirmClaimResponse, StartClaimRequest, StartClaimResponse, }; + pub use super::smart_shelves::*; pub use super::system_collections::*; pub use super::users_admin::{ AdminUserInfo, CreateUserRequest, UpdateUserRequest, diff --git a/crates/ferrex-core/src/api/types/smart_shelves.rs b/crates/ferrex-core/src/api/types/smart_shelves.rs new file mode 100644 index 00000000..dc6adae6 --- /dev/null +++ b/crates/ferrex-core/src/api/types/smart_shelves.rs @@ -0,0 +1,672 @@ +//! Typed smart-shelf DTOs layered over generic intelligence drafts. +//! +//! Smart shelves are intentionally narrow: they start a constrained grounded +//! intelligence run, read a draft artifact as a typed ordered shelf, validate +//! recoverable draft issues, and save an accepted draft as a private manual +//! collection. + +use std::collections::HashSet; + +use ferrex_model::{LibraryId, MediaID}; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +use super::{ + collections::{CollectionId, CollectionSummary}, + intelligence::{ + IntelligenceArtifactSourceEdge, IntelligenceCaps, + IntelligenceDraftArtifactPayload, IntelligenceMediaKind, + IntelligenceRunStartResponse, IntelligenceRunStatus, + IntelligenceSummary, + }, +}; + +/// Schema version used by smart-shelf draft artifact content. +pub const SMART_SHELF_DRAFT_SCHEMA_VERSION: u16 = 1; +/// Default number of items requested for a smart shelf. +pub const DEFAULT_SMART_SHELF_ITEM_COUNT: u16 = 8; +/// Maximum number of items a smart-shelf request or draft can ask the server to +/// validate/save in one operation. +pub const MAX_SMART_SHELF_ITEM_COUNT: u16 = 50; + +fn smart_shelf_draft_schema_version() -> u16 { + SMART_SHELF_DRAFT_SCHEMA_VERSION +} + +fn default_smart_shelf_item_count() -> u16 { + DEFAULT_SMART_SHELF_ITEM_COUNT +} + +fn deserialize_smart_shelf_item_count<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = u16::deserialize(deserializer)?; + Ok(clamp_smart_shelf_item_count(value)) +} + +/// Clamp a requested smart-shelf item count to stable server bounds. +pub const fn clamp_smart_shelf_item_count(value: u16) -> u16 { + if value == 0 { + DEFAULT_SMART_SHELF_ITEM_COUNT + } else if value > MAX_SMART_SHELF_ITEM_COUNT { + MAX_SMART_SHELF_ITEM_COUNT + } else { + value + } +} + +/// Narrow start request for a grounded smart-shelf run. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SmartShelfStartRequest { + /// Friendly user prompt or selected template expansion. + pub prompt: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub library_id: Option, + /// Media kinds the MVP can safely validate and save. Empty means the server + /// uses its supported defaults. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub media_kinds: Vec, + #[serde( + default = "default_smart_shelf_item_count", + deserialize_with = "deserialize_smart_shelf_item_count" + )] + pub item_count: u16, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub template_id: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub locked_media_ids: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub idempotency_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default)] + pub caps: IntelligenceCaps, + #[serde(default, skip_serializing_if = "serde_json::Value::is_null")] + pub constraints: Value, + #[serde(default, skip_serializing_if = "serde_json::Value::is_null")] + pub metadata: Value, +} + +/// Smart-shelf start response with the same runtime identifiers as the generic +/// intelligence start route plus the draft schema the provider was constrained +/// to produce. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SmartShelfStartResponse { + pub run_id: Uuid, + pub status: IntelligenceRunStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub queued_at_epoch_seconds: Option, + #[serde(default = "smart_shelf_draft_schema_version")] + pub draft_schema_version: u16, +} + +impl From for SmartShelfStartResponse { + fn from(value: IntelligenceRunStartResponse) -> Self { + Self { + run_id: value.run_id, + status: value.status, + provider: value.provider, + model: value.model, + queued_at_epoch_seconds: value.queued_at_epoch_seconds, + draft_schema_version: SMART_SHELF_DRAFT_SCHEMA_VERSION, + } + } +} + +/// Typed smart-shelf draft content stored in a generic draft artifact body. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SmartShelfDraftContent { + #[serde(default = "smart_shelf_draft_schema_version")] + pub schema_version: u16, + pub title: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub interpreted_intent: Option, + #[serde(default, skip_serializing_if = "serde_json::Value::is_null")] + pub requested_constraints: Value, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub items: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub alternates: Vec, +} + +/// A selected item in an ordered smart-shelf draft. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SmartShelfDraftItem { + /// One-based ordinal requested by the model. Save preserves vector order; + /// the ordinal is retained as provenance for UI/debugging. + #[serde(default)] + pub ordinal: u32, + pub media_id: MediaID, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subtitle: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub year: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub sources: Vec, + #[serde(default, skip_serializing_if = "is_false")] + pub locked: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub replacement_of: Option, +} + +/// Alternate item that can replace a selected smart-shelf item. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SmartShelfDraftAlternate { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target_ordinal: Option, + pub media_id: MediaID, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subtitle: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub year: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub sources: Vec, +} + +impl SmartShelfDraftAlternate { + pub fn into_item(self, ordinal: u32) -> SmartShelfDraftItem { + SmartShelfDraftItem { + ordinal, + media_id: self.media_id, + title: self.title, + subtitle: self.subtitle, + year: self.year, + reason: self.reason, + sources: self.sources, + locked: false, + replacement_of: None, + } + } +} + +/// Bounded provenance chip for an item reason. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SmartShelfDraftSource { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub media_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub artifact_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub field: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub evidence: Option, +} + +/// Validation severity for recoverable smart-shelf draft issues. +#[derive( + Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Default, +)] +#[serde(rename_all = "snake_case")] +pub enum SmartShelfDraftValidationSeverity { + #[default] + Error, + Warning, +} + +/// Stable validation issue codes returned by typed draft reads. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum SmartShelfDraftValidationIssueCode { + MalformedContent, + EmptyDraft, + DuplicateMedia, + UnsupportedMedia, + UngroundedItem, + MissingReason, + MissingSource, +} + +/// Stable smart-shelf save/read error codes. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum SmartShelfErrorCode { + DraftHidden, + DraftStale, + DraftMalformed, + DraftEmpty, + DuplicateMedia, + UnsupportedMedia, + UngroundedItem, + MissingReason, + MissingSource, + AlreadySaved, + Unauthorized, + InvalidRequest, + CollectionConflict, + CollectionStorageError, + Internal, +} + +impl SmartShelfDraftValidationIssueCode { + pub const fn save_error_code(self) -> SmartShelfErrorCode { + match self { + Self::MalformedContent => SmartShelfErrorCode::DraftMalformed, + Self::EmptyDraft => SmartShelfErrorCode::DraftEmpty, + Self::DuplicateMedia => SmartShelfErrorCode::DuplicateMedia, + Self::UnsupportedMedia => SmartShelfErrorCode::UnsupportedMedia, + Self::UngroundedItem => SmartShelfErrorCode::UngroundedItem, + Self::MissingReason => SmartShelfErrorCode::MissingReason, + Self::MissingSource => SmartShelfErrorCode::MissingSource, + } + } +} + +/// A single recoverable validation issue in a typed draft. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SmartShelfDraftValidationIssue { + pub code: SmartShelfDraftValidationIssueCode, + #[serde(default)] + pub severity: SmartShelfDraftValidationSeverity, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ordinal: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub media_id: Option, + pub message: String, +} + +impl SmartShelfDraftValidationIssue { + pub fn error( + code: SmartShelfDraftValidationIssueCode, + message: impl Into, + ) -> Self { + Self { + code, + severity: SmartShelfDraftValidationSeverity::Error, + ordinal: None, + media_id: None, + message: message.into(), + } + } + + pub fn for_item( + code: SmartShelfDraftValidationIssueCode, + ordinal: u32, + media_id: MediaID, + message: impl Into, + ) -> Self { + Self { + code, + severity: SmartShelfDraftValidationSeverity::Error, + ordinal: Some(ordinal), + media_id: Some(media_id), + message: message.into(), + } + } +} + +/// Aggregate validation report for a typed draft. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct SmartShelfDraftValidation { + pub valid: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub issues: Vec, +} + +impl SmartShelfDraftValidation { + pub fn from_issues(issues: Vec) -> Self { + let valid = !issues.iter().any(|issue| { + issue.severity == SmartShelfDraftValidationSeverity::Error + }); + Self { valid, issues } + } + + pub fn first_save_error_code(&self) -> Option { + self.issues + .iter() + .find(|issue| { + issue.severity == SmartShelfDraftValidationSeverity::Error + }) + .map(|issue| issue.code.save_error_code()) + } +} + +/// Typed draft read response. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SmartShelfDraftResponse { + pub artifact_id: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub run_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub owner_user_id: Option, + pub title: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub draft: Option, + pub validation: SmartShelfDraftValidation, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub saved_collection_id: Option, +} + +impl SmartShelfDraftResponse { + /// Parse a generic intelligence draft artifact into the typed smart-shelf + /// response. Malformed content is returned as validation issues rather than + /// an error so clients can display recoverable draft failures. + pub fn from_draft_artifact( + payload: IntelligenceDraftArtifactPayload, + ) -> Self { + let saved_collection_id = + saved_collection_id_from_metadata(&payload.metadata) + .map(CollectionId); + let artifact_id = payload.artifact_id; + let run_id = payload.run_id; + let owner_user_id = payload.owner_user_id; + let title = payload.title; + let summary = payload.summary; + let content = payload.content.clone(); + let source_media_ids = + grounded_media_ids(payload.media_id, payload.sources.as_slice()); + + match serde_json::from_value::(content) { + Ok(draft) => { + let validation = validate_smart_shelf_draft_items( + &draft.items, + &source_media_ids, + ); + Self { + artifact_id, + run_id, + owner_user_id, + title, + summary, + draft: Some(draft), + validation, + saved_collection_id, + } + } + Err(error) => Self { + artifact_id, + run_id, + owner_user_id, + title, + summary, + draft: None, + validation: SmartShelfDraftValidation::from_issues(vec![ + SmartShelfDraftValidationIssue::error( + SmartShelfDraftValidationIssueCode::MalformedContent, + format!( + "smart-shelf draft content is malformed: {error}" + ), + ), + ]), + saved_collection_id, + }, + } + } +} + +/// Save request for accepting a typed smart-shelf draft as a private manual +/// collection. Empty `items` means save the validated draft order as-is; +/// otherwise the provided order is treated as the accepted replacement/lock +/// state and each selected media id must come from the draft item/alternate set. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct SmartShelfSaveRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub items: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub idempotency_key: Option, +} + +/// Accepted item state supplied by the UI at save time. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SmartShelfSaveItem { + pub media_id: MediaID, + #[serde(default, skip_serializing_if = "is_false")] + pub locked: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub replacement_of: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub sources: Vec, +} + +/// Save response returned after a private manual collection is created. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SmartShelfSaveResponse { + pub draft_artifact_id: Uuid, + pub collection_id: CollectionId, + pub collection: CollectionSummary, + pub item_count: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub saved_at_epoch_seconds: Option, +} + +/// Error envelope used by smart-shelf-specific routes. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SmartShelfError { + pub code: SmartShelfErrorCode, + pub message: String, + #[serde(default, skip_serializing_if = "is_false")] + pub retryable: bool, + #[serde(default, skip_serializing_if = "serde_json::Value::is_null")] + pub details: Value, +} + +/// Build the grounded media-id set from durable artifact source edges. +pub fn grounded_media_ids( + seed_media_id: Option, + sources: &[IntelligenceArtifactSourceEdge], +) -> HashSet { + let mut media_ids = HashSet::new(); + if let Some(media_id) = seed_media_id { + media_ids.insert(media_id); + } + for source in sources { + if let Some(media_id) = source.source_media_id { + media_ids.insert(media_id); + } + } + media_ids +} + +/// Validate the selected smart-shelf item list against durable grounding. +pub fn validate_smart_shelf_draft_items( + items: &[SmartShelfDraftItem], + grounded_media_ids: &HashSet, +) -> SmartShelfDraftValidation { + let mut issues = Vec::new(); + if items.is_empty() { + issues.push(SmartShelfDraftValidationIssue::error( + SmartShelfDraftValidationIssueCode::EmptyDraft, + "smart-shelf draft contains no selected items", + )); + return SmartShelfDraftValidation::from_issues(issues); + } + + let mut seen = HashSet::new(); + for (index, item) in items.iter().enumerate() { + let ordinal = if item.ordinal == 0 { + u32::try_from(index + 1).unwrap_or(u32::MAX) + } else { + item.ordinal + }; + + if !is_supported_smart_shelf_media(item.media_id) { + issues.push(SmartShelfDraftValidationIssue::for_item( + SmartShelfDraftValidationIssueCode::UnsupportedMedia, + ordinal, + item.media_id, + "smart-shelf drafts currently support movie and series items only", + )); + } + + if !seen.insert(item.media_id) { + issues.push(SmartShelfDraftValidationIssue::for_item( + SmartShelfDraftValidationIssueCode::DuplicateMedia, + ordinal, + item.media_id, + "smart-shelf draft contains the same media item more than once", + )); + } + + if item + .reason + .as_deref() + .is_none_or(|reason| reason.trim().is_empty()) + { + issues.push(SmartShelfDraftValidationIssue::for_item( + SmartShelfDraftValidationIssueCode::MissingReason, + ordinal, + item.media_id, + "smart-shelf draft item is missing a grounded reason", + )); + } + + if !item.sources.iter().any(smart_shelf_source_present) { + issues.push(SmartShelfDraftValidationIssue::for_item( + SmartShelfDraftValidationIssueCode::MissingSource, + ordinal, + item.media_id, + "smart-shelf draft item is missing a source/provenance indicator", + )); + } + + if !grounded_media_ids.contains(&item.media_id) { + issues.push(SmartShelfDraftValidationIssue::for_item( + SmartShelfDraftValidationIssueCode::UngroundedItem, + ordinal, + item.media_id, + "smart-shelf draft item is not grounded by the draft artifact sources", + )); + } + } + + SmartShelfDraftValidation::from_issues(issues) +} + +/// Smart-shelf MVP supports media kinds that existing collection/detail UX can +/// safely render for this flow. +pub const fn is_supported_smart_shelf_media(media_id: MediaID) -> bool { + matches!(media_id, MediaID::Movie(_) | MediaID::Series(_)) +} + +pub fn smart_shelf_source_present(source: &SmartShelfDraftSource) -> bool { + source + .label + .as_deref() + .is_some_and(|label| !label.trim().is_empty()) + || source.media_id.is_some() + || source.artifact_id.is_some() + || source + .field + .as_deref() + .is_some_and(|field| !field.trim().is_empty()) +} + +pub fn saved_collection_id_from_metadata(metadata: &Value) -> Option { + metadata + .get("smart_shelf_save") + .and_then(|value| value.get("collection_id")) + .and_then(Value::as_str) + .and_then(|value| Uuid::parse_str(value).ok()) +} + +fn is_false(value: &bool) -> bool { + !*value +} + +#[cfg(test)] +mod tests { + use super::*; + use ferrex_model::{EpisodeID, MovieID}; + + #[test] + fn validates_duplicate_unsupported_ungrounded_missing_fields() { + let grounded = + HashSet::from([MediaID::Movie(MovieID(Uuid::from_u128(1)))]); + let source = SmartShelfDraftSource { + label: Some("Library metadata".to_string()), + media_id: Some(MediaID::Movie(MovieID(Uuid::from_u128(1)))), + artifact_id: None, + field: None, + evidence: None, + }; + let validation = validate_smart_shelf_draft_items( + &[ + SmartShelfDraftItem { + ordinal: 1, + media_id: MediaID::Movie(MovieID(Uuid::from_u128(1))), + title: Some("One".to_string()), + subtitle: None, + year: None, + reason: Some("Grounded reason".to_string()), + sources: vec![source.clone()], + locked: false, + replacement_of: None, + }, + SmartShelfDraftItem { + ordinal: 2, + media_id: MediaID::Movie(MovieID(Uuid::from_u128(1))), + title: Some("Duplicate".to_string()), + subtitle: None, + year: None, + reason: None, + sources: Vec::new(), + locked: false, + replacement_of: None, + }, + SmartShelfDraftItem { + ordinal: 3, + media_id: MediaID::Episode(EpisodeID(Uuid::from_u128(3))), + title: Some("Unsupported".to_string()), + subtitle: None, + year: None, + reason: Some("Has a reason".to_string()), + sources: vec![source], + locked: false, + replacement_of: None, + }, + ], + &grounded, + ); + + let codes = validation + .issues + .iter() + .map(|issue| issue.code) + .collect::>(); + assert!(!validation.valid); + assert!( + codes.contains(&SmartShelfDraftValidationIssueCode::DuplicateMedia) + ); + assert!( + codes.contains(&SmartShelfDraftValidationIssueCode::MissingReason) + ); + assert!( + codes.contains(&SmartShelfDraftValidationIssueCode::MissingSource) + ); + assert!( + codes.contains( + &SmartShelfDraftValidationIssueCode::UnsupportedMedia + ) + ); + assert!( + codes.contains(&SmartShelfDraftValidationIssueCode::UngroundedItem) + ); + } +} diff --git a/crates/ferrex-player-api/src/adapters/api_client_adapter.rs b/crates/ferrex-player-api/src/adapters/api_client_adapter.rs index 2d1b0169..dcb5e599 100644 --- a/crates/ferrex-player-api/src/adapters/api_client_adapter.rs +++ b/crates/ferrex-player-api/src/adapters/api_client_adapter.rs @@ -11,7 +11,9 @@ use crate::{ }; use ferrex_player_foundation::repository::{RepositoryError, RepositoryResult}; -use ferrex_core::api::types::collections::*; +use ferrex_core::api::types::{ + collections::*, intelligence::*, smart_shelves::*, +}; use ferrex_core::player_prelude::{ ActiveScansResponse, AuthToken, AuthenticatedDevice, CreateLibraryRequest, FilterIndicesRequest, ImageManifestRequest, ImageManifestResponse, @@ -78,6 +80,50 @@ fn map_collection_update_error(error: anyhow::Error) -> RepositoryError { } } +fn map_intelligence_query_error(error: anyhow::Error) -> RepositoryError { + RepositoryError::QueryFailed(error.to_string()) +} + +fn map_smart_shelf_start_error(error: anyhow::Error) -> RepositoryError { + RepositoryError::CreateFailed(error.to_string()) +} + +fn map_intelligence_cancel_error(error: anyhow::Error) -> RepositoryError { + RepositoryError::UpdateFailed(error.to_string()) +} + +fn map_smart_shelf_save_error(error: anyhow::Error) -> RepositoryError { + let message = error.to_string(); + let lower = message.to_lowercase(); + if message.contains("422") + || lower.contains("unprocessable") + || lower.contains("validation") + || lower.contains("duplicate_media") + || lower.contains("unsupported_media") + || lower.contains("ungrounded_item") + || lower.contains("missing_reason") + || lower.contains("missing_source") + || lower.contains("draft_malformed") + || lower.contains("draft_empty") + { + RepositoryError::UpdateFailed(format!( + "Smart-shelf validation failed: {}", + message + )) + } else if message.contains("409") + || lower.contains("conflict") + || lower.contains("already_saved") + || lower.contains("draft_stale") + { + RepositoryError::UpdateFailed(format!( + "Smart-shelf conflict: {}", + message + )) + } else { + RepositoryError::UpdateFailed(message) + } +} + /// Adapter that implements ApiService using the existing ApiClient #[derive(Debug, Clone)] pub struct ApiClientAdapter { @@ -632,6 +678,67 @@ impl ApiService for ApiClientAdapter { .map_err(|e| RepositoryError::QueryFailed(e.to_string())) } + async fn fetch_intelligence_provider_status( + &self, + ) -> RepositoryResult { + self.client + .intelligence_provider_status() + .await + .map_err(map_intelligence_query_error) + } + + async fn start_smart_shelf( + &self, + request: SmartShelfStartRequest, + ) -> RepositoryResult { + self.client + .start_smart_shelf(&request) + .await + .map_err(map_smart_shelf_start_error) + } + + async fn fetch_intelligence_run_status( + &self, + run_id: Uuid, + ) -> RepositoryResult { + self.client + .get_intelligence_run_status(run_id) + .await + .map_err(map_intelligence_query_error) + } + + async fn cancel_intelligence_run( + &self, + run_id: Uuid, + request: IntelligenceRunCancelRequest, + ) -> RepositoryResult { + self.client + .cancel_intelligence_run(run_id, &request) + .await + .map_err(map_intelligence_cancel_error) + } + + async fn fetch_smart_shelf_draft( + &self, + artifact_id: Uuid, + ) -> RepositoryResult { + self.client + .get_smart_shelf_draft(artifact_id) + .await + .map_err(map_intelligence_query_error) + } + + async fn save_smart_shelf( + &self, + artifact_id: Uuid, + request: SmartShelfSaveRequest, + ) -> RepositoryResult { + self.client + .save_smart_shelf(artifact_id, &request) + .await + .map_err(map_smart_shelf_save_error) + } + async fn list_collections( &self, request: ListCollectionsRequest, @@ -1183,3 +1290,62 @@ impl ApiClientAdapter { ); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn smart_shelf_error_mapping_classifies_validation_and_conflict() { + let validation = map_smart_shelf_save_error(anyhow::anyhow!( + "{}", + "Request failed with status 422 Unprocessable Entity: {\"error\":{\"code\":\"duplicate_media\"}}" + )); + assert!(matches!( + validation, + RepositoryError::UpdateFailed(message) + if message.contains("Smart-shelf validation failed") + && message.contains("duplicate_media") + )); + + let conflict = map_smart_shelf_save_error(anyhow::anyhow!( + "{}", + "Request failed with status 409 Conflict: {\"error\":{\"code\":\"already_saved\"}}" + )); + assert!(matches!( + conflict, + RepositoryError::UpdateFailed(message) + if message.contains("Smart-shelf conflict") + && message.contains("already_saved") + )); + } + + #[test] + fn intelligence_adapter_maps_reads_starts_and_cancels_like_existing_apis() { + let query = map_intelligence_query_error(anyhow::anyhow!( + "provider unavailable" + )); + assert!(matches!( + query, + RepositoryError::QueryFailed(message) + if message.contains("provider unavailable") + )); + + let start = + map_smart_shelf_start_error(anyhow::anyhow!("invalid prompt")); + assert!(matches!( + start, + RepositoryError::CreateFailed(message) + if message.contains("invalid prompt") + )); + + let cancel = map_intelligence_cancel_error(anyhow::anyhow!( + "run already terminal" + )); + assert!(matches!( + cancel, + RepositoryError::UpdateFailed(message) + if message.contains("run already terminal") + )); + } +} diff --git a/crates/ferrex-player-api/src/api_client.rs b/crates/ferrex-player-api/src/api_client.rs index 779a5800..15751dd3 100644 --- a/crates/ferrex-player-api/src/api_client.rs +++ b/crates/ferrex-player-api/src/api_client.rs @@ -7,7 +7,7 @@ use ferrex_core::{ api::{ routes::{utils::replace_param, v1}, - types::collections::*, + types::{collections::*, intelligence::*, smart_shelves::*}, }, player_prelude::{ ApiResponse, AuthToken, AuthenticatedDevice, ConfirmClaimRequest, @@ -904,6 +904,79 @@ impl ApiClient { replace_param(route, "{collection_id}", collection_id.to_string()) } + fn intelligence_run_path(route: &str, run_id: uuid::Uuid) -> String { + replace_param(route, "{run_id}", run_id.to_string()) + } + + fn intelligence_artifact_path( + route: &str, + artifact_id: uuid::Uuid, + ) -> String { + replace_param(route, "{artifact_id}", artifact_id.to_string()) + } + + /// Fetch configured intelligence provider/model readiness. + pub async fn intelligence_provider_status( + &self, + ) -> Result { + self.get(v1::intelligence::PROVIDER_STATUS).await + } + + /// Start a grounded smart-shelf intelligence run. + pub async fn start_smart_shelf( + &self, + request: &SmartShelfStartRequest, + ) -> Result { + self.post(v1::intelligence::SMART_SHELF_START, request) + .await + } + + /// Poll the current status of an intelligence run. + pub async fn get_intelligence_run_status( + &self, + run_id: uuid::Uuid, + ) -> Result { + let path = + Self::intelligence_run_path(v1::intelligence::RUN_STATUS, run_id); + self.get(&path).await + } + + /// Cancel a running intelligence run. + pub async fn cancel_intelligence_run( + &self, + run_id: uuid::Uuid, + request: &IntelligenceRunCancelRequest, + ) -> Result { + let path = + Self::intelligence_run_path(v1::intelligence::RUN_CANCEL, run_id); + self.post(&path, request).await + } + + /// Read a smart-shelf draft as a typed, validated shelf payload. + pub async fn get_smart_shelf_draft( + &self, + artifact_id: uuid::Uuid, + ) -> Result { + let path = Self::intelligence_artifact_path( + v1::intelligence::SMART_SHELF_DRAFT_DETAIL, + artifact_id, + ); + self.get(&path).await + } + + /// Save an accepted smart-shelf draft as a private manual collection. + pub async fn save_smart_shelf( + &self, + artifact_id: uuid::Uuid, + request: &SmartShelfSaveRequest, + ) -> Result { + let path = Self::intelligence_artifact_path( + v1::intelligence::SMART_SHELF_SAVE, + artifact_id, + ); + self.post(&path, request).await + } + /// List player collections with filtering and pagination. pub async fn list_collections( &self, @@ -1236,6 +1309,31 @@ mod tests { client.build_url(v1::collections::tmdb::LIST), "https://ferrex.example/api/v1/collections/tmdb/lists" ); + + let run_id = uuid("018f0c8a-2eab-7f03-a989-1fd8f8f03a14"); + assert_eq!( + ApiClient::intelligence_run_path( + v1::intelligence::RUN_CANCEL, + run_id + ), + "/api/v1/intelligence/runs/018f0c8a-2eab-7f03-a989-1fd8f8f03a14:cancel" + ); + let artifact_id = uuid("018f0c8a-2eab-7f03-a989-1fd8f8f03a15"); + assert_eq!( + ApiClient::intelligence_artifact_path( + v1::intelligence::SMART_SHELF_SAVE, + artifact_id + ), + "/api/v1/intelligence/smart-shelves/drafts/018f0c8a-2eab-7f03-a989-1fd8f8f03a15/save" + ); + assert_eq!( + client.build_url(v1::intelligence::PROVIDER_STATUS), + "https://ferrex.example/api/v1/intelligence/provider/status" + ); + assert_eq!( + client.build_url(v1::intelligence::SMART_SHELF_START), + "https://ferrex.example/api/v1/intelligence/smart-shelves/runs" + ); } #[test] @@ -1298,4 +1396,84 @@ mod tests { .expect("deserialize shelf request"); assert_eq!(decoded, shelf); } + + #[test] + fn smart_shelf_contract_dtos_round_trip_through_json() { + let media_id = MediaID::Movie(MovieID(uuid( + "018f0c8a-2eab-7f03-a989-1fd8f8f03a16", + ))); + let source = SmartShelfDraftSource { + label: Some("Library metadata".into()), + media_id: Some(media_id), + artifact_id: None, + field: Some("genres".into()), + evidence: Some(IntelligenceSummary::new("Grounded evidence")), + }; + + let start = SmartShelfStartRequest { + prompt: "Moody science fiction".into(), + library_id: None, + media_kinds: vec![IntelligenceMediaKind::Movie], + item_count: 8, + template_id: Some("mood-board".into()), + locked_media_ids: vec![media_id], + idempotency_key: Some("smart-shelf-test".into()), + model: Some("test-model".into()), + caps: IntelligenceCaps::default(), + constraints: serde_json::json!({"tone": "moody"}), + metadata: serde_json::json!({"source": "unit-test"}), + }; + let decoded_start: SmartShelfStartRequest = serde_json::from_str( + &serde_json::to_string(&start) + .expect("serialize smart shelf start"), + ) + .expect("deserialize smart shelf start"); + assert_eq!(decoded_start, start); + + let draft = SmartShelfDraftContent { + schema_version: SMART_SHELF_DRAFT_SCHEMA_VERSION, + title: "Moody sci-fi".into(), + description: Some("A deterministic smart-shelf draft".into()), + interpreted_intent: Some("Find atmospheric movies".into()), + requested_constraints: serde_json::json!({"tone": "moody"}), + items: vec![SmartShelfDraftItem { + ordinal: 1, + media_id, + title: Some("Arrival".into()), + subtitle: None, + year: Some(2016), + reason: Some( + "Grounded in metadata and transcript context".into(), + ), + sources: vec![source.clone()], + locked: false, + replacement_of: None, + }], + alternates: Vec::new(), + }; + let decoded_draft: SmartShelfDraftContent = serde_json::from_str( + &serde_json::to_string(&draft) + .expect("serialize smart shelf draft"), + ) + .expect("deserialize smart shelf draft"); + assert_eq!(decoded_draft, draft); + + let save = SmartShelfSaveRequest { + title: Some("Moody sci-fi".into()), + description: Some("Accepted by the player".into()), + items: vec![SmartShelfSaveItem { + media_id, + locked: true, + replacement_of: None, + reason: Some("Keep the grounded top pick".into()), + sources: vec![source], + }], + idempotency_key: Some("save-test".into()), + }; + let decoded_save: SmartShelfSaveRequest = serde_json::from_str( + &serde_json::to_string(&save).expect("serialize smart shelf save"), + ) + .expect("deserialize smart shelf save"); + assert_eq!(decoded_save, save); + } } diff --git a/crates/ferrex-player-api/src/api_types.rs b/crates/ferrex-player-api/src/api_types.rs index 0ddf5ec5..ff250df5 100644 --- a/crates/ferrex-player-api/src/api_types.rs +++ b/crates/ferrex-player-api/src/api_types.rs @@ -7,10 +7,12 @@ // Curated surface from ferrex-core for player-facing code pub use ferrex_contracts::prelude::{EpisodeLike, SeasonLike}; pub use ferrex_core::api::routes::v1::{ - collections as collection_routes, shelves as shelf_routes, + collections as collection_routes, intelligence as intelligence_routes, + shelves as shelf_routes, }; pub use ferrex_core::api::types::collections::*; pub use ferrex_core::api::types::intelligence::*; +pub use ferrex_core::api::types::smart_shelves::*; pub use ferrex_core::player_prelude::{ AdminUserInfo, ApiResponse, BatchMediaRequest, BatchMediaResponse, ConfirmClaimRequest, ConfirmClaimResponse, CreateLibraryRequest, diff --git a/crates/ferrex-player-api/src/services/api.rs b/crates/ferrex-player-api/src/services/api.rs index 2f9f10db..7d4754ca 100644 --- a/crates/ferrex-player-api/src/services/api.rs +++ b/crates/ferrex-player-api/src/services/api.rs @@ -9,7 +9,9 @@ use async_trait::async_trait; use ferrex_core::{ api::types::{ collections::*, + intelligence::*, setup::{ConfirmClaimResponse, StartClaimResponse}, + smart_shelves::*, }, player_prelude::{ ActiveScansResponse, AuthToken, AuthenticatedDevice, @@ -267,6 +269,45 @@ pub trait ApiService: Send + Sync + Debug { query: MediaQuery, ) -> RepositoryResult>; + // === Intelligence and smart-shelf operations === + + /// Fetch configured intelligence provider/model readiness. + async fn fetch_intelligence_provider_status( + &self, + ) -> RepositoryResult; + + /// Start a grounded smart-shelf intelligence run. + async fn start_smart_shelf( + &self, + request: SmartShelfStartRequest, + ) -> RepositoryResult; + + /// Poll the current status of an intelligence run. + async fn fetch_intelligence_run_status( + &self, + run_id: Uuid, + ) -> RepositoryResult; + + /// Cancel an active intelligence run. + async fn cancel_intelligence_run( + &self, + run_id: Uuid, + request: IntelligenceRunCancelRequest, + ) -> RepositoryResult; + + /// Read a smart-shelf draft as a typed, validated shelf payload. + async fn fetch_smart_shelf_draft( + &self, + artifact_id: Uuid, + ) -> RepositoryResult; + + /// Save an accepted smart-shelf draft as a private manual collection. + async fn save_smart_shelf( + &self, + artifact_id: Uuid, + request: SmartShelfSaveRequest, + ) -> RepositoryResult; + // === Collection operations === /// List player collections with filtering and pagination. diff --git a/crates/ferrex-player-api/src/testing/stubs/api.rs b/crates/ferrex-player-api/src/testing/stubs/api.rs index 1a2cb09b..6b262c51 100644 --- a/crates/ferrex-player-api/src/testing/stubs/api.rs +++ b/crates/ferrex-player-api/src/testing/stubs/api.rs @@ -1,10 +1,12 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet, VecDeque}; use std::path::PathBuf; use std::sync::{Arc, RwLock}; use async_trait::async_trait; use chrono::{Duration, Utc}; -use ferrex_core::api::types::collections::*; +use ferrex_core::api::types::{ + collections::*, intelligence::*, smart_shelves::*, +}; use ferrex_core::domain::users::auth::{ device::AuthDeviceStatus, domain::value_objects::SessionScope, }; @@ -46,6 +48,15 @@ struct InnerApiState { collection_order: Vec, shelf_placements: Vec, tmdb_collections: Vec, + intelligence_provider_status: IntelligenceProviderStatus, + smart_shelf_start_queue: VecDeque, + intelligence_run_statuses: + HashMap>, + intelligence_run_poll_positions: HashMap, + smart_shelf_drafts: HashMap, + smart_shelf_saves: HashMap, + next_smart_shelf_save_collection_id: Option, + next_smart_shelf_save_error: Option, next_collection_query_error: Option, next_collection_write_error: Option, watch_state: UserWatchState, @@ -59,6 +70,27 @@ struct InnerApiState { playback_ticket_result: Option>, } +#[derive(Debug, Clone)] +enum SmartShelfStubError { + Validation(String), + Conflict(String), + Storage(String), +} + +impl SmartShelfStubError { + fn into_repository_error(self) -> RepositoryError { + match self { + Self::Validation(message) => RepositoryError::UpdateFailed( + format!("Smart-shelf validation failed: {message}"), + ), + Self::Conflict(message) => RepositoryError::UpdateFailed(format!( + "Smart-shelf conflict: {message}" + )), + Self::Storage(message) => RepositoryError::StorageError(message), + } + } +} + #[derive(Debug, Clone)] struct CollectionRecord { detail: CollectionDetail, @@ -92,6 +124,14 @@ impl TestApiService { collection_order, shelf_placements: Vec::new(), tmdb_collections: sample_tmdb_collections(), + intelligence_provider_status: sample_provider_status(), + smart_shelf_start_queue: VecDeque::new(), + intelligence_run_statuses: HashMap::new(), + intelligence_run_poll_positions: HashMap::new(), + smart_shelf_drafts: HashMap::new(), + smart_shelf_saves: HashMap::new(), + next_smart_shelf_save_collection_id: None, + next_smart_shelf_save_error: None, next_collection_query_error: None, next_collection_write_error: None, watch_state: UserWatchState::new(), @@ -202,6 +242,115 @@ impl TestApiService { guard.next_collection_write_error = Some(message.into()); } } + + /// Replace the deterministic intelligence provider status fixture. + pub fn set_intelligence_provider_status( + &self, + status: IntelligenceProviderStatus, + ) { + if let Ok(mut guard) = self.inner.write() { + guard.intelligence_provider_status = status; + } + } + + /// Queue a deterministic smart-shelf start response for the next start call. + pub fn queue_smart_shelf_start(&self, response: SmartShelfStartResponse) { + if let Ok(mut guard) = self.inner.write() { + guard.smart_shelf_start_queue.push_back(response); + } + } + + /// Replace the poll sequence returned for an intelligence run. + /// + /// Each poll advances by one frame and then stays on the terminal/last + /// frame, making reducer tests deterministic without timers. + pub fn set_intelligence_run_progress( + &self, + run_id: Uuid, + statuses: Vec, + ) { + if let Ok(mut guard) = self.inner.write() { + guard.intelligence_run_statuses.insert(run_id, statuses); + guard.intelligence_run_poll_positions.insert(run_id, 0); + } + } + + /// Append one deterministic progress frame for an intelligence run. + pub fn push_intelligence_run_status( + &self, + status: IntelligenceRunStatusResponse, + ) { + if let Ok(mut guard) = self.inner.write() { + guard + .intelligence_run_statuses + .entry(status.run_id) + .or_default() + .push(status); + } + } + + /// Seed or replace a typed smart-shelf draft fixture. + pub fn upsert_smart_shelf_draft(&self, draft: SmartShelfDraftResponse) { + if let Ok(mut guard) = self.inner.write() { + guard.smart_shelf_drafts.insert(draft.artifact_id, draft); + } + } + + /// Force the next successful smart-shelf save to use a deterministic id. + pub fn set_next_smart_shelf_save_collection_id( + &self, + collection_id: CollectionId, + ) { + if let Ok(mut guard) = self.inner.write() { + guard.next_smart_shelf_save_collection_id = Some(collection_id); + } + } + + /// Cause the next smart-shelf save to fail with a validation error. + pub fn fail_next_smart_shelf_save_validation( + &self, + message: impl Into, + ) { + if let Ok(mut guard) = self.inner.write() { + guard.next_smart_shelf_save_error = + Some(SmartShelfStubError::Validation(message.into())); + } + } + + /// Cause the next smart-shelf save to fail with a conflict error. + pub fn fail_next_smart_shelf_save_conflict( + &self, + message: impl Into, + ) { + if let Ok(mut guard) = self.inner.write() { + guard.next_smart_shelf_save_error = + Some(SmartShelfStubError::Conflict(message.into())); + } + } + + /// Cause the next smart-shelf save to fail with a storage error. + pub fn fail_next_smart_shelf_save_storage( + &self, + message: impl Into, + ) { + if let Ok(mut guard) = self.inner.write() { + guard.next_smart_shelf_save_error = + Some(SmartShelfStubError::Storage(message.into())); + } + } + + /// Return a previously saved smart-shelf response, if any. + pub fn smart_shelf_save( + &self, + artifact_id: Uuid, + ) -> Option { + self.inner + .read() + .expect("lock poisoned") + .smart_shelf_saves + .get(&artifact_id) + .cloned() + } } impl InnerApiState { @@ -440,6 +589,215 @@ fn collection_detail_from_create( } } +fn sample_smart_shelf_start_response(run_id: Uuid) -> SmartShelfStartResponse { + SmartShelfStartResponse { + run_id, + status: IntelligenceRunStatus::Queued, + provider: Some("test-provider".into()), + model: Some("test-model".into()), + queued_at_epoch_seconds: Some(Utc::now().timestamp()), + draft_schema_version: SMART_SHELF_DRAFT_SCHEMA_VERSION, + } +} + +fn run_status_from_smart_shelf_start( + response: &SmartShelfStartResponse, +) -> IntelligenceRunStatusResponse { + IntelligenceRunStatusResponse { + run_id: response.run_id, + purpose: IntelligenceRunPurpose::Recommendation, + status: response.status, + terminal: matches!( + response.status, + IntelligenceRunStatus::Succeeded + | IntelligenceRunStatus::Failed + | IntelligenceRunStatus::Cancelled + ), + current_phase: Some("queued".into()), + provider: response.provider.clone(), + model: response.model.clone(), + queued_at_epoch_seconds: response.queued_at_epoch_seconds, + started_at_epoch_seconds: None, + completed_at_epoch_seconds: None, + current_step: Some(0), + max_steps: Some(1), + draft_artifact_ids: Vec::new(), + output_summary: None, + error: None, + } +} + +fn smart_shelf_validation_error( + validation: &SmartShelfDraftValidation, +) -> RepositoryError { + let message = validation + .issues + .iter() + .map(|issue| issue.message.as_str()) + .collect::>() + .join("; "); + RepositoryError::UpdateFailed(format!( + "Smart-shelf validation failed: {}", + if message.is_empty() { + "draft is invalid" + } else { + &message + } + )) +} + +fn selected_smart_shelf_items( + content: &SmartShelfDraftContent, + request: &SmartShelfSaveRequest, +) -> RepositoryResult> { + if request.items.is_empty() { + return Ok(content.items.clone()); + } + + let mut available = HashMap::new(); + for item in &content.items { + available.insert(item.media_id, item.clone()); + } + for alternate in &content.alternates { + let ordinal = alternate.target_ordinal.unwrap_or_else(|| { + u32::try_from(content.items.len() + available.len() + 1) + .unwrap_or(u32::MAX) + }); + available + .entry(alternate.media_id) + .or_insert_with(|| alternate.clone().into_item(ordinal)); + } + + let mut seen = HashSet::new(); + let mut selected = Vec::with_capacity(request.items.len()); + for (index, save_item) in request.items.iter().enumerate() { + if !seen.insert(save_item.media_id) { + return Err(RepositoryError::UpdateFailed(format!( + "Smart-shelf validation failed: duplicate accepted media {}", + save_item.media_id + ))); + } + let Some(mut item) = available.get(&save_item.media_id).cloned() else { + return Err(RepositoryError::UpdateFailed(format!( + "Smart-shelf validation failed: accepted media {} is not present in the draft", + save_item.media_id + ))); + }; + item.ordinal = u32::try_from(index + 1).unwrap_or(u32::MAX); + item.locked = save_item.locked; + item.replacement_of = save_item.replacement_of; + if save_item.reason.is_some() { + item.reason = save_item.reason.clone(); + } + if !save_item.sources.is_empty() { + item.sources = save_item.sources.clone(); + } + selected.push(item); + } + Ok(selected) +} + +fn smart_shelf_collection_record( + collection_id: CollectionId, + artifact_id: Uuid, + draft: &SmartShelfDraftResponse, + request: SmartShelfSaveRequest, + accepted_items: Vec, +) -> (SmartShelfSaveResponse, CollectionRecord) { + let now = Utc::now(); + let title = request.title.unwrap_or_else(|| { + draft + .draft + .as_ref() + .map(|content| content.title.clone()) + .unwrap_or_else(|| draft.title.clone()) + }); + let description = request.description.or_else(|| { + draft + .draft + .as_ref() + .and_then(|content| content.description.clone()) + }); + let members: Vec<_> = accepted_items + .iter() + .enumerate() + .map(|(index, item)| CollectionMember { + added_at: Some(now), + ..CollectionMember::new( + item.media_id, + item.title + .clone() + .unwrap_or_else(|| item.media_id.to_string()), + u32::try_from(index + 1).unwrap_or(u32::MAX), + ) + }) + .collect(); + let item_keys = accepted_items + .iter() + .map(|item| CollectionMemberKey::for_media(&item.media_id)) + .collect::>(); + let item_count = u32::try_from(members.len()).unwrap_or(u32::MAX); + let summary = CollectionSummary { + identity: CollectionIdentity::for_id(collection_id), + title, + description, + kind: CollectionKind::Manual, + source: CollectionSource::Manual, + owner: CollectionOwner::default(), + scope: CollectionScope::User, + visibility: CollectionVisibility::Private, + presentation: CollectionPresentationMode::Shelf, + media_scope: CollectionMediaScope::ExplicitItems { item_keys }, + duplicate_policy: CollectionDuplicatePolicy::DeduplicateMedia, + artwork: CollectionArtwork::default(), + theme: CollectionTheme::default(), + provenance: CollectionProvenance { + source: CollectionSource::Manual, + generated_by: Some("smart_shelf".into()), + external_id: Some(artifact_id.to_string()), + last_refreshed_at: Some(now), + ..CollectionProvenance::default() + }, + version: CollectionVersion { + revision: 1, + etag: Some(format!("collection-{}-1", collection_id)), + ..CollectionVersion::default() + }, + timestamps: CollectionTimestamps { + created_at: now, + updated_at: now, + archived_at: None, + }, + item_count, + materialization: CollectionMaterializationStatus { + state: CollectionMaterializationState::Ready, + item_count, + generated_at: Some(now), + ..CollectionMaterializationStatus::default() + }, + }; + let response = SmartShelfSaveResponse { + draft_artifact_id: artifact_id, + collection_id, + collection: summary.clone(), + item_count, + saved_at_epoch_seconds: Some(now.timestamp()), + }; + let detail = CollectionDetail { + summary, + rule: None, + items_preview: members.iter().take(12).cloned().collect(), + shelf_placements: Vec::new(), + }; + ( + response, + CollectionRecord { + detail, + items: members, + }, + ) +} + #[async_trait] impl ApiService for TestApiService { async fn get_rkyv( @@ -883,6 +1241,221 @@ impl ApiService for TestApiService { Ok(Vec::new()) } + async fn fetch_intelligence_provider_status( + &self, + ) -> RepositoryResult { + Ok(self + .inner + .read() + .expect("lock poisoned") + .intelligence_provider_status + .clone()) + } + + async fn start_smart_shelf( + &self, + request: SmartShelfStartRequest, + ) -> RepositoryResult { + if request.prompt.trim().is_empty() { + return Err(RepositoryError::CreateFailed( + "smart-shelf prompt must not be empty".into(), + )); + } + + let mut guard = self.inner.write().expect("lock poisoned"); + let response = guard + .smart_shelf_start_queue + .pop_front() + .unwrap_or_else(|| { + sample_smart_shelf_start_response(Uuid::now_v7()) + }); + guard + .intelligence_run_statuses + .entry(response.run_id) + .or_insert_with(|| { + vec![run_status_from_smart_shelf_start(&response)] + }); + guard + .intelligence_run_poll_positions + .entry(response.run_id) + .or_insert(0); + Ok(response) + } + + async fn fetch_intelligence_run_status( + &self, + run_id: Uuid, + ) -> RepositoryResult { + let mut guard = self.inner.write().expect("lock poisoned"); + let (status, next_position) = { + let statuses = guard + .intelligence_run_statuses + .get(&run_id) + .ok_or_else(|| RepositoryError::NotFound { + entity_type: "IntelligenceRun".into(), + id: run_id.to_string(), + })?; + if statuses.is_empty() { + return Err(RepositoryError::QueryFailed(format!( + "Intelligence run {} has no status frames", + run_id + ))); + } + + let position = guard + .intelligence_run_poll_positions + .get(&run_id) + .copied() + .unwrap_or(0) + .min(statuses.len() - 1); + let status = statuses[position].clone(); + let next_position = + (position + 1 < statuses.len()).then_some(position + 1); + (status, next_position) + }; + if let Some(next_position) = next_position { + guard + .intelligence_run_poll_positions + .insert(run_id, next_position); + } + Ok(status) + } + + async fn cancel_intelligence_run( + &self, + run_id: Uuid, + request: IntelligenceRunCancelRequest, + ) -> RepositoryResult { + let mut guard = self.inner.write().expect("lock poisoned"); + let statuses = guard + .intelligence_run_statuses + .get_mut(&run_id) + .ok_or_else(|| RepositoryError::NotFound { + entity_type: "IntelligenceRun".into(), + id: run_id.to_string(), + })?; + let Some(current) = statuses.last().cloned() else { + return Err(RepositoryError::QueryFailed(format!( + "Intelligence run {} has no status frames", + run_id + ))); + }; + let now = Utc::now().timestamp(); + let cancelled = IntelligenceRunStatusResponse { + status: IntelligenceRunStatus::Cancelled, + terminal: true, + current_phase: Some("cancelled".into()), + completed_at_epoch_seconds: Some(now), + error: request.reason.as_ref().map(|reason| IntelligenceError { + code: IntelligenceErrorCode::RunCancelled, + message: reason.clone(), + retryable: false, + details: serde_json::Value::Null, + }), + ..current + }; + let cancelled_position = { + statuses.push(cancelled); + statuses.len() - 1 + }; + guard + .intelligence_run_poll_positions + .insert(run_id, cancelled_position); + + Ok(IntelligenceRunCancelResponse { + run_id, + status: IntelligenceRunStatus::Cancelled, + cancellation_requested: true, + cancelled_at_epoch_seconds: Some(now), + message: request.reason, + error: None, + }) + } + + async fn fetch_smart_shelf_draft( + &self, + artifact_id: Uuid, + ) -> RepositoryResult { + self.inner + .read() + .expect("lock poisoned") + .smart_shelf_drafts + .get(&artifact_id) + .cloned() + .ok_or_else(|| RepositoryError::NotFound { + entity_type: "SmartShelfDraft".into(), + id: artifact_id.to_string(), + }) + } + + async fn save_smart_shelf( + &self, + artifact_id: Uuid, + request: SmartShelfSaveRequest, + ) -> RepositoryResult { + let mut guard = self.inner.write().expect("lock poisoned"); + if let Some(error) = guard.next_smart_shelf_save_error.take() { + return Err(error.into_repository_error()); + } + if let Some(saved) = guard.smart_shelf_saves.get(&artifact_id) { + return Err(RepositoryError::UpdateFailed(format!( + "Smart-shelf conflict: draft {} has already been saved as {}", + artifact_id, saved.collection_id + ))); + } + + let draft = guard + .smart_shelf_drafts + .get(&artifact_id) + .cloned() + .ok_or_else(|| RepositoryError::NotFound { + entity_type: "SmartShelfDraft".into(), + id: artifact_id.to_string(), + })?; + if !draft.validation.valid { + return Err(smart_shelf_validation_error(&draft.validation)); + } + let Some(content) = draft.draft.as_ref() else { + return Err(RepositoryError::UpdateFailed( + "Smart-shelf validation failed: draft content is missing" + .into(), + )); + }; + let accepted_items = selected_smart_shelf_items(content, &request)?; + let accepted_grounding = accepted_items + .iter() + .map(|item| item.media_id) + .collect::>(); + let accepted_validation = validate_smart_shelf_draft_items( + &accepted_items, + &accepted_grounding, + ); + if !accepted_validation.valid { + return Err(smart_shelf_validation_error(&accepted_validation)); + } + + let collection_id = guard + .next_smart_shelf_save_collection_id + .take() + .unwrap_or_default(); + let (response, record) = smart_shelf_collection_record( + collection_id, + artifact_id, + &draft, + request, + accepted_items, + ); + guard.collection_order.push(collection_id); + guard.collections.insert(collection_id, record); + if let Some(draft) = guard.smart_shelf_drafts.get_mut(&artifact_id) { + draft.saved_collection_id = Some(collection_id); + } + guard + .smart_shelf_saves + .insert(artifact_id, response.clone()); + Ok(response) + } + async fn list_collections( &self, request: ListCollectionsRequest, @@ -1943,6 +2516,26 @@ fn sample_collections() -> CollectionFixtures { ) } +fn sample_provider_status() -> IntelligenceProviderStatus { + IntelligenceProviderStatus { + enabled: true, + provider_name: "test-provider".into(), + base_url: "https://llm.test".into(), + api_key_configured: true, + default_model: Some("test-model".into()), + state: IntelligenceProviderState::Ready, + models: vec![IntelligenceModelStatus { + name: "test-model".into(), + selected: true, + available: true, + supports_tools: true, + context_window_tokens: Some(8192), + }], + checked_at_epoch_seconds: Some(Utc::now().timestamp()), + error: None, + } +} + fn sample_tmdb_collections() -> Vec { vec![ TmdbCollectionSummary { @@ -2078,6 +2671,107 @@ mod tests { } } + fn fixed_uuid(value: u128) -> Uuid { + Uuid::from_u128(value) + } + + fn smart_shelf_run_status( + run_id: Uuid, + status: IntelligenceRunStatus, + step: u32, + draft_artifact_ids: Vec, + ) -> IntelligenceRunStatusResponse { + IntelligenceRunStatusResponse { + run_id, + purpose: IntelligenceRunPurpose::Recommendation, + status, + terminal: matches!( + status, + IntelligenceRunStatus::Succeeded + | IntelligenceRunStatus::Failed + | IntelligenceRunStatus::Cancelled + ), + current_phase: Some(format!("step-{step}")), + provider: Some("test-provider".into()), + model: Some("test-model".into()), + queued_at_epoch_seconds: Some(1), + started_at_epoch_seconds: (step > 0).then_some(2), + completed_at_epoch_seconds: matches!( + status, + IntelligenceRunStatus::Succeeded + | IntelligenceRunStatus::Failed + | IntelligenceRunStatus::Cancelled + ) + .then_some(3), + current_step: Some(step), + max_steps: Some(3), + draft_artifact_ids, + output_summary: None, + error: None, + } + } + + fn smart_shelf_draft( + artifact_id: Uuid, + run_id: Uuid, + media_id: MediaID, + valid: bool, + ) -> SmartShelfDraftResponse { + let source = SmartShelfDraftSource { + label: Some("Library metadata".into()), + media_id: Some(media_id), + artifact_id: None, + field: Some("genres".into()), + evidence: Some(IntelligenceSummary::new("Grounded evidence")), + }; + let item = SmartShelfDraftItem { + ordinal: 1, + media_id, + title: Some("Arrival".into()), + subtitle: None, + year: Some(2016), + reason: Some("Grounded in library metadata".into()), + sources: vec![source], + locked: false, + replacement_of: None, + }; + let content = SmartShelfDraftContent { + schema_version: SMART_SHELF_DRAFT_SCHEMA_VERSION, + title: "Moody sci-fi".into(), + description: Some("Atmospheric science fiction".into()), + interpreted_intent: Some("Find moody picks".into()), + requested_constraints: serde_json::json!({"tone": "moody"}), + items: vec![item], + alternates: Vec::new(), + }; + let validation = if valid { + validate_smart_shelf_draft_items( + &content.items, + &HashSet::from([media_id]), + ) + } else { + SmartShelfDraftValidation::from_issues(vec![ + SmartShelfDraftValidationIssue::for_item( + SmartShelfDraftValidationIssueCode::MissingReason, + 1, + media_id, + "smart-shelf draft item is missing a grounded reason", + ), + ]) + }; + + SmartShelfDraftResponse { + artifact_id, + run_id: Some(run_id), + owner_user_id: Some(fixed_uuid(42)), + title: "Moody sci-fi".into(), + summary: Some(IntelligenceSummary::new("A grounded smart shelf")), + draft: Some(content), + validation, + saved_collection_id: None, + } + } + #[tokio::test] async fn collection_stub_lists_details_pages_and_reorders_items() { let service = TestApiService::default(); @@ -2404,4 +3098,191 @@ mod tests { Some("550") ); } + + #[tokio::test] + async fn smart_shelf_stub_progress_cancel_draft_and_save_are_deterministic() + { + let service = TestApiService::default(); + let provider = service + .fetch_intelligence_provider_status() + .await + .expect("provider status"); + assert_eq!(provider.state, IntelligenceProviderState::Ready); + + let run_id = fixed_uuid(100); + let artifact_id = fixed_uuid(101); + let collection_id = CollectionId::from(fixed_uuid(102)); + let media_id = MediaID::Movie(MovieID(fixed_uuid(103))); + service.queue_smart_shelf_start(SmartShelfStartResponse { + run_id, + status: IntelligenceRunStatus::Queued, + provider: Some("test-provider".into()), + model: Some("test-model".into()), + queued_at_epoch_seconds: Some(1), + draft_schema_version: SMART_SHELF_DRAFT_SCHEMA_VERSION, + }); + service.set_intelligence_run_progress( + run_id, + vec![ + smart_shelf_run_status( + run_id, + IntelligenceRunStatus::Queued, + 0, + Vec::new(), + ), + smart_shelf_run_status( + run_id, + IntelligenceRunStatus::Running, + 1, + Vec::new(), + ), + ], + ); + service.upsert_smart_shelf_draft(smart_shelf_draft( + artifact_id, + run_id, + media_id, + true, + )); + service.set_next_smart_shelf_save_collection_id(collection_id); + + let start = service + .start_smart_shelf(SmartShelfStartRequest { + prompt: "Moody science fiction".into(), + library_id: None, + media_kinds: vec![IntelligenceMediaKind::Movie], + item_count: 8, + template_id: None, + locked_media_ids: Vec::new(), + idempotency_key: Some("deterministic-run".into()), + model: None, + caps: IntelligenceCaps::default(), + constraints: serde_json::Value::Null, + metadata: serde_json::Value::Null, + }) + .await + .expect("start smart shelf"); + assert_eq!(start.run_id, run_id); + + let first_poll = service + .fetch_intelligence_run_status(run_id) + .await + .expect("first poll"); + assert_eq!(first_poll.status, IntelligenceRunStatus::Queued); + let second_poll = service + .fetch_intelligence_run_status(run_id) + .await + .expect("second poll"); + assert_eq!(second_poll.status, IntelligenceRunStatus::Running); + + let cancel = service + .cancel_intelligence_run( + run_id, + IntelligenceRunCancelRequest { + reason: Some("user changed prompt".into()), + }, + ) + .await + .expect("cancel run"); + assert!(cancel.cancellation_requested); + let cancelled_poll = service + .fetch_intelligence_run_status(run_id) + .await + .expect("cancelled poll"); + assert_eq!(cancelled_poll.status, IntelligenceRunStatus::Cancelled); + assert!(cancelled_poll.terminal); + + let draft = service + .fetch_smart_shelf_draft(artifact_id) + .await + .expect("smart shelf draft"); + assert!(draft.validation.valid); + assert_eq!(draft.draft.as_ref().expect("draft content").items.len(), 1); + + let saved = service + .save_smart_shelf(artifact_id, SmartShelfSaveRequest::default()) + .await + .expect("save smart shelf"); + assert_eq!(saved.collection_id, collection_id); + assert_eq!(saved.item_count, 1); + assert_eq!(service.smart_shelf_save(artifact_id), Some(saved.clone())); + + let detail = service + .get_collection_detail( + collection_id, + GetCollectionDetailRequest { + include_rule: true, + include_items_preview: true, + include_shelf_placements: false, + }, + ) + .await + .expect("saved collection detail"); + assert_eq!(detail.collection.summary.title, "Moody sci-fi"); + assert_eq!(detail.collection.items_preview.len(), 1); + + let duplicate = service + .save_smart_shelf(artifact_id, SmartShelfSaveRequest::default()) + .await + .expect_err("saving the same draft twice conflicts"); + assert!(duplicate.to_string().contains("Smart-shelf conflict")); + } + + #[tokio::test] + async fn smart_shelf_stub_reports_validation_and_conflict_errors() { + let service = TestApiService::default(); + let run_id = fixed_uuid(200); + let invalid_artifact_id = fixed_uuid(201); + let valid_artifact_id = fixed_uuid(202); + let media_id = MediaID::Movie(MovieID(fixed_uuid(203))); + service.upsert_smart_shelf_draft(smart_shelf_draft( + invalid_artifact_id, + run_id, + media_id, + false, + )); + service.upsert_smart_shelf_draft(smart_shelf_draft( + valid_artifact_id, + run_id, + media_id, + true, + )); + + let invalid = service + .save_smart_shelf( + invalid_artifact_id, + SmartShelfSaveRequest::default(), + ) + .await + .expect_err("invalid draft should not save"); + assert!( + invalid + .to_string() + .contains("Smart-shelf validation failed") + ); + + service.fail_next_smart_shelf_save_validation("duplicate media"); + let forced_validation = service + .save_smart_shelf( + valid_artifact_id, + SmartShelfSaveRequest::default(), + ) + .await + .expect_err("forced validation error"); + assert!( + forced_validation + .to_string() + .contains("Smart-shelf validation failed") + ); + + service.fail_next_smart_shelf_save_conflict("draft was already saved"); + let forced_conflict = service + .save_smart_shelf( + valid_artifact_id, + SmartShelfSaveRequest::default(), + ) + .await + .expect_err("forced conflict error"); + assert!(forced_conflict.to_string().contains("Smart-shelf conflict")); + } } diff --git a/crates/ferrex-player-app/src/app/presets.rs b/crates/ferrex-player-app/src/app/presets.rs index ce42f642..ed16db38 100644 --- a/crates/ferrex-player-app/src/app/presets.rs +++ b/crates/ferrex-player-app/src/app/presets.rs @@ -10,6 +10,10 @@ use crate::{ SetupClaimStatus, SetupStep, TransitionDirection, }, }, + intelligence::{ + ProviderReadiness, SmartShelfDraftState, SmartShelfFailure, + SmartShelfPhase, SmartShelfRunState, + }, settings::{ sections::devices::state::{DeviceManagementState, UserDevice}, state::PreferencesState, @@ -42,6 +46,14 @@ use crate::{ }, }, infra::{ + api_types::{ + IntelligenceMediaKind, IntelligenceRunPurpose, + IntelligenceRunStatus, IntelligenceRunStatusResponse, + IntelligenceSummary, SMART_SHELF_DRAFT_SCHEMA_VERSION, + SmartShelfDraftAlternate, SmartShelfDraftContent, + SmartShelfDraftItem, SmartShelfDraftResponse, + SmartShelfDraftSource, SmartShelfDraftValidation, + }, repository::media_repo::MediaRepo, shader_widgets::poster::PosterInstanceKey, }, @@ -105,6 +117,22 @@ pub enum PlayerScenario { DesktopCollectionsCreateForm, /// Desktop Collections detail surface with manual editor controls open. DesktopCollectionsManualEditor, + /// Smart-shelf composer overlay with provider-ready deterministic fixtures. + SmartShelfComposer, + /// Smart-shelf running/progress overlay with deterministic runtime status. + SmartShelfRunningProgress, + /// Smart-shelf draft review overlay with a valid generated draft. + SmartShelfDraftReady, + /// Smart-shelf draft review overlay showing replacement and alternate choices. + SmartShelfAlternatesReplacement, + /// Smart-shelf provider fallback overlay when the local intelligence provider is unavailable. + SmartShelfProviderUnavailable, + /// Smart-shelf saved private collection detail surface. + SmartShelfSavedCollectionDetail, + /// Smart-shelf saved collection detail with no materialized items. + SmartShelfCollectionEmpty, + /// Smart-shelf collection detail error/retry state. + SmartShelfCollectionError, /// Desktop movie detail surface with seeded media and artwork. DesktopMovieDetail, /// Desktop movie detail surface restored to its lower cast/review section. @@ -161,12 +189,20 @@ impl std::fmt::Display for PlayerScenario { impl PlayerScenario { /// Canonical scenarios exposed to agents. - pub const ALL: [Self; 28] = [ + pub const ALL: [Self; 36] = [ Self::FirstRunAuth, Self::UserSelection, Self::DesktopLibraryHome, Self::DesktopCollectionsCreateForm, Self::DesktopCollectionsManualEditor, + Self::SmartShelfComposer, + Self::SmartShelfRunningProgress, + Self::SmartShelfDraftReady, + Self::SmartShelfAlternatesReplacement, + Self::SmartShelfProviderUnavailable, + Self::SmartShelfSavedCollectionDetail, + Self::SmartShelfCollectionEmpty, + Self::SmartShelfCollectionError, Self::DesktopMovieDetail, Self::DesktopMovieDetailScrolled, Self::DesktopSeriesDetail, @@ -204,6 +240,20 @@ impl PlayerScenario { Self::DesktopCollectionsManualEditor => { "DesktopCollectionsManualEditor" } + Self::SmartShelfComposer => "SmartShelfComposer", + Self::SmartShelfRunningProgress => "SmartShelfRunningProgress", + Self::SmartShelfDraftReady => "SmartShelfDraftReady", + Self::SmartShelfAlternatesReplacement => { + "SmartShelfAlternatesReplacement" + } + Self::SmartShelfProviderUnavailable => { + "SmartShelfProviderUnavailable" + } + Self::SmartShelfSavedCollectionDetail => { + "SmartShelfSavedCollectionDetail" + } + Self::SmartShelfCollectionEmpty => "SmartShelfCollectionEmpty", + Self::SmartShelfCollectionError => "SmartShelfCollectionError", Self::DesktopMovieDetail => "DesktopMovieDetail", Self::DesktopMovieDetailScrolled => "DesktopMovieDetailScrolled", Self::DesktopSeriesDetail => "DesktopSeriesDetail", @@ -256,6 +306,30 @@ impl PlayerScenario { Self::DesktopCollectionsManualEditor => { "Desktop Collections detail surface with manual edit, add, remove, reorder, archive, and conflict recovery states" } + Self::SmartShelfComposer => { + "Smart-shelf composer overlay with deterministic provider-ready prompt, template, scope, and model controls" + } + Self::SmartShelfRunningProgress => { + "Smart-shelf running/progress overlay with deterministic queued/running status, step count, provider, and cancel affordance" + } + Self::SmartShelfDraftReady => { + "Smart-shelf draft review overlay with grounded selected items ready to save as a private collection" + } + Self::SmartShelfAlternatesReplacement => { + "Smart-shelf draft review overlay showing a selected replacement and available alternate choices" + } + Self::SmartShelfProviderUnavailable => { + "Smart-shelf provider fallback overlay for local provider unavailable/retry expectations" + } + Self::SmartShelfSavedCollectionDetail => { + "Collections detail surface for a smart-shelf-saved private manual collection with materialized items" + } + Self::SmartShelfCollectionEmpty => { + "Collections detail surface for a smart-shelf-saved collection with no materialized items" + } + Self::SmartShelfCollectionError => { + "Collections detail error state for retrying a smart-shelf-saved collection load" + } Self::DesktopMovieDetail => { "Desktop movie detail page for a seeded deterministic movie" } @@ -360,6 +434,35 @@ impl PlayerScenario { | "desktopcollectionsmanualeditor" => { Some(Self::DesktopCollectionsManualEditor) } + "smartshelf" | "smartshelfcomposer" => { + Some(Self::SmartShelfComposer) + } + "smartshelfrunning" + | "smartshelfprogress" + | "smartshelfrunningprogress" => { + Some(Self::SmartShelfRunningProgress) + } + "smartshelfdraft" | "smartshelfdraftready" => { + Some(Self::SmartShelfDraftReady) + } + "smartshelfalternates" + | "smartshelfreplacement" + | "smartshelfalternatesreplacement" => { + Some(Self::SmartShelfAlternatesReplacement) + } + "smartshelfproviderunavailable" | "smartshelffallback" => { + Some(Self::SmartShelfProviderUnavailable) + } + "smartshelfsavedcollection" + | "smartshelfsavedcollectiondetail" => { + Some(Self::SmartShelfSavedCollectionDetail) + } + "smartshelfcollectionempty" => { + Some(Self::SmartShelfCollectionEmpty) + } + "smartshelfcollectionerror" => { + Some(Self::SmartShelfCollectionError) + } "desktopmoviedetail" | "moviedetail" => { Some(Self::DesktopMovieDetail) } @@ -433,6 +536,26 @@ impl PlayerScenario { Self::DesktopCollectionsManualEditor => { desktop_collections_manual_editor_state(config) } + Self::SmartShelfComposer => smart_shelf_composer_state(config), + Self::SmartShelfRunningProgress => { + smart_shelf_running_progress_state(config) + } + Self::SmartShelfDraftReady => smart_shelf_draft_ready_state(config), + Self::SmartShelfAlternatesReplacement => { + smart_shelf_alternates_replacement_state(config) + } + Self::SmartShelfProviderUnavailable => { + smart_shelf_provider_unavailable_state(config) + } + Self::SmartShelfSavedCollectionDetail => { + smart_shelf_saved_collection_detail_state(config) + } + Self::SmartShelfCollectionEmpty => { + smart_shelf_collection_empty_state(config) + } + Self::SmartShelfCollectionError => { + smart_shelf_collection_error_state(config) + } Self::DesktopMovieDetail => desktop_movie_detail_state(config), Self::DesktopMovieDetailScrolled => { desktop_movie_detail_scrolled_state(config) @@ -765,6 +888,470 @@ fn desktop_collections_manual_editor_state(config: &AppConfig) -> State { state } +fn smart_shelf_composer_state(config: &AppConfig) -> State { + let mut state = settings_devices_state(config); + configure_smart_shelf_composer(&mut state); + state +} + +fn smart_shelf_running_progress_state(config: &AppConfig) -> State { + let mut state = smart_shelf_composer_state(config); + let run_status = smart_shelf_run_status(IntelligenceRunStatus::Running, 2); + let surface = &mut state.domains.ui.state.smart_shelf; + surface.reducer.phase = SmartShelfPhase::Running; + surface.reducer.run = Some(SmartShelfRunState::from_status(&run_status)); + surface.reducer.last_draft_artifact_id = Some(smart_shelf_artifact_id()); + state +} + +fn smart_shelf_draft_ready_state(config: &AppConfig) -> State { + let mut state = smart_shelf_composer_state(config); + apply_smart_shelf_draft(&mut state, false); + state +} + +fn smart_shelf_alternates_replacement_state(config: &AppConfig) -> State { + let mut state = smart_shelf_composer_state(config); + apply_smart_shelf_draft(&mut state, true); + state +} + +fn smart_shelf_provider_unavailable_state(config: &AppConfig) -> State { + let mut state = smart_shelf_composer_state(config); + let message = "Local intelligence provider is unavailable at http://127.0.0.1:8081/v1"; + let surface = &mut state.domains.ui.state.smart_shelf; + surface.reducer.phase = SmartShelfPhase::ProviderUnavailable; + surface.reducer.provider = ProviderReadiness::Unavailable { + message: message.to_string(), + retryable: true, + }; + surface.reducer.last_error = + Some(SmartShelfFailure::provider_unavailable(message, true)); + surface.provider_fallback = Some( + crate::domains::ui::smart_shelf::SmartShelfProviderFallbackState { + message: message.to_string(), + retryable: true, + }, + ); + state +} + +fn smart_shelf_saved_collection_detail_state(config: &AppConfig) -> State { + smart_shelf_collection_detail_state( + config, + SmartShelfCollectionPreset::Loaded, + ) +} + +fn smart_shelf_collection_empty_state(config: &AppConfig) -> State { + smart_shelf_collection_detail_state( + config, + SmartShelfCollectionPreset::Empty, + ) +} + +fn smart_shelf_collection_error_state(config: &AppConfig) -> State { + smart_shelf_collection_detail_state( + config, + SmartShelfCollectionPreset::Error, + ) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SmartShelfCollectionPreset { + Loaded, + Empty, + Error, +} + +fn configure_smart_shelf_composer(state: &mut State) { + let surface = &mut state.domains.ui.state.smart_shelf; + surface.open = true; + surface.confirm_discard = false; + surface.provider_fallback = None; + surface.notice = None; + surface.reducer.provider = ProviderReadiness::Ready { + provider: "local llama.cpp".to_string(), + model: Some("gemma-4-12b-it".to_string()), + }; + surface.reducer.phase = SmartShelfPhase::Idle; + surface.reducer.composer.prompt = + "Grounded rainy-night science fiction with resilient heroes" + .to_string(); + surface.reducer.composer.selected_template_id = None; + surface.reducer.composer.library_id = Some(seed_library_id(0)); + surface.reducer.composer.media_kinds = + vec![IntelligenceMediaKind::Movie, IntelligenceMediaKind::Series]; + surface.reducer.composer.item_count = 6; + surface.reducer.composer.model = Some("gemma-4-12b-it".to_string()); + surface.reducer.composer.constraints = serde_json::json!({ + "tone": "rainy-night", + "grounding": "library_metadata_only", + }); + surface.reducer.composer.metadata = serde_json::json!({ + "fixture": "smart-shelf-mvp-visual-qa", + "excluded_surfaces": ["android", "android_tv", "home_pinning", "chatbot", "playback_queue"], + }); +} + +fn apply_smart_shelf_draft(state: &mut State, replacement: bool) { + let draft = smart_shelf_draft_response(replacement); + let surface = &mut state.domains.ui.state.smart_shelf; + surface.reducer.phase = SmartShelfPhase::DraftReady; + surface.reducer.run = Some(SmartShelfRunState::from_status( + &smart_shelf_run_status(IntelligenceRunStatus::Succeeded, 4), + )); + surface.reducer.last_draft_artifact_id = Some(draft.artifact_id); + surface.reducer.draft = Some(SmartShelfDraftState::from_response(draft)); +} + +fn smart_shelf_run_status( + status: IntelligenceRunStatus, + step: u32, +) -> IntelligenceRunStatusResponse { + IntelligenceRunStatusResponse { + run_id: smart_shelf_run_id(), + purpose: IntelligenceRunPurpose::Recommendation, + status, + terminal: matches!( + status, + IntelligenceRunStatus::Succeeded + | IntelligenceRunStatus::Failed + | IntelligenceRunStatus::Cancelled + ), + current_phase: Some( + match status { + IntelligenceRunStatus::Queued => "queued", + IntelligenceRunStatus::Running => "grounding candidates", + IntelligenceRunStatus::Succeeded => "draft ready", + IntelligenceRunStatus::Failed => "failed", + IntelligenceRunStatus::Cancelled => "cancelled", + } + .to_string(), + ), + provider: Some("local llama.cpp".to_string()), + model: Some("gemma-4-12b-it".to_string()), + queued_at_epoch_seconds: Some(1_782_048_000), + started_at_epoch_seconds: (step > 0).then_some(1_782_048_005), + completed_at_epoch_seconds: matches!( + status, + IntelligenceRunStatus::Succeeded + ) + .then_some(1_782_048_030), + current_step: Some(step), + max_steps: Some(4), + draft_artifact_ids: matches!(status, IntelligenceRunStatus::Succeeded) + .then(|| vec![smart_shelf_artifact_id()]) + .unwrap_or_default(), + output_summary: Some(IntelligenceSummary::new( + "Deterministic fake provider selected grounded smart-shelf items.", + )), + error: None, + } +} + +fn smart_shelf_draft_response(replacement: bool) -> SmartShelfDraftResponse { + let artifact_id = smart_shelf_artifact_id(); + let run_id = smart_shelf_run_id(); + let first_media_id = MediaID::Movie(seed_movie_id(0)); + let second_media_id = if replacement { + MediaID::Series(seed_series_id(0)) + } else { + MediaID::Movie(seed_movie_id(1)) + }; + let replacement_of = + replacement.then_some(MediaID::Movie(seed_movie_id(1))); + + let items = vec![ + smart_shelf_draft_item( + 1, + first_media_id, + "Aurora Transit", + "Movie · 2024 · grounded by genres", + "Matches the rainy-night prompt through resilient transit stakes and moody sci-fi metadata.", + false, + None, + ), + smart_shelf_draft_item( + 2, + second_media_id, + if replacement { + "Signal Grove" + } else { + "Copper Harbor" + }, + if replacement { + "Series · 2023 · replacement" + } else { + "Movie · 2022 · grounded by mood" + }, + if replacement { + "Replaces Copper Harbor with a grounded series pick while preserving the requested atmosphere." + } else { + "Grounded by warm mystery metadata and compatible rainy-night pacing." + }, + replacement, + replacement_of, + ), + ]; + let alternates = vec![smart_shelf_draft_alternate( + Some(2), + MediaID::Movie(seed_movie_id(1)), + "Copper Harbor", + "Original movie alternate", + "Return to the original deterministic movie pick if series entries are out of scope.", + )]; + + SmartShelfDraftResponse { + artifact_id, + run_id: Some(run_id), + owner_user_id: Some(seed_user_id(0)), + title: "Rainy-night grounded picks".to_string(), + summary: Some(IntelligenceSummary::new( + "A deterministic smart shelf grounded in seeded library metadata.", + )), + draft: Some(SmartShelfDraftContent { + schema_version: SMART_SHELF_DRAFT_SCHEMA_VERSION, + title: "Rainy-night grounded picks".to_string(), + description: Some( + "Private smart shelf generated from deterministic fake-provider fixtures." + .to_string(), + ), + interpreted_intent: Some( + "Find grounded rainy-night science fiction and mystery picks" + .to_string(), + ), + requested_constraints: serde_json::json!({ + "tone": "rainy-night", + "max_items": 6, + }), + items, + alternates, + }), + validation: SmartShelfDraftValidation { + valid: true, + issues: Vec::new(), + }, + saved_collection_id: None, + } +} + +fn smart_shelf_draft_item( + ordinal: u32, + media_id: MediaID, + title: &str, + subtitle: &str, + reason: &str, + locked: bool, + replacement_of: Option, +) -> SmartShelfDraftItem { + SmartShelfDraftItem { + ordinal, + media_id, + title: Some(title.to_string()), + subtitle: Some(subtitle.to_string()), + year: Some(if matches!(media_id, MediaID::Movie(_)) { + 2024 + } else { + 2023 + }), + reason: Some(reason.to_string()), + sources: vec![smart_shelf_source(media_id, "genres")], + locked, + replacement_of, + } +} + +fn smart_shelf_draft_alternate( + target_ordinal: Option, + media_id: MediaID, + title: &str, + subtitle: &str, + reason: &str, +) -> SmartShelfDraftAlternate { + SmartShelfDraftAlternate { + target_ordinal, + media_id, + title: Some(title.to_string()), + subtitle: Some(subtitle.to_string()), + year: Some(2022), + reason: Some(reason.to_string()), + sources: vec![smart_shelf_source(media_id, "overview")], + } +} + +fn smart_shelf_source(media_id: MediaID, field: &str) -> SmartShelfDraftSource { + SmartShelfDraftSource { + label: Some("Deterministic library metadata".to_string()), + media_id: Some(media_id), + artifact_id: Some(smart_shelf_artifact_id()), + field: Some(field.to_string()), + evidence: Some(IntelligenceSummary::new( + "Grounded fake-provider evidence from seeded media metadata.", + )), + } +} + +fn smart_shelf_collection_detail_state( + config: &AppConfig, + preset: SmartShelfCollectionPreset, +) -> State { + let mut state = authenticated_base_state(config, false); + let seed = seed_library_state(&mut state); + let include_items = matches!(preset, SmartShelfCollectionPreset::Loaded); + let (detail, members) = smart_shelf_collection_detail(&seed, include_items); + let collection_id = detail.summary.identity.id; + let summary = detail.summary.clone(); + + state.domains.ui.state.scope = Scope::Collections; + state.domains.ui.state.view = ViewState::CollectionDetail { collection_id }; + state.tab_manager.set_active_tab(TabId::Collections); + + if let TabState::Collections(tab) = + state.tab_manager.get_or_create_tab(TabId::Collections) + { + tab.mark_loaded( + vec![summary.clone()], + CollectionPageInfo { + next_cursor: None, + limit: 50, + total: 1, + }, + ); + match preset { + SmartShelfCollectionPreset::Loaded + | SmartShelfCollectionPreset::Empty => { + tab.mark_detail_loaded(detail); + tab.mark_items_loaded( + collection_id, + members, + CollectionPageInfo { + next_cursor: None, + limit: 50, + total: summary.item_count as u64, + }, + summary.materialization.clone(), + false, + ); + } + SmartShelfCollectionPreset::Error => { + tab.mark_detail_error( + collection_id, + "Saved smart-shelf collection detail could not load; retry keeps the draft save intact.", + ); + } + } + } + + state.loading = false; + state +} + +fn smart_shelf_collection_detail( + seed: &SeededLibraryState, + include_items: bool, +) -> (CollectionDetail, Vec) { + let now = fixed_time(0); + let collection_id = smart_shelf_collection_id(); + let mut members = if include_items { + vec![ + CollectionMember::new( + MediaID::Movie(seed.movies[0].id), + "Aurora Transit", + 1, + ), + CollectionMember::new( + MediaID::Series(seed.series[0].id), + "Signal Grove", + 2, + ), + ] + } else { + Vec::new() + }; + if let Some(member) = members.get_mut(0) { + member.subtitle = + Some("Grounded movie pick from fake provider".to_string()); + } + if let Some(member) = members.get_mut(1) { + member.subtitle = Some("Grounded series replacement".to_string()); + } + let item_count = u32::try_from(members.len()).unwrap_or(u32::MAX); + let summary = CollectionSummary { + identity: CollectionIdentity::for_id(collection_id), + title: "Rainy-night grounded picks".to_string(), + description: Some( + "Private manual collection saved from the smart-shelf draft." + .to_string(), + ), + kind: CollectionKind::Manual, + source: CollectionSource::Manual, + owner: Default::default(), + scope: CollectionScope::User, + visibility: CollectionVisibility::Private, + presentation: CollectionPresentationMode::Shelf, + media_scope: CollectionMediaScope::Types { + media_types: vec![ + CollectionMediaKind::Movie, + CollectionMediaKind::Series, + ], + }, + duplicate_policy: CollectionDuplicatePolicy::DeduplicateMedia, + artwork: CollectionArtwork { + accent_color_hex: Some("#365B8C".to_string()), + ..CollectionArtwork::default() + }, + theme: CollectionTheme { + primary_color_hex: Some("#365B8C".to_string()), + ..CollectionTheme::default() + }, + provenance: CollectionProvenance { + source: CollectionSource::Manual, + generated_by: Some("smart_shelf".to_string()), + external_id: Some(smart_shelf_artifact_id().to_string()), + last_refreshed_at: Some(now), + ..CollectionProvenance::default() + }, + version: CollectionVersion { + revision: 1, + etag: Some(format!("collection-{}-1", collection_id)), + ..CollectionVersion::default() + }, + timestamps: CollectionTimestamps { + created_at: now, + updated_at: now, + archived_at: None, + }, + item_count, + materialization: CollectionMaterializationStatus { + state: CollectionMaterializationState::Ready, + item_count, + generated_at: Some(now), + ..CollectionMaterializationStatus::default() + }, + }; + let detail = CollectionDetail { + summary, + rule: None, + items_preview: members.clone(), + shelf_placements: Vec::new(), + }; + + (detail, members) +} + +fn smart_shelf_run_id() -> Uuid { + Uuid::from_u128(0x65700000000000000000000000000001) +} + +fn smart_shelf_artifact_id() -> Uuid { + Uuid::from_u128(0x65700000000000000000000000000002) +} + +fn smart_shelf_collection_id() -> CollectionId { + CollectionId(Uuid::from_u128(0x65700000000000000000000000000003)) +} + fn desktop_movie_detail_state(config: &AppConfig) -> State { let mut state = authenticated_base_state(config, false); seed_library_state(&mut state); @@ -2701,7 +3288,12 @@ fn sample_user(username: &str) -> ferrex_core::player_prelude::User { #[cfg(test)] mod tests { use super::*; - use crate::state::InterfaceMode; + use crate::{ + domains::ui::tabs::{ + CollectionDetailLoadState, CollectionItemsLoadState, + }, + state::InterfaceMode, + }; use ferrex_core::player_prelude::TheaterPlateGradeClass; fn test_config() -> AppConfig { @@ -2730,6 +3322,21 @@ mod tests { .any(|scenario| scenario.name == "DesktopCollectionsManualEditor") ); + for expected in [ + "SmartShelfComposer", + "SmartShelfRunningProgress", + "SmartShelfDraftReady", + "SmartShelfAlternatesReplacement", + "SmartShelfProviderUnavailable", + "SmartShelfSavedCollectionDetail", + "SmartShelfCollectionEmpty", + "SmartShelfCollectionError", + ] { + assert!( + scenarios.iter().any(|scenario| scenario.name == expected), + "{expected} should be exposed as a screenshot preset", + ); + } assert!( scenarios .iter() @@ -3115,6 +3722,128 @@ mod tests { ); } + #[tokio::test(flavor = "current_thread")] + async fn smart_shelf_mvp_scenarios_seed_visual_qa_states() { + let composer = PlayerScenario::SmartShelfComposer.build(&test_config()); + let composer_surface = &composer.domains.ui.state.smart_shelf; + assert!(composer_surface.open); + assert_eq!(composer_surface.reducer.phase, SmartShelfPhase::Idle); + assert!(composer_surface.reducer.provider.allows_start()); + assert!(!composer_surface.reducer.composer.prompt.is_empty()); + + let running = + PlayerScenario::SmartShelfRunningProgress.build(&test_config()); + let running_smart = &running.domains.ui.state.smart_shelf.reducer; + assert_eq!(running_smart.phase, SmartShelfPhase::Running); + assert!( + running_smart + .run + .as_ref() + .is_some_and(|run| run.can_cancel()) + ); + + let draft_ready = + PlayerScenario::SmartShelfDraftReady.build(&test_config()); + let draft = draft_ready + .domains + .ui + .state + .smart_shelf + .reducer + .draft + .as_ref() + .expect("draft ready preset should load a draft"); + assert!(draft.can_save()); + assert_eq!(draft.items.len(), 2); + + let replacement = PlayerScenario::SmartShelfAlternatesReplacement + .build(&test_config()); + let replacement_draft = replacement + .domains + .ui + .state + .smart_shelf + .reducer + .draft + .as_ref() + .expect("replacement preset should load a draft"); + assert_eq!(replacement_draft.replacements_count(), 1); + assert_eq!(replacement_draft.alternates.len(), 1); + + let provider = + PlayerScenario::SmartShelfProviderUnavailable.build(&test_config()); + let provider_surface = &provider.domains.ui.state.smart_shelf; + assert_eq!( + provider_surface.reducer.phase, + SmartShelfPhase::ProviderUnavailable + ); + assert!(provider_surface.provider_fallback.is_some()); + } + + #[tokio::test(flavor = "current_thread")] + async fn smart_shelf_collection_scenarios_seed_detail_empty_and_error() { + let saved = PlayerScenario::SmartShelfSavedCollectionDetail + .build(&test_config()); + assert_eq!(saved.domains.ui.state.scope, Scope::Collections); + let collection_id = match &saved.domains.ui.state.view { + ViewState::CollectionDetail { collection_id } => *collection_id, + _ => panic!("saved smart-shelf scenario should open detail"), + }; + let Some(TabState::Collections(tab)) = + saved.tab_manager.get_tab(TabId::Collections) + else { + panic!("collections tab should exist"); + }; + match tab.detail_state(collection_id) { + CollectionDetailLoadState::Loaded(detail) => { + assert_eq!( + detail.summary.provenance.generated_by.as_deref(), + Some("smart_shelf") + ); + assert_eq!(detail.summary.item_count, 2); + } + other => panic!("expected loaded detail, got {other:?}"), + } + assert!(matches!( + tab.item_state(collection_id).load_state, + CollectionItemsLoadState::Loaded + )); + assert_eq!(tab.item_state(collection_id).items.len(), 2); + + let empty = + PlayerScenario::SmartShelfCollectionEmpty.build(&test_config()); + let empty_id = match &empty.domains.ui.state.view { + ViewState::CollectionDetail { collection_id } => *collection_id, + _ => panic!("empty smart-shelf scenario should open detail"), + }; + let Some(TabState::Collections(empty_tab)) = + empty.tab_manager.get_tab(TabId::Collections) + else { + panic!("collections tab should exist"); + }; + assert!(matches!( + empty_tab.item_state(empty_id).load_state, + CollectionItemsLoadState::Loaded + )); + assert!(empty_tab.item_state(empty_id).items.is_empty()); + + let error = + PlayerScenario::SmartShelfCollectionError.build(&test_config()); + let error_id = match &error.domains.ui.state.view { + ViewState::CollectionDetail { collection_id } => *collection_id, + _ => panic!("error smart-shelf scenario should open detail"), + }; + let Some(TabState::Collections(error_tab)) = + error.tab_manager.get_tab(TabId::Collections) + else { + panic!("collections tab should exist"); + }; + assert!(matches!( + error_tab.detail_state(error_id), + CollectionDetailLoadState::Error(_) + )); + } + #[tokio::test(flavor = "current_thread")] async fn desktop_movie_detail_scenario_selects_seeded_movie_detail() { let state = PlayerScenario::DesktopMovieDetail.build(&test_config()); diff --git a/crates/ferrex-player-app/src/screenshot.rs b/crates/ferrex-player-app/src/screenshot.rs index 25c188a5..5566f615 100644 --- a/crates/ferrex-player-app/src/screenshot.rs +++ b/crates/ferrex-player-app/src/screenshot.rs @@ -67,6 +67,7 @@ USAGE: ferrex-player screenshot list ferrex-player screenshot matrix list ferrex-player screenshot matrix --output-dir ./artifacts/detail-typography-qa [--only ] + ferrex-player screenshot matrix smart-shelf --output-dir ./artifacts/smart-shelf-mvp-qa [--only ] OPTIONS: -p, --preset Named app preset to render. Run `ferrex-player screenshot list` @@ -81,10 +82,11 @@ OPTIONS: -o, --output PNG output path. Required. --ice Optional .ice script to replay before capture. If the script has preset/viewport/mode metadata, explicit CLI values must match it. - matrix List or capture the detail typography visual QA matrix. Matrix - captures write PNGs plus a JSON manifest to --output-dir. Use - --only with a case id (for example movie-detail-720-top) or coverage tag - (for example surface:movie) to narrow the run. + matrix [NAME] List or capture a visual QA matrix. Defaults to detail typography; + pass smart-shelf for the smart-shelf MVP matrix. Matrix captures + write PNGs plus a JSON manifest to --output-dir. Use --only with a + case id (for example movie-detail-720-top) or coverage tag (for + example surface:movie or state:collection-error) to narrow the run. -h, --help Print this help text. EXAMPLE: @@ -102,11 +104,11 @@ pub enum CommandOutcome { HelpRequested, /// Scenario metadata was requested for agent discovery. ListedScenarios(Vec), - /// Detail typography matrix metadata was requested for visual QA. + /// Visual QA matrix metadata was requested. ListedVisualQaMatrix(Vec), /// A screenshot was captured. Captured(CaptureOutput), - /// A detail typography visual QA matrix was captured. + /// A visual QA matrix was captured. CapturedVisualQaMatrix(visual_qa::MatrixRunOutput), } @@ -1386,6 +1388,10 @@ mod tests { assert!(scenarios.iter().any(|scenario| { scenario.name == "PlayerLoadingOverlay" })); + assert!(scenarios.iter().any(|scenario| { + scenario.name == "SmartShelfDraftReady" + && scenario.description.contains("grounded") + })); } other => panic!("unexpected outcome: {other:?}"), } diff --git a/crates/ferrex-player-app/src/screenshot/visual_qa.rs b/crates/ferrex-player-app/src/screenshot/visual_qa.rs index 1240fde5..415cb7ce 100644 --- a/crates/ferrex-player-app/src/screenshot/visual_qa.rs +++ b/crates/ferrex-player-app/src/screenshot/visual_qa.rs @@ -1,8 +1,9 @@ -//! Detail typography visual QA matrix for deterministic screenshot artifacts. +//! Visual QA matrices for deterministic screenshot artifacts. //! -//! The matrix is executable instead of living in a durable process document: -//! `ferrex-player screenshot matrix --output-dir ` captures the detail -//! typography review set and writes a JSON manifest next to the PNGs. +//! The matrices are executable instead of living only in process documents: +//! `ferrex-player screenshot matrix --output-dir ` captures the default +//! detail typography review set, while `ferrex-player screenshot matrix +//! smart-shelf --output-dir ` captures the smart-shelf MVP review set. use std::{collections::BTreeSet, fs, path::PathBuf}; @@ -31,7 +32,18 @@ const DETAIL_TYPOGRAPHY_REVIEW_NOTES: &[&str] = &[ "Runtime mode top bar: verify desktop and 10-foot home/detail captures show the top-right mode toggle, and 10-foot captures omit settings/admin/profile controls.", ]; -/// CLI command outcome for the detail typography matrix. +const SMART_SHELF_MVP_MATRIX_NAME: &str = "smart-shelf-mvp-visual-qa"; +const SMART_SHELF_MVP_MANIFEST: &str = "smart-shelf-mvp-visual-qa-matrix.json"; + +const SMART_SHELF_MVP_REVIEW_NOTES: &[&str] = &[ + "MVP boundaries: verify the capture stays within desktop smart-shelf composer/review and saved Collections surfaces only.", + "Deterministic provider: verify copy and progress states read as fake/local-provider QA data rather than live model output.", + "Recovery: verify provider unavailable, empty collection, and collection error states expose retry/edit/recovery paths without app-data wipes.", + "Grounding/provenance: verify draft items, replacements, source chips, and saved collection provenance remain visible and understandable.", + "Excluded surfaces: verify no Android/TV, Home pinning, chatbot, dynamic rail, or playback queue behavior is introduced by the preset.", +]; + +/// CLI command outcome for a visual QA matrix. #[derive(Debug, Clone, PartialEq, Eq)] pub enum MatrixCommandOutcome { /// List the matrix cases without capturing screenshots. @@ -58,7 +70,80 @@ pub struct MatrixRunOutput { pub captures: Vec, } -/// A deterministic screenshot case in the detail typography QA matrix. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MatrixKind { + DetailTypography, + SmartShelfMvp, +} + +impl MatrixKind { + fn resolve(args: &[String]) -> (Self, &[String]) { + if let Some(first) = args.first() + && let Some(kind) = Self::parse(first) + { + return (kind, &args[1..]); + } + + (Self::DetailTypography, args) + } + + fn parse(value: &str) -> Option { + let normalized = value + .trim() + .chars() + .filter(|ch| *ch != '-' && *ch != '_' && !ch.is_whitespace()) + .collect::() + .to_ascii_lowercase(); + + match normalized.as_str() { + "detail" + | "detailtypography" + | "detailtypographyvisualqa" + | "typography" => Some(Self::DetailTypography), + "smartshelf" | "smartshelfmvp" | "smartshelfmvpvisualqa" => { + Some(Self::SmartShelfMvp) + } + _ => None, + } + } + + fn name(self) -> &'static str { + match self { + Self::DetailTypography => DETAIL_TYPOGRAPHY_MATRIX_NAME, + Self::SmartShelfMvp => SMART_SHELF_MVP_MATRIX_NAME, + } + } + + fn manifest_filename(self) -> &'static str { + match self { + Self::DetailTypography => DETAIL_TYPOGRAPHY_MANIFEST, + Self::SmartShelfMvp => SMART_SHELF_MVP_MANIFEST, + } + } + + fn review_notes(self) -> &'static [&'static str] { + match self { + Self::DetailTypography => DETAIL_TYPOGRAPHY_REVIEW_NOTES, + Self::SmartShelfMvp => SMART_SHELF_MVP_REVIEW_NOTES, + } + } + + fn required_tags(self) -> &'static [&'static str] { + match self { + Self::DetailTypography => DETAIL_TYPOGRAPHY_REQUIRED_COVERAGE_TAGS, + Self::SmartShelfMvp => SMART_SHELF_MVP_REQUIRED_COVERAGE_TAGS, + } + } + + fn cases(self) -> Vec { + match self { + Self::DetailTypography => detail_typography_matrix(), + Self::SmartShelfMvp => smart_shelf_mvp_matrix(), + } + } +} + +/// A deterministic screenshot case in a visual QA matrix. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct VisualQaCase { /// Stable artifact and filtering identifier. @@ -99,7 +184,7 @@ impl VisualQaCase { } } -const REQUIRED_COVERAGE_TAGS: &[&str] = &[ +const DETAIL_TYPOGRAPHY_REQUIRED_COVERAGE_TAGS: &[&str] = &[ "assertion:no-unintended-app-card-rectangles", "criteria:10ft-readability", "criteria:cast-captions", @@ -136,6 +221,22 @@ const REQUIRED_COVERAGE_TAGS: &[&str] = &[ "viewport:ultrawide", ]; +const SMART_SHELF_MVP_REQUIRED_COVERAGE_TAGS: &[&str] = &[ + "assertion:no-excluded-surfaces", + "fixture:deterministic-fake-provider", + "provider:unavailable", + "state:alternates-replacement", + "state:collection-empty", + "state:collection-error", + "state:composer", + "state:draft-ready", + "state:running-progress", + "state:saved-collection-detail", + "surface:collections", + "surface:smart-shelf", + "viewport:1280x720", +]; + /// Return the full detail typography visual QA matrix. pub fn detail_typography_matrix() -> Vec { vec![ @@ -540,6 +641,148 @@ pub fn detail_typography_matrix() -> Vec { ] } +/// Return the smart-shelf MVP visual QA matrix. +pub fn smart_shelf_mvp_matrix() -> Vec { + vec![ + smart_shelf_case( + "smart-shelf-composer", + ScreenshotPreset::SmartShelfComposer, + Viewport { + width: 1280, + height: 720, + }, + 150, + &[ + "assertion:no-excluded-surfaces", + "fixture:deterministic-fake-provider", + "state:composer", + "surface:smart-shelf", + "viewport:1280x720", + ], + "composer prompt, template, scope, item-count, provider/model, and fake-provider fixture controls before generation", + ), + smart_shelf_case( + "smart-shelf-running-progress", + ScreenshotPreset::SmartShelfRunningProgress, + Viewport { + width: 1280, + height: 720, + }, + 200, + &[ + "assertion:no-excluded-surfaces", + "fixture:deterministic-fake-provider", + "state:running-progress", + "surface:smart-shelf", + "viewport:1280x720", + ], + "running/progress panel with deterministic provider, step count, skeleton rows, and cancel affordance", + ), + smart_shelf_case( + "smart-shelf-draft-ready", + ScreenshotPreset::SmartShelfDraftReady, + Viewport { + width: 1280, + height: 720, + }, + 200, + &[ + "assertion:no-excluded-surfaces", + "fixture:deterministic-fake-provider", + "state:draft-ready", + "surface:smart-shelf", + "viewport:1280x720", + ], + "valid draft review with grounded item reasons, source chips, save action, regenerate, lock, and discard controls", + ), + smart_shelf_case( + "smart-shelf-alternates-replacement", + ScreenshotPreset::SmartShelfAlternatesReplacement, + Viewport { + width: 1280, + height: 720, + }, + 200, + &[ + "assertion:no-excluded-surfaces", + "fixture:deterministic-fake-provider", + "state:alternates-replacement", + "surface:smart-shelf", + "viewport:1280x720", + ], + "draft review with one selected replacement, available alternate row, replacement badge, and stable source chips", + ), + smart_shelf_case( + "smart-shelf-provider-unavailable", + ScreenshotPreset::SmartShelfProviderUnavailable, + Viewport { + width: 1280, + height: 720, + }, + 150, + &[ + "assertion:no-excluded-surfaces", + "provider:unavailable", + "state:provider-unavailable", + "surface:smart-shelf", + "viewport:1280x720", + ], + "provider fallback with local provider setup copy, edit prompt, and retry provider check recovery actions", + ), + smart_shelf_case( + "smart-shelf-saved-collection-detail", + ScreenshotPreset::SmartShelfSavedCollectionDetail, + Viewport { + width: 1280, + height: 720, + }, + 150, + &[ + "assertion:no-excluded-surfaces", + "fixture:deterministic-fake-provider", + "state:saved-collection-detail", + "surface:collections", + "viewport:1280x720", + ], + "saved private collection detail with smart-shelf provenance, ready materialization, and visible items", + ), + smart_shelf_case( + "smart-shelf-collection-empty", + ScreenshotPreset::SmartShelfCollectionEmpty, + Viewport { + width: 1280, + height: 720, + }, + 150, + &[ + "assertion:no-excluded-surfaces", + "fixture:deterministic-fake-provider", + "state:collection-empty", + "surface:collections", + "viewport:1280x720", + ], + "saved collection detail with zero materialized items and a browse/manage recovery copy instead of a blank panel", + ), + smart_shelf_case( + "smart-shelf-collection-error", + ScreenshotPreset::SmartShelfCollectionError, + Viewport { + width: 1280, + height: 720, + }, + 150, + &[ + "assertion:no-excluded-surfaces", + "fixture:deterministic-fake-provider", + "state:collection-error", + "surface:collections", + "viewport:1280x720", + ], + "saved collection detail error/retry panel that preserves the collection title and retry affordance", + ), + ] +} + fn case( id: &'static str, preset: ScreenshotPreset, @@ -547,6 +790,45 @@ fn case( settle_ms: u64, tags: &'static [&'static str], review_focus: &'static str, +) -> VisualQaCase { + case_with_notes( + id, + preset, + viewport, + settle_ms, + tags, + review_focus, + DETAIL_TYPOGRAPHY_REVIEW_NOTES, + ) +} + +fn smart_shelf_case( + id: &'static str, + preset: ScreenshotPreset, + viewport: Viewport, + settle_ms: u64, + tags: &'static [&'static str], + review_focus: &'static str, +) -> VisualQaCase { + case_with_notes( + id, + preset, + viewport, + settle_ms, + tags, + review_focus, + SMART_SHELF_MVP_REVIEW_NOTES, + ) +} + +fn case_with_notes( + id: &'static str, + preset: ScreenshotPreset, + viewport: Viewport, + settle_ms: u64, + tags: &'static [&'static str], + review_focus: &'static str, + review_notes: &'static [&'static str], ) -> VisualQaCase { VisualQaCase { id, @@ -556,18 +838,28 @@ fn case( settle_ms, tags, review_focus, - review_notes: DETAIL_TYPOGRAPHY_REVIEW_NOTES, + review_notes, } } -/// Return required coverage tags missing from a matrix. +/// Return detail typography required coverage tags missing from a matrix. pub fn missing_required_coverage(cases: &[VisualQaCase]) -> Vec<&'static str> { + missing_required_coverage_for( + cases, + DETAIL_TYPOGRAPHY_REQUIRED_COVERAGE_TAGS, + ) +} + +fn missing_required_coverage_for( + cases: &[VisualQaCase], + required_tags: &'static [&'static str], +) -> Vec<&'static str> { let present: BTreeSet<&str> = cases .iter() .flat_map(|case| case.tags.iter().copied()) .collect(); - REQUIRED_COVERAGE_TAGS + required_tags .iter() .copied() .filter(|tag| !present.contains(tag)) @@ -578,8 +870,10 @@ pub fn missing_required_coverage(cases: &[VisualQaCase]) -> Vec<&'static str> { pub fn run_matrix_command( args: &[String], ) -> Result { - let spec = MatrixCliSpec::parse(args)?; - let cases = select_cases(&detail_typography_matrix(), spec.only)?; + let (matrix, matrix_args) = MatrixKind::resolve(args); + let spec = MatrixCliSpec::parse(matrix_args)?; + let all_cases = matrix.cases(); + let cases = select_cases(matrix, &all_cases, spec.only)?; if spec.list || spec.dry_run { return Ok(MatrixCommandOutcome::Listed(cases)); @@ -593,11 +887,12 @@ pub fn run_matrix_command( }); }; - capture_matrix(&cases, output_dir, spec.settle_ms) + capture_matrix(matrix, &cases, output_dir, spec.settle_ms) .map(MatrixCommandOutcome::Captured) } fn select_cases( + matrix: MatrixKind, all_cases: &[VisualQaCase], only: Option<&str>, ) -> Result, ScreenshotError> { @@ -614,7 +909,9 @@ fn select_cases( if selected.is_empty() { return Err(ScreenshotError::MatrixArgument { message: format!( - "unknown detail typography QA matrix case or tag {only:?}; run `ferrex-player screenshot matrix list`" + "unknown {} QA matrix case or tag {only:?}; run `ferrex-player screenshot matrix {} list`", + matrix.name(), + matrix.name(), ), }); } @@ -623,6 +920,7 @@ fn select_cases( } fn capture_matrix( + matrix: MatrixKind, cases: &[VisualQaCase], output_dir: PathBuf, settle_ms_override: Option, @@ -647,8 +945,14 @@ fn capture_matrix( }); } - let manifest_path = output_dir.join(DETAIL_TYPOGRAPHY_MANIFEST); - write_manifest(&manifest_path, cases, &captures, settle_ms_override)?; + let manifest_path = output_dir.join(matrix.manifest_filename()); + write_manifest( + matrix, + &manifest_path, + cases, + &captures, + settle_ms_override, + )?; Ok(MatrixRunOutput { manifest_path, @@ -657,15 +961,19 @@ fn capture_matrix( } fn write_manifest( + matrix: MatrixKind, path: &PathBuf, cases: &[VisualQaCase], captures: &[MatrixCaseCapture], settle_ms_override: Option, ) -> Result<(), ScreenshotError> { let manifest = MatrixManifest { - matrix: DETAIL_TYPOGRAPHY_MATRIX_NAME, - missing_required_coverage: missing_required_coverage(cases), - required_review_notes: DETAIL_TYPOGRAPHY_REVIEW_NOTES.to_vec(), + matrix: matrix.name(), + missing_required_coverage: missing_required_coverage_for( + cases, + matrix.required_tags(), + ), + required_review_notes: matrix.review_notes().to_vec(), cases: cases .iter() .map(|case| { @@ -849,6 +1157,20 @@ mod tests { ); } + #[test] + fn smart_shelf_mvp_matrix_covers_required_tags() { + let cases = smart_shelf_mvp_matrix(); + let missing = missing_required_coverage_for( + &cases, + SMART_SHELF_MVP_REQUIRED_COVERAGE_TAGS, + ); + + assert!( + missing.is_empty(), + "missing smart-shelf MVP QA coverage tags: {missing:?}" + ); + } + #[test] fn matrix_cli_lists_by_default_and_filters_by_tag() { let outcome = run_matrix_command(&[]).expect("default list"); @@ -884,6 +1206,20 @@ mod tests { .any(|case| case.id == "season-detail-1080-scrolled-rail") ); assert!(cases.iter().all(|case| case.has_tag("state:scrolled-rail"))); + + let outcome = run_matrix_command(&[ + "smart-shelf".to_string(), + "--dry-run".to_string(), + "--only".to_string(), + "state:collection-error".to_string(), + ]) + .expect("filter smart-shelf matrix by tag"); + let MatrixCommandOutcome::Listed(cases) = outcome else { + panic!("expected list outcome"); + }; + assert_eq!(cases.len(), 1); + assert_eq!(cases[0].id, "smart-shelf-collection-error"); + assert!(cases[0].has_tag("surface:collections")); } #[test] @@ -897,19 +1233,22 @@ mod tests { #[test] fn matrix_cases_include_review_notes_for_each_row() { - let cases = detail_typography_matrix(); - - for case in cases { - assert_eq!( - case.review_notes, DETAIL_TYPOGRAPHY_REVIEW_NOTES, - "{} should carry the full human-review note set", - case.id - ); - assert!( - !case.review_focus.trim().is_empty(), - "{} should have a reviewer focus", - case.id - ); + for (cases, expected_notes) in [ + (detail_typography_matrix(), DETAIL_TYPOGRAPHY_REVIEW_NOTES), + (smart_shelf_mvp_matrix(), SMART_SHELF_MVP_REVIEW_NOTES), + ] { + for case in cases { + assert_eq!( + case.review_notes, expected_notes, + "{} should carry the full human-review note set", + case.id + ); + assert!( + !case.review_focus.trim().is_empty(), + "{} should have a reviewer focus", + case.id + ); + } } } } diff --git a/crates/ferrex-player-app/tests/smart_shelf_mvp.rs b/crates/ferrex-player-app/tests/smart_shelf_mvp.rs new file mode 100644 index 00000000..c297ffc0 --- /dev/null +++ b/crates/ferrex-player-app/tests/smart_shelf_mvp.rs @@ -0,0 +1,234 @@ +use std::collections::HashSet; + +use ferrex_player_app::infra::{ + api_types::*, services::api::ApiService, testing::stubs::TestApiService, +}; +use uuid::Uuid; + +fn fixed_uuid(value: u128) -> Uuid { + Uuid::from_u128(value) +} + +fn media_id(value: u128) -> MediaID { + MediaID::Movie(MovieID(fixed_uuid(value))) +} + +fn smart_shelf_run_status( + run_id: Uuid, + status: IntelligenceRunStatus, + step: u32, + draft_artifact_ids: Vec, +) -> IntelligenceRunStatusResponse { + IntelligenceRunStatusResponse { + run_id, + purpose: IntelligenceRunPurpose::Recommendation, + status, + terminal: matches!( + status, + IntelligenceRunStatus::Succeeded + | IntelligenceRunStatus::Failed + | IntelligenceRunStatus::Cancelled + ), + current_phase: Some(format!("mvp-step-{step}")), + provider: Some("deterministic-fake-provider".into()), + model: Some("fake-smart-shelf-model".into()), + queued_at_epoch_seconds: Some(1), + started_at_epoch_seconds: (step > 0).then_some(2), + completed_at_epoch_seconds: matches!( + status, + IntelligenceRunStatus::Succeeded + ) + .then_some(3), + current_step: Some(step), + max_steps: Some(3), + draft_artifact_ids, + output_summary: Some(IntelligenceSummary::new( + "Deterministic MVP fixture produced a smart-shelf draft.", + )), + error: None, + } +} + +fn smart_shelf_draft( + artifact_id: Uuid, + run_id: Uuid, + selected_media_id: MediaID, + alternate_media_id: MediaID, +) -> SmartShelfDraftResponse { + let selected_source = SmartShelfDraftSource { + label: Some("Library metadata".into()), + media_id: Some(selected_media_id), + artifact_id: Some(artifact_id), + field: Some("genres".into()), + evidence: Some(IntelligenceSummary::new("Grounded selected item")), + }; + let alternate_source = SmartShelfDraftSource { + label: Some("Library metadata".into()), + media_id: Some(alternate_media_id), + artifact_id: Some(artifact_id), + field: Some("overview".into()), + evidence: Some(IntelligenceSummary::new("Grounded alternate item")), + }; + let content = SmartShelfDraftContent { + schema_version: SMART_SHELF_DRAFT_SCHEMA_VERSION, + title: "Deterministic rainy-night shelf".into(), + description: Some("Fake-provider MVP integration fixture".into()), + interpreted_intent: Some("Grounded rainy-night recommendations".into()), + requested_constraints: serde_json::json!({"fixture": "smart-shelf-mvp"}), + items: vec![SmartShelfDraftItem { + ordinal: 1, + media_id: selected_media_id, + title: Some("Aurora Transit".into()), + subtitle: Some("Movie · deterministic fixture".into()), + year: Some(2024), + reason: Some("Grounded by deterministic library metadata".into()), + sources: vec![selected_source], + locked: false, + replacement_of: None, + }], + alternates: vec![SmartShelfDraftAlternate { + target_ordinal: Some(1), + media_id: alternate_media_id, + title: Some("Copper Harbor".into()), + subtitle: Some("Alternate movie".into()), + year: Some(2022), + reason: Some("Grounded alternate for replacement QA".into()), + sources: vec![alternate_source], + }], + }; + let validation = validate_smart_shelf_draft_items( + &content.items, + &HashSet::from([selected_media_id, alternate_media_id]), + ); + + SmartShelfDraftResponse { + artifact_id, + run_id: Some(run_id), + owner_user_id: Some(fixed_uuid(0x65700000000000000000000000000100)), + title: content.title.clone(), + summary: Some(IntelligenceSummary::new( + "A deterministic fake-provider draft", + )), + draft: Some(content), + validation, + saved_collection_id: None, + } +} + +#[tokio::test] +async fn smart_shelf_mvp_start_draft_save_opens_collection_detail_fixture() { + let service = TestApiService::default(); + let run_id = fixed_uuid(0x65700000000000000000000000000200); + let artifact_id = fixed_uuid(0x65700000000000000000000000000201); + let collection_id = + CollectionId::from(fixed_uuid(0x65700000000000000000000000000202)); + let selected_media_id = media_id(0x65700000000000000000000000000203); + let alternate_media_id = media_id(0x65700000000000000000000000000204); + + service.queue_smart_shelf_start(SmartShelfStartResponse { + run_id, + status: IntelligenceRunStatus::Queued, + provider: Some("deterministic-fake-provider".into()), + model: Some("fake-smart-shelf-model".into()), + queued_at_epoch_seconds: Some(1), + draft_schema_version: SMART_SHELF_DRAFT_SCHEMA_VERSION, + }); + service.set_intelligence_run_progress( + run_id, + vec![ + smart_shelf_run_status( + run_id, + IntelligenceRunStatus::Queued, + 0, + Vec::new(), + ), + smart_shelf_run_status( + run_id, + IntelligenceRunStatus::Succeeded, + 3, + vec![artifact_id], + ), + ], + ); + service.upsert_smart_shelf_draft(smart_shelf_draft( + artifact_id, + run_id, + selected_media_id, + alternate_media_id, + )); + service.set_next_smart_shelf_save_collection_id(collection_id); + + let start = service + .start_smart_shelf(SmartShelfStartRequest { + prompt: "Grounded rainy-night picks".into(), + library_id: None, + media_kinds: vec![IntelligenceMediaKind::Movie], + item_count: 6, + template_id: Some("rainy-night".into()), + locked_media_ids: Vec::new(), + idempotency_key: Some("smart-shelf-mvp-fixture".into()), + model: Some("fake-smart-shelf-model".into()), + caps: IntelligenceCaps::default(), + constraints: serde_json::json!({"fixture": "smart-shelf-mvp"}), + metadata: serde_json::json!({"test": "start-draft-save-detail"}), + }) + .await + .expect("start smart shelf fixture"); + assert_eq!(start.run_id, run_id); + + let queued = service + .fetch_intelligence_run_status(run_id) + .await + .expect("queued status"); + assert_eq!(queued.status, IntelligenceRunStatus::Queued); + + let succeeded = service + .fetch_intelligence_run_status(run_id) + .await + .expect("succeeded status"); + assert_eq!(succeeded.status, IntelligenceRunStatus::Succeeded); + assert_eq!(succeeded.draft_artifact_ids, vec![artifact_id]); + + let draft = service + .fetch_smart_shelf_draft(artifact_id) + .await + .expect("draft fixture"); + assert!(draft.validation.valid); + assert_eq!( + draft + .draft + .as_ref() + .expect("draft content") + .alternates + .len(), + 1 + ); + + let saved = service + .save_smart_shelf(artifact_id, SmartShelfSaveRequest::default()) + .await + .expect("save smart shelf fixture"); + assert_eq!(saved.collection_id, collection_id); + assert_eq!(saved.item_count, 1); + + let detail = service + .get_collection_detail( + collection_id, + GetCollectionDetailRequest { + include_rule: true, + include_items_preview: true, + include_shelf_placements: false, + }, + ) + .await + .expect("saved collection detail"); + assert_eq!( + detail.collection.summary.provenance.generated_by.as_deref(), + Some("smart_shelf") + ); + assert_eq!(detail.collection.items_preview.len(), 1); + assert_eq!( + detail.collection.summary.title, + "Deterministic rainy-night shelf" + ); +} diff --git a/crates/ferrex-player-intelligence/Cargo.toml b/crates/ferrex-player-intelligence/Cargo.toml new file mode 100644 index 00000000..66d28809 --- /dev/null +++ b/crates/ferrex-player-intelligence/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "ferrex-player-intelligence" +description = "UI-agnostic smart-shelf state, reducer, and command intents for Ferrex player clients" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +keywords.workspace = true +categories.workspace = true +readme = "../../README.md" +publish = true + +[lints.rustdoc] +broken_intra_doc_links = "warn" +private_intra_doc_links = "warn" +missing_crate_level_docs = "warn" +private_doc_tests = "warn" +invalid_codeblock_attributes = "warn" +invalid_rust_codeblocks = "warn" +bare_urls = "warn" + +[lints.rust] +# Smart-shelf reducer surfaces are stabilizing during the player extraction stack. +missing_docs = "allow" +missing_debug_implementations = "warn" + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + +[dependencies] +ferrex-player-api = { workspace = true } + +serde.workspace = true +serde_json.workspace = true +uuid.workspace = true + +[dev-dependencies] +chrono.workspace = true diff --git a/crates/ferrex-player-intelligence/src/commands.rs b/crates/ferrex-player-intelligence/src/commands.rs new file mode 100644 index 00000000..07ccc834 --- /dev/null +++ b/crates/ferrex-player-intelligence/src/commands.rs @@ -0,0 +1,308 @@ +//! Reducer input messages, side-effect commands, and UI/application intents. + +use ferrex_player_api::api_types::{ + CollectionId, IntelligenceError, IntelligenceErrorCode, + IntelligenceProviderStatus, IntelligenceRunCancelRequest, + IntelligenceRunCancelResponse, IntelligenceRunStatusResponse, + SmartShelfDraftResponse, SmartShelfError, SmartShelfErrorCode, + SmartShelfSaveRequest, SmartShelfSaveResponse, SmartShelfStartRequest, + SmartShelfStartResponse, +}; +use uuid::Uuid; + +use crate::state::{ + SmartShelfSaveConfirmation, SmartShelfSaveConflict, + SmartShelfSaveConflictRecovery, +}; + +/// Messages accepted by the smart-shelf reducer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SmartShelfMessage { + /// Ask the application shell to refresh provider readiness. + ProviderRefreshRequested, + /// Provider readiness was loaded from the API. + ProviderStatusLoaded(IntelligenceProviderStatus), + /// Provider readiness could not be loaded. + ProviderStatusFailed(SmartShelfFailure), + /// User changed the free-form composer prompt. + PromptChanged(String), + /// User selected a built-in or supplied composer template. + TemplateSelected(String), + /// User cleared the active composer template while keeping prompt text. + TemplateCleared, + /// User chose a library scope for the generated shelf. + LibrarySelected(Option), + /// User changed the requested output item count. + ItemCountChanged(u16), + /// User changed the optional model override. + ModelChanged(Option), + /// User asked to start a smart-shelf run. + StartRequested, + /// Smart-shelf run start was accepted by the API. + StartAccepted(SmartShelfStartResponse), + /// Smart-shelf run start failed. + StartFailed(SmartShelfFailure), + /// Runtime progress was loaded for the active run. + RunProgressLoaded(IntelligenceRunStatusResponse), + /// Runtime progress polling failed. + RunProgressFailed(SmartShelfFailure), + /// User asked to cancel the active run. + CancelRequested, + /// Runtime cancel request completed. + CancelFinished(IntelligenceRunCancelResponse), + /// Runtime cancel request failed. + CancelFailed(SmartShelfFailure), + /// A typed draft was loaded for review/edit/save. + DraftLoaded(SmartShelfDraftResponse), + /// Typed draft loading failed. + DraftLoadFailed(SmartShelfFailure), + /// User toggled the lock state of a draft item. + ToggleLock(ferrex_player_api::api_types::MediaID), + /// User accepted an alternate to replace a selected draft item. + ReplaceWithAlternate { + /// Currently selected item to replace. + target_media_id: ferrex_player_api::api_types::MediaID, + /// Alternate item to move into the selected list. + alternate_media_id: ferrex_player_api::api_types::MediaID, + }, + /// User asked to regenerate only unlocked items. + RegenerateUnlockedRequested, + /// User asked to retry the most recent recoverable operation. + RetryRequested, + /// User chose to edit the prompt after an error or validation failure. + EditPromptRequested, + /// User initiated a discard flow. + DiscardRequested, + /// User confirmed discard/reset. + DiscardConfirmed, + /// User initiated save and should see a confirmation first. + SaveRequested, + /// User confirmed the save dialog. + SaveConfirmed, + /// Smart-shelf save succeeded. + SaveSucceeded(SmartShelfSaveResponse), + /// Smart-shelf save failed. + SaveFailed(SmartShelfFailure), + /// User selected a recovery action for a save conflict. + RecoverSaveConflict(SmartShelfSaveConflictRecovery), +} + +/// Side-effect commands emitted by the reducer for an app shell to execute. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SmartShelfCommand { + /// Load provider/model readiness from the API. + FetchProviderStatus, + /// Start a smart-shelf run with a DTO-shaped API request. + StartSmartShelf(SmartShelfStartRequest), + /// Poll a running intelligence run. + PollRun { run_id: Uuid }, + /// Cancel a running intelligence run. + CancelRun { + run_id: Uuid, + request: IntelligenceRunCancelRequest, + }, + /// Read a typed smart-shelf draft. + FetchDraft { artifact_id: Uuid }, + /// Save the accepted smart-shelf draft. + SaveSmartShelf { + artifact_id: Uuid, + request: SmartShelfSaveRequest, + }, +} + +/// UI/application intents emitted by the reducer without depending on a UI toolkit. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SmartShelfIntent { + /// Move input focus to the composer prompt. + FocusPrompt, + /// Render a provider fallback/recovery surface instead of starting a run. + ShowProviderFallback { message: String, retryable: bool }, + /// Show a transient notice. + ShowNotice(SmartShelfNotice), + /// Present draft validation issues to the user. + ShowDraftValidation( + Vec, + ), + /// Present a draft/runtime error. + ShowDraftError(SmartShelfFailure), + /// Ask the user to confirm save. + ShowSaveConfirmation(SmartShelfSaveConfirmation), + /// Present a non-conflict save error. + ShowSaveError(SmartShelfFailure), + /// Present conflict recovery choices. + ShowSaveConflict(SmartShelfSaveConflict), + /// Navigate/open the saved collection. + OpenSavedCollection(CollectionId), + /// Ask the user to confirm discarding local state. + ConfirmDiscard, + /// Close the smart-shelf surface after a discard/reset. + CloseSmartShelf, + /// Inform the shell that a regenerate-unlocked run was requested. + RegenerateUnlocked { + locked_media_ids: Vec, + }, +} + +/// Severity for displayable smart-shelf notices. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SmartShelfNoticeLevel { + /// Informational notice. + Info, + /// Warning notice. + Warning, + /// Error notice. + Error, +} + +/// Displayable notice emitted by the reducer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SmartShelfNotice { + /// Notice severity. + pub level: SmartShelfNoticeLevel, + /// Human-readable message safe for UI display. + pub message: String, +} + +impl SmartShelfNotice { + /// Build an informational notice. + pub fn info(message: impl Into) -> Self { + Self { + level: SmartShelfNoticeLevel::Info, + message: message.into(), + } + } + + /// Build a warning notice. + pub fn warning(message: impl Into) -> Self { + Self { + level: SmartShelfNoticeLevel::Warning, + message: message.into(), + } + } + + /// Build an error notice. + pub fn error(message: impl Into) -> Self { + Self { + level: SmartShelfNoticeLevel::Error, + message: message.into(), + } + } +} + +/// Stable reducer-level failure code preserving API error classes when present. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SmartShelfFailureCode { + /// Failure came from an intelligence runtime/provider endpoint. + Intelligence(IntelligenceErrorCode), + /// Failure came from a smart-shelf draft/save endpoint. + SmartShelf(SmartShelfErrorCode), + /// Local composer or draft validation failed before an API call. + Validation, + /// The provider is not ready enough to start a run. + ProviderUnavailable, + /// The expected active run was missing. + MissingRun, + /// The expected draft was missing. + MissingDraft, + /// A replacement target or alternate was not present in the draft. + ReplacementUnavailable, + /// The requested operation conflicts with current server or local state. + Conflict, + /// Unknown or transport-level failure without a typed API code. + Unknown, +} + +/// UI-safe failure value used by reducer state and intents. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SmartShelfFailure { + /// Stable failure code. + pub code: SmartShelfFailureCode, + /// Human-readable message safe for UI display. + pub message: String, + /// Whether retry is reasonable without editing state first. + pub retryable: bool, +} + +impl SmartShelfFailure { + /// Build a failure with an explicit code. + pub fn new( + code: SmartShelfFailureCode, + message: impl Into, + retryable: bool, + ) -> Self { + Self { + code, + message: message.into(), + retryable, + } + } + + /// Build a validation failure. + pub fn validation(message: impl Into) -> Self { + Self::new(SmartShelfFailureCode::Validation, message, false) + } + + /// Build a provider-unavailable failure. + pub fn provider_unavailable( + message: impl Into, + retryable: bool, + ) -> Self { + Self::new( + SmartShelfFailureCode::ProviderUnavailable, + message, + retryable, + ) + } + + /// Build an unknown retryable/non-retryable failure. + pub fn unknown(message: impl Into, retryable: bool) -> Self { + Self::new(SmartShelfFailureCode::Unknown, message, retryable) + } + + /// Whether this save failure should show conflict recovery actions. + pub const fn is_save_conflict(&self) -> bool { + matches!( + self.code, + SmartShelfFailureCode::SmartShelf( + SmartShelfErrorCode::AlreadySaved + | SmartShelfErrorCode::DraftStale + | SmartShelfErrorCode::CollectionConflict + ) | SmartShelfFailureCode::Conflict + ) + } + + /// Whether this failure means the provider readiness fallback should be shown. + pub const fn is_provider_unavailable(&self) -> bool { + matches!( + self.code, + SmartShelfFailureCode::ProviderUnavailable + | SmartShelfFailureCode::Intelligence( + IntelligenceErrorCode::FeatureDisabled + | IntelligenceErrorCode::ProviderNotConfigured + | IntelligenceErrorCode::ProviderUnavailable + | IntelligenceErrorCode::ProviderUnauthorized + | IntelligenceErrorCode::ModelUnavailable + ) + ) + } +} + +impl From for SmartShelfFailure { + fn from(value: IntelligenceError) -> Self { + Self { + code: SmartShelfFailureCode::Intelligence(value.code), + message: value.message, + retryable: value.retryable, + } + } +} + +impl From for SmartShelfFailure { + fn from(value: SmartShelfError) -> Self { + Self { + code: SmartShelfFailureCode::SmartShelf(value.code), + message: value.message, + retryable: value.retryable, + } + } +} diff --git a/crates/ferrex-player-intelligence/src/lib.rs b/crates/ferrex-player-intelligence/src/lib.rs new file mode 100644 index 00000000..e1e6c7d0 --- /dev/null +++ b/crates/ferrex-player-intelligence/src/lib.rs @@ -0,0 +1,32 @@ +//! UI-agnostic smart-shelf domain state and reducer logic. +//! +//! This crate keeps smart-shelf composer state, provider readiness handling, +//! runtime progress, draft editing, save confirmation, and recovery transitions +//! out of any concrete UI framework. App shells translate emitted commands into +//! API calls and render emitted intents with their own UI toolkit. + +#![forbid(unsafe_code)] + +/// Commands, messages, failures, and UI/application intents used by the reducer. +pub mod commands; +/// Smart-shelf reducer implementation. +pub mod reducer; +/// Smart-shelf domain state types. +pub mod state; +/// Built-in composer templates and template DTOs. +pub mod templates; + +pub use commands::{ + SmartShelfCommand, SmartShelfFailure, SmartShelfFailureCode, + SmartShelfIntent, SmartShelfMessage, SmartShelfNotice, + SmartShelfNoticeLevel, +}; +pub use reducer::{SmartShelfTransition, reduce}; +pub use state::{ + ProviderReadiness, SmartShelfAlternateState, SmartShelfComposer, + SmartShelfDraftState, SmartShelfItemState, SmartShelfPhase, + SmartShelfRunState, SmartShelfSaveConfirmation, SmartShelfSaveConflict, + SmartShelfSaveConflictRecovery, SmartShelfSaveState, SmartShelfSaveStatus, + SmartShelfState, +}; +pub use templates::{SmartShelfTemplate, built_in_templates}; diff --git a/crates/ferrex-player-intelligence/src/reducer.rs b/crates/ferrex-player-intelligence/src/reducer.rs new file mode 100644 index 00000000..ffb17a95 --- /dev/null +++ b/crates/ferrex-player-intelligence/src/reducer.rs @@ -0,0 +1,1478 @@ +//! Smart-shelf reducer implementation. + +use ferrex_player_api::api_types::{ + IntelligenceRunCancelRequest, IntelligenceRunStatus, +}; +use uuid::Uuid; + +use crate::{ + ProviderReadiness, SmartShelfCommand, SmartShelfDraftState, + SmartShelfFailure, SmartShelfFailureCode, SmartShelfIntent, + SmartShelfMessage, SmartShelfNotice, SmartShelfPhase, SmartShelfRunState, + SmartShelfSaveConflictRecovery, SmartShelfSaveStatus, SmartShelfState, + state::{ + already_saved_failure, failure_from_run_error, is_terminal_status, + }, +}; + +/// Commands and UI/application intents produced by a reducer step. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SmartShelfTransition { + /// Side-effect commands for the app shell to execute. + pub commands: Vec, + /// UI/application intents for the app shell to render/route. + pub intents: Vec, +} + +impl SmartShelfTransition { + /// Create an empty transition. + pub fn none() -> Self { + Self::default() + } + + /// Append a side-effect command. + pub fn command(&mut self, command: SmartShelfCommand) { + self.commands.push(command); + } + + /// Append a UI/application intent. + pub fn intent(&mut self, intent: SmartShelfIntent) { + self.intents.push(intent); + } + + /// Whether no command or intent was produced. + pub fn is_empty(&self) -> bool { + self.commands.is_empty() && self.intents.is_empty() + } +} + +/// Apply one smart-shelf message to state and return commands/intents for the shell. +pub fn reduce( + state: &mut SmartShelfState, + message: SmartShelfMessage, +) -> SmartShelfTransition { + let mut transition = SmartShelfTransition::none(); + + match message { + SmartShelfMessage::ProviderRefreshRequested => { + state.provider = ProviderReadiness::Checking; + transition.command(SmartShelfCommand::FetchProviderStatus); + } + SmartShelfMessage::ProviderStatusLoaded(status) => { + let readiness = ProviderReadiness::from_status(&status); + state.provider = readiness.clone(); + match readiness { + ProviderReadiness::Ready { .. } => { + if state.phase == SmartShelfPhase::ProviderUnavailable { + state.phase = SmartShelfPhase::Idle; + } + state.last_error = None; + } + ProviderReadiness::Degraded { message, .. } => { + if state.phase == SmartShelfPhase::ProviderUnavailable { + state.phase = SmartShelfPhase::Idle; + } + if let Some(message) = message { + transition.intent(SmartShelfIntent::ShowNotice( + SmartShelfNotice::warning(message), + )); + } + } + ProviderReadiness::Unavailable { message, retryable } => { + state.phase = SmartShelfPhase::ProviderUnavailable; + state.last_error = + Some(SmartShelfFailure::provider_unavailable( + message.clone(), + retryable, + )); + transition.intent(SmartShelfIntent::ShowProviderFallback { + message, + retryable, + }); + } + ProviderReadiness::Unknown | ProviderReadiness::Checking => {} + } + } + SmartShelfMessage::ProviderStatusFailed(failure) => { + state.provider = ProviderReadiness::Unavailable { + message: failure.message.clone(), + retryable: failure.retryable, + }; + state.phase = SmartShelfPhase::ProviderUnavailable; + state.last_error = Some(failure.clone()); + transition.intent(SmartShelfIntent::ShowProviderFallback { + message: failure.message, + retryable: failure.retryable, + }); + } + SmartShelfMessage::PromptChanged(prompt) => { + state.composer.prompt = prompt; + state.composer.selected_template_id = None; + state.composer.validation_error = None; + if matches!( + state.phase, + SmartShelfPhase::DraftError + | SmartShelfPhase::DraftInvalid + | SmartShelfPhase::SaveError + ) { + state.phase = SmartShelfPhase::Idle; + } + } + SmartShelfMessage::TemplateSelected(template_id) => { + if !state.composer.select_template(&template_id) { + let failure = SmartShelfFailure::validation(format!( + "Smart-shelf template '{template_id}' is not available" + )); + state.composer.validation_error = Some(failure.clone()); + state.last_error = Some(failure.clone()); + transition.intent(SmartShelfIntent::ShowNotice( + SmartShelfNotice::error(failure.message), + )); + } + } + SmartShelfMessage::TemplateCleared => { + state.composer.clear_template(); + } + SmartShelfMessage::LibrarySelected(library_id) => { + state.composer.library_id = library_id; + } + SmartShelfMessage::ItemCountChanged(item_count) => { + state.composer.item_count = + ferrex_player_api::api_types::clamp_smart_shelf_item_count( + item_count, + ); + } + SmartShelfMessage::ModelChanged(model) => { + state.composer.model = model.and_then(|value| { + let trimmed = value.trim().to_string(); + (!trimmed.is_empty()).then_some(trimmed) + }); + } + SmartShelfMessage::StartRequested => { + start_from_composer(state, &mut transition); + } + SmartShelfMessage::StartAccepted(response) => { + let run = SmartShelfRunState::from_start(response); + let run_id = run.run_id; + let status = run.status; + state.run = Some(run); + match status { + IntelligenceRunStatus::Queued + | IntelligenceRunStatus::Running => { + state.phase = SmartShelfPhase::Running; + transition.command(SmartShelfCommand::PollRun { run_id }); + } + IntelligenceRunStatus::Succeeded => { + state.phase = SmartShelfPhase::Running; + transition.command(SmartShelfCommand::PollRun { run_id }); + } + IntelligenceRunStatus::Failed => { + let failure = SmartShelfFailure::unknown( + "Smart-shelf run failed before progress details were available", + true, + ); + state.phase = SmartShelfPhase::DraftError; + state.last_error = Some(failure.clone()); + transition + .intent(SmartShelfIntent::ShowDraftError(failure)); + } + IntelligenceRunStatus::Cancelled => { + state.phase = SmartShelfPhase::Cancelled; + transition.intent(SmartShelfIntent::ShowNotice( + SmartShelfNotice::info("Smart-shelf run was cancelled"), + )); + } + } + } + SmartShelfMessage::StartFailed(failure) => { + state.phase = if failure.is_provider_unavailable() { + SmartShelfPhase::ProviderUnavailable + } else { + SmartShelfPhase::DraftError + }; + state.last_error = Some(failure.clone()); + if failure.is_provider_unavailable() { + transition.intent(SmartShelfIntent::ShowProviderFallback { + message: failure.message, + retryable: failure.retryable, + }); + } else { + transition.intent(SmartShelfIntent::ShowDraftError(failure)); + } + } + SmartShelfMessage::RunProgressLoaded(response) => { + let run_id = response.run_id; + let status = response.status; + let terminal = response.terminal || is_terminal_status(status); + state + .run + .get_or_insert_with(|| { + SmartShelfRunState::from_status(&response) + }) + .apply_status(&response); + + match status { + IntelligenceRunStatus::Queued + | IntelligenceRunStatus::Running => { + state.phase = SmartShelfPhase::Running; + if !terminal { + transition + .command(SmartShelfCommand::PollRun { run_id }); + } + } + IntelligenceRunStatus::Succeeded => { + if let Some(artifact_id) = + response.draft_artifact_ids.first().copied() + { + state.phase = SmartShelfPhase::Running; + state.last_draft_artifact_id = Some(artifact_id); + transition.command(SmartShelfCommand::FetchDraft { + artifact_id, + }); + } else { + let failure = SmartShelfFailure::unknown( + "Smart-shelf run finished without a draft artifact", + true, + ); + state.phase = SmartShelfPhase::DraftError; + state.last_error = Some(failure.clone()); + transition + .intent(SmartShelfIntent::ShowDraftError(failure)); + } + } + IntelligenceRunStatus::Failed => { + let failure = failure_from_run_error( + response.error, + "Smart-shelf run failed before producing a draft", + ); + state.phase = SmartShelfPhase::DraftError; + state.last_error = Some(failure.clone()); + transition + .intent(SmartShelfIntent::ShowDraftError(failure)); + } + IntelligenceRunStatus::Cancelled => { + state.phase = SmartShelfPhase::Cancelled; + transition.intent(SmartShelfIntent::ShowNotice( + SmartShelfNotice::info("Smart-shelf run was cancelled"), + )); + } + } + } + SmartShelfMessage::RunProgressFailed(failure) => { + state.last_error = Some(failure.clone()); + transition.intent(SmartShelfIntent::ShowNotice(SmartShelfNotice { + level: if failure.retryable { + crate::SmartShelfNoticeLevel::Warning + } else { + crate::SmartShelfNoticeLevel::Error + }, + message: failure.message, + })); + } + SmartShelfMessage::CancelRequested => { + if let Some(run_id) = state.cancellable_run_id() { + state.phase = SmartShelfPhase::Cancelling; + transition.command(SmartShelfCommand::CancelRun { + run_id, + request: IntelligenceRunCancelRequest { + reason: Some( + "User cancelled smart-shelf generation".to_string(), + ), + }, + }); + } else { + transition.intent(SmartShelfIntent::ShowNotice( + SmartShelfNotice::info( + "There is no active smart-shelf run to cancel", + ), + )); + } + } + SmartShelfMessage::CancelFinished(response) => { + if let Some(run) = state.run.as_mut() { + if run.run_id == response.run_id { + run.status = response.status; + run.terminal = true; + run.error = response.error.map(SmartShelfFailure::from); + } + } + state.phase = SmartShelfPhase::Cancelled; + transition.intent(SmartShelfIntent::ShowNotice( + SmartShelfNotice::info(response.message.unwrap_or_else(|| { + "Smart-shelf run was cancelled".to_string() + })), + )); + } + SmartShelfMessage::CancelFailed(failure) => { + state.last_error = Some(failure.clone()); + transition.intent(SmartShelfIntent::ShowNotice( + SmartShelfNotice::error(failure.message), + )); + } + SmartShelfMessage::DraftLoaded(response) => { + let draft = SmartShelfDraftState::from_response(response); + state.last_draft_artifact_id = Some(draft.artifact_id); + let saved_collection_id = draft.saved_collection_id; + let valid = draft.can_save(); + let validation = draft.validation.clone(); + state.draft = Some(draft); + state.save.reset(); + + if let Some(collection_id) = saved_collection_id { + state.phase = SmartShelfPhase::Saved; + state.last_error = Some(already_saved_failure(collection_id)); + transition.intent(SmartShelfIntent::OpenSavedCollection( + collection_id, + )); + } else if valid { + state.phase = SmartShelfPhase::DraftReady; + state.last_error = None; + } else { + state.phase = SmartShelfPhase::DraftInvalid; + let issues = validation.issues; + if issues.is_empty() { + let failure = SmartShelfFailure::validation( + "Smart-shelf draft did not contain any saveable items", + ); + state.last_error = Some(failure.clone()); + transition + .intent(SmartShelfIntent::ShowDraftError(failure)); + } else { + state.last_error = Some(SmartShelfFailure::validation( + "Smart-shelf draft needs review before it can be saved", + )); + transition + .intent(SmartShelfIntent::ShowDraftValidation(issues)); + } + } + } + SmartShelfMessage::DraftLoadFailed(failure) => { + state.phase = SmartShelfPhase::DraftError; + state.last_error = Some(failure.clone()); + transition.intent(SmartShelfIntent::ShowDraftError(failure)); + } + SmartShelfMessage::ToggleLock(media_id) => match state.draft.as_mut() { + Some(draft) => match draft.toggle_lock(media_id) { + Ok(locked) => { + state.save.reset(); + if matches!( + state.phase, + SmartShelfPhase::SaveConflict + | SmartShelfPhase::SaveError + ) { + state.phase = SmartShelfPhase::DraftReady; + } + let message = if locked { + "Smart-shelf item locked" + } else { + "Smart-shelf item unlocked" + }; + transition.intent(SmartShelfIntent::ShowNotice( + SmartShelfNotice::info(message), + )); + } + Err(failure) => { + state.last_error = Some(failure.clone()); + transition.intent(SmartShelfIntent::ShowNotice( + SmartShelfNotice::error(failure.message), + )); + } + }, + None => missing_draft(state, &mut transition), + }, + SmartShelfMessage::ReplaceWithAlternate { + target_media_id, + alternate_media_id, + } => match state.draft.as_mut() { + Some(draft) => match draft + .replace_with_alternate(target_media_id, alternate_media_id) + { + Ok(()) => { + state.save.reset(); + state.phase = SmartShelfPhase::DraftReady; + transition.intent(SmartShelfIntent::ShowNotice( + SmartShelfNotice::info("Smart-shelf item replaced"), + )); + } + Err(failure) => { + state.last_error = Some(failure.clone()); + transition.intent(SmartShelfIntent::ShowNotice( + SmartShelfNotice::error(failure.message), + )); + } + }, + None => missing_draft(state, &mut transition), + }, + SmartShelfMessage::RegenerateUnlockedRequested => { + regenerate_unlocked(state, &mut transition); + } + SmartShelfMessage::RetryRequested => { + retry_last_operation(state, &mut transition); + } + SmartShelfMessage::EditPromptRequested => { + state.run = None; + state.draft = None; + state.save.reset(); + state.phase = SmartShelfPhase::Idle; + state.last_error = None; + transition.intent(SmartShelfIntent::FocusPrompt); + } + SmartShelfMessage::DiscardRequested => { + if state.has_recoverable_work() { + transition.intent(SmartShelfIntent::ConfirmDiscard); + } else { + state.reset_work(); + transition.intent(SmartShelfIntent::CloseSmartShelf); + } + } + SmartShelfMessage::DiscardConfirmed => { + let cancel_run_id = state.cancellable_run_id(); + state.reset_work(); + if let Some(run_id) = cancel_run_id { + transition.command(SmartShelfCommand::CancelRun { + run_id, + request: IntelligenceRunCancelRequest { + reason: Some( + "User discarded smart-shelf generation".to_string(), + ), + }, + }); + } + transition.intent(SmartShelfIntent::CloseSmartShelf); + } + SmartShelfMessage::SaveRequested => { + request_save_confirmation(state, &mut transition); + } + SmartShelfMessage::SaveConfirmed => { + confirm_save(state, &mut transition); + } + SmartShelfMessage::SaveSucceeded(response) => { + let collection_id = response.collection_id; + state.phase = SmartShelfPhase::Saved; + state.save.succeeded(response); + if let Some(draft) = state.draft.as_mut() { + draft.saved_collection_id = Some(collection_id); + draft.dirty = false; + } + state.last_error = None; + transition + .intent(SmartShelfIntent::OpenSavedCollection(collection_id)); + } + SmartShelfMessage::SaveFailed(failure) => { + let artifact_id = state + .draft + .as_ref() + .map(|draft| draft.artifact_id) + .or(state.last_draft_artifact_id) + .unwrap_or_else(Uuid::nil); + if let Some(conflict) = + state.save.failed(artifact_id, failure.clone()) + { + state.phase = SmartShelfPhase::SaveConflict; + state.last_error = Some(failure); + transition.intent(SmartShelfIntent::ShowSaveConflict(conflict)); + } else { + state.phase = SmartShelfPhase::SaveError; + state.last_error = Some(failure.clone()); + transition.intent(SmartShelfIntent::ShowSaveError(failure)); + } + } + SmartShelfMessage::RecoverSaveConflict(action) => { + recover_save_conflict(state, action, &mut transition); + } + } + + transition +} + +fn start_from_composer( + state: &mut SmartShelfState, + transition: &mut SmartShelfTransition, +) { + if !state.provider.allows_start() { + match state.provider.fallback_message() { + Some((message, retryable)) => { + state.phase = SmartShelfPhase::ProviderUnavailable; + transition.intent(SmartShelfIntent::ShowProviderFallback { + message, + retryable, + }); + if matches!( + state.provider, + ProviderReadiness::Unknown | ProviderReadiness::Checking + ) { + state.provider = ProviderReadiness::Checking; + transition.command(SmartShelfCommand::FetchProviderStatus); + } + } + None => {} + } + return; + } + + match state.composer.start_request() { + Ok(request) => { + state.phase = SmartShelfPhase::Starting; + state.run = None; + state.draft = None; + state.save.reset(); + state.last_error = None; + state.last_start_request = Some(request.clone()); + transition.command(SmartShelfCommand::StartSmartShelf(request)); + } + Err(failure) => { + state.phase = SmartShelfPhase::Idle; + state.last_error = Some(failure.clone()); + transition.intent(SmartShelfIntent::FocusPrompt); + transition.intent(SmartShelfIntent::ShowNotice( + SmartShelfNotice::error(failure.message), + )); + } + } +} + +fn regenerate_unlocked( + state: &mut SmartShelfState, + transition: &mut SmartShelfTransition, +) { + if !state.provider.allows_start() { + if let Some((message, retryable)) = state.provider.fallback_message() { + state.phase = SmartShelfPhase::ProviderUnavailable; + transition.intent(SmartShelfIntent::ShowProviderFallback { + message, + retryable, + }); + } + return; + } + + let Some(draft) = state.draft.as_ref().cloned() else { + missing_draft(state, transition); + return; + }; + + let locked_media_ids = draft.locked_media_ids(); + match state + .composer + .start_request_with_regenerate_metadata(&draft) + { + Ok(request) => { + state.phase = SmartShelfPhase::Starting; + state.run = None; + state.save.reset(); + state.last_error = None; + state.last_start_request = Some(request.clone()); + transition.intent(SmartShelfIntent::RegenerateUnlocked { + locked_media_ids, + }); + transition.command(SmartShelfCommand::StartSmartShelf(request)); + } + Err(failure) => { + state.last_error = Some(failure.clone()); + transition.intent(SmartShelfIntent::FocusPrompt); + transition.intent(SmartShelfIntent::ShowNotice( + SmartShelfNotice::error(failure.message), + )); + } + } +} + +fn retry_last_operation( + state: &mut SmartShelfState, + transition: &mut SmartShelfTransition, +) { + if matches!( + state.save.status, + SmartShelfSaveStatus::Error | SmartShelfSaveStatus::Conflict + ) { + if let (Some(draft), Some(request)) = + (state.draft.as_ref(), state.save.last_request.clone()) + { + state.phase = SmartShelfPhase::Saving; + state.save.saving(request.clone()); + transition.command(SmartShelfCommand::SaveSmartShelf { + artifact_id: draft.artifact_id, + request, + }); + return; + } + } + + if let Some(artifact_id) = state.last_draft_artifact_id { + if state.phase == SmartShelfPhase::DraftError { + transition.command(SmartShelfCommand::FetchDraft { artifact_id }); + return; + } + } + + if let Some(run) = state.run.as_ref().filter(|run| !run.terminal) { + state.phase = SmartShelfPhase::Running; + transition.command(SmartShelfCommand::PollRun { run_id: run.run_id }); + return; + } + + if let Some(request) = state.last_start_request.clone() { + if state.provider.allows_start() { + state.phase = SmartShelfPhase::Starting; + transition.command(SmartShelfCommand::StartSmartShelf(request)); + return; + } + } + + if matches!( + state.provider, + ProviderReadiness::Unknown + | ProviderReadiness::Checking + | ProviderReadiness::Unavailable { .. } + ) { + state.provider = ProviderReadiness::Checking; + transition.command(SmartShelfCommand::FetchProviderStatus); + return; + } + + transition.intent(SmartShelfIntent::FocusPrompt); +} + +fn request_save_confirmation( + state: &mut SmartShelfState, + transition: &mut SmartShelfTransition, +) { + let Some(draft) = state.draft.as_ref() else { + missing_draft(state, transition); + return; + }; + + if let Some(collection_id) = draft.saved_collection_id { + let failure = already_saved_failure(collection_id); + state.phase = SmartShelfPhase::Saved; + state.last_error = Some(failure.clone()); + transition.intent(SmartShelfIntent::ShowSaveError(failure)); + transition.intent(SmartShelfIntent::OpenSavedCollection(collection_id)); + return; + } + + if !draft.can_save() { + state.phase = SmartShelfPhase::DraftInvalid; + if draft.validation.issues.is_empty() { + transition.intent(SmartShelfIntent::ShowNotice( + SmartShelfNotice::error( + "Smart-shelf draft is not ready to save", + ), + )); + } else { + transition.intent(SmartShelfIntent::ShowDraftValidation( + draft.validation.issues.clone(), + )); + } + return; + } + + let confirmation = draft.save_confirmation(); + state.save.confirm(confirmation.clone()); + transition.intent(SmartShelfIntent::ShowSaveConfirmation(confirmation)); +} + +fn confirm_save( + state: &mut SmartShelfState, + transition: &mut SmartShelfTransition, +) { + let Some(draft) = state.draft.as_ref() else { + missing_draft(state, transition); + return; + }; + + if !draft.can_save() { + state.phase = SmartShelfPhase::DraftInvalid; + transition.intent(SmartShelfIntent::ShowDraftValidation( + draft.validation.issues.clone(), + )); + return; + } + + let request = draft.save_request(); + let artifact_id = draft.artifact_id; + state.phase = SmartShelfPhase::Saving; + state.save.saving(request.clone()); + transition.command(SmartShelfCommand::SaveSmartShelf { + artifact_id, + request, + }); +} + +fn recover_save_conflict( + state: &mut SmartShelfState, + action: SmartShelfSaveConflictRecovery, + transition: &mut SmartShelfTransition, +) { + let Some(conflict) = state.save.conflict.clone() else { + transition.intent(SmartShelfIntent::ShowNotice( + SmartShelfNotice::info( + "There is no active smart-shelf save conflict to recover", + ), + )); + return; + }; + + match action { + SmartShelfSaveConflictRecovery::ReloadDraft => { + state.phase = SmartShelfPhase::Running; + state.save.reset(); + state.last_draft_artifact_id = Some(conflict.artifact_id); + transition.command(SmartShelfCommand::FetchDraft { + artifact_id: conflict.artifact_id, + }); + } + SmartShelfSaveConflictRecovery::EditSelection => { + state.phase = SmartShelfPhase::DraftReady; + state.save.reset(); + transition.intent(SmartShelfIntent::ShowNotice( + SmartShelfNotice::info( + "Review the shelf selection before saving again", + ), + )); + } + SmartShelfSaveConflictRecovery::RetrySave => { + if let Some(request) = state.save.last_request.clone() { + state.phase = SmartShelfPhase::Saving; + state.save.saving(request.clone()); + transition.command(SmartShelfCommand::SaveSmartShelf { + artifact_id: conflict.artifact_id, + request, + }); + } else { + transition.intent(SmartShelfIntent::ShowNotice( + SmartShelfNotice::warning( + "The previous smart-shelf save request is no longer available", + ), + )); + } + } + SmartShelfSaveConflictRecovery::Discard => { + state.reset_work(); + transition.intent(SmartShelfIntent::CloseSmartShelf); + } + } +} + +fn missing_draft( + state: &mut SmartShelfState, + transition: &mut SmartShelfTransition, +) { + let failure = SmartShelfFailure::new( + SmartShelfFailureCode::MissingDraft, + "Load a smart-shelf draft before editing or saving it", + false, + ); + state.last_error = Some(failure.clone()); + transition.intent(SmartShelfIntent::ShowNotice(SmartShelfNotice::error( + failure.message, + ))); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::SmartShelfComposer; + use chrono::Utc; + use ferrex_player_api::api_types::{ + CollectionArtwork, CollectionDuplicatePolicy, CollectionId, + CollectionIdentity, CollectionKind, CollectionMediaScope, + CollectionOwner, CollectionPresentationMode, CollectionProvenance, + CollectionScope, CollectionSource, CollectionSummary, CollectionTheme, + CollectionTimestamps, CollectionVersion, CollectionVisibility, + IntelligenceCaps, IntelligenceError, IntelligenceErrorCode, + IntelligenceProviderState, IntelligenceProviderStatus, + IntelligenceRunPurpose, IntelligenceRunStatusResponse, + IntelligenceSummary, MediaID, MovieID, SmartShelfDraftAlternate, + SmartShelfDraftContent, SmartShelfDraftItem, SmartShelfDraftResponse, + SmartShelfDraftSource, SmartShelfDraftValidation, + SmartShelfDraftValidationIssue, SmartShelfDraftValidationIssueCode, + SmartShelfError, SmartShelfErrorCode, SmartShelfSaveResponse, + SmartShelfStartResponse, + }; + use serde_json::{Value, json}; + + fn movie(n: u128) -> MediaID { + MediaID::Movie(MovieID(Uuid::from_u128(n))) + } + + fn provider( + state: IntelligenceProviderState, + ) -> IntelligenceProviderStatus { + IntelligenceProviderStatus { + enabled: matches!( + state, + IntelligenceProviderState::Ready + | IntelligenceProviderState::Degraded + | IntelligenceProviderState::Unavailable + ), + provider_name: "test-provider".to_string(), + base_url: "http://provider.invalid".to_string(), + api_key_configured: matches!( + state, + IntelligenceProviderState::Ready + | IntelligenceProviderState::Degraded + | IntelligenceProviderState::Unavailable + ), + default_model: Some("test-model".to_string()), + state, + models: Vec::new(), + checked_at_epoch_seconds: Some(1), + error: (state == IntelligenceProviderState::Unavailable).then( + || IntelligenceError { + code: IntelligenceErrorCode::ProviderUnavailable, + message: "provider is down".to_string(), + retryable: true, + details: Value::Null, + }, + ), + } + } + + fn source(media_id: MediaID) -> SmartShelfDraftSource { + SmartShelfDraftSource { + label: Some("Library".to_string()), + media_id: Some(media_id), + artifact_id: None, + field: None, + evidence: Some(IntelligenceSummary::new("Grounded")), + } + } + + fn draft_response() -> SmartShelfDraftResponse { + let first = movie(1); + let second = movie(2); + let alternate = movie(3); + SmartShelfDraftResponse { + artifact_id: Uuid::from_u128(100), + run_id: Some(Uuid::from_u128(10)), + owner_user_id: Some(Uuid::from_u128(20)), + title: "Rain shelf".to_string(), + summary: Some(IntelligenceSummary::new("A useful shelf")), + draft: Some(SmartShelfDraftContent { + schema_version: 1, + title: "Rain shelf".to_string(), + description: Some("Atmospheric picks".to_string()), + interpreted_intent: Some("rainy night".to_string()), + requested_constraints: json!({"mood": "rain"}), + items: vec![ + SmartShelfDraftItem { + ordinal: 1, + media_id: first, + title: Some("First".to_string()), + subtitle: None, + year: Some(2020), + reason: Some("Grounded reason".to_string()), + sources: vec![source(first)], + locked: false, + replacement_of: None, + }, + SmartShelfDraftItem { + ordinal: 2, + media_id: second, + title: Some("Second".to_string()), + subtitle: None, + year: Some(2021), + reason: Some("Another grounded reason".to_string()), + sources: vec![source(second)], + locked: false, + replacement_of: None, + }, + ], + alternates: vec![SmartShelfDraftAlternate { + target_ordinal: Some(1), + media_id: alternate, + title: Some("Alternate".to_string()), + subtitle: None, + year: Some(2022), + reason: Some("Alternate reason".to_string()), + sources: vec![source(alternate)], + }], + }), + validation: SmartShelfDraftValidation { + valid: true, + issues: Vec::new(), + }, + saved_collection_id: None, + } + } + + fn invalid_draft_response() -> SmartShelfDraftResponse { + let mut response = draft_response(); + response.validation = SmartShelfDraftValidation { + valid: false, + issues: vec![SmartShelfDraftValidationIssue::for_item( + SmartShelfDraftValidationIssueCode::MissingReason, + 1, + movie(1), + "missing reason", + )], + }; + response + } + + fn progress( + status: IntelligenceRunStatus, + artifact_ids: Vec, + ) -> IntelligenceRunStatusResponse { + IntelligenceRunStatusResponse { + run_id: Uuid::from_u128(10), + purpose: IntelligenceRunPurpose::Recommendation, + status, + terminal: is_terminal_status(status), + current_phase: Some("phase".to_string()), + provider: Some("test-provider".to_string()), + model: Some("test-model".to_string()), + queued_at_epoch_seconds: Some(1), + started_at_epoch_seconds: Some(2), + completed_at_epoch_seconds: None, + current_step: Some(1), + max_steps: Some(3), + draft_artifact_ids: artifact_ids, + output_summary: None, + error: None, + } + } + + fn collection_summary(collection_id: CollectionId) -> CollectionSummary { + let now = Utc::now(); + CollectionSummary { + identity: CollectionIdentity::for_id(collection_id), + title: "Rain shelf".to_string(), + description: Some("Atmospheric picks".to_string()), + kind: CollectionKind::Manual, + source: CollectionSource::Manual, + owner: CollectionOwner::default(), + scope: CollectionScope::User, + visibility: CollectionVisibility::Private, + presentation: CollectionPresentationMode::Shelf, + media_scope: CollectionMediaScope::All, + duplicate_policy: CollectionDuplicatePolicy::DeduplicateMedia, + artwork: CollectionArtwork::default(), + theme: CollectionTheme::default(), + provenance: CollectionProvenance::default(), + version: CollectionVersion::default(), + timestamps: CollectionTimestamps { + created_at: now, + updated_at: now, + archived_at: None, + }, + item_count: 2, + materialization: Default::default(), + } + } + + fn save_response() -> SmartShelfSaveResponse { + let collection_id = CollectionId(Uuid::from_u128(200)); + SmartShelfSaveResponse { + draft_artifact_id: Uuid::from_u128(100), + collection_id, + collection: collection_summary(collection_id), + item_count: 2, + saved_at_epoch_seconds: Some(3), + } + } + + fn ready_state() -> SmartShelfState { + let mut state = SmartShelfState::default(); + reduce( + &mut state, + SmartShelfMessage::ProviderStatusLoaded(provider( + IntelligenceProviderState::Ready, + )), + ); + state + } + + #[test] + fn normal_flow_starts_polls_fetches_and_loads_valid_draft() { + let mut state = ready_state(); + reduce( + &mut state, + SmartShelfMessage::PromptChanged("rain shelf".to_string()), + ); + + let start = reduce(&mut state, SmartShelfMessage::StartRequested); + assert_eq!(state.phase, SmartShelfPhase::Starting); + assert!(matches!( + start.commands.as_slice(), + [SmartShelfCommand::StartSmartShelf(request)] if request.prompt == "rain shelf" + )); + + let poll = reduce( + &mut state, + SmartShelfMessage::StartAccepted(SmartShelfStartResponse { + run_id: Uuid::from_u128(10), + status: IntelligenceRunStatus::Queued, + provider: Some("test-provider".to_string()), + model: Some("test-model".to_string()), + queued_at_epoch_seconds: Some(1), + draft_schema_version: 1, + }), + ); + assert_eq!(state.phase, SmartShelfPhase::Running); + assert_eq!( + poll.commands, + vec![SmartShelfCommand::PollRun { + run_id: Uuid::from_u128(10), + }] + ); + + let fetch = reduce( + &mut state, + SmartShelfMessage::RunProgressLoaded(progress( + IntelligenceRunStatus::Succeeded, + vec![Uuid::from_u128(100)], + )), + ); + assert_eq!( + fetch.commands, + vec![SmartShelfCommand::FetchDraft { + artifact_id: Uuid::from_u128(100), + }] + ); + + let loaded = reduce( + &mut state, + SmartShelfMessage::DraftLoaded(draft_response()), + ); + assert!(loaded.is_empty()); + assert_eq!(state.phase, SmartShelfPhase::DraftReady); + assert_eq!(state.draft.as_ref().expect("draft").items.len(), 2); + } + + #[test] + fn provider_unavailable_uses_fallback_instead_of_starting() { + let mut state = SmartShelfState::default(); + let transition = reduce( + &mut state, + SmartShelfMessage::ProviderStatusLoaded(provider( + IntelligenceProviderState::Unavailable, + )), + ); + assert_eq!(state.phase, SmartShelfPhase::ProviderUnavailable); + assert!(transition.commands.is_empty()); + assert!(matches!( + transition.intents.as_slice(), + [SmartShelfIntent::ShowProviderFallback { + retryable: true, + .. + }] + )); + + reduce( + &mut state, + SmartShelfMessage::PromptChanged("rain shelf".to_string()), + ); + let start = reduce(&mut state, SmartShelfMessage::StartRequested); + assert!(start.commands.is_empty()); + assert!(matches!( + start.intents.as_slice(), + [SmartShelfIntent::ShowProviderFallback { .. }] + )); + } + + #[test] + fn validation_errors_focus_prompt_and_show_invalid_draft_issues() { + let mut state = ready_state(); + let empty_start = reduce(&mut state, SmartShelfMessage::StartRequested); + assert_eq!(state.phase, SmartShelfPhase::Idle); + assert!(empty_start.commands.is_empty()); + assert!(empty_start.intents.contains(&SmartShelfIntent::FocusPrompt)); + + let invalid = reduce( + &mut state, + SmartShelfMessage::DraftLoaded(invalid_draft_response()), + ); + assert_eq!(state.phase, SmartShelfPhase::DraftInvalid); + assert!(matches!( + invalid.intents.as_slice(), + [SmartShelfIntent::ShowDraftValidation(issues)] if issues.len() == 1 + )); + } + + #[test] + fn cancel_emits_cancel_command_and_marks_cancelled() { + let mut state = ready_state(); + state.run = + Some(SmartShelfRunState::from_start(SmartShelfStartResponse { + run_id: Uuid::from_u128(10), + status: IntelligenceRunStatus::Running, + provider: None, + model: None, + queued_at_epoch_seconds: None, + draft_schema_version: 1, + })); + state.phase = SmartShelfPhase::Running; + + let cancel = reduce(&mut state, SmartShelfMessage::CancelRequested); + assert_eq!(state.phase, SmartShelfPhase::Cancelling); + assert!(matches!( + cancel.commands.as_slice(), + [SmartShelfCommand::CancelRun { run_id, .. }] if *run_id == Uuid::from_u128(10) + )); + + let done = reduce( + &mut state, + SmartShelfMessage::CancelFinished( + ferrex_player_api::api_types::IntelligenceRunCancelResponse { + run_id: Uuid::from_u128(10), + status: IntelligenceRunStatus::Cancelled, + cancellation_requested: true, + cancelled_at_epoch_seconds: Some(4), + message: Some("cancelled".to_string()), + error: None, + }, + ), + ); + assert_eq!(state.phase, SmartShelfPhase::Cancelled); + assert!(matches!( + done.intents.as_slice(), + [SmartShelfIntent::ShowNotice(_)] + )); + } + + #[test] + fn retry_can_restart_and_edit_prompt_returns_to_composer() { + let mut state = ready_state(); + reduce( + &mut state, + SmartShelfMessage::PromptChanged("rain shelf".to_string()), + ); + let start = reduce(&mut state, SmartShelfMessage::StartRequested); + let request = match start.commands.first().expect("start command") { + SmartShelfCommand::StartSmartShelf(request) => request.clone(), + other => panic!("unexpected command: {other:?}"), + }; + reduce( + &mut state, + SmartShelfMessage::StartFailed(SmartShelfFailure::unknown( + "timeout", true, + )), + ); + + let retry = reduce(&mut state, SmartShelfMessage::RetryRequested); + assert_eq!(state.phase, SmartShelfPhase::Starting); + assert_eq!( + retry.commands, + vec![SmartShelfCommand::StartSmartShelf(request)] + ); + + let edit = reduce(&mut state, SmartShelfMessage::EditPromptRequested); + assert_eq!(state.phase, SmartShelfPhase::Idle); + assert!(state.run.is_none()); + assert!(state.draft.is_none()); + assert_eq!(edit.intents, vec![SmartShelfIntent::FocusPrompt]); + } + + #[test] + fn replacements_update_save_items_and_keep_locked_items_protected() { + let mut state = ready_state(); + reduce(&mut state, SmartShelfMessage::DraftLoaded(draft_response())); + + let lock = reduce(&mut state, SmartShelfMessage::ToggleLock(movie(1))); + assert!(matches!( + lock.intents.as_slice(), + [SmartShelfIntent::ShowNotice(_)] + )); + assert!(state.draft.as_ref().unwrap().items[0].locked); + + let blocked = reduce( + &mut state, + SmartShelfMessage::ReplaceWithAlternate { + target_media_id: movie(1), + alternate_media_id: movie(3), + }, + ); + assert!(matches!( + blocked.intents.as_slice(), + [SmartShelfIntent::ShowNotice(_)] + )); + assert_eq!(state.draft.as_ref().unwrap().items[0].media_id, movie(1)); + + reduce(&mut state, SmartShelfMessage::ToggleLock(movie(1))); + reduce( + &mut state, + SmartShelfMessage::ReplaceWithAlternate { + target_media_id: movie(1), + alternate_media_id: movie(3), + }, + ); + let draft = state.draft.as_ref().unwrap(); + assert_eq!(draft.items[0].media_id, movie(3)); + assert_eq!(draft.items[0].replacement_of, Some(movie(1))); + assert!(draft.dirty); + + let save = draft.save_request(); + assert_eq!(save.items[0].media_id, movie(3)); + assert_eq!(save.items[0].replacement_of, Some(movie(1))); + } + + #[test] + fn regenerate_unlocked_preserves_locked_media_ids_in_start_request() { + let mut state = ready_state(); + state.composer.prompt = "rain shelf".to_string(); + reduce(&mut state, SmartShelfMessage::DraftLoaded(draft_response())); + reduce(&mut state, SmartShelfMessage::ToggleLock(movie(2))); + + let transition = + reduce(&mut state, SmartShelfMessage::RegenerateUnlockedRequested); + assert_eq!(state.phase, SmartShelfPhase::Starting); + assert!(matches!( + transition.intents.as_slice(), + [SmartShelfIntent::RegenerateUnlocked { locked_media_ids }] if locked_media_ids == &vec![movie(2)] + )); + assert!(matches!( + transition.commands.as_slice(), + [SmartShelfCommand::StartSmartShelf(request)] + if request.locked_media_ids == vec![movie(2)] + && request.metadata["regenerate_unlocked"] == json!(true) + && request.metadata["previous_artifact_id"] == json!(Uuid::from_u128(100)) + )); + } + + #[test] + fn save_confirmation_and_success_open_saved_collection() { + let mut state = ready_state(); + reduce(&mut state, SmartShelfMessage::DraftLoaded(draft_response())); + + let confirmation = reduce(&mut state, SmartShelfMessage::SaveRequested); + assert_eq!(state.save.status, SmartShelfSaveStatus::Confirming); + assert!(matches!( + confirmation.intents.as_slice(), + [SmartShelfIntent::ShowSaveConfirmation(summary)] if summary.item_count == 2 + )); + + let command = reduce(&mut state, SmartShelfMessage::SaveConfirmed); + assert_eq!(state.phase, SmartShelfPhase::Saving); + assert!(matches!( + command.commands.as_slice(), + [SmartShelfCommand::SaveSmartShelf { artifact_id, request }] + if *artifact_id == Uuid::from_u128(100) && request.items.len() == 2 + )); + + let response = save_response(); + let collection_id = response.collection_id; + let success = + reduce(&mut state, SmartShelfMessage::SaveSucceeded(response)); + assert_eq!(state.phase, SmartShelfPhase::Saved); + assert_eq!(state.save.status, SmartShelfSaveStatus::Saved); + assert_eq!( + state.draft.as_ref().unwrap().saved_collection_id, + Some(collection_id) + ); + assert_eq!( + success.intents, + vec![SmartShelfIntent::OpenSavedCollection(collection_id)] + ); + } + + #[test] + fn save_conflict_can_reload_retry_edit_or_discard() { + let mut state = ready_state(); + reduce(&mut state, SmartShelfMessage::DraftLoaded(draft_response())); + reduce(&mut state, SmartShelfMessage::SaveConfirmed); + let request = state.save.last_request.clone().expect("save request"); + let conflict = SmartShelfFailure::from(SmartShelfError { + code: SmartShelfErrorCode::CollectionConflict, + message: "collection write conflict".to_string(), + retryable: true, + details: Value::Null, + }); + + let failed = + reduce(&mut state, SmartShelfMessage::SaveFailed(conflict)); + assert_eq!(state.phase, SmartShelfPhase::SaveConflict); + assert_eq!(state.save.status, SmartShelfSaveStatus::Conflict); + assert!(matches!( + failed.intents.as_slice(), + [SmartShelfIntent::ShowSaveConflict(conflict)] if conflict.recovery_actions.len() == 4 + )); + + let retry = reduce( + &mut state, + SmartShelfMessage::RecoverSaveConflict( + SmartShelfSaveConflictRecovery::RetrySave, + ), + ); + assert_eq!(state.phase, SmartShelfPhase::Saving); + assert_eq!( + retry.commands, + vec![SmartShelfCommand::SaveSmartShelf { + artifact_id: Uuid::from_u128(100), + request: request.clone(), + }] + ); + + let conflict = SmartShelfFailure::new( + SmartShelfFailureCode::Conflict, + "stale draft", + true, + ); + reduce(&mut state, SmartShelfMessage::SaveFailed(conflict)); + let reload = reduce( + &mut state, + SmartShelfMessage::RecoverSaveConflict( + SmartShelfSaveConflictRecovery::ReloadDraft, + ), + ); + assert_eq!( + reload.commands, + vec![SmartShelfCommand::FetchDraft { + artifact_id: Uuid::from_u128(100), + }] + ); + + reduce(&mut state, SmartShelfMessage::DraftLoaded(draft_response())); + reduce(&mut state, SmartShelfMessage::SaveConfirmed); + reduce( + &mut state, + SmartShelfMessage::SaveFailed(SmartShelfFailure::new( + SmartShelfFailureCode::Conflict, + "stale draft", + true, + )), + ); + let edit = reduce( + &mut state, + SmartShelfMessage::RecoverSaveConflict( + SmartShelfSaveConflictRecovery::EditSelection, + ), + ); + assert_eq!(state.phase, SmartShelfPhase::DraftReady); + assert!(matches!( + edit.intents.as_slice(), + [SmartShelfIntent::ShowNotice(_)] + )); + + reduce(&mut state, SmartShelfMessage::SaveConfirmed); + reduce( + &mut state, + SmartShelfMessage::SaveFailed(SmartShelfFailure::new( + SmartShelfFailureCode::Conflict, + "stale draft", + true, + )), + ); + let discard = reduce( + &mut state, + SmartShelfMessage::RecoverSaveConflict( + SmartShelfSaveConflictRecovery::Discard, + ), + ); + assert_eq!(state.phase, SmartShelfPhase::Idle); + assert!(state.draft.is_none()); + assert_eq!(discard.intents, vec![SmartShelfIntent::CloseSmartShelf]); + } + + #[test] + fn discard_confirmation_cancels_active_run_and_resets_state() { + let mut state = ready_state(); + state.composer = SmartShelfComposer::default(); + state.composer.prompt = "rain shelf".to_string(); + state.run = + Some(SmartShelfRunState::from_start(SmartShelfStartResponse { + run_id: Uuid::from_u128(10), + status: IntelligenceRunStatus::Running, + provider: None, + model: None, + queued_at_epoch_seconds: None, + draft_schema_version: 1, + })); + state.phase = SmartShelfPhase::Running; + + let confirm = reduce(&mut state, SmartShelfMessage::DiscardRequested); + assert_eq!(confirm.intents, vec![SmartShelfIntent::ConfirmDiscard]); + + let discarded = reduce(&mut state, SmartShelfMessage::DiscardConfirmed); + assert_eq!(state.phase, SmartShelfPhase::Idle); + assert!(state.run.is_none()); + assert_eq!( + discarded.commands, + vec![SmartShelfCommand::CancelRun { + run_id: Uuid::from_u128(10), + request: IntelligenceRunCancelRequest { + reason: Some( + "User discarded smart-shelf generation".to_string() + ), + }, + }] + ); + assert_eq!(discarded.intents, vec![SmartShelfIntent::CloseSmartShelf]); + } + + #[test] + fn template_selection_populates_composer_start_request() { + let mut state = ready_state(); + let first_template = state.composer.templates[0].clone(); + reduce( + &mut state, + SmartShelfMessage::TemplateSelected(first_template.id.clone()), + ); + let transition = reduce(&mut state, SmartShelfMessage::StartRequested); + assert!(matches!( + transition.commands.as_slice(), + [SmartShelfCommand::StartSmartShelf(request)] + if request.template_id == Some(first_template.id) + && request.prompt == first_template.prompt + && request.constraints == first_template.constraints + )); + } + + #[test] + fn unknown_provider_start_checks_status_before_generating() { + let mut state = SmartShelfState::default(); + state.composer.prompt = "rain shelf".to_string(); + let transition = reduce(&mut state, SmartShelfMessage::StartRequested); + assert_eq!(state.phase, SmartShelfPhase::ProviderUnavailable); + assert_eq!( + transition.commands, + vec![SmartShelfCommand::FetchProviderStatus] + ); + assert!(matches!( + transition.intents.as_slice(), + [SmartShelfIntent::ShowProviderFallback { + retryable: true, + .. + }] + )); + } + + #[test] + fn failed_run_surfaces_typed_intelligence_error() { + let mut state = ready_state(); + let mut failed = progress(IntelligenceRunStatus::Failed, Vec::new()); + failed.error = Some(IntelligenceError { + code: IntelligenceErrorCode::ProviderTimeout, + message: "provider timed out".to_string(), + retryable: true, + details: Value::Null, + }); + + let transition = + reduce(&mut state, SmartShelfMessage::RunProgressLoaded(failed)); + assert_eq!(state.phase, SmartShelfPhase::DraftError); + assert!(matches!( + transition.intents.as_slice(), + [SmartShelfIntent::ShowDraftError(failure)] + if failure.code == SmartShelfFailureCode::Intelligence(IntelligenceErrorCode::ProviderTimeout) + )); + } + + #[test] + fn item_count_is_clamped_before_start() { + let mut state = ready_state(); + reduce( + &mut state, + SmartShelfMessage::PromptChanged("rain shelf".to_string()), + ); + reduce(&mut state, SmartShelfMessage::ItemCountChanged(u16::MAX)); + let transition = reduce(&mut state, SmartShelfMessage::StartRequested); + assert!(matches!( + transition.commands.as_slice(), + [SmartShelfCommand::StartSmartShelf(request)] + if request.item_count == ferrex_player_api::api_types::MAX_SMART_SHELF_ITEM_COUNT + && request.caps == IntelligenceCaps::default() + )); + } +} diff --git a/crates/ferrex-player-intelligence/src/state.rs b/crates/ferrex-player-intelligence/src/state.rs new file mode 100644 index 00000000..4c472217 --- /dev/null +++ b/crates/ferrex-player-intelligence/src/state.rs @@ -0,0 +1,907 @@ +//! Smart-shelf reducer state types. + +use ferrex_player_api::api_types::{ + CollectionId, IntelligenceCaps, IntelligenceError, IntelligenceMediaKind, + IntelligenceProviderState, IntelligenceProviderStatus, + IntelligenceRunStatus, IntelligenceRunStatusResponse, IntelligenceSummary, + LibraryId, MediaID, SmartShelfDraftAlternate, SmartShelfDraftContent, + SmartShelfDraftItem, SmartShelfDraftResponse, SmartShelfDraftSource, + SmartShelfDraftValidation, SmartShelfErrorCode, SmartShelfSaveItem, + SmartShelfSaveRequest, SmartShelfSaveResponse, SmartShelfStartRequest, + SmartShelfStartResponse, clamp_smart_shelf_item_count, +}; +use serde_json::{Value, json}; +use uuid::Uuid; + +use crate::{ + SmartShelfFailure, SmartShelfFailureCode, + templates::{SmartShelfTemplate, built_in_templates}, +}; + +/// Provider readiness distilled into UI-safe states. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProviderReadiness { + /// No provider status has been loaded yet. + Unknown, + /// Provider status is currently being refreshed. + Checking, + /// Provider and selected/default model are ready. + Ready { + provider: String, + model: Option, + }, + /// Provider can be used, but the UI should surface degraded readiness. + Degraded { + provider: String, + model: Option, + message: Option, + }, + /// Provider cannot currently start smart-shelf runs. + Unavailable { message: String, retryable: bool }, +} + +impl ProviderReadiness { + /// Convert the API provider status into a reducer-friendly readiness value. + pub fn from_status(status: &IntelligenceProviderStatus) -> Self { + match status.state { + IntelligenceProviderState::Ready => Self::Ready { + provider: status.provider_name.clone(), + model: status.default_model.clone(), + }, + IntelligenceProviderState::Degraded => Self::Degraded { + provider: status.provider_name.clone(), + model: status.default_model.clone(), + message: status.error.as_ref().map(|error| error.message.clone()), + }, + IntelligenceProviderState::Disabled => Self::Unavailable { + message: "Smart shelves are disabled for this server".to_string(), + retryable: false, + }, + IntelligenceProviderState::NotConfigured => Self::Unavailable { + message: "Configure an intelligence provider before generating smart shelves".to_string(), + retryable: false, + }, + IntelligenceProviderState::Unavailable => Self::Unavailable { + message: status + .error + .as_ref() + .map(|error| error.message.clone()) + .unwrap_or_else(|| { + "The configured intelligence provider is unavailable".to_string() + }), + retryable: status.error.as_ref().is_none_or(|error| error.retryable), + }, + } + } + + /// Whether reducer start requests should be allowed to issue API commands. + pub const fn allows_start(&self) -> bool { + matches!(self, Self::Ready { .. } | Self::Degraded { .. }) + } + + /// User-displayable fallback message for non-ready states. + pub fn fallback_message(&self) -> Option<(String, bool)> { + match self { + Self::Unknown | Self::Checking => Some(( + "Checking intelligence provider readiness before starting a smart shelf".to_string(), + true, + )), + Self::Unavailable { message, retryable } => { + Some((message.clone(), *retryable)) + } + Self::Ready { .. } | Self::Degraded { .. } => None, + } + } +} + +impl Default for ProviderReadiness { + fn default() -> Self { + Self::Unknown + } +} + +/// High-level smart-shelf lifecycle phase. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum SmartShelfPhase { + /// Composer is idle and ready for input. + #[default] + Idle, + /// Provider fallback is active. + ProviderUnavailable, + /// Start command has been emitted and the API response is pending. + Starting, + /// Runtime is queued/running/polling. + Running, + /// Cancel command has been emitted. + Cancelling, + /// Runtime was cancelled. + Cancelled, + /// Draft is valid and editable. + DraftReady, + /// Draft loaded but has validation errors. + DraftInvalid, + /// Draft/runtime failed. + DraftError, + /// Save command has been emitted. + Saving, + /// Save succeeded. + Saved, + /// Save failed with a recoverable conflict/stale state. + SaveConflict, + /// Save failed with a non-conflict error. + SaveError, +} + +/// Composer state for prompt/template driven smart-shelf starts. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SmartShelfComposer { + /// User-edited prompt text. + pub prompt: String, + /// Active template id, if the prompt came from a template. + pub selected_template_id: Option, + /// Templates offered by the shell. + pub templates: Vec, + /// Optional library scope. + pub library_id: Option, + /// Requested media kinds. + pub media_kinds: Vec, + /// Requested result count, clamped to the API bounds. + pub item_count: u16, + /// Media ids that should be preserved by the next start/regenerate request. + pub locked_media_ids: Vec, + /// Optional model override. + pub model: Option, + /// Request caps forwarded to the API. + pub caps: IntelligenceCaps, + /// Structured constraints forwarded to the API. + pub constraints: Value, + /// Structured metadata forwarded to the API. + pub metadata: Value, + /// Last composer-level validation failure. + pub validation_error: Option, +} + +impl SmartShelfComposer { + /// Create a composer with caller-supplied templates. + pub fn with_templates(templates: Vec) -> Self { + Self { + templates, + ..Self::default() + } + } + + /// Apply a template by id. Returns `true` when the template was found. + pub fn select_template(&mut self, template_id: &str) -> bool { + let Some(template) = self + .templates + .iter() + .find(|template| template.id == template_id) + .cloned() + else { + return false; + }; + + self.prompt = template.prompt; + self.selected_template_id = Some(template.id); + self.media_kinds = template.media_kinds; + self.item_count = clamp_smart_shelf_item_count(template.item_count); + self.constraints = template.constraints; + self.validation_error = None; + true + } + + /// Clear template selection without discarding current prompt text. + pub fn clear_template(&mut self) { + self.selected_template_id = None; + } + + /// Build the API start request from current composer state. + pub fn start_request( + &mut self, + ) -> Result { + let prompt = self.prompt.trim().to_string(); + if prompt.is_empty() { + let failure = SmartShelfFailure::validation( + "Describe the smart shelf before starting generation", + ); + self.validation_error = Some(failure.clone()); + return Err(failure); + } + + self.validation_error = None; + Ok(SmartShelfStartRequest { + prompt, + library_id: self.library_id, + media_kinds: self.media_kinds.clone(), + item_count: clamp_smart_shelf_item_count(self.item_count), + template_id: self.selected_template_id.clone(), + locked_media_ids: self.locked_media_ids.clone(), + idempotency_key: None, + model: self.model.clone(), + caps: self.caps, + constraints: self.constraints.clone(), + metadata: self.metadata.clone(), + }) + } + + pub(crate) fn start_request_with_regenerate_metadata( + &mut self, + draft: &SmartShelfDraftState, + ) -> Result { + if self.prompt.trim().is_empty() { + if let Some(intent) = draft.interpreted_intent.as_deref() { + self.prompt = intent.to_string(); + } else { + self.prompt = draft.title.clone(); + } + } + + self.locked_media_ids = draft.locked_media_ids(); + let mut request = self.start_request()?; + request.locked_media_ids = draft.locked_media_ids(); + request.metadata = merge_regenerate_metadata( + &request.metadata, + draft.artifact_id, + request.locked_media_ids.len(), + ); + Ok(request) + } +} + +impl Default for SmartShelfComposer { + fn default() -> Self { + Self { + prompt: String::new(), + selected_template_id: None, + templates: built_in_templates(), + library_id: None, + media_kinds: vec![ + IntelligenceMediaKind::Movie, + IntelligenceMediaKind::Series, + ], + item_count: clamp_smart_shelf_item_count(8), + locked_media_ids: Vec::new(), + model: None, + caps: IntelligenceCaps::default(), + constraints: Value::Null, + metadata: Value::Null, + validation_error: None, + } + } +} + +/// Runtime state for an active or recently completed smart-shelf run. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SmartShelfRunState { + /// Intelligence run id. + pub run_id: Uuid, + /// Current runtime status. + pub status: IntelligenceRunStatus, + /// Whether the status is terminal. + pub terminal: bool, + /// Optional phase label from the runtime. + pub current_phase: Option, + /// Current step, when available. + pub current_step: Option, + /// Max step, when available. + pub max_steps: Option, + /// Provider selected by the runtime. + pub provider: Option, + /// Model selected by the runtime. + pub model: Option, + /// Draft artifacts produced by the run. + pub draft_artifact_ids: Vec, + /// Runtime error, when terminal failure occurred. + pub error: Option, +} + +impl SmartShelfRunState { + /// Create run state from a start response. + pub fn from_start(response: SmartShelfStartResponse) -> Self { + Self { + run_id: response.run_id, + status: response.status, + terminal: is_terminal_status(response.status), + current_phase: None, + current_step: None, + max_steps: None, + provider: response.provider, + model: response.model, + draft_artifact_ids: Vec::new(), + error: None, + } + } + + /// Create run state from a status response. + pub fn from_status(response: &IntelligenceRunStatusResponse) -> Self { + Self { + run_id: response.run_id, + status: response.status, + terminal: response.terminal, + current_phase: response.current_phase.clone(), + current_step: response.current_step, + max_steps: response.max_steps, + provider: response.provider.clone(), + model: response.model.clone(), + draft_artifact_ids: response.draft_artifact_ids.clone(), + error: response.error.clone().map(SmartShelfFailure::from), + } + } + + /// Update this state from a status response. + pub fn apply_status(&mut self, response: &IntelligenceRunStatusResponse) { + *self = Self::from_status(response); + } + + /// Whether the run can still be cancelled. + pub const fn can_cancel(&self) -> bool { + !self.terminal + && matches!( + self.status, + IntelligenceRunStatus::Queued | IntelligenceRunStatus::Running + ) + } +} + +/// Editable selected draft item state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SmartShelfItemState { + /// One-based item ordinal. + pub ordinal: u32, + /// Selected media id. + pub media_id: MediaID, + /// Display title. + pub title: Option, + /// Display subtitle. + pub subtitle: Option, + /// Release year. + pub year: Option, + /// Grounded selection reason. + pub reason: Option, + /// Source/provenance chips. + pub sources: Vec, + /// Whether the user locked this item across replacement/regeneration. + pub locked: bool, + /// Original selected item this item replaced, when applicable. + pub replacement_of: Option, +} + +impl SmartShelfItemState { + /// Build editable item state from an API draft item. + pub fn from_draft_item(item: SmartShelfDraftItem) -> Self { + Self { + ordinal: item.ordinal, + media_id: item.media_id, + title: item.title, + subtitle: item.subtitle, + year: item.year, + reason: item.reason, + sources: item.sources, + locked: item.locked, + replacement_of: item.replacement_of, + } + } + + /// Convert item state to a save request item. + pub fn to_save_item(&self) -> SmartShelfSaveItem { + SmartShelfSaveItem { + media_id: self.media_id, + locked: self.locked, + replacement_of: self.replacement_of, + reason: self.reason.clone(), + sources: self.sources.clone(), + } + } +} + +/// Editable alternate item state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SmartShelfAlternateState { + /// Target ordinal suggested by the provider. + pub target_ordinal: Option, + /// Alternate media id. + pub media_id: MediaID, + /// Display title. + pub title: Option, + /// Display subtitle. + pub subtitle: Option, + /// Release year. + pub year: Option, + /// Grounded alternate reason. + pub reason: Option, + /// Source/provenance chips. + pub sources: Vec, +} + +impl SmartShelfAlternateState { + /// Build alternate state from an API draft alternate. + pub fn from_draft_alternate(alternate: SmartShelfDraftAlternate) -> Self { + Self { + target_ordinal: alternate.target_ordinal, + media_id: alternate.media_id, + title: alternate.title, + subtitle: alternate.subtitle, + year: alternate.year, + reason: alternate.reason, + sources: alternate.sources, + } + } + + fn into_item_replacing( + self, + ordinal: u32, + replaced_media_id: MediaID, + ) -> SmartShelfItemState { + SmartShelfItemState { + ordinal, + media_id: self.media_id, + title: self.title, + subtitle: self.subtitle, + year: self.year, + reason: self.reason, + sources: self.sources, + locked: false, + replacement_of: Some(replaced_media_id), + } + } +} + +/// Typed draft state that the UI can render and edit. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SmartShelfDraftState { + /// Draft artifact id. + pub artifact_id: Uuid, + /// Producing run id, when available. + pub run_id: Option, + /// Draft title. + pub title: String, + /// Bounded server summary. + pub summary: Option, + /// Draft description. + pub description: Option, + /// Provider-interpreted prompt/intent. + pub interpreted_intent: Option, + /// Selected/editable items. + pub items: Vec, + /// Alternate items available for replacement. + pub alternates: Vec, + /// Server validation result for the loaded draft. + pub validation: SmartShelfDraftValidation, + /// Collection id when the draft has already been saved. + pub saved_collection_id: Option, + /// Whether local item state differs from the loaded draft. + pub dirty: bool, +} + +impl SmartShelfDraftState { + /// Convert an API draft response into editable reducer state. + pub fn from_response(response: SmartShelfDraftResponse) -> Self { + let SmartShelfDraftResponse { + artifact_id, + run_id, + title, + summary, + draft, + validation, + saved_collection_id, + .. + } = response; + + let (description, interpreted_intent, items, alternates) = draft + .map(draft_parts) + .unwrap_or_else(|| (None, None, Vec::new(), Vec::new())); + + Self { + artifact_id, + run_id, + title, + summary, + description, + interpreted_intent, + items, + alternates, + validation, + saved_collection_id, + dirty: false, + } + } + + /// Whether the draft can be saved without first fixing validation errors. + pub fn can_save(&self) -> bool { + self.validation.valid + && !self.items.is_empty() + && self.saved_collection_id.is_none() + } + + /// Count locked selected items. + pub fn locked_count(&self) -> usize { + self.items.iter().filter(|item| item.locked).count() + } + + /// Count selected replacement items. + pub fn replacements_count(&self) -> usize { + self.items + .iter() + .filter(|item| item.replacement_of.is_some()) + .count() + } + + /// Media ids locked by the user. + pub fn locked_media_ids(&self) -> Vec { + self.items + .iter() + .filter(|item| item.locked) + .map(|item| item.media_id) + .collect() + } + + /// Toggle lock for a selected item. + pub fn toggle_lock( + &mut self, + media_id: MediaID, + ) -> Result { + let Some(item) = + self.items.iter_mut().find(|item| item.media_id == media_id) + else { + return Err(SmartShelfFailure::new( + SmartShelfFailureCode::MissingDraft, + "The selected smart-shelf item is no longer available", + false, + )); + }; + + item.locked = !item.locked; + self.dirty = true; + Ok(item.locked) + } + + /// Replace a selected item with an alternate. + pub fn replace_with_alternate( + &mut self, + target_media_id: MediaID, + alternate_media_id: MediaID, + ) -> Result<(), SmartShelfFailure> { + let Some(target_index) = self + .items + .iter() + .position(|item| item.media_id == target_media_id) + else { + return Err(SmartShelfFailure::new( + SmartShelfFailureCode::ReplacementUnavailable, + "The selected item is no longer in this draft", + false, + )); + }; + + if self.items[target_index].locked { + return Err(SmartShelfFailure::new( + SmartShelfFailureCode::Conflict, + "Unlock this item before replacing it", + false, + )); + } + + if self + .items + .iter() + .any(|item| item.media_id == alternate_media_id) + { + return Err(SmartShelfFailure::new( + SmartShelfFailureCode::Conflict, + "That alternate is already selected in the shelf", + false, + )); + } + + let Some(alternate_index) = self + .alternates + .iter() + .position(|alternate| alternate.media_id == alternate_media_id) + else { + return Err(SmartShelfFailure::new( + SmartShelfFailureCode::ReplacementUnavailable, + "The requested alternate is no longer available", + false, + )); + }; + + let target = self.items.remove(target_index); + let alternate = self.alternates.remove(alternate_index); + let replacement = alternate.into_item_replacing( + target.ordinal, + target.replacement_of.unwrap_or(target.media_id), + ); + self.alternates.push(SmartShelfAlternateState { + target_ordinal: Some(target.ordinal), + media_id: target.media_id, + title: target.title, + subtitle: target.subtitle, + year: target.year, + reason: target.reason, + sources: target.sources, + }); + self.items.insert(target_index, replacement); + self.dirty = true; + Ok(()) + } + + /// Build a save request from current draft item state. + pub fn save_request(&self) -> SmartShelfSaveRequest { + SmartShelfSaveRequest { + title: Some(self.title.clone()), + description: self.description.clone(), + items: self + .items + .iter() + .map(SmartShelfItemState::to_save_item) + .collect(), + idempotency_key: None, + } + } + + /// Build a save confirmation summary from current draft state. + pub fn save_confirmation(&self) -> SmartShelfSaveConfirmation { + SmartShelfSaveConfirmation { + artifact_id: self.artifact_id, + title: self.title.clone(), + item_count: self.items.len(), + locked_count: self.locked_count(), + replacements_count: self.replacements_count(), + } + } +} + +/// Save status tracked independently from the high-level phase. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum SmartShelfSaveStatus { + /// No save is active. + #[default] + Idle, + /// User is reviewing save confirmation. + Confirming, + /// Save command has been emitted. + Saving, + /// Save succeeded. + Saved, + /// Save failed with recoverable conflict. + Conflict, + /// Save failed with non-conflict error. + Error, +} + +/// Summary shown before issuing a save command. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SmartShelfSaveConfirmation { + /// Draft artifact being saved. + pub artifact_id: Uuid, + /// Collection title to be saved. + pub title: String, + /// Number of accepted items. + pub item_count: usize, + /// Number of locked accepted items. + pub locked_count: usize, + /// Number of accepted replacement items. + pub replacements_count: usize, +} + +/// Recovery choices for save conflict/stale failures. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SmartShelfSaveConflictRecovery { + /// Fetch the draft again from the server. + ReloadDraft, + /// Return to draft editing with the current local selections. + EditSelection, + /// Retry the previous save request. + RetrySave, + /// Discard local smart-shelf state. + Discard, +} + +/// Save conflict state and available recovery actions. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SmartShelfSaveConflict { + /// Draft artifact id that failed to save. + pub artifact_id: Uuid, + /// Conflict failure returned by the API or local reducer. + pub failure: SmartShelfFailure, + /// User-facing recovery actions. + pub recovery_actions: Vec, +} + +/// Save state retained for confirmation, retry, success, and recovery. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct SmartShelfSaveState { + /// Current save status. + pub status: SmartShelfSaveStatus, + /// Pending confirmation summary. + pub confirmation: Option, + /// Last request emitted by the reducer, used for retry. + pub last_request: Option, + /// Last successful save response. + pub last_response: Option, + /// Last non-success failure. + pub last_error: Option, + /// Active conflict recovery state. + pub conflict: Option, +} + +impl SmartShelfSaveState { + /// Reset save state before a new run/draft is started. + pub fn reset(&mut self) { + *self = Self::default(); + } + + /// Store a pending confirmation. + pub fn confirm(&mut self, confirmation: SmartShelfSaveConfirmation) { + self.status = SmartShelfSaveStatus::Confirming; + self.confirmation = Some(confirmation); + self.last_error = None; + self.conflict = None; + } + + /// Mark save as in-flight. + pub fn saving(&mut self, request: SmartShelfSaveRequest) { + self.status = SmartShelfSaveStatus::Saving; + self.confirmation = None; + self.last_request = Some(request); + self.last_error = None; + self.conflict = None; + } + + /// Mark save as successful. + pub fn succeeded(&mut self, response: SmartShelfSaveResponse) { + self.status = SmartShelfSaveStatus::Saved; + self.confirmation = None; + self.last_response = Some(response); + self.last_error = None; + self.conflict = None; + } + + /// Mark save as failed. + pub fn failed( + &mut self, + artifact_id: Uuid, + failure: SmartShelfFailure, + ) -> Option { + self.confirmation = None; + self.last_error = Some(failure.clone()); + if failure.is_save_conflict() { + let conflict = SmartShelfSaveConflict { + artifact_id, + failure, + recovery_actions: vec![ + SmartShelfSaveConflictRecovery::ReloadDraft, + SmartShelfSaveConflictRecovery::EditSelection, + SmartShelfSaveConflictRecovery::RetrySave, + SmartShelfSaveConflictRecovery::Discard, + ], + }; + self.status = SmartShelfSaveStatus::Conflict; + self.conflict = Some(conflict.clone()); + Some(conflict) + } else { + self.status = SmartShelfSaveStatus::Error; + self.conflict = None; + None + } + } +} + +/// Complete smart-shelf reducer state. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct SmartShelfState { + /// High-level lifecycle phase. + pub phase: SmartShelfPhase, + /// Provider readiness/fallback state. + pub provider: ProviderReadiness, + /// Prompt/template composer. + pub composer: SmartShelfComposer, + /// Active or most recent run. + pub run: Option, + /// Loaded editable draft. + pub draft: Option, + /// Save confirmation/progress/recovery state. + pub save: SmartShelfSaveState, + /// Last failure from any reducer operation. + pub last_error: Option, + /// Last start request emitted by the reducer, used for retry. + pub last_start_request: Option, + /// Last draft artifact id requested, used for reload/retry. + pub last_draft_artifact_id: Option, +} + +impl SmartShelfState { + /// Whether the current state contains local/user-visible work. + pub fn has_recoverable_work(&self) -> bool { + self.run.is_some() + || self.draft.is_some() + || self.save.confirmation.is_some() + || self.save.last_request.is_some() + || !self.composer.prompt.trim().is_empty() + } + + /// Reset all run/draft/save state while keeping provider status and templates. + pub fn reset_work(&mut self) { + let provider = self.provider.clone(); + let templates = self.composer.templates.clone(); + let model = self.composer.model.clone(); + *self = Self::default(); + self.provider = provider; + self.composer.templates = templates; + self.composer.model = model; + } + + /// Return active run id when it can be cancelled. + pub fn cancellable_run_id(&self) -> Option { + self.run + .as_ref() + .filter(|run| run.can_cancel()) + .map(|run| run.run_id) + } +} + +pub(crate) const fn is_terminal_status(status: IntelligenceRunStatus) -> bool { + matches!( + status, + IntelligenceRunStatus::Succeeded + | IntelligenceRunStatus::Failed + | IntelligenceRunStatus::Cancelled + ) +} + +pub(crate) fn failure_from_run_error( + error: Option, + fallback: impl Into, +) -> SmartShelfFailure { + error + .map(SmartShelfFailure::from) + .unwrap_or_else(|| SmartShelfFailure::unknown(fallback, true)) +} + +pub(crate) fn already_saved_failure( + collection_id: CollectionId, +) -> SmartShelfFailure { + SmartShelfFailure::new( + SmartShelfFailureCode::SmartShelf(SmartShelfErrorCode::AlreadySaved), + format!( + "This smart shelf has already been saved as collection {collection_id}" + ), + false, + ) +} + +fn draft_parts( + draft: SmartShelfDraftContent, +) -> ( + Option, + Option, + Vec, + Vec, +) { + ( + draft.description, + draft.interpreted_intent, + draft + .items + .into_iter() + .map(SmartShelfItemState::from_draft_item) + .collect(), + draft + .alternates + .into_iter() + .map(SmartShelfAlternateState::from_draft_alternate) + .collect(), + ) +} + +fn merge_regenerate_metadata( + existing: &Value, + artifact_id: Uuid, + locked_count: usize, +) -> Value { + let mut object = existing.as_object().cloned().unwrap_or_default(); + object.insert("regenerate_unlocked".to_string(), Value::Bool(true)); + object.insert("previous_artifact_id".to_string(), json!(artifact_id)); + object.insert("locked_item_count".to_string(), json!(locked_count)); + Value::Object(object) +} diff --git a/crates/ferrex-player-intelligence/src/templates.rs b/crates/ferrex-player-intelligence/src/templates.rs new file mode 100644 index 00000000..44783403 --- /dev/null +++ b/crates/ferrex-player-intelligence/src/templates.rs @@ -0,0 +1,111 @@ +//! Smart-shelf composer templates. + +use ferrex_player_api::api_types::{ + IntelligenceMediaKind, clamp_smart_shelf_item_count, +}; +use serde_json::{Value, json}; + +/// Prompt template used by smart-shelf composer state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SmartShelfTemplate { + /// Stable template id. + pub id: String, + /// Display label. + pub label: String, + /// Short display description. + pub description: Option, + /// Prompt inserted into the composer. + pub prompt: String, + /// Media kinds requested by the template. + pub media_kinds: Vec, + /// Requested item count. + pub item_count: u16, + /// Structured constraints to send with the start request. + pub constraints: Value, +} + +impl SmartShelfTemplate { + /// Build a template with default movie/series media kinds. + pub fn new( + id: impl Into, + label: impl Into, + prompt: impl Into, + ) -> Self { + Self { + id: id.into(), + label: label.into(), + description: None, + prompt: prompt.into(), + media_kinds: vec![ + IntelligenceMediaKind::Movie, + IntelligenceMediaKind::Series, + ], + item_count: clamp_smart_shelf_item_count(8), + constraints: Value::Null, + } + } + + /// Add a display description. + pub fn with_description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + /// Override the requested media kinds. + pub fn with_media_kinds( + mut self, + media_kinds: Vec, + ) -> Self { + self.media_kinds = media_kinds; + self + } + + /// Override the requested item count. + pub fn with_item_count(mut self, item_count: u16) -> Self { + self.item_count = clamp_smart_shelf_item_count(item_count); + self + } + + /// Attach structured API constraints. + pub fn with_constraints(mut self, constraints: Value) -> Self { + self.constraints = constraints; + self + } +} + +/// Built-in templates that require no server state and can be safely shown by UI shells. +pub fn built_in_templates() -> Vec { + vec![ + SmartShelfTemplate::new( + "rainy-night", + "Rainy night", + "Build a cozy rainy-night shelf with atmospheric movies and series from my library.", + ) + .with_description("Cozy, moody, rewatch-friendly picks") + .with_constraints(json!({ + "mood": "cozy_rainy_night", + "avoid_duplicates": true + })), + SmartShelfTemplate::new( + "hidden-gems", + "Hidden gems", + "Find under-watched gems in my library that deserve a spot on a smart shelf.", + ) + .with_description("Lower-profile titles grounded in library metadata") + .with_constraints(json!({ + "novelty": "under_watched", + "avoid_duplicates": true + })), + SmartShelfTemplate::new( + "quick-comfort", + "Quick comfort", + "Create a comfort-watch shelf with shorter movies or easy series starts.", + ) + .with_description("Lower-friction choices for short viewing windows") + .with_item_count(6) + .with_constraints(json!({ + "pace": "low_friction", + "runtime_preference": "shorter" + })), + ] +} diff --git a/crates/ferrex-player-ui/Cargo.toml b/crates/ferrex-player-ui/Cargo.toml index ac370f4e..e4416fe6 100644 --- a/crates/ferrex-player-ui/Cargo.toml +++ b/crates/ferrex-player-ui/Cargo.toml @@ -41,6 +41,7 @@ ferrex-contracts = { path = "../ferrex-contracts", features = [ ferrex-player-api = { workspace = true } ferrex-player-auth = { workspace = true } ferrex-player-foundation = { workspace = true } +ferrex-player-intelligence = { workspace = true } ferrex-player-repository = { workspace = true } ferrex-player-library = { workspace = true } ferrex-player-media = { workspace = true } diff --git a/crates/ferrex-player-ui/src/domains/intelligence/mod.rs b/crates/ferrex-player-ui/src/domains/intelligence/mod.rs new file mode 100644 index 00000000..69760f6d --- /dev/null +++ b/crates/ferrex-player-ui/src/domains/intelligence/mod.rs @@ -0,0 +1,8 @@ +//! UI-facing compatibility surface for smart-shelf intelligence state. +//! +//! The reducer and DTO-shaped command/intent values live in +//! `ferrex-player-intelligence` so desktop views can render and schedule the +//! flow without duplicating smart-shelf business logic or depending on Iced from +//! the lower-level domain crate. + +pub use ferrex_player_intelligence::*; diff --git a/crates/ferrex-player-ui/src/domains/mod.rs b/crates/ferrex-player-ui/src/domains/mod.rs index 5dddac1a..27aa144f 100644 --- a/crates/ferrex-player-ui/src/domains/mod.rs +++ b/crates/ferrex-player-ui/src/domains/mod.rs @@ -5,6 +5,7 @@ //! testable domains. pub mod auth; +pub mod intelligence; pub mod library; pub mod media; pub mod metadata; diff --git a/crates/ferrex-player-ui/src/domains/ui/collections.rs b/crates/ferrex-player-ui/src/domains/ui/collections.rs index b7a30ae6..ae12a390 100644 --- a/crates/ferrex-player-ui/src/domains/ui/collections.rs +++ b/crates/ferrex-player-ui/src/domains/ui/collections.rs @@ -92,6 +92,8 @@ pub enum CollectionsMessage { CreateScopeChanged(CollectionMediaScopeChoice), SubmitCreate, CreateCompleted(Result), + EnterEditMode(CollectionId), + ExitEditMode(CollectionId), EditTitleChanged(CollectionId, String), EditDescriptionChanged(CollectionId, String), EditScopeChanged(CollectionId, CollectionMediaScopeChoice), @@ -169,6 +171,8 @@ impl CollectionsMessage { Self::CreateScopeChanged(_) => "UI::CollectionCreateScopeChanged", Self::SubmitCreate => "UI::CollectionCreateSubmit", Self::CreateCompleted(_) => "UI::CollectionCreateCompleted", + Self::EnterEditMode(_) => "UI::CollectionEnterEditMode", + Self::ExitEditMode(_) => "UI::CollectionExitEditMode", Self::EditTitleChanged(_, _) => "UI::CollectionEditTitleChanged", Self::EditDescriptionChanged(_, _) => { "UI::CollectionEditDescriptionChanged" @@ -539,6 +543,14 @@ pub fn update_collections_ui( CollectionsMessage::CreateCompleted(result) => { handle_create_completed(state, result) } + CollectionsMessage::EnterEditMode(collection_id) => { + collections_tab_mut(state).enter_detail_edit_mode(collection_id); + DomainUpdateResult::task(Task::none()) + } + CollectionsMessage::ExitEditMode(collection_id) => { + collections_tab_mut(state).exit_detail_edit_mode(collection_id); + DomainUpdateResult::task(Task::none()) + } CollectionsMessage::EditTitleChanged(collection_id, value) => { let form = collections_tab_mut(state).ensure_edit_form(collection_id); diff --git a/crates/ferrex-player-ui/src/domains/ui/messages/mod.rs b/crates/ferrex-player-ui/src/domains/ui/messages/mod.rs index 9c5dde24..7c82e106 100644 --- a/crates/ferrex-player-ui/src/domains/ui/messages/mod.rs +++ b/crates/ferrex-player-ui/src/domains/ui/messages/mod.rs @@ -13,6 +13,7 @@ use crate::domains::ui::{ playback_ui::PlaybackMessage, settings_ui::SettingsUiMessage, shell_ui::UiShellMessage, + smart_shelf::SmartShelfUiMessage, view_model_ui::ViewModelMessage, views::{ tenfoot::{detail::TenFootDetailMessage, home::TenFootHomeMessage}, @@ -38,6 +39,7 @@ pub enum UiMessage { Playback(PlaybackMessage), Feedback(FeedbackMessage), Collections(CollectionsMessage), + SmartShelf(SmartShelfUiMessage), // Virtual carousel events (new module) VirtualCarousel(VirtualCarouselMessage), @@ -75,6 +77,7 @@ impl UiMessage { Self::Playback(msg) => msg.name(), Self::Feedback(msg) => msg.name(), Self::Collections(msg) => msg.name(), + Self::SmartShelf(msg) => msg.name(), Self::VirtualCarousel(_) => "UI::VirtualCarousel", Self::TenFootHome(_) => "UI::TenFootHome", @@ -107,6 +110,7 @@ impl std::fmt::Debug for UiMessage { Self::Collections(msg) => { write!(f, "UI::Collections({:?})", msg) } + Self::SmartShelf(msg) => write!(f, "UI::SmartShelf({:?})", msg), Self::VirtualCarousel(msg) => { write!(f, "UI::VirtualCarousel({:?})", msg) diff --git a/crates/ferrex-player-ui/src/domains/ui/mod.rs b/crates/ferrex-player-ui/src/domains/ui/mod.rs index e8ac5923..6735a3ce 100644 --- a/crates/ferrex-player-ui/src/domains/ui/mod.rs +++ b/crates/ferrex-player-ui/src/domains/ui/mod.rs @@ -17,6 +17,7 @@ pub mod scroll_manager; pub mod search_surface; pub mod settings_ui; pub mod shell_ui; +pub mod smart_shelf; pub mod tabs; pub mod theme; pub mod types; @@ -39,6 +40,7 @@ use crate::{ messages::UiMessage as UIMessage, scroll_manager::ScrollPositionManager, shell_ui::Scope, + smart_shelf::SmartShelfUiState, types::ViewState, view_model_ui::ViewModelMessage, views::{ @@ -160,6 +162,9 @@ pub struct UIDomainState { // Toast notification manager pub toast_manager: feedback_ui::ToastManager, + // Smart-shelf composer/review overlay state + pub smart_shelf: SmartShelfUiState, + // 10-foot Home and detail focus/window state pub tenfoot_home: TenFootHomeState, pub tenfoot_detail: TenFootDetailState, diff --git a/crates/ferrex-player-ui/src/domains/ui/smart_shelf.rs b/crates/ferrex-player-ui/src/domains/ui/smart_shelf.rs new file mode 100644 index 00000000..eea46069 --- /dev/null +++ b/crates/ferrex-player-ui/src/domains/ui/smart_shelf.rs @@ -0,0 +1,428 @@ +use std::{sync::Arc, time::Duration}; + +use ferrex_player_api::{ + api_types::{ + IntelligenceRunCancelRequest, SmartShelfSaveRequest, + SmartShelfStartRequest, + }, + services::api::ApiService, +}; +use ferrex_player_foundation::repository::RepositoryError; +use ferrex_player_intelligence::{ + ProviderReadiness, SmartShelfCommand, SmartShelfFailure, + SmartShelfFailureCode, SmartShelfIntent, SmartShelfMessage, + SmartShelfNotice, SmartShelfSaveConflictRecovery, SmartShelfSaveStatus, + SmartShelfState, reduce, +}; +use iced::Task; + +use crate::{ + common::messages::{DomainMessage, DomainUpdateResult}, + domains::ui::{collections, messages::UiMessage}, + state::State, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SmartShelfProviderFallbackState { + pub message: String, + pub retryable: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct SmartShelfUiState { + pub open: bool, + pub reducer: SmartShelfState, + pub notice: Option, + pub provider_fallback: Option, + pub confirm_discard: bool, +} + +impl SmartShelfUiState { + pub fn reset_transient(&mut self) { + self.notice = None; + self.provider_fallback = None; + self.confirm_discard = false; + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SmartShelfUiMessage { + OpenComposer, + CloseRequested, + ConfirmDiscard, + CancelDiscard, + CancelSaveConfirmation, + DismissNotice, + Reducer(SmartShelfMessage), +} + +impl SmartShelfUiMessage { + pub fn name(&self) -> &'static str { + match self { + Self::OpenComposer => "UI::SmartShelf::OpenComposer", + Self::CloseRequested => "UI::SmartShelf::CloseRequested", + Self::ConfirmDiscard => "UI::SmartShelf::ConfirmDiscard", + Self::CancelDiscard => "UI::SmartShelf::CancelDiscard", + Self::CancelSaveConfirmation => { + "UI::SmartShelf::CancelSaveConfirmation" + } + Self::DismissNotice => "UI::SmartShelf::DismissNotice", + Self::Reducer(_) => "UI::SmartShelf::Reducer", + } + } +} + +impl From for UiMessage { + fn from(message: SmartShelfUiMessage) -> Self { + UiMessage::SmartShelf(message) + } +} + +pub fn update_smart_shelf_ui( + state: &mut State, + message: SmartShelfUiMessage, +) -> DomainUpdateResult { + match message { + SmartShelfUiMessage::OpenComposer => open_smart_shelf(state), + SmartShelfUiMessage::CloseRequested => close_smart_shelf(state), + SmartShelfUiMessage::ConfirmDiscard => { + apply_reducer_message(state, SmartShelfMessage::DiscardConfirmed) + } + SmartShelfUiMessage::CancelDiscard => { + state.domains.ui.state.smart_shelf.confirm_discard = false; + DomainUpdateResult::task(Task::none()) + } + SmartShelfUiMessage::CancelSaveConfirmation => { + let surface = &mut state.domains.ui.state.smart_shelf; + surface.reducer.save.reset(); + if surface + .reducer + .draft + .as_ref() + .is_some_and(|draft| draft.can_save()) + { + surface.reducer.phase = + ferrex_player_intelligence::SmartShelfPhase::DraftReady; + } + DomainUpdateResult::task(Task::none()) + } + SmartShelfUiMessage::DismissNotice => { + state.domains.ui.state.smart_shelf.notice = None; + DomainUpdateResult::task(Task::none()) + } + SmartShelfUiMessage::Reducer(message) => { + apply_reducer_message(state, message) + } + } +} + +fn open_smart_shelf(state: &mut State) -> DomainUpdateResult { + state.domains.ui.state.smart_shelf.open = true; + state.domains.ui.state.smart_shelf.confirm_discard = false; + + if matches!( + state.domains.ui.state.smart_shelf.reducer.provider, + ProviderReadiness::Unknown + ) { + apply_reducer_message( + state, + SmartShelfMessage::ProviderRefreshRequested, + ) + } else { + DomainUpdateResult::task(Task::none()) + } +} + +fn close_smart_shelf(state: &mut State) -> DomainUpdateResult { + let has_recoverable_work = state + .domains + .ui + .state + .smart_shelf + .reducer + .has_recoverable_work(); + + if has_recoverable_work { + apply_reducer_message(state, SmartShelfMessage::DiscardRequested) + } else { + let surface = &mut state.domains.ui.state.smart_shelf; + surface.open = false; + surface.reset_transient(); + DomainUpdateResult::task(Task::none()) + } +} + +fn apply_reducer_message( + state: &mut State, + message: SmartShelfMessage, +) -> DomainUpdateResult { + let transition = { + let surface = &mut state.domains.ui.state.smart_shelf; + surface.confirm_discard = false; + surface.provider_fallback = None; + reduce(&mut surface.reducer, message) + }; + + apply_transition(state, transition) +} + +fn apply_transition( + state: &mut State, + transition: ferrex_player_intelligence::SmartShelfTransition, +) -> DomainUpdateResult { + let mut tasks = transition + .commands + .into_iter() + .map(|command| command_task(state.api_service.clone(), command)) + .collect::>(); + let mut events = Vec::new(); + + for intent in transition.intents { + match intent { + SmartShelfIntent::FocusPrompt => {} + SmartShelfIntent::ShowProviderFallback { message, retryable } => { + let surface = &mut state.domains.ui.state.smart_shelf; + surface.open = true; + surface.provider_fallback = + Some(SmartShelfProviderFallbackState { + message, + retryable, + }); + } + SmartShelfIntent::ShowNotice(notice) => { + state.domains.ui.state.smart_shelf.notice = Some(notice); + } + SmartShelfIntent::ShowDraftValidation(_) => {} + SmartShelfIntent::ShowDraftError(failure) + | SmartShelfIntent::ShowSaveError(failure) => { + state.domains.ui.state.smart_shelf.notice = + Some(SmartShelfNotice::error(failure.message)); + } + SmartShelfIntent::ShowSaveConfirmation(_) => {} + SmartShelfIntent::ShowSaveConflict(_) => {} + SmartShelfIntent::OpenSavedCollection(collection_id) => { + let surface = &mut state.domains.ui.state.smart_shelf; + surface.open = false; + surface.reset_transient(); + let navigation = + collections::open_collection_detail(state, collection_id); + tasks.push(navigation.task); + events.extend(navigation.events); + } + SmartShelfIntent::ConfirmDiscard => { + state.domains.ui.state.smart_shelf.confirm_discard = true; + } + SmartShelfIntent::CloseSmartShelf => { + let surface = &mut state.domains.ui.state.smart_shelf; + surface.open = false; + surface.reset_transient(); + } + SmartShelfIntent::RegenerateUnlocked { .. } => {} + } + } + + DomainUpdateResult::with_events(Task::batch(tasks), events) +} + +fn command_task( + api_service: Arc, + command: SmartShelfCommand, +) -> Task { + match command { + SmartShelfCommand::FetchProviderStatus => Task::perform( + async move { + api_service + .fetch_intelligence_provider_status() + .await + .map_err(|error| repository_failure(error, true)) + }, + |result| { + smart_shelf_domain_message(match result { + Ok(status) => { + SmartShelfMessage::ProviderStatusLoaded(status) + } + Err(failure) => { + SmartShelfMessage::ProviderStatusFailed(failure) + } + }) + }, + ), + SmartShelfCommand::StartSmartShelf(request) => { + Task::perform(start_smart_shelf(api_service, request), |result| { + smart_shelf_domain_message(match result { + Ok(response) => SmartShelfMessage::StartAccepted(response), + Err(failure) => SmartShelfMessage::StartFailed(failure), + }) + }) + } + SmartShelfCommand::PollRun { run_id } => Task::perform( + async move { + tokio::time::sleep(Duration::from_millis(750)).await; + api_service + .fetch_intelligence_run_status(run_id) + .await + .map_err(|error| repository_failure(error, true)) + }, + |result| { + smart_shelf_domain_message(match result { + Ok(response) => { + SmartShelfMessage::RunProgressLoaded(response) + } + Err(failure) => { + SmartShelfMessage::RunProgressFailed(failure) + } + }) + }, + ), + SmartShelfCommand::CancelRun { run_id, request } => Task::perform( + cancel_smart_shelf_run(api_service, run_id, request), + |result| { + smart_shelf_domain_message(match result { + Ok(response) => SmartShelfMessage::CancelFinished(response), + Err(failure) => SmartShelfMessage::CancelFailed(failure), + }) + }, + ), + SmartShelfCommand::FetchDraft { artifact_id } => Task::perform( + async move { + api_service + .fetch_smart_shelf_draft(artifact_id) + .await + .map_err(|error| repository_failure(error, true)) + }, + |result| { + smart_shelf_domain_message(match result { + Ok(response) => SmartShelfMessage::DraftLoaded(response), + Err(failure) => SmartShelfMessage::DraftLoadFailed(failure), + }) + }, + ), + SmartShelfCommand::SaveSmartShelf { + artifact_id, + request, + } => Task::perform( + save_smart_shelf(api_service, artifact_id, request), + |result| { + smart_shelf_domain_message(match result { + Ok(response) => SmartShelfMessage::SaveSucceeded(response), + Err(failure) => SmartShelfMessage::SaveFailed(failure), + }) + }, + ), + } +} + +async fn start_smart_shelf( + api_service: Arc, + request: SmartShelfStartRequest, +) -> Result< + ferrex_player_api::api_types::SmartShelfStartResponse, + SmartShelfFailure, +> { + api_service + .start_smart_shelf(request) + .await + .map_err(|error| repository_failure(error, true)) +} + +async fn cancel_smart_shelf_run( + api_service: Arc, + run_id: uuid::Uuid, + request: IntelligenceRunCancelRequest, +) -> Result< + ferrex_player_api::api_types::IntelligenceRunCancelResponse, + SmartShelfFailure, +> { + api_service + .cancel_intelligence_run(run_id, request) + .await + .map_err(|error| repository_failure(error, true)) +} + +async fn save_smart_shelf( + api_service: Arc, + artifact_id: uuid::Uuid, + request: SmartShelfSaveRequest, +) -> Result< + ferrex_player_api::api_types::SmartShelfSaveResponse, + SmartShelfFailure, +> { + api_service + .save_smart_shelf(artifact_id, request) + .await + .map_err(|error| repository_failure(error, true)) +} + +fn smart_shelf_domain_message(message: SmartShelfMessage) -> DomainMessage { + DomainMessage::Ui(UiMessage::SmartShelf(SmartShelfUiMessage::Reducer( + message, + ))) +} + +fn repository_failure( + error: RepositoryError, + default_retryable: bool, +) -> SmartShelfFailure { + let message = error.to_string(); + let lower = message.to_ascii_lowercase(); + + let code = if lower.contains("provider") + || lower.contains("model") + || lower.contains("intelligence") + && (lower.contains("configured") + || lower.contains("unavailable") + || lower.contains("unauthorized")) + { + SmartShelfFailureCode::ProviderUnavailable + } else if lower.contains("conflict") + || lower.contains("already been saved") + || lower.contains("stale") + || lower.contains("revision") + || lower.contains("version") + { + SmartShelfFailureCode::Conflict + } else if lower.contains("validation") + || lower.contains("duplicate") + || lower.contains("scope") + || lower.contains("unsupported") + || lower.contains("missing") + { + SmartShelfFailureCode::Validation + } else { + SmartShelfFailureCode::Unknown + }; + + let retryable = matches!(code, SmartShelfFailureCode::Unknown) + .then_some(default_retryable) + .unwrap_or_else(|| { + matches!( + code, + SmartShelfFailureCode::ProviderUnavailable + | SmartShelfFailureCode::Conflict + ) + }); + + SmartShelfFailure::new(code, message, retryable) +} + +pub fn save_conflict_recovery_label( + action: SmartShelfSaveConflictRecovery, +) -> &'static str { + match action { + SmartShelfSaveConflictRecovery::ReloadDraft => "Reload draft", + SmartShelfSaveConflictRecovery::EditSelection => "Edit selection", + SmartShelfSaveConflictRecovery::RetrySave => "Retry save", + SmartShelfSaveConflictRecovery::Discard => "Discard", + } +} + +pub fn save_status_label(status: SmartShelfSaveStatus) -> &'static str { + match status { + SmartShelfSaveStatus::Idle => "Ready to save", + SmartShelfSaveStatus::Confirming => "Confirm save", + SmartShelfSaveStatus::Saving => "Saving…", + SmartShelfSaveStatus::Saved => "Saved", + SmartShelfSaveStatus::Conflict => "Needs recovery", + SmartShelfSaveStatus::Error => "Save failed", + } +} diff --git a/crates/ferrex-player-ui/src/domains/ui/tabs/state.rs b/crates/ferrex-player-ui/src/domains/ui/tabs/state.rs index c9ca70c9..fa389ea2 100644 --- a/crates/ferrex-player-ui/src/domains/ui/tabs/state.rs +++ b/crates/ferrex-player-ui/src/domains/ui/tabs/state.rs @@ -784,6 +784,9 @@ pub struct CollectionsTabState { /// Per-collection metadata editing forms, seeded from loaded details. pub edit_forms: HashMap, + /// Collections whose detail view is currently showing explicit manage/edit controls. + pub edit_mode_collection_ids: HashSet, + /// Per-collection media picker/search state for manual additions. pub picker_states: HashMap, @@ -803,6 +806,7 @@ impl CollectionsTabState { refresh_states: HashMap::new(), create_form: CollectionCreateFormState::default(), edit_forms: HashMap::new(), + edit_mode_collection_ids: HashSet::new(), picker_states: HashMap::new(), item_action_states: HashMap::new(), } @@ -929,6 +933,19 @@ impl CollectionsTabState { .expect("edit form inserted") } + pub fn is_detail_editing(&self, collection_id: CollectionId) -> bool { + self.edit_mode_collection_ids.contains(&collection_id) + } + + pub fn enter_detail_edit_mode(&mut self, collection_id: CollectionId) { + self.ensure_edit_form(collection_id); + self.edit_mode_collection_ids.insert(collection_id); + } + + pub fn exit_detail_edit_mode(&mut self, collection_id: CollectionId) { + self.edit_mode_collection_ids.remove(&collection_id); + } + pub fn picker_state( &self, collection_id: CollectionId, @@ -990,6 +1007,7 @@ impl CollectionsTabState { self.item_states.remove(&collection_id); self.refresh_states.remove(&collection_id); self.edit_forms.remove(&collection_id); + self.edit_mode_collection_ids.remove(&collection_id); self.picker_states.remove(&collection_id); self.item_action_states.remove(&collection_id); if self.summaries.is_empty() && self.load_state.is_ready() { diff --git a/crates/ferrex-player-ui/src/domains/ui/update.rs b/crates/ferrex-player-ui/src/domains/ui/update.rs index 09a1af1c..eea1394b 100644 --- a/crates/ferrex-player-ui/src/domains/ui/update.rs +++ b/crates/ferrex-player-ui/src/domains/ui/update.rs @@ -7,7 +7,7 @@ use crate::{ messages::UiMessage, motion_controller::messages::MotionMessage as KineticMotionMessage, playback_ui::update_playback_ui, settings_ui::update_settings_ui, - shell_ui::update_shell_ui, + shell_ui::update_shell_ui, smart_shelf::update_smart_shelf_ui, update_handlers::handle_virtual_carousel_message, update_handlers::home_focus, view_model_ui::update_view_model_ui, views::virtual_carousel::VirtualCarouselMessage as VCM, @@ -38,6 +38,9 @@ pub fn update_ui(state: &mut State, message: UiMessage) -> DomainUpdateResult { collections_msg, ) } + UiMessage::SmartShelf(smart_shelf_msg) => { + update_smart_shelf_ui(state, smart_shelf_msg) + } UiMessage::Window(window_msg) => update_window_ui(state, window_msg), UiMessage::Header(header_msg) => update_header_ui(state, header_msg), UiMessage::VirtualCarousel(vc_msg) => DomainUpdateResult::task( diff --git a/crates/ferrex-player-ui/src/domains/ui/views/collections.rs b/crates/ferrex-player-ui/src/domains/ui/views/collections.rs index 259feaf8..b4901ffa 100644 --- a/crates/ferrex-player-ui/src/domains/ui/views/collections.rs +++ b/crates/ferrex-player-ui/src/domains/ui/views/collections.rs @@ -28,6 +28,7 @@ use crate::{ collections::{self, CollectionItemMoveDirection, CollectionsMessage}, messages::UiMessage, shell_ui::UiShellMessage, + smart_shelf::SmartShelfUiMessage, tabs::{ CollectionCreateFormState, CollectionDetailLoadState, CollectionEditFormState, CollectionItemActionState, @@ -130,6 +131,12 @@ pub struct CollectionItemsViewModel { pub hidden_summary: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CollectionItemsEmptyStateCopy { + pub title: &'static str, + pub body: &'static str, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct CollectionStatusSummary { pub source_summary: String, @@ -217,6 +224,52 @@ pub fn collection_items_view_model( } } +pub fn collection_items_empty_state_copy( + model: &CollectionItemsViewModel, + item_state: Option<&CollectionItemsState>, + edit_mode: bool, + can_manage: bool, +) -> Option { + if !model.rows.is_empty() { + return None; + } + + let loading = item_state.is_none_or(|state| { + matches!( + state.load_state, + CollectionItemsLoadState::NotLoaded + | CollectionItemsLoadState::Loading + ) + }); + + Some(if loading { + CollectionItemsEmptyStateCopy { + title: "Loading collection items…", + body: "Fetching the first page of materialized members.", + } + } else if model.hidden_count > 0 { + CollectionItemsEmptyStateCopy { + title: "No available items to show", + body: "Unavailable, missing, or archived members are hidden from the normal detail view.", + } + } else if edit_mode { + CollectionItemsEmptyStateCopy { + title: "No items in this collection", + body: "Search for existing media above to add the first manual item.", + } + } else if can_manage { + CollectionItemsEmptyStateCopy { + title: "No items in this collection", + body: "Use Manage collection to add the first manual item.", + } + } else { + CollectionItemsEmptyStateCopy { + title: "No items in this collection", + body: "The API did not return visible materialized members for this collection.", + } + }) +} + pub fn collection_status_summary( detail: &CollectionDetail, item_state: Option<&CollectionItemsState>, @@ -408,9 +461,12 @@ fn collections_header(state: &State) -> Element<'_, UiMessage> { ] .spacing(6), Space::new().width(Length::Fill), + button("Smart shelf") + .on_press(SmartShelfUiMessage::OpenComposer.into()) + .style(theme::Button::Primary.style()), button("New manual collection") .on_press(CollectionsMessage::ToggleCreateForm.into()) - .style(theme::Button::Primary.style()), + .style(theme::Button::Secondary.style()), button(if loading { "Refreshing…" } else { "Refresh" }) .on_press(CollectionsMessage::Refresh.into()) .style(theme::Button::Secondary.style()), @@ -680,6 +736,8 @@ fn collection_detail_content<'a>( let collection_id = summary.identity.id; let can_edit = collections::is_manual_collection(summary); let tab = collections::collections_tab(state); + let edit_mode = + can_edit && tab.is_some_and(|tab| tab.is_detail_editing(collection_id)); let edit_form = tab.and_then(|tab| tab.edit_forms.get(&collection_id)); let picker_state = tab.and_then(|tab| tab.picker_states.get(&collection_id)); @@ -687,7 +745,13 @@ fn collection_detail_content<'a>( tab.and_then(|tab| tab.item_action_states.get(&collection_id)); let mut content = column![ - collection_detail_header_card(row_model.clone(), can_edit, fonts), + collection_detail_header_card( + row_model.clone(), + can_edit, + edit_mode, + collection_id, + fonts, + ), collection_status_cards( status, detail, @@ -698,7 +762,7 @@ fn collection_detail_content<'a>( ] .spacing(18); - content = if can_edit { + content = if can_edit && edit_mode { if let Some(edit_form) = edit_form { content.push(collection_manual_editing_section( collection_id, @@ -709,6 +773,8 @@ fn collection_detail_content<'a>( } else { content.push(collection_editor_state_notice(fonts)) } + } else if can_edit { + content } else { content.push(collection_read_only_notice(summary, fonts)) }; @@ -717,6 +783,7 @@ fn collection_detail_content<'a>( items_model, item_state, collection_id, + edit_mode, can_edit, item_action_state, fonts, @@ -728,54 +795,76 @@ fn collection_detail_content<'a>( fn collection_detail_header_card<'a>( row_model: CollectionSummaryRow, editable: bool, + edit_mode: bool, + collection_id: ferrex_core::api::types::collections::CollectionId, fonts: &crate::infra::design_tokens::fonts::FontTokens, ) -> Element<'a, UiMessage> { - container( - row![ - collection_art_block( - row_model.artwork.clone(), - row_model.theme.clone(), - fonts.caption, - ), - column![ - text(row_model.description.clone()) - .size(fonts.body) - .color(theme::MediaServerTheme::TEXT_SECONDARY), - row(vec![ - badge(row_model.kind.clone(), fonts.caption), - badge(row_model.source.clone(), fonts.caption), - badge(row_model.visibility.clone(), fonts.caption), - badge(row_model.status.clone(), fonts.caption), - badge( - if editable { - "Manual editing enabled" - } else { - "Read-only" - }, - fonts.caption, - ), - ]) - .spacing(8), - text(row_model.media_scope.clone()) - .size(fonts.caption) - .color(theme::MediaServerTheme::TEXT_SECONDARY), - text(row_model.materialization.clone()) - .size(fonts.caption) - .color(if row_model.is_stale { - theme::MediaServerTheme::WARNING - } else { - theme::MediaServerTheme::TEXT_SECONDARY - }), - ] - .spacing(8) - .width(Length::Fill), + let mode_label = if edit_mode { + "Edit mode" + } else if editable { + "Browse mode" + } else { + "Read-only" + }; + + let mut header = row![ + collection_art_block( + row_model.artwork.clone(), + row_model.theme.clone(), + fonts.caption, + ), + column![ + text(row_model.description.clone()) + .size(fonts.body) + .color(theme::MediaServerTheme::TEXT_SECONDARY), + row(vec![ + badge(row_model.kind.clone(), fonts.caption), + badge(row_model.source.clone(), fonts.caption), + badge(row_model.visibility.clone(), fonts.caption), + badge(row_model.status.clone(), fonts.caption), + badge(mode_label, fonts.caption), + ]) + .spacing(8), + text(row_model.media_scope.clone()) + .size(fonts.caption) + .color(theme::MediaServerTheme::TEXT_SECONDARY), + text(row_model.materialization.clone()) + .size(fonts.caption) + .color(if row_model.is_stale { + theme::MediaServerTheme::WARNING + } else { + theme::MediaServerTheme::TEXT_SECONDARY + },), ] - .spacing(16) - .align_y(iced::Alignment::Center), - ) - .padding(18) - .style(theme::Container::Card.style()) - .into() + .spacing(8) + .width(Length::Fill), + ] + .spacing(16) + .align_y(iced::Alignment::Center); + + if editable { + let mut manage = button(if edit_mode { + "Done managing" + } else { + "Manage collection" + }) + .style(theme::Button::Secondary.style()); + manage = if edit_mode { + manage.on_press( + CollectionsMessage::ExitEditMode(collection_id).into(), + ) + } else { + manage.on_press( + CollectionsMessage::EnterEditMode(collection_id).into(), + ) + }; + header = header.push(manage); + } + + container(header) + .padding(18) + .style(theme::Container::Card.style()) + .into() } fn collection_status_cards<'a>( @@ -1228,16 +1317,17 @@ fn collection_items_section<'a>( model: CollectionItemsViewModel, item_state: Option<&'a CollectionItemsState>, collection_id: ferrex_core::api::types::collections::CollectionId, - editable: bool, + edit_mode: bool, + can_manage: bool, action_state: Option<&'a CollectionItemActionState>, fonts: &crate::infra::design_tokens::fonts::FontTokens, ) -> Element<'a, UiMessage> { - let order_help = if editable && model.can_load_more { + let order_help = if edit_mode && model.can_load_more { "Load all items before reordering so the saved order is stable" - } else if editable { + } else if edit_mode { "Move up/down controls save an explicit stable order" } else { - "Stable collection order" + "Open available items from the ordered collection cards" }; let mut section = column![ @@ -1287,44 +1377,17 @@ fn collection_items_section<'a>( } if model.rows.is_empty() { - let loading = item_state.is_none_or(|state| { - matches!( - state.load_state, - CollectionItemsLoadState::NotLoaded - | CollectionItemsLoadState::Loading - ) - }); - section = section.push(if loading { - center_panel( - "Loading collection items…", - "Fetching the first page of materialized members.", - None, - fonts, - ) - } else if model.hidden_count > 0 { - center_panel( - "No available items to show", - "Unavailable, missing, or archived members are hidden from the normal detail view.", - None, - fonts, - ) - } else { - center_panel( - "No items in this collection", - if editable { - "Search for existing media above to add the first manual item." - } else { - "The API did not return visible materialized members for this collection." - }, - None, - fonts, - ) - }); + let copy = collection_items_empty_state_copy( + &model, item_state, edit_mode, can_manage, + ) + .expect("empty item model should produce empty state copy"); + section = + section.push(center_panel(copy.title, copy.body, None, fonts)); } else { section = section.push(collection_item_grid( model.rows.clone(), collection_id, - editable, + edit_mode, action_state, fonts, )); diff --git a/crates/ferrex-player-ui/src/domains/ui/views/header.rs b/crates/ferrex-player-ui/src/domains/ui/views/header.rs index 89669513..407a2591 100644 --- a/crates/ferrex-player-ui/src/domains/ui/views/header.rs +++ b/crates/ferrex-player-ui/src/domains/ui/views/header.rs @@ -6,6 +6,7 @@ use crate::{ messages::UiMessage, settings_ui::SettingsUiMessage, shell_ui::{Scope, UiShellMessage}, + smart_shelf::SmartShelfUiMessage, theme, types::ViewState, }, @@ -102,8 +103,19 @@ pub fn view_header<'a>(state: &'a State) -> Element<'a, UiMessage> { .width(Length::Fixed(HEIGHT)) .height(HEIGHT); + let smart_shelf_button = button( + container(icon_text_with_size(Icon::Sparkles, 16.0)) + .center_x(Length::Fill) + .center_y(Length::Fill), + ) + .on_press(SmartShelfUiMessage::OpenComposer.into()) + .style(theme::Button::HeaderIcon.style()) + .width(Length::Fixed(HEIGHT)) + .height(HEIGHT); + let mut right_section = row![ search_button, + smart_shelf_button, button( container(icon_text_with_size( if fullscreen_active(state) { @@ -257,8 +269,19 @@ pub fn view_header<'a>(state: &'a State) -> Element<'a, UiMessage> { .width(Length::Fixed(HEIGHT)) .height(HEIGHT); + let smart_shelf_button = button( + container(icon_text_with_size(Icon::Sparkles, 16.0)) + .center_x(Length::Fill) + .center_y(Length::Fill), + ) + .on_press(SmartShelfUiMessage::OpenComposer.into()) + .style(theme::Button::HeaderIcon.style()) + .width(Length::Fixed(HEIGHT)) + .height(HEIGHT); + let right_section = row![ search_button, + smart_shelf_button, // Fullscreen toggle button( container(icon_text_with_size( diff --git a/crates/ferrex-player-ui/src/domains/ui/views/mod.rs b/crates/ferrex-player-ui/src/domains/ui/views/mod.rs index 4c1ad6ec..4942f1e6 100644 --- a/crates/ferrex-player-ui/src/domains/ui/views/mod.rs +++ b/crates/ferrex-player-ui/src/domains/ui/views/mod.rs @@ -12,6 +12,7 @@ pub mod library_filter_panel; pub mod loading; pub mod movies; pub mod settings; +pub mod smart_shelf; pub mod tenfoot; pub mod toast_overlay; pub mod tv; diff --git a/crates/ferrex-player-ui/src/domains/ui/views/smart_shelf.rs b/crates/ferrex-player-ui/src/domains/ui/views/smart_shelf.rs new file mode 100644 index 00000000..f44e780a --- /dev/null +++ b/crates/ferrex-player-ui/src/domains/ui/views/smart_shelf.rs @@ -0,0 +1,1262 @@ +use ferrex_player_api::api_types::{ + IntelligenceMediaKind, IntelligenceRunStatus, MediaID, + SmartShelfDraftSource, SmartShelfDraftValidationSeverity, +}; +use ferrex_player_intelligence::{ + ProviderReadiness, SmartShelfAlternateState, SmartShelfDraftState, + SmartShelfItemState, SmartShelfMessage, SmartShelfPhase, + SmartShelfRunState, SmartShelfSaveStatus, SmartShelfState, +}; +use iced::{ + Element, Length, + widget::{Space, button, column, container, row, text, text_input}, +}; + +use crate::{ + domains::ui::{ + messages::UiMessage, + shell_ui::Scope, + smart_shelf::{ + SmartShelfUiMessage, save_conflict_recovery_label, + save_status_label, + }, + theme, + }, + state::State, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SmartShelfComposerSummary { + pub prompt: String, + pub template_labels: Vec, + pub selected_template: Option, + pub media_scope: String, + pub item_count: u16, + pub constraints: String, + pub provider_status: String, + pub model: String, + pub can_start: bool, + pub fallback: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SmartShelfProgressSummary { + pub status: String, + pub phase: String, + pub step: String, + pub provider_model: String, + pub skeleton_rows: usize, + pub can_cancel: bool, + pub can_retry: bool, + pub can_edit_prompt: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SmartShelfDraftReviewSummary { + pub title: String, + pub item_count: usize, + pub validation_issue_count: usize, + pub locked_count: usize, + pub replacement_count: usize, + pub alternate_count: usize, + pub can_save: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SmartShelfSaveReviewSummary { + pub title: String, + pub description: String, + pub scope: String, + pub visibility: String, + pub status: String, + pub conflict_help: String, + pub error: Option, +} + +pub fn smart_shelf_composer_summary( + state: &State, +) -> SmartShelfComposerSummary { + let smart = &state.domains.ui.state.smart_shelf.reducer; + let composer = &smart.composer; + let provider_status = provider_status_label(&smart.provider); + let fallback = smart + .provider + .fallback_message() + .map(|(message, _)| message); + let selected_template = + composer.selected_template_id.as_ref().and_then(|id| { + composer + .templates + .iter() + .find(|template| &template.id == id) + .map(|template| template.label.clone()) + }); + + SmartShelfComposerSummary { + prompt: composer.prompt.clone(), + template_labels: composer + .templates + .iter() + .map(|template| template.label.clone()) + .collect(), + selected_template, + media_scope: composer_media_scope_label(state), + item_count: composer.item_count, + constraints: constraints_label(&composer.constraints), + provider_status, + model: composer + .model + .clone() + .or_else(|| provider_model(&smart.provider)) + .unwrap_or_else(|| "Default model".to_string()), + can_start: smart.provider.allows_start() + && !composer.prompt.trim().is_empty() + && !matches!( + smart.phase, + SmartShelfPhase::Starting + | SmartShelfPhase::Running + | SmartShelfPhase::Saving + ), + fallback, + } +} + +pub fn smart_shelf_progress_summary( + smart: &SmartShelfState, +) -> SmartShelfProgressSummary { + let run = smart.run.as_ref(); + let status = run + .map(|run| run_status_label(run.status).to_string()) + .unwrap_or_else(|| phase_label(smart.phase).to_string()); + let phase = run + .and_then(|run| run.current_phase.clone()) + .unwrap_or_else(|| phase_label(smart.phase).to_string()); + let step = run + .map(step_label) + .unwrap_or_else(|| "Preparing run".to_string()); + let provider_model = run + .map(provider_model_from_run) + .or_else(|| provider_readiness_model(&smart.provider)) + .unwrap_or_else(|| "Provider/model pending".to_string()); + + SmartShelfProgressSummary { + status, + phase, + step, + provider_model, + skeleton_rows: usize::from(smart.composer.item_count.min(8).max(3)), + can_cancel: run.is_some_and(SmartShelfRunState::can_cancel), + can_retry: matches!( + smart.phase, + SmartShelfPhase::DraftError + | SmartShelfPhase::Cancelled + | SmartShelfPhase::ProviderUnavailable + ), + can_edit_prompt: matches!( + smart.phase, + SmartShelfPhase::DraftError + | SmartShelfPhase::Cancelled + | SmartShelfPhase::ProviderUnavailable + ), + } +} + +pub fn smart_shelf_draft_review_summary( + smart: &SmartShelfState, +) -> Option { + let draft = smart.draft.as_ref()?; + Some(SmartShelfDraftReviewSummary { + title: draft.title.clone(), + item_count: draft.items.len(), + validation_issue_count: draft.validation.issues.len(), + locked_count: draft.locked_count(), + replacement_count: draft.replacements_count(), + alternate_count: draft.alternates.len(), + can_save: draft.can_save() + && !matches!(smart.save.status, SmartShelfSaveStatus::Saving), + }) +} + +pub fn smart_shelf_save_review_summary( + smart: &SmartShelfState, +) -> Option { + let draft = smart.draft.as_ref()?; + let title = smart + .save + .confirmation + .as_ref() + .map(|confirmation| confirmation.title.clone()) + .unwrap_or_else(|| draft.title.clone()); + let error = smart + .save + .conflict + .as_ref() + .map(|conflict| conflict.failure.message.clone()) + .or_else(|| { + smart + .save + .last_error + .as_ref() + .map(|failure| failure.message.clone()) + }); + + Some(SmartShelfSaveReviewSummary { + title, + description: draft + .description + .clone() + .unwrap_or_else(|| "No description supplied".to_string()), + scope: save_scope_label(draft), + visibility: "Private manual collection".to_string(), + status: save_status_label(smart.save.status).to_string(), + conflict_help: "Duplicate media, media-scope mismatches, stale draft versions, and API conflicts stay recoverable here before retrying.".to_string(), + error, + }) +} + +pub fn view_smart_shelf_surface( + state: &State, +) -> Option> { + let surface = &state.domains.ui.state.smart_shelf; + if !surface.open { + return None; + } + + let fonts = &state.domains.ui.state.size_provider.font; + let smart = &surface.reducer; + + let header = row![ + column![ + text("Smart shelf") + .size(fonts.title) + .color(theme::MediaServerTheme::TEXT_PRIMARY), + text("Generate a grounded private collection without changing exact catalog Search.") + .size(fonts.caption) + .color(theme::MediaServerTheme::TEXT_SECONDARY), + ] + .spacing(4) + .width(Length::Fill), + button("Close") + .on_press(SmartShelfUiMessage::CloseRequested.into()) + .style(theme::Button::Secondary.style()), + ] + .align_y(iced::Alignment::Center) + .spacing(12); + + let mut panel = column![header].spacing(16); + + if let Some(notice) = surface.notice.as_ref() { + panel = panel.push(notice_banner(notice.message.clone())); + } + + if surface.confirm_discard { + panel = panel.push(discard_confirmation(fonts)); + } else { + panel = panel.push(match smart.save.status { + SmartShelfSaveStatus::Confirming => { + save_confirmation_panel(smart, fonts) + } + _ => match smart.phase { + SmartShelfPhase::ProviderUnavailable => { + provider_fallback_panel(state, fonts) + } + SmartShelfPhase::Starting + | SmartShelfPhase::Running + | SmartShelfPhase::Cancelling => progress_panel(smart, fonts), + SmartShelfPhase::DraftReady + | SmartShelfPhase::DraftInvalid + | SmartShelfPhase::Saving + | SmartShelfPhase::Saved + | SmartShelfPhase::SaveConflict + | SmartShelfPhase::SaveError => { + draft_review_panel(smart, fonts) + } + SmartShelfPhase::DraftError | SmartShelfPhase::Cancelled => { + column![ + progress_panel(smart, fonts), + composer_panel(state, fonts) + ] + .spacing(16) + .into() + } + SmartShelfPhase::Idle => composer_panel(state, fonts), + }, + }); + } + + let panel = container(panel) + .padding(24) + .width(Length::Fixed(680.0)) + .height(Length::Fill) + .style(theme::Container::Card.style()); + + Some( + container( + row![Space::new().width(Length::Fill), panel] + .width(Length::Fill) + .height(Length::Fill), + ) + .padding([72, 24]) + .width(Length::Fill) + .height(Length::Fill) + .style(theme::Container::HeaderAccent.style()) + .into(), + ) +} + +fn composer_panel<'a>( + state: &'a State, + fonts: &crate::infra::design_tokens::fonts::FontTokens, +) -> Element<'a, UiMessage> { + let smart = &state.domains.ui.state.smart_shelf.reducer; + let composer = &smart.composer; + let summary = smart_shelf_composer_summary(state); + + let mut template_row = row![].spacing(8).align_y(iced::Alignment::Center); + for template in &composer.templates { + let selected = composer.selected_template_id.as_deref() + == Some(template.id.as_str()); + let style = if selected { + theme::Button::Primary.style() + } else { + theme::Button::Secondary.style() + }; + template_row = template_row.push( + button(text(template.label.as_str()).size(fonts.caption)) + .on_press( + SmartShelfUiMessage::Reducer( + SmartShelfMessage::TemplateSelected( + template.id.clone(), + ), + ) + .into(), + ) + .style(style), + ); + } + + let mut count_row = row![].spacing(8).align_y(iced::Alignment::Center); + for count in [6_u16, 8, 12] { + let style = if composer.item_count == count { + theme::Button::Primary.style() + } else { + theme::Button::Secondary.style() + }; + count_row = count_row.push( + button(text(count.to_string()).size(fonts.caption)) + .on_press( + SmartShelfUiMessage::Reducer( + SmartShelfMessage::ItemCountChanged(count), + ) + .into(), + ) + .style(style), + ); + } + + let mut scope_row = row![ + badge(summary.media_scope.clone(), fonts.caption), + button("All libraries") + .on_press( + SmartShelfUiMessage::Reducer( + SmartShelfMessage::LibrarySelected(None) + ) + .into(), + ) + .style(theme::Button::Secondary.style()), + ] + .spacing(8) + .align_y(iced::Alignment::Center); + + if let Scope::Library(library_id) = state.domains.ui.state.scope { + scope_row = scope_row.push( + button("Use current library") + .on_press( + SmartShelfUiMessage::Reducer( + SmartShelfMessage::LibrarySelected(Some(library_id)), + ) + .into(), + ) + .style(theme::Button::Secondary.style()), + ); + } + + let provider_color = if smart.provider.allows_start() { + theme::MediaServerTheme::SUCCESS + } else { + theme::MediaServerTheme::WARNING + }; + + let mut start_button = + button("Generate draft").style(theme::Button::Primary.style()); + if summary.can_start { + start_button = start_button.on_press( + SmartShelfUiMessage::Reducer(SmartShelfMessage::StartRequested) + .into(), + ); + } + + let mut fields = column![ + text("Describe the shelf") + .size(fonts.subtitle) + .color(theme::MediaServerTheme::TEXT_PRIMARY), + text_input("e.g. Moody movies for a rainy Friday", &composer.prompt) + .padding(12) + .size(fonts.body) + .style(theme::TextInput::style()) + .on_input(|value| SmartShelfUiMessage::Reducer( + SmartShelfMessage::PromptChanged(value) + ) + .into()) + .on_submit( + SmartShelfUiMessage::Reducer(SmartShelfMessage::StartRequested) + .into(), + ), + text("Templates") + .size(fonts.caption) + .color(theme::MediaServerTheme::TEXT_SECONDARY), + template_row, + text("Media scope") + .size(fonts.caption) + .color(theme::MediaServerTheme::TEXT_SECONDARY), + scope_row, + row![ + column![ + text("Item count") + .size(fonts.caption) + .color(theme::MediaServerTheme::TEXT_SECONDARY), + count_row, + ] + .spacing(8), + Space::new().width(Length::Fill), + column![ + text("Provider/model") + .size(fonts.caption) + .color(theme::MediaServerTheme::TEXT_SECONDARY), + text(format!( + "{} · {}", + summary.provider_status, summary.model + )) + .size(fonts.caption) + .color(provider_color), + ] + .spacing(8), + ] + .align_y(iced::Alignment::Start), + text_input( + "Model override (optional)", + composer.model.as_deref().unwrap_or("") + ) + .padding(12) + .size(fonts.body) + .style(theme::TextInput::style()) + .on_input(|value| SmartShelfUiMessage::Reducer( + SmartShelfMessage::ModelChanged(Some(value)) + ) + .into()), + text(format!("Optional constraints: {}", summary.constraints)) + .size(fonts.caption) + .color(theme::MediaServerTheme::TEXT_SECONDARY), + row![ + start_button, + button("Refresh provider") + .on_press( + SmartShelfUiMessage::Reducer( + SmartShelfMessage::ProviderRefreshRequested, + ) + .into(), + ) + .style(theme::Button::Secondary.style()), + ] + .spacing(12) + .align_y(iced::Alignment::Center), + ] + .spacing(12); + + if let Some(error) = composer.validation_error.as_ref() { + fields = fields.push( + text(error.message.clone()) + .size(fonts.caption) + .color(theme::MediaServerTheme::ERROR), + ); + } + + if let Some(fallback) = summary.fallback { + fields = fields.push( + text(fallback) + .size(fonts.caption) + .color(theme::MediaServerTheme::WARNING), + ); + } + + container(fields) + .padding(18) + .style(theme::Container::Card.style()) + .into() +} + +fn provider_fallback_panel<'a>( + state: &'a State, + fonts: &crate::infra::design_tokens::fonts::FontTokens, +) -> Element<'a, UiMessage> { + let surface = &state.domains.ui.state.smart_shelf; + let message = surface + .provider_fallback + .as_ref() + .map(|fallback| fallback.message.clone()) + .or_else(|| { + surface + .reducer + .provider + .fallback_message() + .map(|(message, _)| message) + }) + .unwrap_or_else(|| "Provider readiness is unavailable".to_string()); + let retryable = surface + .provider_fallback + .as_ref() + .map(|fallback| fallback.retryable) + .unwrap_or(true); + + let mut fallback_actions = row![ + button("Edit prompt") + .on_press( + SmartShelfUiMessage::Reducer( + SmartShelfMessage::EditPromptRequested + ) + .into(), + ) + .style(theme::Button::Secondary.style()), + ] + .spacing(12); + if retryable { + fallback_actions = fallback_actions.push( + button("Retry provider check") + .on_press( + SmartShelfUiMessage::Reducer( + SmartShelfMessage::ProviderRefreshRequested, + ) + .into(), + ) + .style(theme::Button::Primary.style()), + ); + } + + column![ + container( + column![ + text("Provider fallback") + .size(fonts.subtitle) + .color(theme::MediaServerTheme::TEXT_PRIMARY), + text(message) + .size(fonts.body) + .color(theme::MediaServerTheme::TEXT_SECONDARY), + fallback_actions, + ] + .spacing(12), + ) + .padding(18) + .style(theme::Container::Card.style()), + composer_panel(state, fonts), + ] + .spacing(16) + .into() +} + +fn progress_panel<'a>( + smart: &'a SmartShelfState, + fonts: &crate::infra::design_tokens::fonts::FontTokens, +) -> Element<'a, UiMessage> { + let summary = smart_shelf_progress_summary(smart); + let mut skeleton = column![].spacing(8); + for index in 0..summary.skeleton_rows { + skeleton = skeleton.push( + container( + row![ + text(format!("#{}", index + 1)) + .size(fonts.caption) + .color(theme::MediaServerTheme::TEXT_SECONDARY), + text("Finding grounded media and reasons…") + .size(fonts.caption) + .color(theme::MediaServerTheme::TEXT_SECONDARY), + ] + .spacing(10), + ) + .padding(10) + .style(theme::Container::HeaderAccent.style()), + ); + } + + let mut actions = row![].spacing(12).align_y(iced::Alignment::Center); + if summary.can_cancel { + actions = actions.push( + button("Cancel") + .on_press( + SmartShelfUiMessage::Reducer( + SmartShelfMessage::CancelRequested, + ) + .into(), + ) + .style(theme::Button::Secondary.style()), + ); + } + if summary.can_retry { + actions = actions.push( + button("Retry") + .on_press( + SmartShelfUiMessage::Reducer( + SmartShelfMessage::RetryRequested, + ) + .into(), + ) + .style(theme::Button::Primary.style()), + ); + } + if summary.can_edit_prompt { + actions = actions.push( + button("Edit prompt") + .on_press( + SmartShelfUiMessage::Reducer( + SmartShelfMessage::EditPromptRequested, + ) + .into(), + ) + .style(theme::Button::Secondary.style()), + ); + } + + container( + column![ + text("Generating draft") + .size(fonts.subtitle) + .color(theme::MediaServerTheme::TEXT_PRIMARY), + row![ + badge(summary.status, fonts.caption), + badge(summary.phase, fonts.caption), + badge(summary.step, fonts.caption), + ] + .spacing(8), + text(summary.provider_model) + .size(fonts.caption) + .color(theme::MediaServerTheme::TEXT_SECONDARY), + skeleton, + actions, + ] + .spacing(12), + ) + .padding(18) + .style(theme::Container::Card.style()) + .into() +} + +fn draft_review_panel<'a>( + smart: &'a SmartShelfState, + fonts: &crate::infra::design_tokens::fonts::FontTokens, +) -> Element<'a, UiMessage> { + let Some(draft) = smart.draft.as_ref() else { + return empty_panel( + "No draft loaded", + "Run the composer before reviewing a smart shelf.", + fonts, + ); + }; + let summary = + smart_shelf_draft_review_summary(smart).expect("draft summary"); + + let mut items = column![].spacing(12); + for item in &draft.items { + items = items.push(draft_item_card(item, draft, fonts)); + } + + let mut validation = column![].spacing(8); + for issue in &draft.validation.issues { + let color = + if issue.severity == SmartShelfDraftValidationSeverity::Error { + theme::MediaServerTheme::ERROR + } else { + theme::MediaServerTheme::WARNING + }; + validation = validation.push( + text(format!("{:?}: {}", issue.code, issue.message)) + .size(fonts.caption) + .color(color), + ); + } + + let mut save_button = + button("Save private collection").style(theme::Button::Primary.style()); + if summary.can_save { + save_button = save_button.on_press( + SmartShelfUiMessage::Reducer(SmartShelfMessage::SaveRequested) + .into(), + ); + } + + let mut actions = row![ + button("Regenerate unlocked") + .on_press( + SmartShelfUiMessage::Reducer( + SmartShelfMessage::RegenerateUnlockedRequested, + ) + .into(), + ) + .style(theme::Button::Secondary.style()), + button("Discard") + .on_press( + SmartShelfUiMessage::Reducer( + SmartShelfMessage::DiscardRequested + ) + .into(), + ) + .style(theme::Button::Secondary.style()), + save_button, + ] + .spacing(12) + .align_y(iced::Alignment::Center); + + if matches!(smart.save.status, SmartShelfSaveStatus::Saving) { + actions = actions.push(badge("Saving…", fonts.caption)); + } + + let mut content = column![ + text(summary.title) + .size(fonts.subtitle) + .color(theme::MediaServerTheme::TEXT_PRIMARY), + text(format!( + "{} items · {} locked · {} replacement{} · {} alternate{}", + summary.item_count, + summary.locked_count, + summary.replacement_count, + if summary.replacement_count == 1 { + "" + } else { + "s" + }, + summary.alternate_count, + if summary.alternate_count == 1 { + "" + } else { + "s" + }, + )) + .size(fonts.caption) + .color(theme::MediaServerTheme::TEXT_SECONDARY), + ] + .spacing(12); + + if summary.validation_issue_count > 0 { + content = content.push(validation); + } + + content = content.push(items).push(actions); + + if let Some(save_summary) = smart_shelf_save_review_summary(smart) + && let Some(error) = save_summary.error + { + content = content.push( + container( + column![ + text(save_summary.status) + .size(fonts.caption) + .color(theme::MediaServerTheme::WARNING), + text(error) + .size(fonts.caption) + .color(theme::MediaServerTheme::ERROR), + text(save_summary.conflict_help) + .size(fonts.caption) + .color(theme::MediaServerTheme::TEXT_SECONDARY), + conflict_recovery_actions(smart, fonts), + ] + .spacing(8), + ) + .padding(12) + .style(theme::Container::HeaderAccent.style()), + ); + } + + container(content) + .padding(18) + .style(theme::Container::Card.style()) + .into() +} + +fn draft_item_card<'a>( + item: &'a SmartShelfItemState, + draft: &'a SmartShelfDraftState, + fonts: &crate::infra::design_tokens::fonts::FontTokens, +) -> Element<'a, UiMessage> { + let mut source_row = row![].spacing(8); + for source in &item.sources { + source_row = + source_row.push(badge(source_label(source), fonts.caption)); + } + if item.sources.is_empty() { + source_row = source_row.push(badge("No source chip", fonts.caption)); + } + + let matching_alternates = draft + .alternates + .iter() + .filter(|alternate| { + alternate.target_ordinal.is_none() + || alternate.target_ordinal == Some(item.ordinal) + }) + .collect::>(); + let mut alternates = column![].spacing(8); + for alternate in matching_alternates { + alternates = alternates.push(alternate_row(item, alternate, fonts)); + } + + let lock_label = if item.locked { "Unlock" } else { "Lock" }; + let replacement = item + .replacement_of + .map(|media_id| { + format!("Replacement for {}", media_id_label(&media_id)) + }) + .unwrap_or_else(|| "Original selection".to_string()); + + container( + column![ + row![ + text(format!( + "{}. {}", + item.ordinal, + item.title + .clone() + .unwrap_or_else(|| media_id_label(&item.media_id)) + )) + .size(fonts.body) + .color(theme::MediaServerTheme::TEXT_PRIMARY), + Space::new().width(Length::Fill), + button(lock_label) + .on_press( + SmartShelfUiMessage::Reducer( + SmartShelfMessage::ToggleLock(item.media_id,) + ) + .into(), + ) + .style(theme::Button::Secondary.style()), + ] + .align_y(iced::Alignment::Center), + text( + item.subtitle + .clone() + .unwrap_or_else(|| media_id_label(&item.media_id)) + ) + .size(fonts.caption) + .color(theme::MediaServerTheme::TEXT_SECONDARY), + text(item.reason.clone().unwrap_or_else(|| { + "No grounded reason supplied".to_string() + })) + .size(fonts.caption) + .color(theme::MediaServerTheme::TEXT_SECONDARY), + source_row, + badge(replacement, fonts.caption), + alternates, + ] + .spacing(8), + ) + .padding(12) + .style(theme::Container::HeaderAccent.style()) + .into() +} + +fn alternate_row<'a>( + target: &'a SmartShelfItemState, + alternate: &'a SmartShelfAlternateState, + fonts: &crate::infra::design_tokens::fonts::FontTokens, +) -> Element<'a, UiMessage> { + let mut source_row = row![].spacing(6); + for source in &alternate.sources { + source_row = + source_row.push(badge(source_label(source), fonts.caption)); + } + if alternate.sources.is_empty() { + source_row = source_row.push(badge("No source chip", fonts.caption)); + } + + row![ + column![ + text( + alternate + .title + .clone() + .unwrap_or_else(|| media_id_label(&alternate.media_id)) + ) + .size(fonts.caption) + .color(theme::MediaServerTheme::TEXT_PRIMARY), + text( + alternate + .reason + .clone() + .unwrap_or_else(|| "Alternate replacement".to_string()) + ) + .size(fonts.caption) + .color(theme::MediaServerTheme::TEXT_SECONDARY), + source_row, + ] + .spacing(4) + .width(Length::Fill), + button("Replace") + .on_press( + SmartShelfUiMessage::Reducer( + SmartShelfMessage::ReplaceWithAlternate { + target_media_id: target.media_id, + alternate_media_id: alternate.media_id, + } + ) + .into(), + ) + .style(theme::Button::Secondary.style()), + ] + .align_y(iced::Alignment::Center) + .spacing(8) + .into() +} + +fn save_confirmation_panel<'a>( + smart: &'a SmartShelfState, + fonts: &crate::infra::design_tokens::fonts::FontTokens, +) -> Element<'a, UiMessage> { + let Some(summary) = smart_shelf_save_review_summary(smart) else { + return empty_panel( + "Nothing to save", + "Load a valid draft first.", + fonts, + ); + }; + + container( + column![ + text("Confirm save") + .size(fonts.subtitle) + .color(theme::MediaServerTheme::TEXT_PRIMARY), + text(format!("Title: {}", summary.title)) + .size(fonts.body) + .color(theme::MediaServerTheme::TEXT_PRIMARY), + text(format!("Description: {}", summary.description)) + .size(fonts.caption) + .color(theme::MediaServerTheme::TEXT_SECONDARY), + text(format!("Scope: {}", summary.scope)) + .size(fonts.caption) + .color(theme::MediaServerTheme::TEXT_SECONDARY), + text(format!("Visibility: {}", summary.visibility)) + .size(fonts.caption) + .color(theme::MediaServerTheme::TEXT_SECONDARY), + text(summary.conflict_help) + .size(fonts.caption) + .color(theme::MediaServerTheme::TEXT_SECONDARY), + row![ + button("Back to review") + .on_press( + SmartShelfUiMessage::CancelSaveConfirmation.into() + ) + .style(theme::Button::Secondary.style()), + button("Save collection") + .on_press( + SmartShelfUiMessage::Reducer( + SmartShelfMessage::SaveConfirmed + ) + .into(), + ) + .style(theme::Button::Primary.style()), + ] + .spacing(12) + ] + .spacing(12), + ) + .padding(18) + .style(theme::Container::Card.style()) + .into() +} + +fn conflict_recovery_actions<'a>( + smart: &'a SmartShelfState, + fonts: &crate::infra::design_tokens::fonts::FontTokens, +) -> Element<'a, UiMessage> { + let mut actions = row![].spacing(8); + if let Some(conflict) = smart.save.conflict.as_ref() { + for action in &conflict.recovery_actions { + actions = actions.push( + button( + text(save_conflict_recovery_label(*action)) + .size(fonts.caption), + ) + .on_press( + SmartShelfUiMessage::Reducer( + SmartShelfMessage::RecoverSaveConflict(*action), + ) + .into(), + ) + .style(theme::Button::Secondary.style()), + ); + } + } else { + actions = actions.push( + button("Retry") + .on_press( + SmartShelfUiMessage::Reducer( + SmartShelfMessage::SaveConfirmed, + ) + .into(), + ) + .style(theme::Button::Secondary.style()), + ); + } + actions.into() +} + +fn discard_confirmation( + fonts: &crate::infra::design_tokens::fonts::FontTokens, +) -> Element<'_, UiMessage> { + container( + column![ + text("Discard smart-shelf work?") + .size(fonts.subtitle) + .color(theme::MediaServerTheme::TEXT_PRIMARY), + text("The current prompt, progress, draft selections, locks, replacements, and save confirmation will be cleared.") + .size(fonts.caption) + .color(theme::MediaServerTheme::TEXT_SECONDARY), + row![ + button("Keep editing") + .on_press(SmartShelfUiMessage::CancelDiscard.into()) + .style(theme::Button::Secondary.style()), + button("Discard") + .on_press(SmartShelfUiMessage::ConfirmDiscard.into()) + .style(theme::Button::Primary.style()), + ] + .spacing(12), + ] + .spacing(12), + ) + .padding(18) + .style(theme::Container::Card.style()) + .into() +} + +fn empty_panel<'a>( + title: impl Into, + body: impl Into, + fonts: &crate::infra::design_tokens::fonts::FontTokens, +) -> Element<'a, UiMessage> { + container( + column![ + text(title.into()) + .size(fonts.subtitle) + .color(theme::MediaServerTheme::TEXT_PRIMARY), + text(body.into()) + .size(fonts.caption) + .color(theme::MediaServerTheme::TEXT_SECONDARY), + ] + .spacing(8), + ) + .padding(18) + .style(theme::Container::Card.style()) + .into() +} + +fn notice_banner<'a>(message: String) -> Element<'a, UiMessage> { + container( + row![ + text(message).color(theme::MediaServerTheme::TEXT_PRIMARY), + Space::new().width(Length::Fill), + button("Dismiss") + .on_press(SmartShelfUiMessage::DismissNotice.into()) + .style(theme::Button::Text.style()), + ] + .align_y(iced::Alignment::Center), + ) + .padding(12) + .style(theme::Container::HeaderAccent.style()) + .into() +} + +fn badge<'a>( + label: impl Into, + font_size: f32, +) -> Element<'a, UiMessage> { + container( + text(label.into()) + .size(font_size) + .color(theme::MediaServerTheme::TEXT_PRIMARY), + ) + .padding([4, 8]) + .style(theme::Container::HeaderAccent.style()) + .into() +} + +fn provider_status_label(readiness: &ProviderReadiness) -> String { + match readiness { + ProviderReadiness::Unknown => "Provider unknown".to_string(), + ProviderReadiness::Checking => "Checking provider".to_string(), + ProviderReadiness::Ready { provider, .. } => { + format!("{provider} ready") + } + ProviderReadiness::Degraded { + provider, message, .. + } => message + .as_ref() + .map(|message| format!("{provider} degraded: {message}")) + .unwrap_or_else(|| format!("{provider} degraded")), + ProviderReadiness::Unavailable { message, .. } => { + format!("Unavailable: {message}") + } + } +} + +fn provider_model(readiness: &ProviderReadiness) -> Option { + match readiness { + ProviderReadiness::Ready { model, .. } + | ProviderReadiness::Degraded { model, .. } => model.clone(), + ProviderReadiness::Unknown + | ProviderReadiness::Checking + | ProviderReadiness::Unavailable { .. } => None, + } +} + +fn provider_readiness_model(readiness: &ProviderReadiness) -> Option { + match readiness { + ProviderReadiness::Ready { provider, model } + | ProviderReadiness::Degraded { + provider, model, .. + } => Some( + model + .as_ref() + .map(|model| format!("{provider} · {model}")) + .unwrap_or_else(|| provider.clone()), + ), + ProviderReadiness::Unknown + | ProviderReadiness::Checking + | ProviderReadiness::Unavailable { .. } => None, + } +} + +fn provider_model_from_run(run: &SmartShelfRunState) -> String { + match (&run.provider, &run.model) { + (Some(provider), Some(model)) => format!("{provider} · {model}"), + (Some(provider), None) => provider.clone(), + (None, Some(model)) => model.clone(), + (None, None) => "Provider/model pending".to_string(), + } +} + +fn run_status_label(status: IntelligenceRunStatus) -> &'static str { + match status { + IntelligenceRunStatus::Queued => "Queued", + IntelligenceRunStatus::Running => "Running", + IntelligenceRunStatus::Succeeded => "Succeeded", + IntelligenceRunStatus::Failed => "Failed", + IntelligenceRunStatus::Cancelled => "Cancelled", + } +} + +fn phase_label(phase: SmartShelfPhase) -> &'static str { + match phase { + SmartShelfPhase::Idle => "Composer", + SmartShelfPhase::ProviderUnavailable => "Provider fallback", + SmartShelfPhase::Starting => "Starting", + SmartShelfPhase::Running => "Generating", + SmartShelfPhase::Cancelling => "Cancelling", + SmartShelfPhase::Cancelled => "Cancelled", + SmartShelfPhase::DraftReady => "Draft ready", + SmartShelfPhase::DraftInvalid => "Draft needs review", + SmartShelfPhase::DraftError => "Draft error", + SmartShelfPhase::Saving => "Saving", + SmartShelfPhase::Saved => "Saved", + SmartShelfPhase::SaveConflict => "Save conflict", + SmartShelfPhase::SaveError => "Save error", + } +} + +fn step_label(run: &SmartShelfRunState) -> String { + match (run.current_step, run.max_steps) { + (Some(current), Some(max)) => format!("Step {current}/{max}"), + (Some(current), None) => format!("Step {current}"), + _ => "Preparing grounded draft".to_string(), + } +} + +fn composer_media_scope_label(state: &State) -> String { + let composer = &state.domains.ui.state.smart_shelf.reducer.composer; + let library = composer + .library_id + .map(|library_id| format!("Library {}", library_id)) + .unwrap_or_else(|| "All libraries".to_string()); + let kinds = composer + .media_kinds + .iter() + .map(|kind| media_kind_label(*kind)) + .collect::>() + .join(" + "); + format!("{library} · {kinds}") +} + +fn media_kind_label(kind: IntelligenceMediaKind) -> &'static str { + match kind { + IntelligenceMediaKind::Movie => "movies", + IntelligenceMediaKind::Series => "series", + IntelligenceMediaKind::Season => "seasons", + IntelligenceMediaKind::Episode => "episodes", + } +} + +fn constraints_label(value: &serde_json::Value) -> String { + match value { + serde_json::Value::Null => "No extra constraints".to_string(), + serde_json::Value::Object(object) if object.is_empty() => { + "No extra constraints".to_string() + } + serde_json::Value::Object(object) => object + .keys() + .map(String::as_str) + .collect::>() + .join(", "), + _ => "Template constraints active".to_string(), + } +} + +fn save_scope_label(draft: &SmartShelfDraftState) -> String { + let kinds = draft + .items + .iter() + .map(|item| match item.media_id { + MediaID::Movie(_) => "movies", + MediaID::Series(_) => "series", + MediaID::Season(_) => "seasons", + MediaID::Episode(_) => "episodes", + }) + .collect::>() + .into_iter() + .collect::>() + .join(" + "); + format!( + "Accepted {} item{}{}", + draft.items.len(), + if draft.items.len() == 1 { "" } else { "s" }, + if kinds.is_empty() { + "".to_string() + } else { + format!(" ({kinds})") + } + ) +} + +fn media_id_label(media_id: &MediaID) -> String { + match media_id { + MediaID::Movie(id) => format!("movie {}", id), + MediaID::Series(id) => format!("series {}", id), + MediaID::Season(id) => format!("season {}", id), + MediaID::Episode(id) => format!("episode {}", id), + } +} + +fn source_label(source: &SmartShelfDraftSource) -> String { + source + .label + .clone() + .or_else(|| source.field.clone()) + .or_else(|| { + source.evidence.as_ref().map(|summary| summary.text.clone()) + }) + .unwrap_or_else(|| "Grounded source".to_string()) +} diff --git a/crates/ferrex-player-ui/src/state.rs b/crates/ferrex-player-ui/src/state.rs index 3d9f1389..c7e019f1 100644 --- a/crates/ferrex-player-ui/src/state.rs +++ b/crates/ferrex-player-ui/src/state.rs @@ -275,6 +275,7 @@ impl State { poster_menu_open: None, poster_menu_states: HashMap::new(), toast_manager: crate::domains::ui::feedback_ui::ToastManager::new(), + smart_shelf: crate::domains::ui::smart_shelf::SmartShelfUiState::default(), tenfoot_home: crate::domains::ui::views::tenfoot::home::TenFootHomeState::new( ), diff --git a/crates/ferrex-player-ui/src/view.rs b/crates/ferrex-player-ui/src/view.rs index 7beab61d..aa457a46 100644 --- a/crates/ferrex-player-ui/src/view.rs +++ b/crates/ferrex-player-ui/src/view.rs @@ -20,6 +20,7 @@ use crate::domains::ui::views::library::view_library; use crate::domains::ui::views::library_controls_bar::view_library_controls_bar; use crate::domains::ui::views::movies::view_movie_detail; use crate::domains::ui::views::settings::view_unified_settings; +use crate::domains::ui::views::smart_shelf::view_smart_shelf_surface; use crate::domains::ui::views::tenfoot::{ detail::{is_tenfoot_detail_route, view_tenfoot_detail}, home::{is_tenfoot_home_route, view_tenfoot_home}, @@ -387,18 +388,30 @@ pub fn view( layered }; + let with_smart_shelf_overlay = + if let Some(overlay) = view_smart_shelf_surface(state) { + Stack::new() + .push(with_search_overlay) + .push(overlay.map(DomainMessage::from)) + .width(Length::Fill) + .height(Length::Fill) + .into() + } else { + with_search_overlay + }; + // Overlay toast notifications if any are active if state.domains.ui.state.toast_manager.has_toasts() { let toast_overlay = crate::domains::ui::views::toast_overlay::view_toast_overlay(state); Stack::new() - .push(with_search_overlay) + .push(with_smart_shelf_overlay) .push(toast_overlay.map(DomainMessage::from)) .width(Length::Fill) .height(Length::Fill) .into() } else { - with_search_overlay + with_smart_shelf_overlay } } diff --git a/crates/ferrex-player-ui/tests/collections_tab.rs b/crates/ferrex-player-ui/tests/collections_tab.rs index 72c10396..e3b12571 100644 --- a/crates/ferrex-player-ui/tests/collections_tab.rs +++ b/crates/ferrex-player-ui/tests/collections_tab.rs @@ -35,8 +35,9 @@ use ferrex_player_ui::{ types::ViewState, views::collections::{ CollectionItemAction, collection_item_rows, - collection_items_view_model, collection_status_summary, - collection_summary_row, view_collection_detail, view_collections, + collection_items_empty_state_copy, collection_items_view_model, + collection_status_summary, collection_summary_row, + view_collection_detail, view_collections, }, }, state::State, @@ -165,6 +166,69 @@ async fn collection_detail_view_renders_loaded_stub_detail() { let _ = view_collection_detail(&state, collection_id); } +#[tokio::test(flavor = "current_thread")] +async fn collection_detail_browse_mode_enters_and_exits_editor() { + let api = TestApiService::default(); + let api_service: Arc = Arc::new(api); + let payload = load_collection_summaries(api_service.clone()) + .await + .unwrap(); + let collection_id = payload.summaries[0].identity.id; + let detail = api_service + .get_collection_detail( + collection_id, + ferrex_core::api::types::collections::GetCollectionDetailRequest { + include_rule: true, + include_items_preview: true, + include_shelf_placements: true, + }, + ) + .await + .unwrap() + .collection; + + let mut state = test_state(); + state.domains.ui.state.scope = Scope::Collections; + state.domains.ui.state.view = ViewState::CollectionDetail { collection_id }; + if let TabState::Collections(tab) = + state.tab_manager.get_or_create_tab(TabId::Collections) + { + tab.mark_loaded(payload.summaries, payload.page); + tab.mark_detail_loaded(detail); + assert!(!tab.is_detail_editing(collection_id)); + assert!(tab.edit_forms.contains_key(&collection_id)); + } + + let _ = view_collection_detail(&state, collection_id); + + let _ = update_collections_ui( + &mut state, + CollectionsMessage::EnterEditMode(collection_id), + ); + let Some(TabState::Collections(tab)) = + state.tab_manager.get_tab(TabId::Collections) + else { + panic!("collections tab should exist"); + }; + assert!(tab.is_detail_editing(collection_id)); + assert!(tab.edit_forms.contains_key(&collection_id)); + + let _ = view_collection_detail(&state, collection_id); + + let _ = update_collections_ui( + &mut state, + CollectionsMessage::ExitEditMode(collection_id), + ); + let Some(TabState::Collections(tab)) = + state.tab_manager.get_tab(TabId::Collections) + else { + panic!("collections tab should exist"); + }; + assert!(!tab.is_detail_editing(collection_id)); + + let _ = view_collection_detail(&state, collection_id); +} + #[tokio::test(flavor = "current_thread")] async fn collection_detail_loads_paginated_items_in_stable_order() { let api = TestApiService::default(); @@ -276,6 +340,74 @@ fn collection_items_model_hides_unavailable_members_and_preserves_actions() { assert!(model.hidden_summary.unwrap().contains("hidden")); } +#[test] +fn collection_item_empty_copy_covers_browse_edit_and_unavailable_states() { + let loaded_empty = CollectionItemsState { + items: Vec::new(), + page: Some(CollectionPageInfo { + next_cursor: None, + limit: 50, + total: 0, + }), + materialization: None, + load_state: CollectionItemsLoadState::Loaded, + }; + let model = collection_items_view_model(Some(&loaded_empty), 0); + + let browse = collection_items_empty_state_copy( + &model, + Some(&loaded_empty), + false, + true, + ) + .expect("empty browse state should produce copy"); + assert_eq!(browse.title, "No items in this collection"); + assert!(browse.body.contains("Manage collection")); + + let edit = collection_items_empty_state_copy( + &model, + Some(&loaded_empty), + true, + true, + ) + .expect("empty edit state should produce copy"); + assert!(edit.body.contains("Search for existing media")); + + let loading = collection_items_empty_state_copy(&model, None, false, true) + .expect("missing item state should be treated as loading"); + assert_eq!(loading.title, "Loading collection items…"); + + let mut unavailable = CollectionMember::new( + MediaID::Movie(MovieID(Uuid::from_u128(5))), + "Unavailable", + 1, + ); + unavailable.availability = CollectionMemberAvailability { + status: CollectionMemberAvailabilityStatus::Unavailable, + ..CollectionMemberAvailability::default() + }; + let unavailable_state = CollectionItemsState { + items: vec![unavailable], + page: Some(CollectionPageInfo { + next_cursor: None, + limit: 50, + total: 1, + }), + materialization: None, + load_state: CollectionItemsLoadState::Loaded, + }; + let hidden_model = collection_items_view_model(Some(&unavailable_state), 1); + let hidden = collection_items_empty_state_copy( + &hidden_model, + Some(&unavailable_state), + false, + true, + ) + .expect("hidden-only state should produce copy"); + assert_eq!(hidden.title, "No available items to show"); + assert!(hidden.body.contains("Unavailable")); +} + #[tokio::test(flavor = "current_thread")] async fn collection_status_model_surfaces_rule_provenance_refresh_and_errors() { let api = TestApiService::default(); @@ -562,6 +694,7 @@ async fn manual_collection_editing_view_renders_recovery_states() { tab.create_form.is_open = true; tab.create_form.error = Some("Server is offline".to_string()); tab.mark_detail_loaded(detail.clone()); + tab.enter_detail_edit_mode(collection_id); tab.mark_items_loaded( collection_id, items.items.clone(), diff --git a/crates/ferrex-player-ui/tests/smart_shelf_flow.rs b/crates/ferrex-player-ui/tests/smart_shelf_flow.rs new file mode 100644 index 00000000..c796a69a --- /dev/null +++ b/crates/ferrex-player-ui/tests/smart_shelf_flow.rs @@ -0,0 +1,500 @@ +use chrono::Utc; +use ferrex_player_api::api_types::{ + CollectionArtwork, CollectionDuplicatePolicy, CollectionId, + CollectionIdentity, CollectionKind, CollectionMaterializationStatus, + CollectionMediaKind, CollectionMediaScope, CollectionOwner, + CollectionPresentationMode, CollectionProvenance, CollectionScope, + CollectionSource, CollectionSummary, CollectionTheme, CollectionTimestamps, + CollectionVersion, CollectionVisibility, IntelligenceError, + IntelligenceErrorCode, IntelligenceModelStatus, IntelligenceProviderState, + IntelligenceProviderStatus, IntelligenceRunPurpose, IntelligenceRunStatus, + IntelligenceRunStatusResponse, IntelligenceSummary, MediaID, MovieID, + SMART_SHELF_DRAFT_SCHEMA_VERSION, SmartShelfDraftAlternate, + SmartShelfDraftContent, SmartShelfDraftItem, SmartShelfDraftResponse, + SmartShelfDraftSource, SmartShelfDraftValidation, SmartShelfErrorCode, + SmartShelfSaveResponse, SmartShelfStartResponse, +}; +use ferrex_player_intelligence::{ + SmartShelfFailure, SmartShelfFailureCode, SmartShelfMessage, + SmartShelfPhase, SmartShelfSaveConflictRecovery, SmartShelfSaveStatus, +}; +use ferrex_player_ui::{ + domains::{ + search::SearchPresentation, + ui::{ + collections::CollectionsMessage, + shell_ui::Scope, + smart_shelf::{ + SmartShelfUiMessage, save_conflict_recovery_label, + update_smart_shelf_ui, + }, + tabs::TabId, + views::{ + collections::view_collections, + header::view_header, + smart_shelf::{ + smart_shelf_composer_summary, + smart_shelf_draft_review_summary, + smart_shelf_progress_summary, + smart_shelf_save_review_summary, view_smart_shelf_surface, + }, + }, + }, + }, + state::State, +}; +use uuid::Uuid; + +fn test_state() -> State { + State::new("http://localhost:3000".to_string()) +} + +fn movie(n: u128) -> MediaID { + MediaID::Movie(MovieID(Uuid::from_u128(n))) +} + +fn source(media_id: MediaID, label: &str) -> SmartShelfDraftSource { + SmartShelfDraftSource { + label: Some(label.to_string()), + media_id: Some(media_id), + artifact_id: None, + field: Some("watch_state".to_string()), + evidence: Some(IntelligenceSummary::new(format!( + "{label} grounded this selection" + ))), + } +} + +fn provider_ready() -> IntelligenceProviderStatus { + IntelligenceProviderStatus { + enabled: true, + provider_name: "test-provider".to_string(), + base_url: "https://llm.test".to_string(), + api_key_configured: true, + default_model: Some("test-model".to_string()), + state: IntelligenceProviderState::Ready, + models: vec![IntelligenceModelStatus { + name: "test-model".to_string(), + selected: true, + available: true, + supports_tools: true, + context_window_tokens: Some(8192), + }], + checked_at_epoch_seconds: Some(Utc::now().timestamp()), + error: None, + } +} + +fn provider_unavailable() -> IntelligenceProviderStatus { + IntelligenceProviderStatus { + enabled: true, + provider_name: "test-provider".to_string(), + base_url: "https://llm.test".to_string(), + api_key_configured: false, + default_model: None, + state: IntelligenceProviderState::NotConfigured, + models: Vec::new(), + checked_at_epoch_seconds: Some(Utc::now().timestamp()), + error: Some(IntelligenceError { + code: IntelligenceErrorCode::ProviderNotConfigured, + message: "Configure a provider before generating shelves" + .to_string(), + retryable: false, + details: serde_json::Value::Null, + }), + } +} + +fn start_response(run_id: Uuid) -> SmartShelfStartResponse { + SmartShelfStartResponse { + run_id, + status: IntelligenceRunStatus::Running, + provider: Some("test-provider".to_string()), + model: Some("test-model".to_string()), + queued_at_epoch_seconds: Some(Utc::now().timestamp()), + draft_schema_version: SMART_SHELF_DRAFT_SCHEMA_VERSION, + } +} + +fn running_status(run_id: Uuid) -> IntelligenceRunStatusResponse { + IntelligenceRunStatusResponse { + run_id, + purpose: IntelligenceRunPurpose::Recommendation, + status: IntelligenceRunStatus::Running, + terminal: false, + current_phase: Some("ranking grounded candidates".to_string()), + provider: Some("test-provider".to_string()), + model: Some("test-model".to_string()), + queued_at_epoch_seconds: Some(Utc::now().timestamp()), + started_at_epoch_seconds: Some(Utc::now().timestamp()), + completed_at_epoch_seconds: None, + current_step: Some(2), + max_steps: Some(4), + draft_artifact_ids: Vec::new(), + output_summary: None, + error: None, + } +} + +fn draft_response(artifact_id: Uuid) -> SmartShelfDraftResponse { + let first = movie(1); + let second = movie(2); + let alternate = movie(3); + + SmartShelfDraftResponse { + artifact_id, + run_id: Some(Uuid::from_u128(10)), + owner_user_id: None, + title: "Rainy night smart shelf".to_string(), + summary: Some(IntelligenceSummary::new("A cozy grounded shelf")), + draft: Some(SmartShelfDraftContent { + schema_version: SMART_SHELF_DRAFT_SCHEMA_VERSION, + title: "Rainy night smart shelf".to_string(), + description: Some( + "Atmospheric movies for a stormy evening".to_string(), + ), + interpreted_intent: Some("cozy rainy-night shelf".to_string()), + requested_constraints: serde_json::json!({ + "mood": "cozy_rainy_night", + "avoid_duplicates": true + }), + items: vec![ + SmartShelfDraftItem { + ordinal: 1, + media_id: first, + title: Some("First Rain Movie".to_string()), + subtitle: Some("Movie · 1999".to_string()), + year: Some(1999), + reason: Some("Matches the rainy-night mood".to_string()), + sources: vec![source(first, "Mood match")], + locked: false, + replacement_of: None, + }, + SmartShelfDraftItem { + ordinal: 2, + media_id: second, + title: Some("Second Comfort Movie".to_string()), + subtitle: Some("Movie · 2005".to_string()), + year: Some(2005), + reason: Some("Balances the shelf with comfort".to_string()), + sources: vec![source(second, "Watch history")], + locked: false, + replacement_of: None, + }, + ], + alternates: vec![SmartShelfDraftAlternate { + target_ordinal: Some(1), + media_id: alternate, + title: Some("Alternate Storm Movie".to_string()), + subtitle: Some("Movie · 2010".to_string()), + year: Some(2010), + reason: Some( + "Similar atmosphere with fresher pacing".to_string(), + ), + sources: vec![source(alternate, "Related metadata")], + }], + }), + validation: SmartShelfDraftValidation { + valid: true, + issues: Vec::new(), + }, + saved_collection_id: None, + } +} + +fn collection_summary(collection_id: CollectionId) -> CollectionSummary { + let now = Utc::now(); + CollectionSummary { + identity: CollectionIdentity::for_id(collection_id), + title: "Rainy night smart shelf".to_string(), + description: Some( + "Atmospheric movies for a stormy evening".to_string(), + ), + kind: CollectionKind::Manual, + source: CollectionSource::Manual, + owner: CollectionOwner::default(), + scope: CollectionScope::User, + visibility: CollectionVisibility::Private, + presentation: CollectionPresentationMode::Shelf, + media_scope: CollectionMediaScope::Types { + media_types: vec![CollectionMediaKind::Movie], + }, + duplicate_policy: CollectionDuplicatePolicy::DeduplicateMedia, + artwork: CollectionArtwork::default(), + theme: CollectionTheme::default(), + provenance: CollectionProvenance::default(), + version: CollectionVersion { + revision: 1, + etag: Some("collection-test-1".to_string()), + ..CollectionVersion::default() + }, + timestamps: CollectionTimestamps { + created_at: now, + updated_at: now, + archived_at: None, + }, + item_count: 2, + materialization: CollectionMaterializationStatus::default(), + } +} + +fn load_ready_draft(state: &mut State, artifact_id: Uuid) { + update_smart_shelf_ui(state, SmartShelfUiMessage::OpenComposer); + update_smart_shelf_ui( + state, + SmartShelfUiMessage::Reducer(SmartShelfMessage::ProviderStatusLoaded( + provider_ready(), + )), + ); + update_smart_shelf_ui( + state, + SmartShelfUiMessage::Reducer(SmartShelfMessage::DraftLoaded( + draft_response(artifact_id), + )), + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn entry_ctas_open_composer_without_replacing_exact_search() { + let mut state = test_state(); + assert!(matches!( + state.domains.search.state.presentation, + SearchPresentation::Hidden + )); + + let _ = view_header(&state); + state.domains.ui.state.scope = Scope::Collections; + state.tab_manager.set_active_tab(TabId::Collections); + let _ = view_collections(&state); + + update_smart_shelf_ui(&mut state, SmartShelfUiMessage::OpenComposer); + + assert!(state.domains.ui.state.smart_shelf.open); + assert!(matches!( + state.domains.search.state.presentation, + SearchPresentation::Hidden + )); + let summary = smart_shelf_composer_summary(&state); + assert!(summary.template_labels.len() >= 3); + assert!(summary.media_scope.contains("All libraries")); + assert!(view_smart_shelf_surface(&state).is_some()); +} + +#[tokio::test(flavor = "current_thread")] +async fn provider_unavailable_renders_fallback_and_retry_state() { + let mut state = test_state(); + update_smart_shelf_ui(&mut state, SmartShelfUiMessage::OpenComposer); + update_smart_shelf_ui( + &mut state, + SmartShelfUiMessage::Reducer(SmartShelfMessage::ProviderStatusLoaded( + provider_unavailable(), + )), + ); + + let surface = &state.domains.ui.state.smart_shelf; + assert_eq!(surface.reducer.phase, SmartShelfPhase::ProviderUnavailable); + assert!(surface.provider_fallback.is_some()); + assert!( + smart_shelf_composer_summary(&state) + .provider_status + .contains("Unavailable") + ); + assert!(view_smart_shelf_surface(&state).is_some()); +} + +#[tokio::test(flavor = "current_thread")] +async fn progress_ui_model_reports_phase_skeleton_cancel_retry_and_edit_prompt() +{ + let mut state = test_state(); + let run_id = Uuid::from_u128(20); + update_smart_shelf_ui(&mut state, SmartShelfUiMessage::OpenComposer); + update_smart_shelf_ui( + &mut state, + SmartShelfUiMessage::Reducer(SmartShelfMessage::ProviderStatusLoaded( + provider_ready(), + )), + ); + update_smart_shelf_ui( + &mut state, + SmartShelfUiMessage::Reducer(SmartShelfMessage::PromptChanged( + "rain shelf".to_string(), + )), + ); + update_smart_shelf_ui( + &mut state, + SmartShelfUiMessage::Reducer(SmartShelfMessage::StartAccepted( + start_response(run_id), + )), + ); + update_smart_shelf_ui( + &mut state, + SmartShelfUiMessage::Reducer(SmartShelfMessage::RunProgressLoaded( + running_status(run_id), + )), + ); + + let summary = smart_shelf_progress_summary( + &state.domains.ui.state.smart_shelf.reducer, + ); + assert_eq!(summary.status, "Running"); + assert_eq!(summary.phase, "ranking grounded candidates"); + assert_eq!(summary.step, "Step 2/4"); + assert!(summary.skeleton_rows >= 3); + assert!(summary.can_cancel); + assert!(!summary.can_retry); + assert!(!summary.can_edit_prompt); + assert!(view_smart_shelf_surface(&state).is_some()); +} + +#[tokio::test(flavor = "current_thread")] +async fn draft_ready_renders_ordered_cards_and_replacement_flow() { + let mut state = test_state(); + let artifact_id = Uuid::from_u128(30); + load_ready_draft(&mut state, artifact_id); + + let summary = smart_shelf_draft_review_summary( + &state.domains.ui.state.smart_shelf.reducer, + ) + .expect("draft summary"); + assert_eq!(summary.item_count, 2); + assert_eq!(summary.alternate_count, 1); + assert!(summary.can_save); + + update_smart_shelf_ui( + &mut state, + SmartShelfUiMessage::Reducer(SmartShelfMessage::ReplaceWithAlternate { + target_media_id: movie(1), + alternate_media_id: movie(3), + }), + ); + + let draft = state + .domains + .ui + .state + .smart_shelf + .reducer + .draft + .as_ref() + .expect("draft"); + assert_eq!(draft.items[0].media_id, movie(3)); + assert_eq!(draft.items[0].replacement_of, Some(movie(1))); + assert!(draft.dirty); + assert_eq!(draft.replacements_count(), 1); + assert!(view_smart_shelf_surface(&state).is_some()); +} + +#[tokio::test(flavor = "current_thread")] +async fn save_confirmation_makes_private_scope_and_conflict_copy_explicit() { + let mut state = test_state(); + let artifact_id = Uuid::from_u128(40); + load_ready_draft(&mut state, artifact_id); + + update_smart_shelf_ui( + &mut state, + SmartShelfUiMessage::Reducer(SmartShelfMessage::SaveRequested), + ); + + let smart = &state.domains.ui.state.smart_shelf.reducer; + assert_eq!(smart.save.status, SmartShelfSaveStatus::Confirming); + let summary = smart_shelf_save_review_summary(smart).expect("save summary"); + assert_eq!(summary.title, "Rainy night smart shelf"); + assert!(summary.description.contains("Atmospheric")); + assert!(summary.scope.contains("Accepted 2 items")); + assert_eq!(summary.visibility, "Private manual collection"); + assert!(summary.conflict_help.contains("Duplicate media")); + assert!(summary.conflict_help.contains("API conflicts")); + assert!(view_smart_shelf_surface(&state).is_some()); +} + +#[tokio::test(flavor = "current_thread")] +async fn save_error_surfaces_recoverable_conflict_actions() { + let mut state = test_state(); + let artifact_id = Uuid::from_u128(50); + load_ready_draft(&mut state, artifact_id); + update_smart_shelf_ui( + &mut state, + SmartShelfUiMessage::Reducer(SmartShelfMessage::SaveConfirmed), + ); + + update_smart_shelf_ui( + &mut state, + SmartShelfUiMessage::Reducer(SmartShelfMessage::SaveFailed( + SmartShelfFailure::new( + SmartShelfFailureCode::SmartShelf( + SmartShelfErrorCode::CollectionConflict, + ), + "Smart-shelf conflict: draft version changed", + true, + ), + )), + ); + + let smart = &state.domains.ui.state.smart_shelf.reducer; + assert_eq!(smart.phase, SmartShelfPhase::SaveConflict); + let summary = smart_shelf_save_review_summary(smart).expect("save summary"); + assert_eq!(summary.status, "Needs recovery"); + assert!(summary.error.expect("error").contains("version")); + let actions = smart + .save + .conflict + .as_ref() + .expect("conflict") + .recovery_actions + .iter() + .map(|action| save_conflict_recovery_label(*action)) + .collect::>(); + assert_eq!( + actions, + vec!["Reload draft", "Edit selection", "Retry save", "Discard"] + ); + assert!(actions.contains(&save_conflict_recovery_label( + SmartShelfSaveConflictRecovery::RetrySave + ))); + assert!(view_smart_shelf_surface(&state).is_some()); +} + +#[tokio::test(flavor = "current_thread")] +async fn successful_save_closes_surface_and_navigates_to_collection_detail() { + let mut state = test_state(); + let artifact_id = Uuid::from_u128(60); + let collection_id = CollectionId::from(Uuid::from_u128(61)); + load_ready_draft(&mut state, artifact_id); + update_smart_shelf_ui( + &mut state, + SmartShelfUiMessage::Reducer(SmartShelfMessage::SaveConfirmed), + ); + + update_smart_shelf_ui( + &mut state, + SmartShelfUiMessage::Reducer(SmartShelfMessage::SaveSucceeded( + SmartShelfSaveResponse { + draft_artifact_id: artifact_id, + collection_id: collection_id.clone(), + collection: collection_summary(collection_id.clone()), + item_count: 2, + saved_at_epoch_seconds: Some(Utc::now().timestamp()), + }, + )), + ); + + assert!(!state.domains.ui.state.smart_shelf.open); + assert!(matches!( + state.domains.ui.state.view, + ferrex_player_ui::domains::ui::types::ViewState::CollectionDetail { collection_id: ref navigated } + if navigated == &collection_id + )); + assert!(view_smart_shelf_surface(&state).is_none()); +} + +#[tokio::test(flavor = "current_thread")] +async fn collection_refresh_message_keeps_smart_shelf_state_independent() { + let mut state = test_state(); + update_smart_shelf_ui(&mut state, SmartShelfUiMessage::OpenComposer); + let before = state.domains.ui.state.smart_shelf.open; + let _ = ferrex_player_ui::domains::ui::collections::update_collections_ui( + &mut state, + CollectionsMessage::Refresh, + ); + assert_eq!(state.domains.ui.state.smart_shelf.open, before); +} diff --git a/crates/ferrex-player/src/main.rs b/crates/ferrex-player/src/main.rs index b2bca6ee..bd0945ae 100644 --- a/crates/ferrex-player/src/main.rs +++ b/crates/ferrex-player/src/main.rs @@ -22,7 +22,7 @@ fn main() -> ferrex_player::Result { cases, ), ) => { - println!("detail typography visual QA matrix:"); + println!("visual QA matrix:"); for case in cases { println!( " {:<28} {:<32} {:<10} {}", @@ -45,7 +45,7 @@ fn main() -> ferrex_player::Result { ), ) => { println!( - "captured detail typography matrix: {} screenshots (manifest: {})", + "captured visual QA matrix: {} screenshots (manifest: {})", output.captures.len(), output.manifest_path.display() ); diff --git a/crates/ferrex-server/src/handlers/intelligence.rs b/crates/ferrex-server/src/handlers/intelligence.rs index 09d89b78..55ac09e9 100644 --- a/crates/ferrex-server/src/handlers/intelligence.rs +++ b/crates/ferrex-server/src/handlers/intelligence.rs @@ -1,4 +1,7 @@ -use std::{convert::Infallible, pin::Pin, sync::Arc, time::Duration}; +use std::{ + collections::HashMap, convert::Infallible, pin::Pin, sync::Arc, + time::Duration, +}; use axum::{ Extension, Json, @@ -9,9 +12,24 @@ use axum::{ sse::{Event, KeepAlive}, }, }; +use chrono::Utc; use ferrex_core::{ - api::{ApiResponse, types::intelligence::*}, + api::{ + ApiResponse, + types::{ + collections::{ + CollectionId, CollectionMediaKind, CollectionMediaScope, + CollectionMemberAvailabilityStatus, CollectionMemberKey, + GetCollectionDetailRequest, + }, + intelligence::*, + smart_shelves::*, + }, + }, application::intelligence_runtime::IntelligenceRunManager, + database::repository_ports::collections::{ + CollectionItemIdentity, CollectionReadMode, + }, domain::intelligence::IntelligenceProviderError, error::MediaError, player_prelude::User, @@ -21,6 +39,7 @@ use ferrex_model::{ }; use serde::Deserialize; use serde_json::{Value, json}; +use sqlx::Row; use uuid::Uuid; use crate::infra::{ @@ -756,6 +775,98 @@ pub(crate) async fn draft_artifact_list_handler( ))) } +pub(crate) async fn smart_shelf_start_handler( + State(state): State, + Extension(user): Extension, + Json(request): Json, +) -> Result>, IntelligenceHttpError> { + let request = smart_shelf_run_start_request(request)?; + validate_run_start_request(&request)?; + let runtime = intelligence_runtime(&state)?; + ensure_provider_available(&runtime).await?; + let response = runtime + .start_run(request, Some(user.id)) + .await + .map_err(IntelligenceHttpError::from_media_error)?; + + Ok(Json(ApiResponse::success(response.into()))) +} + +pub(crate) async fn smart_shelf_draft_detail_handler( + State(state): State, + Extension(user): Extension, + Path(artifact_id): Path, +) -> Result>, SmartShelfHttpError> { + let access = load_smart_shelf_draft_access(&state, artifact_id).await?; + ensure_smart_shelf_draft_readable(&access, user.id)?; + let draft = state + .unit_of_work() + .intelligence + .get_draft_artifact(artifact_id, Some(user.id)) + .await + .map_err(SmartShelfHttpError::from_media_error)? + .ok_or_else(|| SmartShelfHttpError::draft_hidden())?; + + Ok(Json(ApiResponse::success( + SmartShelfDraftResponse::from_draft_artifact(draft), + ))) +} + +pub(crate) async fn smart_shelf_save_handler( + State(state): State, + Extension(user): Extension, + Path(artifact_id): Path, + Json(request): Json, +) -> Result>, SmartShelfHttpError> { + validate_smart_shelf_save_request(&request)?; + let access = load_smart_shelf_draft_access(&state, artifact_id).await?; + ensure_smart_shelf_draft_saveable(&access, user.id)?; + let payload = state + .unit_of_work() + .intelligence + .get_draft_artifact(artifact_id, Some(user.id)) + .await + .map_err(SmartShelfHttpError::from_media_error)? + .ok_or_else(|| SmartShelfHttpError::draft_hidden())?; + let response = + SmartShelfDraftResponse::from_draft_artifact(payload.clone()); + if !response.validation.valid { + return Err(SmartShelfHttpError::from_validation( + &response.validation, + "smart-shelf draft is not valid for save", + )); + } + let draft = response.draft.clone().ok_or_else(|| { + SmartShelfHttpError::new( + StatusCode::UNPROCESSABLE_ENTITY, + SmartShelfErrorCode::DraftMalformed, + "smart-shelf draft content is malformed", + ) + })?; + let accepted_items = accepted_smart_shelf_items(&draft, &request)?; + let grounded = grounded_media_ids(payload.media_id, &payload.sources); + let validation = + validate_smart_shelf_draft_items(&accepted_items, &grounded); + if !validation.valid { + return Err(SmartShelfHttpError::from_validation( + &validation, + "accepted smart-shelf items are not valid for save", + )); + } + + let response = save_smart_shelf_collection( + &state, + &user, + &payload, + &draft, + &accepted_items, + &request, + ) + .await?; + + Ok(Json(ApiResponse::success(response))) +} + pub(crate) async fn provider_status_handler( State(state): State, ) -> Result>, IntelligenceHttpError> @@ -768,6 +879,732 @@ pub(crate) async fn provider_status_handler( Ok(Json(ApiResponse::success(status))) } +#[derive(Debug, Clone)] +struct SmartShelfDraftAccess { + user_id: Option, + status: String, + metadata: Value, +} + +#[derive(Debug)] +pub(crate) struct SmartShelfHttpError { + status: StatusCode, + error: SmartShelfError, +} + +impl SmartShelfHttpError { + fn new( + status: StatusCode, + code: SmartShelfErrorCode, + message: impl Into, + ) -> Self { + Self { + status, + error: SmartShelfError { + code, + message: message.into(), + retryable: false, + details: Value::Null, + }, + } + } + + fn with_details( + status: StatusCode, + code: SmartShelfErrorCode, + message: impl Into, + details: Value, + ) -> Self { + Self { + status, + error: SmartShelfError { + code, + message: message.into(), + retryable: false, + details, + }, + } + } + + fn draft_hidden() -> Self { + Self::new( + StatusCode::NOT_FOUND, + SmartShelfErrorCode::DraftHidden, + "smart-shelf draft was not found", + ) + } + + fn unauthorized() -> Self { + Self::new( + StatusCode::FORBIDDEN, + SmartShelfErrorCode::Unauthorized, + "smart-shelf draft is not owned by the requesting user", + ) + } + + fn stale() -> Self { + Self::new( + StatusCode::CONFLICT, + SmartShelfErrorCode::DraftStale, + "smart-shelf draft is no longer saveable", + ) + } + + fn already_saved(collection_id: Option) -> Self { + let details = collection_id + .map(|id| json!({"collection_id": id})) + .unwrap_or(Value::Null); + Self::with_details( + StatusCode::CONFLICT, + SmartShelfErrorCode::AlreadySaved, + "smart-shelf draft has already been saved", + details, + ) + } + + fn invalid_request(message: impl Into) -> Self { + Self::new( + StatusCode::BAD_REQUEST, + SmartShelfErrorCode::InvalidRequest, + message, + ) + } + + fn collection_conflict(message: impl Into) -> Self { + Self::new( + StatusCode::CONFLICT, + SmartShelfErrorCode::CollectionConflict, + message, + ) + } + + fn storage(message: impl Into) -> Self { + Self::new( + StatusCode::INTERNAL_SERVER_ERROR, + SmartShelfErrorCode::CollectionStorageError, + message, + ) + } + + fn internal(message: impl Into) -> Self { + Self::new( + StatusCode::INTERNAL_SERVER_ERROR, + SmartShelfErrorCode::Internal, + message, + ) + } + + fn from_validation( + validation: &SmartShelfDraftValidation, + message: impl Into, + ) -> Self { + let code = validation + .first_save_error_code() + .unwrap_or(SmartShelfErrorCode::DraftMalformed); + Self::with_details( + StatusCode::UNPROCESSABLE_ENTITY, + code, + message, + json!({"issues": validation.issues}), + ) + } + + fn from_media_error(error: MediaError) -> Self { + match error { + MediaError::NotFound(message) => Self::new( + StatusCode::NOT_FOUND, + SmartShelfErrorCode::DraftHidden, + message, + ), + MediaError::Conflict(message) => Self::collection_conflict(message), + MediaError::InvalidMedia(message) => Self::invalid_request(message), + MediaError::Database(error) => Self::storage(format!( + "smart-shelf collection storage error: {error}" + )), + MediaError::Internal(message) => Self::internal(message), + other => Self::internal(other.to_string()), + } + } +} + +impl IntoResponse for SmartShelfHttpError { + fn into_response(self) -> axum::response::Response { + let message = self.error.message.clone(); + let body = Json(json!({ + "status": "error", + "error": self.error, + "message": message, + })); + (self.status, body).into_response() + } +} + +fn smart_shelf_run_start_request( + request: SmartShelfStartRequest, +) -> Result { + let prompt = request.prompt.trim(); + if prompt.is_empty() { + return Err(IntelligenceHttpError::new( + StatusCode::BAD_REQUEST, + IntelligenceErrorCode::InvalidRequest, + "smart-shelf prompt must not be empty", + )); + } + if !request.constraints.is_null() && !request.constraints.is_object() { + return Err(IntelligenceHttpError::new( + StatusCode::BAD_REQUEST, + IntelligenceErrorCode::InvalidRequest, + "smart-shelf constraints must be a JSON object", + )); + } + if !request.metadata.is_null() && !request.metadata.is_object() { + return Err(IntelligenceHttpError::new( + StatusCode::BAD_REQUEST, + IntelligenceErrorCode::InvalidRequest, + "smart-shelf metadata must be a JSON object", + )); + } + let media_kinds = if request.media_kinds.is_empty() { + vec![IntelligenceMediaKind::Movie, IntelligenceMediaKind::Series] + } else { + request.media_kinds.clone() + }; + if media_kinds.iter().any(|kind| { + !matches!( + kind, + IntelligenceMediaKind::Movie | IntelligenceMediaKind::Series + ) + }) { + return Err(IntelligenceHttpError::new( + StatusCode::BAD_REQUEST, + IntelligenceErrorCode::InvalidRequest, + "smart-shelf runs currently support movie and series media kinds only", + )); + } + + let prompt = build_smart_shelf_prompt(&request, &media_kinds); + let metadata = json!({ + "smart_shelf": { + "schema_version": SMART_SHELF_DRAFT_SCHEMA_VERSION, + "template_id": request.template_id, + "item_count": request.item_count, + "media_kinds": media_kinds, + "constraints": request.constraints, + "locked_media_ids": request.locked_media_ids, + }, + "client_metadata": request.metadata, + }); + + Ok(IntelligenceRunStartRequest { + purpose: IntelligenceRunPurpose::Recommendation, + library_id: request.library_id, + media_id: None, + prompt, + idempotency_key: request.idempotency_key, + model: request.model, + caps: request.caps, + metadata, + }) +} + +fn build_smart_shelf_prompt( + request: &SmartShelfStartRequest, + media_kinds: &[IntelligenceMediaKind], +) -> String { + let constraints = if request.constraints.is_null() { + json!({}) + } else { + request.constraints.clone() + }; + let prompt_context = json!({ + "user_prompt": request.prompt.trim(), + "template_id": request.template_id, + "library_id": request.library_id, + "item_count": request.item_count, + "media_kinds": media_kinds, + "constraints": constraints, + "locked_media_ids": request.locked_media_ids, + }); + format!( + "Draft a Ferrex smart shelf from the bounded request below. Use Ferrex tools to ground every selected item. Create exactly one draft artifact with create_draft, then finish with final_response. The draft artifact content must be a JSON object with schema_version {schema_version}, title, optional description, optional interpreted_intent, requested_constraints, items, and optional alternates. Each item must include ordinal, media_id exactly as returned by Ferrex tools, title when available, a non-empty reason, and at least one source chip with label and media_id or artifact_id. Select only movie or series media, avoid duplicates, preserve any locked_media_ids, and include alternates only when they are grounded. Do not create collections or shelf placements; saving happens through the explicit smart-shelf save route.\n\nBounded smart-shelf request:\n{context}", + schema_version = SMART_SHELF_DRAFT_SCHEMA_VERSION, + context = prompt_context, + ) +} + +async fn load_smart_shelf_draft_access( + state: &AppState, + artifact_id: Uuid, +) -> Result { + let row = sqlx::query( + r#" + SELECT user_id, status::text AS status, metadata + FROM intelligence_artifacts + WHERE id = $1 + "#, + ) + .bind(artifact_id) + .fetch_optional(state.postgres().pool()) + .await + .map_err(|error| { + SmartShelfHttpError::storage(format!( + "load smart-shelf draft access failed: {error}" + )) + })? + .ok_or_else(SmartShelfHttpError::draft_hidden)?; + + Ok(SmartShelfDraftAccess { + user_id: row.try_get("user_id").map_err(|error| { + SmartShelfHttpError::storage(format!( + "decode smart-shelf draft owner failed: {error}" + )) + })?, + status: row.try_get("status").map_err(|error| { + SmartShelfHttpError::storage(format!( + "decode smart-shelf draft status failed: {error}" + )) + })?, + metadata: row.try_get("metadata").map_err(|error| { + SmartShelfHttpError::storage(format!( + "decode smart-shelf draft metadata failed: {error}" + )) + })?, + }) +} + +fn ensure_smart_shelf_draft_readable( + access: &SmartShelfDraftAccess, + user_id: Uuid, +) -> Result<(), SmartShelfHttpError> { + if access.user_id != Some(user_id) { + return Err(SmartShelfHttpError::draft_hidden()); + } + ensure_smart_shelf_status_saveable(access) +} + +fn ensure_smart_shelf_draft_saveable( + access: &SmartShelfDraftAccess, + user_id: Uuid, +) -> Result<(), SmartShelfHttpError> { + if access.user_id != Some(user_id) { + return Err(SmartShelfHttpError::unauthorized()); + } + ensure_smart_shelf_status_saveable(access) +} + +fn ensure_smart_shelf_status_saveable( + access: &SmartShelfDraftAccess, +) -> Result<(), SmartShelfHttpError> { + if let Some(collection_id) = + saved_collection_id_from_metadata(&access.metadata).map(CollectionId) + { + return Err(SmartShelfHttpError::already_saved(Some(collection_id))); + } + if access.status != "draft" { + return Err(SmartShelfHttpError::stale()); + } + Ok(()) +} + +fn validate_smart_shelf_save_request( + request: &SmartShelfSaveRequest, +) -> Result<(), SmartShelfHttpError> { + if request + .title + .as_deref() + .is_some_and(|title| title.trim().is_empty()) + { + return Err(SmartShelfHttpError::invalid_request( + "smart-shelf save title must not be empty when provided", + )); + } + if request + .idempotency_key + .as_deref() + .is_some_and(|value| value.trim().is_empty()) + { + return Err(SmartShelfHttpError::invalid_request( + "smart-shelf save idempotency_key must not be empty when provided", + )); + } + if request + .idempotency_key + .as_deref() + .is_some_and(|value| value.chars().count() > 128) + { + return Err(SmartShelfHttpError::invalid_request( + "smart-shelf save idempotency_key exceeds the 128 character limit", + )); + } + if request.items.len() > usize::from(MAX_SMART_SHELF_ITEM_COUNT) { + return Err(SmartShelfHttpError::invalid_request(format!( + "smart-shelf save cannot include more than {MAX_SMART_SHELF_ITEM_COUNT} items" + ))); + } + Ok(()) +} + +fn accepted_smart_shelf_items( + draft: &SmartShelfDraftContent, + request: &SmartShelfSaveRequest, +) -> Result, SmartShelfHttpError> { + if request.items.is_empty() { + return Ok(draft.items.clone()); + } + + let mut item_pool: HashMap = HashMap::new(); + for item in &draft.items { + item_pool + .entry(item.media_id) + .or_insert_with(|| item.clone()); + } + for alternate in &draft.alternates { + let ordinal = alternate.target_ordinal.unwrap_or_else(|| { + u32::try_from(draft.items.len().saturating_add(1)) + .unwrap_or(u32::MAX) + }); + item_pool + .entry(alternate.media_id) + .or_insert_with(|| alternate.clone().into_item(ordinal)); + } + + let mut accepted = Vec::with_capacity(request.items.len()); + for (index, selected) in request.items.iter().enumerate() { + let Some(candidate) = item_pool.get(&selected.media_id) else { + let validation = SmartShelfDraftValidation::from_issues(vec![ + SmartShelfDraftValidationIssue::for_item( + SmartShelfDraftValidationIssueCode::UngroundedItem, + u32::try_from(index + 1).unwrap_or(u32::MAX), + selected.media_id, + "accepted smart-shelf item was not present in the draft or alternates", + ), + ]); + return Err(SmartShelfHttpError::from_validation( + &validation, + "accepted smart-shelf item is not part of the draft", + )); + }; + let mut item = candidate.clone(); + item.ordinal = u32::try_from(index + 1).unwrap_or(u32::MAX); + item.locked = selected.locked; + item.replacement_of = selected.replacement_of.or(item.replacement_of); + if selected + .reason + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + { + item.reason = selected.reason.clone(); + } + if !selected.sources.is_empty() { + item.sources = selected.sources.clone(); + } + accepted.push(item); + } + + Ok(accepted) +} + +async fn save_smart_shelf_collection( + state: &AppState, + user: &User, + payload: &IntelligenceDraftArtifactPayload, + draft: &SmartShelfDraftContent, + accepted_items: &[SmartShelfDraftItem], + request: &SmartShelfSaveRequest, +) -> Result { + let identities = accepted_items + .iter() + .map(|item| CollectionItemIdentity::new(item.media_id)) + .collect::>(); + let resolved = state + .unit_of_work() + .collections + .resolve_collection_items(&identities) + .await + .map_err(SmartShelfHttpError::from_media_error)?; + let resolved_by_media = resolved + .into_iter() + .map(|item| (item.media_id, item)) + .collect::>(); + for item in accepted_items { + let Some(resolved) = resolved_by_media.get(&item.media_id) else { + return Err(SmartShelfHttpError::new( + StatusCode::UNPROCESSABLE_ENTITY, + SmartShelfErrorCode::UnsupportedMedia, + format!( + "smart-shelf item {} could not be resolved", + item.media_id + ), + )); + }; + if resolved.availability.status + != CollectionMemberAvailabilityStatus::Available + { + return Err(SmartShelfHttpError::with_details( + StatusCode::UNPROCESSABLE_ENTITY, + SmartShelfErrorCode::UnsupportedMedia, + format!( + "smart-shelf item {} is not available for collection save", + item.media_id + ), + json!({ + "media_id": item.media_id, + "availability": resolved.availability, + }), + )); + } + } + + let title = request + .title + .as_deref() + .unwrap_or(&draft.title) + .trim() + .to_string(); + if title.is_empty() { + return Err(SmartShelfHttpError::invalid_request( + "smart-shelf save title must not be empty", + )); + } + let description = request + .description + .clone() + .or_else(|| draft.description.clone()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let collection_id = CollectionId::new(); + let stable_key = collection_id.stable_key(); + let external_key = request + .idempotency_key + .as_deref() + .map(|value| format!("smart-shelf:{}:{}", user.id, value.trim())); + let etag = format!("collection:{collection_id}:v1"); + let media_scope = smart_shelf_collection_media_scope(accepted_items); + let media_scope = serde_json::to_value(media_scope).map_err(|error| { + SmartShelfHttpError::internal(format!( + "encode smart-shelf media scope failed: {error}" + )) + })?; + let provenance = json!({ + "source": "manual", + "imported_from": "intelligence_draft", + "external_id": payload.artifact_id.to_string(), + "generated_by": "ferrex-smart-shelf", + "last_refreshed_at": Utc::now(), + }); + let saved_at = Utc::now(); + let save_metadata = json!({ + "collection_id": collection_id, + "saved_at": saved_at, + "saved_by_user_id": user.id, + "item_count": accepted_items.len(), + }); + + let pool = state.postgres().pool().clone(); + let mut tx = pool.begin().await.map_err(|error| { + SmartShelfHttpError::storage(format!( + "begin smart-shelf save transaction failed: {error}" + )) + })?; + + sqlx::query( + r#" + INSERT INTO collection_definitions ( + id, stable_key, external_key, title, description, kind, source, + owner_type, owner_user_id, owner_display_name, scope, library_id, + visibility, presentation, media_scope, duplicate_policy, artwork, + theme, provenance, contract_version, revision, etag + ) VALUES ( + $1, $2, $3, $4, $5, 'manual', 'manual', + 'user', $6, $7, 'user', $8, + 'private', 'shelf', $9::jsonb, 'reject_duplicates', '{}'::jsonb, + '{}'::jsonb, $10::jsonb, 1, 1, $11 + ) + "#, + ) + .bind(collection_id.to_uuid()) + .bind(&stable_key) + .bind(external_key.as_deref()) + .bind(&title) + .bind(description.as_deref()) + .bind(user.id) + .bind(&user.display_name) + .bind(payload.library_id.map(|id| id.to_uuid())) + .bind(&media_scope) + .bind(&provenance) + .bind(&etag) + .execute(&mut *tx) + .await + .map_err(map_smart_shelf_sqlx_error)?; + + for (index, item) in accepted_items.iter().enumerate() { + let resolved = + resolved_by_media.get(&item.media_id).ok_or_else(|| { + SmartShelfHttpError::internal(format!( + "resolved smart-shelf item disappeared: {}", + item.media_id + )) + })?; + let item_key = CollectionMemberKey::for_media(&item.media_id); + let media_type = collection_media_type_slug(item.media_id); + let position = i64::try_from(index + 1).map_err(|_| { + SmartShelfHttpError::invalid_request( + "smart-shelf item position exceeds i64", + ) + })?; + let title_snapshot = item + .title + .clone() + .or_else(|| resolved.title.clone()) + .unwrap_or_else(|| item.media_id.to_string()); + let subtitle_snapshot = + item.subtitle.clone().or_else(|| resolved.subtitle.clone()); + let membership_metadata = json!({ + "smart_shelf": { + "draft_artifact_id": payload.artifact_id, + "run_id": payload.run_id, + "draft_ordinal": item.ordinal, + "saved_position": position, + "reason": item.reason, + "sources": item.sources, + "locked": item.locked, + "replacement_of": item.replacement_of, + "title": item.title, + } + }); + + sqlx::query( + r#" + INSERT INTO collection_manual_memberships ( + collection_id, item_key, media_type, media_id, + title_snapshot, subtitle_snapshot, position_key, sort_key, + availability_status, availability_reason, + availability_checked_at, added_by, metadata + ) VALUES ( + $1, $2, ($3::text)::media_type, $4, + $5, $6, ($7::text)::numeric, $8, + 'available', NULL, + NOW(), $9, $10::jsonb + ) + "#, + ) + .bind(collection_id.to_uuid()) + .bind(item_key.as_str()) + .bind(media_type) + .bind(*item.media_id.as_uuid()) + .bind(&title_snapshot) + .bind(subtitle_snapshot.as_deref()) + .bind(position.to_string()) + .bind(item.reason.as_deref()) + .bind(user.id) + .bind(&membership_metadata) + .execute(&mut *tx) + .await + .map_err(map_smart_shelf_sqlx_error)?; + } + + let updated = sqlx::query( + r#" + UPDATE intelligence_artifacts + SET status = 'superseded', + metadata = jsonb_set(metadata, '{smart_shelf_save}', $2::jsonb, true), + updated_at = NOW() + WHERE id = $1 + AND user_id = $3 + AND status = 'draft' + "#, + ) + .bind(payload.artifact_id) + .bind(&save_metadata) + .bind(user.id) + .execute(&mut *tx) + .await + .map_err(map_smart_shelf_sqlx_error)?; + if updated.rows_affected() != 1 { + return Err(SmartShelfHttpError::stale()); + } + + tx.commit().await.map_err(|error| { + SmartShelfHttpError::storage(format!( + "commit smart-shelf save transaction failed: {error}" + )) + })?; + + let detail = state + .unit_of_work() + .collections + .get_collection_detail( + collection_id, + GetCollectionDetailRequest { + include_rule: false, + include_items_preview: true, + include_shelf_placements: false, + }, + CollectionReadMode::Admin, + ) + .await + .map_err(SmartShelfHttpError::from_media_error)? + .ok_or_else(|| { + SmartShelfHttpError::storage(format!( + "saved smart-shelf collection {collection_id} could not be loaded" + )) + })?; + + Ok(SmartShelfSaveResponse { + draft_artifact_id: payload.artifact_id, + collection_id, + collection: detail.summary, + item_count: u32::try_from(accepted_items.len()).unwrap_or(u32::MAX), + saved_at_epoch_seconds: Some(saved_at.timestamp()), + }) +} + +fn smart_shelf_collection_media_scope( + items: &[SmartShelfDraftItem], +) -> CollectionMediaScope { + let mut media_types = Vec::new(); + for item in items { + let media_type = CollectionMediaKind::from(&item.media_id); + if !media_types.contains(&media_type) { + media_types.push(media_type); + } + } + if media_types.is_empty() { + CollectionMediaScope::All + } else { + CollectionMediaScope::Types { media_types } + } +} + +fn collection_media_type_slug(media_id: MediaID) -> &'static str { + match CollectionMediaKind::from(&media_id) { + CollectionMediaKind::Movie => "movie", + CollectionMediaKind::Series => "series", + CollectionMediaKind::Season => "season", + CollectionMediaKind::Episode => "episode", + } +} + +fn map_smart_shelf_sqlx_error(error: sqlx::Error) -> SmartShelfHttpError { + if let sqlx::Error::Database(database) = &error + && let Some(constraint) = database.constraint() + && matches!( + constraint, + "uq_collection_definitions_external_key" + | "uq_collection_manual_memberships_item_key" + | "uq_collection_manual_memberships_media" + | "uq_collection_manual_memberships_position" + ) + { + return SmartShelfHttpError::collection_conflict( + "smart-shelf save conflicted with an existing collection write", + ); + } + SmartShelfHttpError::storage(format!( + "smart-shelf collection save failed: {error}" + )) +} + fn build_run_events_stream( runtime: Arc, request: IntelligenceRunEventsRequest, diff --git a/crates/ferrex-server/src/routes/v1.rs b/crates/ferrex-server/src/routes/v1.rs index 7567cfea..a4a1ea77 100644 --- a/crates/ferrex-server/src/routes/v1.rs +++ b/crates/ferrex-server/src/routes/v1.rs @@ -391,6 +391,18 @@ fn create_protected_routes(state: AppState) -> Router { v1::intelligence::PROVIDER_STATUS, get(intelligence_handlers::provider_status_handler), ) + .route( + v1::intelligence::SMART_SHELF_START, + post(intelligence_handlers::smart_shelf_start_handler), + ) + .route( + v1::intelligence::SMART_SHELF_DRAFT_DETAIL, + get(intelligence_handlers::smart_shelf_draft_detail_handler), + ) + .route( + v1::intelligence::SMART_SHELF_SAVE, + post(intelligence_handlers::smart_shelf_save_handler), + ) // Query system .route(v1::media::QUERY, post(query_media_handler)) // Scanning: pending-based triggers and counts @@ -648,3 +660,56 @@ fn create_role_routes(state: AppState) -> Router { auth::middleware::auth_middleware, )) } + +#[cfg(test)] +mod tests { + use axum::{ + Router, + body::{Body, to_bytes}, + extract::Path, + http::{Method, Request, StatusCode}, + routing::{get, post}, + }; + use tower::ServiceExt; + use uuid::Uuid; + + use super::v1; + + async fn draft_detail(Path(artifact_id): Path) -> String { + format!("detail:{artifact_id}") + } + + async fn save_draft(Path(artifact_id): Path) -> String { + format!("save:{artifact_id}") + } + + #[tokio::test] + async fn smart_shelf_save_route_matches_api_contract() { + let artifact_id = Uuid::from_u128(0x8103); + let save_path = v1::intelligence::SMART_SHELF_SAVE + .replace("{artifact_id}", &artifact_id.to_string()); + let router = Router::new() + .route( + v1::intelligence::SMART_SHELF_DRAFT_DETAIL, + get(draft_detail), + ) + .route(v1::intelligence::SMART_SHELF_SAVE, post(save_draft)); + + let response = router + .oneshot( + Request::builder() + .method(Method::POST) + .uri(save_path) + .body(Body::empty()) + .expect("request builds"), + ) + .await + .expect("route handles request"); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("response body reads"); + assert_eq!(body.as_ref(), format!("save:{artifact_id}").as_bytes()); + } +} diff --git a/crates/ferrex-server/tests/intelligence_routes.rs b/crates/ferrex-server/tests/intelligence_routes.rs index 3f4d22da..8bf290bf 100644 --- a/crates/ferrex-server/tests/intelligence_routes.rs +++ b/crates/ferrex-server/tests/intelligence_routes.rs @@ -12,7 +12,7 @@ use axum_test::TestServer; use ferrex_core::{ api::{ routes::{self, utils as route_utils}, - types::intelligence::*, + types::{intelligence::*, smart_shelves::*}, }, application::intelligence_runtime::IntelligenceRunManagerConfig, database::repository_ports::intelligence::{ @@ -25,10 +25,10 @@ use ferrex_core::{ IntelligenceProviderRequestOptions, IntelligenceProviderResult, }, }; -use ferrex_model::{LibraryId, MediaID, MovieID}; +use ferrex_model::{EpisodeID, LibraryId, MediaID, MovieID}; use ferrex_server::infra::{app_state::AppState, startup::NoopStartupHooks}; use serde_json::{Value, json}; -use sqlx::PgPool; +use sqlx::{PgPool, Row}; use tempfile::TempDir; use uuid::Uuid; @@ -493,6 +493,114 @@ fn media_segment(movie_id: Uuid) -> String { format!("movie:{movie_id}") } +fn smart_shelf_draft_path(artifact_id: Uuid) -> String { + route_utils::replace_param( + routes::v1::intelligence::SMART_SHELF_DRAFT_DETAIL, + "{artifact_id}", + artifact_id.to_string(), + ) +} + +fn smart_shelf_save_path(artifact_id: Uuid) -> String { + route_utils::replace_param( + routes::v1::intelligence::SMART_SHELF_SAVE, + "{artifact_id}", + artifact_id.to_string(), + ) +} + +fn smart_shelf_item(movie_id: Uuid, title: &str) -> SmartShelfDraftItem { + let media_id = MediaID::Movie(MovieID(movie_id)); + SmartShelfDraftItem { + ordinal: 1, + media_id, + title: Some(title.to_string()), + subtitle: None, + year: Some(2026), + reason: Some(format!("{title} matches the grounded shelf request")), + sources: vec![SmartShelfDraftSource { + label: Some("Library metadata".to_string()), + media_id: Some(media_id), + artifact_id: None, + field: Some("title".to_string()), + evidence: None, + }], + locked: false, + replacement_of: None, + } +} + +fn smart_shelf_content(items: Vec) -> Value { + serde_json::to_value(SmartShelfDraftContent { + schema_version: SMART_SHELF_DRAFT_SCHEMA_VERSION, + title: "Rainy night shelf".to_string(), + description: Some("Grounded private shelf draft".to_string()), + interpreted_intent: Some("Find a compact grounded shelf".to_string()), + requested_constraints: json!({"mood": "rainy"}), + items, + alternates: Vec::new(), + }) + .expect("smart shelf content serializes") +} + +async fn seed_smart_shelf_draft( + pool: &PgPool, + artifact_id: Uuid, + owner_id: Uuid, + library_id: Uuid, + status: &str, + content: Value, + metadata: Value, + source_media_ids: &[MediaID], +) { + sqlx::query( + r#" + INSERT INTO intelligence_artifacts ( + id, artifact_kind, scope, status, library_id, user_id, + title, summary, content_hash, content, metadata, source_revision + ) VALUES ( + $1, 'recommendation', 'user', $2, $3, $4, + 'Rainy night shelf draft', 'A typed smart-shelf draft.', + $5, $6::jsonb, $7::jsonb, 1 + ) + "#, + ) + .bind(artifact_id) + .bind(status) + .bind(library_id) + .bind(owner_id) + .bind(hex_hash(0x5eed)) + .bind(&content) + .bind(&metadata) + .execute(pool) + .await + .expect("insert smart shelf draft"); + + for (ordinal, media_id) in source_media_ids.iter().enumerate() { + sqlx::query( + r#" + INSERT INTO intelligence_artifact_sources ( + artifact_id, source_ordinal, source_kind, source_library_id, + source_media_id, source_media_type + ) VALUES ($1, $2, 'media', $3, $4, ($5::text)::media_type) + "#, + ) + .bind(artifact_id) + .bind(i32::try_from(ordinal).expect("source ordinal fits i32")) + .bind(library_id) + .bind(*media_id.as_uuid()) + .bind(match media_id { + MediaID::Movie(_) => "movie", + MediaID::Series(_) => "series", + MediaID::Season(_) => "season", + MediaID::Episode(_) => "episode", + }) + .execute(pool) + .await + .expect("insert smart shelf draft source"); + } +} + fn candidate_search_action( query: &str, library_id: Uuid, @@ -1337,6 +1445,480 @@ async fn intelligence_runtime_routes_cover_auth_runs_sse_cancel_and_drafts( Ok(()) } +#[sqlx::test(migrator = "ferrex_core::MIGRATOR")] +async fn smart_shelf_start_route_enforces_auth_and_provider_status( + pool: PgPool, +) -> Result<()> { + let (disabled_server, _disabled_state, _disabled_tempdir) = + build_server(pool.clone()).await?; + let (_disabled_user_id, disabled_token) = + register_user(&disabled_server, "smart_start_disabled").await?; + + let unauthenticated = disabled_server + .post(routes::v1::intelligence::SMART_SHELF_START) + .json(&json!({"prompt": "rainy night", "item_count": 4})) + .await; + unauthenticated.assert_status(StatusCode::UNAUTHORIZED); + + let disabled = disabled_server + .post(routes::v1::intelligence::SMART_SHELF_START) + .add_header("Authorization", bearer(&disabled_token)) + .json(&json!({"prompt": "rainy night", "item_count": 4})) + .await; + disabled.assert_status(StatusCode::SERVICE_UNAVAILABLE); + let disabled_body: Value = disabled.json(); + assert_eq!(disabled_body["error"]["code"], "feature_disabled"); + + let unavailable_provider = Arc::new(RouteFakeProvider::default()); + unavailable_provider.push_models(Err( + IntelligenceProviderError::Unavailable { + message: "connection refused".to_string(), + }, + )); + let (unavailable_server, _unavailable_state, _unavailable_tempdir) = + build_server_with_provider(pool.clone(), unavailable_provider).await?; + let (_unavailable_user_id, unavailable_token) = + register_user(&unavailable_server, "smart_start_unavailable").await?; + let unavailable = unavailable_server + .post(routes::v1::intelligence::SMART_SHELF_START) + .add_header("Authorization", bearer(&unavailable_token)) + .json(&json!({"prompt": "rainy night", "item_count": 4})) + .await; + unavailable.assert_status(StatusCode::SERVICE_UNAVAILABLE); + let unavailable_body: Value = unavailable.json(); + assert_eq!(unavailable_body["error"]["code"], "provider_unavailable"); + + let provider = Arc::new(RouteFakeProvider::default()); + let (server, _state, _tempdir) = + build_server_with_provider(pool.clone(), provider).await?; + let (_user_id, access_token) = + register_user(&server, "smart_start_owner").await?; + let start = server + .post(routes::v1::intelligence::SMART_SHELF_START) + .add_header("Authorization", bearer(&access_token)) + .json(&json!({ + "prompt": "rainy night", + "item_count": 4, + "media_kinds": ["movie"], + "constraints": {"max_runtime_minutes": 120} + })) + .await; + start.assert_status_ok(); + let start_body: Value = start.json(); + assert_eq!(start_body["data"]["status"], "queued"); + assert_eq!(start_body["data"]["draft_schema_version"], 1); + + Ok(()) +} + +#[sqlx::test(migrator = "ferrex_core::MIGRATOR")] +async fn smart_shelf_draft_read_and_save_are_typed_scoped_and_atomic( + pool: PgPool, +) -> Result<()> { + let library_id = Uuid::from_u128(0x8100); + let movie_id = Uuid::from_u128(0x8101); + let replacement_id = Uuid::from_u128(0x8102); + let draft_id = Uuid::from_u128(0x8103); + seed_library(&pool, library_id).await; + seed_movie( + &pool, + library_id, + movie_id, + Uuid::from_u128(0x8111), + 8101, + "Rain Arrival", + 878, + "Science Fiction", + ) + .await; + seed_movie( + &pool, + library_id, + replacement_id, + Uuid::from_u128(0x8112), + 8102, + "Rain Neighbor", + 878, + "Science Fiction", + ) + .await; + + let (server, _state, _tempdir) = build_server(pool.clone()).await?; + let (owner_id, owner_token) = + register_user(&server, "smart_save_owner").await?; + let (_other_id, other_token) = + register_user(&server, "smart_save_other").await?; + + let mut first = smart_shelf_item(movie_id, "Rain Arrival"); + first.locked = true; + let mut second = smart_shelf_item(replacement_id, "Rain Neighbor"); + second.ordinal = 2; + seed_smart_shelf_draft( + &pool, + draft_id, + owner_id, + library_id, + "draft", + smart_shelf_content(vec![first.clone(), second.clone()]), + json!({}), + &[ + MediaID::Movie(MovieID(movie_id)), + MediaID::Movie(MovieID(replacement_id)), + ], + ) + .await; + + let draft_path = smart_shelf_draft_path(draft_id); + let save_path = smart_shelf_save_path(draft_id); + let unauthenticated = server.get(&draft_path).await; + unauthenticated.assert_status(StatusCode::UNAUTHORIZED); + + let hidden_read = server + .get(&draft_path) + .add_header("Authorization", bearer(&other_token)) + .await; + hidden_read.assert_status(StatusCode::NOT_FOUND); + + let unauthorized_save = server + .post(&save_path) + .add_header("Authorization", bearer(&other_token)) + .json(&json!({})) + .await; + unauthorized_save.assert_status(StatusCode::FORBIDDEN); + let unauthorized_body: Value = unauthorized_save.json(); + assert_eq!(unauthorized_body["error"]["code"], "unauthorized"); + + let typed = server + .get(&draft_path) + .add_header("Authorization", bearer(&owner_token)) + .await; + typed.assert_status_ok(); + let typed_body: Value = typed.json(); + assert_eq!(typed_body["data"]["validation"]["valid"], true); + assert_eq!( + typed_body["data"]["draft"]["items"] + .as_array() + .unwrap() + .len(), + 2 + ); + + let save = server + .post(&save_path) + .add_header("Authorization", bearer(&owner_token)) + .json(&json!({ + "title": "Saved Rainy Night", + "items": [ + {"media_id": MediaID::Movie(MovieID(movie_id)), "locked": true}, + { + "media_id": MediaID::Movie(MovieID(replacement_id)), + "replacement_of": MediaID::Movie(MovieID(movie_id)) + } + ] + })) + .await; + save.assert_status_ok(); + let save_body: Value = save.json(); + let collection_id = Uuid::parse_str( + save_body["data"]["collection_id"] + .as_str() + .expect("collection id"), + )?; + assert_eq!( + save_body["data"]["collection"]["title"], + "Saved Rainy Night" + ); + assert_eq!(save_body["data"]["collection"]["visibility"], "private"); + assert_eq!(save_body["data"]["collection"]["kind"], "manual"); + assert_eq!(save_body["data"]["item_count"], 2); + + let rows = sqlx::query( + r#" + SELECT media_id, position_key::bigint AS position, metadata + FROM collection_manual_memberships + WHERE collection_id = $1 + ORDER BY position_key + "#, + ) + .bind(collection_id) + .fetch_all(&pool) + .await?; + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].try_get::("media_id")?, movie_id); + assert_eq!(rows[0].try_get::("position")?, 1); + let first_metadata: Value = rows[0].try_get("metadata")?; + assert_eq!(first_metadata["smart_shelf"]["locked"], true); + let second_metadata: Value = rows[1].try_get("metadata")?; + assert_eq!( + second_metadata["smart_shelf"]["replacement_of"]["Movie"], + movie_id.to_string() + ); + + let definition = sqlx::query( + r#" + SELECT owner_user_id, visibility::text AS visibility, provenance + FROM collection_definitions + WHERE id = $1 + "#, + ) + .bind(collection_id) + .fetch_one(&pool) + .await?; + assert_eq!(definition.try_get::("owner_user_id")?, owner_id); + assert_eq!(definition.try_get::("visibility")?, "private"); + let provenance: Value = definition.try_get("provenance")?; + assert_eq!(provenance["generated_by"], "ferrex-smart-shelf"); + assert_eq!(provenance["external_id"], draft_id.to_string()); + + let draft_row = sqlx::query( + r#" + SELECT status::text AS status, metadata + FROM intelligence_artifacts + WHERE id = $1 + "#, + ) + .bind(draft_id) + .fetch_one(&pool) + .await?; + assert_eq!(draft_row.try_get::("status")?, "superseded"); + let draft_metadata: Value = draft_row.try_get("metadata")?; + assert_eq!( + draft_metadata["smart_shelf_save"]["collection_id"], + collection_id.to_string() + ); + + let save_again = server + .post(&save_path) + .add_header("Authorization", bearer(&owner_token)) + .json(&json!({})) + .await; + save_again.assert_status(StatusCode::CONFLICT); + let save_again_body: Value = save_again.json(); + assert_eq!(save_again_body["error"]["code"], "already_saved"); + + Ok(()) +} + +#[sqlx::test(migrator = "ferrex_core::MIGRATOR")] +async fn smart_shelf_validation_and_collection_conflicts_are_stable( + pool: PgPool, +) -> Result<()> { + let library_id = Uuid::from_u128(0x8200); + let movie_id = Uuid::from_u128(0x8201); + let second_movie_id = Uuid::from_u128(0x8202); + let ungrounded_movie_id = Uuid::from_u128(0x8203); + seed_library(&pool, library_id).await; + seed_movie( + &pool, + library_id, + movie_id, + Uuid::from_u128(0x8211), + 8201, + "Conflict Arrival", + 878, + "Science Fiction", + ) + .await; + seed_movie( + &pool, + library_id, + second_movie_id, + Uuid::from_u128(0x8212), + 8202, + "Conflict Neighbor", + 878, + "Science Fiction", + ) + .await; + + let (server, _state, _tempdir) = build_server(pool.clone()).await?; + let (owner_id, owner_token) = + register_user(&server, "smart_validation_owner").await?; + + let invalid_draft_id = Uuid::from_u128(0x8220); + let mut duplicate = smart_shelf_item(movie_id, "Duplicate Arrival"); + duplicate.ordinal = 2; + duplicate.reason = None; + duplicate.sources = Vec::new(); + let mut unsupported = SmartShelfDraftItem { + ordinal: 3, + media_id: MediaID::Episode(EpisodeID(Uuid::from_u128(0x8299))), + title: Some("Unsupported episode".to_string()), + subtitle: None, + year: None, + reason: Some("Episode should not be saveable".to_string()), + sources: vec![SmartShelfDraftSource { + label: Some("Episode source".to_string()), + media_id: Some(MediaID::Episode(EpisodeID(Uuid::from_u128( + 0x8299, + )))), + artifact_id: None, + field: None, + evidence: None, + }], + locked: false, + replacement_of: None, + }; + unsupported.replacement_of = Some(MediaID::Movie(MovieID(movie_id))); + let mut ungrounded = smart_shelf_item(ungrounded_movie_id, "Ungrounded"); + ungrounded.ordinal = 4; + seed_smart_shelf_draft( + &pool, + invalid_draft_id, + owner_id, + library_id, + "draft", + smart_shelf_content(vec![ + smart_shelf_item(movie_id, "Conflict Arrival"), + duplicate, + unsupported, + ungrounded, + ]), + json!({}), + &[MediaID::Movie(MovieID(movie_id))], + ) + .await; + + let invalid_path = smart_shelf_draft_path(invalid_draft_id); + let invalid = server + .get(&invalid_path) + .add_header("Authorization", bearer(&owner_token)) + .await; + invalid.assert_status_ok(); + let invalid_body: Value = invalid.json(); + assert_eq!(invalid_body["data"]["validation"]["valid"], false); + let issue_codes = invalid_body["data"]["validation"]["issues"] + .as_array() + .expect("validation issues") + .iter() + .filter_map(|issue| issue["code"].as_str()) + .collect::>(); + assert!(issue_codes.contains(&"duplicate_media")); + assert!(issue_codes.contains(&"missing_reason")); + assert!(issue_codes.contains(&"missing_source")); + assert!(issue_codes.contains(&"unsupported_media")); + assert!(issue_codes.contains(&"ungrounded_item")); + + let invalid_save = server + .post(&smart_shelf_save_path(invalid_draft_id)) + .add_header("Authorization", bearer(&owner_token)) + .json(&json!({})) + .await; + invalid_save.assert_status(StatusCode::UNPROCESSABLE_ENTITY); + let invalid_save_body: Value = invalid_save.json(); + assert_eq!(invalid_save_body["error"]["code"], "duplicate_media"); + + let malformed_id = Uuid::from_u128(0x8221); + seed_smart_shelf_draft( + &pool, + malformed_id, + owner_id, + library_id, + "draft", + json!({"items": "not-an-array"}), + json!({}), + &[MediaID::Movie(MovieID(movie_id))], + ) + .await; + let malformed = server + .get(&smart_shelf_draft_path(malformed_id)) + .add_header("Authorization", bearer(&owner_token)) + .await; + malformed.assert_status_ok(); + let malformed_body: Value = malformed.json(); + assert_eq!( + malformed_body["data"]["validation"]["issues"][0]["code"], + "malformed_content" + ); + let malformed_save = server + .post(&smart_shelf_save_path(malformed_id)) + .add_header("Authorization", bearer(&owner_token)) + .json(&json!({})) + .await; + malformed_save.assert_status(StatusCode::UNPROCESSABLE_ENTITY); + let malformed_save_body: Value = malformed_save.json(); + assert_eq!(malformed_save_body["error"]["code"], "draft_malformed"); + + let empty_id = Uuid::from_u128(0x8222); + seed_smart_shelf_draft( + &pool, + empty_id, + owner_id, + library_id, + "draft", + smart_shelf_content(Vec::new()), + json!({}), + &[], + ) + .await; + let empty = server + .get(&smart_shelf_draft_path(empty_id)) + .add_header("Authorization", bearer(&owner_token)) + .await; + empty.assert_status_ok(); + let empty_body: Value = empty.json(); + assert_eq!( + empty_body["data"]["validation"]["issues"][0]["code"], + "empty_draft" + ); + let empty_save = server + .post(&smart_shelf_save_path(empty_id)) + .add_header("Authorization", bearer(&owner_token)) + .json(&json!({})) + .await; + empty_save.assert_status(StatusCode::UNPROCESSABLE_ENTITY); + let empty_save_body: Value = empty_save.json(); + assert_eq!(empty_save_body["error"]["code"], "draft_empty"); + + let first_conflict_id = Uuid::from_u128(0x8230); + let second_conflict_id = Uuid::from_u128(0x8231); + seed_smart_shelf_draft( + &pool, + first_conflict_id, + owner_id, + library_id, + "draft", + smart_shelf_content(vec![smart_shelf_item( + movie_id, + "Conflict Arrival", + )]), + json!({}), + &[MediaID::Movie(MovieID(movie_id))], + ) + .await; + seed_smart_shelf_draft( + &pool, + second_conflict_id, + owner_id, + library_id, + "draft", + smart_shelf_content(vec![smart_shelf_item( + second_movie_id, + "Conflict Neighbor", + )]), + json!({}), + &[MediaID::Movie(MovieID(second_movie_id))], + ) + .await; + let first_save = server + .post(&smart_shelf_save_path(first_conflict_id)) + .add_header("Authorization", bearer(&owner_token)) + .json(&json!({"idempotency_key": "same-key"})) + .await; + first_save.assert_status_ok(); + + let conflict_save = server + .post(&smart_shelf_save_path(second_conflict_id)) + .add_header("Authorization", bearer(&owner_token)) + .json(&json!({"idempotency_key": "same-key"})) + .await; + conflict_save.assert_status(StatusCode::CONFLICT); + let conflict_body: Value = conflict_save.json(); + assert_eq!(conflict_body["error"]["code"], "collection_conflict"); + + Ok(()) +} + #[sqlx::test(migrator = "ferrex_core::MIGRATOR")] async fn transcript_purge_and_rebuild_routes_remove_searchable_segments( pool: PgPool, diff --git a/docs/src/content/docs/developer/player-dependency-boundaries.md b/docs/src/content/docs/developer/player-dependency-boundaries.md index 122dac7e..f44eecb8 100644 --- a/docs/src/content/docs/developer/player-dependency-boundaries.md +++ b/docs/src/content/docs/developer/player-dependency-boundaries.md @@ -24,6 +24,7 @@ ferrex-player (binary/facade) -> ferrex-player-media -> ferrex-player-metadata -> ferrex-player-playback (Iced/subwave playback runtime) + -> ferrex-player-intelligence -> ferrex-player-search -> ferrex-player-settings -> ferrex-player-user-admin @@ -58,6 +59,7 @@ shell translates into concrete runtime tasks. | `ferrex-player-media` | API/foundation/library crates and watch-state selectors | Iced tasks/subscriptions/widgets, subwave, app/UI modules | | `ferrex-player-metadata` | Metadata-domain contracts that are independent of UI image handles | Iced image handles/tasks/subscriptions, subwave, app/UI modules | | `ferrex-player-playback` | API/foundation crates plus Iced/subwave playback runtime and overlay helpers | App bootstrap, root state composition, or UI shell modules | +| `ferrex-player-intelligence` | API DTOs and smart-shelf reducer/domain state | Iced event/key types, tasks/subscriptions/widgets, subwave, app/UI modules | | `ferrex-player-search` | API/foundation/library crates and search data-domain logic | Iced event/key types, tasks/subscriptions/widgets, subwave, app/UI modules | | `ferrex-player-settings` | Foundation/domain state, settings validation, section reducers, color utilities, `iced_core` color/point DTOs | Iced widgets/tasks/subscriptions, subwave, app/UI modules | | `ferrex-player-user-admin` | User-admin state, sanitized messages, reducer helpers | Iced widgets/tasks/subscriptions, subwave, app/UI modules | @@ -92,7 +94,8 @@ loading subscriptions live under `ferrex-player-ui`; playback/video runtime code lives under `ferrex-player-playback`. Do not add `#[path = ...]` includes from lower crates or move Iced widget/image-handle code back into `ferrex-player-auth`, `ferrex-player-repository`, `ferrex-player-library`, -`ferrex-player-media`, `ferrex-player-metadata`, `ferrex-player-search`, +`ferrex-player-media`, `ferrex-player-metadata`, +`ferrex-player-intelligence`, `ferrex-player-search`, `ferrex-player-settings`, or `ferrex-player-user-admin`. When new player crates are created, import from the owning supporting crate diff --git a/docs/src/content/docs/developer/smart-shelf-mvp.md b/docs/src/content/docs/developer/smart-shelf-mvp.md new file mode 100644 index 00000000..2f067f7f --- /dev/null +++ b/docs/src/content/docs/developer/smart-shelf-mvp.md @@ -0,0 +1,90 @@ +--- +title: "Smart-shelf MVP QA" +description: "MVP boundaries, local provider setup, deterministic fake-provider testing, screenshots, and excluded surfaces for desktop smart shelves." +sidebar: + order: 8 +--- + +Smart shelves are a desktop Ferrex Player MVP surface for turning a grounded intelligence draft into a private manual collection. The MVP is intentionally narrow: a user opens the composer, starts a local-provider run, reviews a grounded draft, optionally locks/replaces items, saves the draft, and lands on the saved collection detail. + +## MVP boundaries + +Included in this MVP: + +- Desktop player smart-shelf composer, provider fallback, running/progress, draft review, alternates/replacement, save, and saved collection detail states. +- Grounded draft items with media ids, reasons, source chips, and validation issues surfaced before save. +- Saving a valid draft as a private manual collection with `generated_by = "smart_shelf"` provenance. +- Empty and error collection states after save so recovery is visible instead of blank or data-wipe-class failure. + +Excluded from this MVP: + +- Android and Android TV behavior. +- Home pinning, dynamic rails, chatbot surfaces, and playback queue mutation. +- Server-driven collection promotion beyond the private manual collection created by accepting a draft. +- Live-model visual baselines in committed tests. + +## Local provider setup expectations + +The real runtime is local-provider first and disabled unless an operator opts in. For a local OpenAI-compatible provider, run a trusted local server such as `llama.cpp` and configure Ferrex with the same expectations documented in the intelligence foundation: + +```bash +FERREX_INTELLIGENCE_ENABLED=true +FERREX_INTELLIGENCE_BASE_URL=http://127.0.0.1:8081/v1 +FERREX_INTELLIGENCE_MODEL=gemma-4-12b-it +# Optional for providers that require it; local providers can leave this empty. +FERREX_INTELLIGENCE_API_KEY= +``` + +Before using the composer against a live server, confirm `GET /api/v1/intelligence/provider/status` reports a ready provider for the authenticated user. Provider failures must remain recoverable through the smart-shelf provider fallback; users should be able to edit the prompt or retry readiness without clearing app data. + +## Deterministic fake-provider testing + +Committed tests and screenshot presets do not require a live model. They use deterministic fake-provider fixtures with stable run ids, artifact ids, media ids, draft content, alternates, validation, save responses, and collection detail rows. + +Focused Rust coverage: + +```bash +cargo test -p ferrex-player-app --test smart_shelf_mvp +cargo test -p ferrex-player-app app::presets::tests::smart_shelf_mvp_scenarios_seed_visual_qa_states +cargo test -p ferrex-player-app screenshot::visual_qa::tests::smart_shelf_mvp_matrix_covers_required_tags +``` + +The `smart_shelf_mvp_start_draft_save_opens_collection_detail_fixture` integration test exercises the deterministic start -> run status -> draft -> save -> collection detail path without a live provider. + +## Screenshot presets and visual QA matrix + +The player screenshot harness exposes one preset per MVP visual state: + +| State | Preset | Default artifact path | +| --- | --- | --- | +| Composer | `SmartShelfComposer` | `target/ui-screenshots/smart-shelf-mvp/01-smart-shelf-composer.png` | +| Running/progress | `SmartShelfRunningProgress` | `target/ui-screenshots/smart-shelf-mvp/02-smart-shelf-running-progress.png` | +| Draft ready | `SmartShelfDraftReady` | `target/ui-screenshots/smart-shelf-mvp/03-smart-shelf-draft-ready.png` | +| Alternates/replacement | `SmartShelfAlternatesReplacement` | `target/ui-screenshots/smart-shelf-mvp/04-smart-shelf-alternates-replacement.png` | +| Provider unavailable | `SmartShelfProviderUnavailable` | `target/ui-screenshots/smart-shelf-mvp/05-smart-shelf-provider-unavailable.png` | +| Saved collection detail | `SmartShelfSavedCollectionDetail` | `target/ui-screenshots/smart-shelf-mvp/06-smart-shelf-saved-collection-detail.png` | +| Collection empty | `SmartShelfCollectionEmpty` | `target/ui-screenshots/smart-shelf-mvp/07-smart-shelf-collection-empty.png` | +| Collection error | `SmartShelfCollectionError` | `target/ui-screenshots/smart-shelf-mvp/08-smart-shelf-collection-error.png` | + +Capture the full MVP matrix when a headless renderer is available: + +```bash +cargo run -p ferrex-player --profile priority -- screenshot matrix smart-shelf \ + --output-dir target/ui-screenshots/smart-shelf-mvp +``` + +The command writes PNGs plus `target/ui-screenshots/smart-shelf-mvp/smart-shelf-mvp-visual-qa-matrix.json`. Use `--dry-run` or `list` for non-renderer metadata checks: + +```bash +cargo run -p ferrex-player --profile priority -- screenshot matrix smart-shelf list +cargo run -p ferrex-player --profile priority -- screenshot matrix smart-shelf --dry-run --only state:collection-error +``` + +## QA checklist + +For each capture, verify: + +- The visible UI matches only the desktop smart-shelf/Collections MVP boundaries. +- Provider unavailable, empty collection, and collection error states include retry/edit/recovery copy. +- Draft items, replacement badges, alternates, source chips, save affordance, and saved collection provenance are legible. +- No Android/TV, Home pinning, chatbot, dynamic rail, or playback queue behavior appears in code, copy, or screenshots. diff --git a/docs/src/content/docs/developer/ui-testing-workflow.md b/docs/src/content/docs/developer/ui-testing-workflow.md index 59682af0..58c841c1 100644 --- a/docs/src/content/docs/developer/ui-testing-workflow.md +++ b/docs/src/content/docs/developer/ui-testing-workflow.md @@ -155,6 +155,25 @@ for preset in DesktopMovieDetail DesktopSeriesDetail DesktopSeasonDetail Desktop done ``` +## Smart-shelf MVP QA matrix + +Smart-shelf desktop MVP states have deterministic presets and a named visual QA matrix: + +```bash +cargo run -p ferrex-player --profile priority -- screenshot matrix smart-shelf list +cargo run -p ferrex-player --profile priority -- screenshot matrix smart-shelf \ + --output-dir target/ui-screenshots/smart-shelf-mvp +``` + +The matrix covers `SmartShelfComposer`, `SmartShelfRunningProgress`, +`SmartShelfDraftReady`, `SmartShelfAlternatesReplacement`, +`SmartShelfProviderUnavailable`, `SmartShelfSavedCollectionDetail`, +`SmartShelfCollectionEmpty`, and `SmartShelfCollectionError`. Captures write +PNGs plus `smart-shelf-mvp-visual-qa-matrix.json` under the output directory. +See [Smart-shelf MVP QA](/developer/smart-shelf-mvp/) for boundaries, local provider +setup, deterministic fake-provider testing, excluded surfaces, and artifact +paths. + ## Run UI tests and smoke tests Replay every committed `.ice` script: diff --git a/scripts/check-player-crate-boundaries.sh b/scripts/check-player-crate-boundaries.sh index 52897923..116c4ca6 100755 --- a/scripts/check-player-crate-boundaries.sh +++ b/scripts/check-player-crate-boundaries.sh @@ -7,6 +7,7 @@ non_ui_crates=( ferrex-player-foundation ferrex-player-api ferrex-player-auth + ferrex-player-intelligence ferrex-player-repository ferrex-player-library ferrex-player-media