diff --git a/synctv-core/src/provider/emby.rs b/synctv-core/src/provider/emby.rs index dfe5abf7..63a29ec8 100644 --- a/synctv-core/src/provider/emby.rs +++ b/synctv-core/src/provider/emby.rs @@ -3030,9 +3030,16 @@ impl DynamicPlaylistProvider for EmbyProvider { break; } - let item = self + let item = match self .fetch_item(&resolved, ¤t_id, ctx.request_context()) - .await?; + .await + { + Ok(item) => item, + Err(error) if emby_browse_path_can_end_at_missing_ancestor(&error, &segments) => { + break; + } + Err(error) => return Err(error), + }; segments.push(DynamicBrowsePathSegment { name: item.name, target: Self::encode_target(¤t_id)?, @@ -3049,6 +3056,13 @@ impl DynamicPlaylistProvider for EmbyProvider { } } +fn emby_browse_path_can_end_at_missing_ancestor( + error: &ProviderError, + resolved_segments: &[DynamicBrowsePathSegment], +) -> bool { + !resolved_segments.is_empty() && matches!(error, ProviderError::NotFound) +} + #[cfg(test)] mod tests { use super::*; @@ -3092,6 +3106,27 @@ mod tests { assert!(EmbyProvider::emby_list_item_from_item(item).is_folder); } + #[test] + fn browse_path_stops_only_when_an_ancestor_is_missing() { + let resolved = vec![DynamicBrowsePathSegment { + name: "Visible folder".to_string(), + target: crate::models::ProviderTarget::emby("visible-folder".to_string()), + }]; + + assert!(emby_browse_path_can_end_at_missing_ancestor( + &ProviderError::NotFound, + &resolved, + )); + assert!(!emby_browse_path_can_end_at_missing_ancestor( + &ProviderError::NotFound, + &[], + )); + assert!(!emby_browse_path_can_end_at_missing_ancestor( + &ProviderError::Authentication("expired".to_string()), + &resolved, + )); + } + #[test] fn playback_cache_key_is_room_scoped() { let room_a = RoomId::expect_positive(1); diff --git a/synctv-core/src/provider/provider_client.rs b/synctv-core/src/provider/provider_client.rs index e00224d8..667fcc36 100644 --- a/synctv-core/src/provider/provider_client.rs +++ b/synctv-core/src/provider/provider_client.rs @@ -148,12 +148,18 @@ impl From for ProviderError { use synctv_media_providers::ProviderClientError; match error { ProviderClientError::Network(msg) => Self::NetworkError(msg), + ProviderClientError::Api { code: 404, .. } => Self::NotFound, ProviderClientError::Api { message, .. } => Self::ApiError(message), ProviderClientError::Parse(msg) | ProviderClientError::InvalidHeader(msg) => { Self::ParseError(msg) } ProviderClientError::Auth(msg) => Self::Authentication(msg), ProviderClientError::InvalidConfig(msg) => Self::InvalidConfig(msg), + ProviderClientError::Http { status, .. } + if status == reqwest::StatusCode::NOT_FOUND => + { + Self::NotFound + } ProviderClientError::Http { status, url, .. } => Self::UpstreamHttp { status: status.as_u16(), url, diff --git a/synctv-core/src/provider/provider_client_tests.rs b/synctv-core/src/provider/provider_client_tests.rs index e8100286..fc44b8dd 100644 --- a/synctv-core/src/provider/provider_client_tests.rs +++ b/synctv-core/src/provider/provider_client_tests.rs @@ -46,3 +46,26 @@ fn test_custom_clients_injection() { assert_eq!(Arc::as_ptr(&bilibili), bilibili_ptr); assert_eq!(Arc::as_ptr(&emby), emby_ptr); } + +#[test] +fn provider_not_found_errors_map_to_not_found() { + let api_error = synctv_media_providers::ProviderClientError::Api { + code: 404, + message: "missing".to_string(), + }; + assert!(matches!( + ProviderError::from(api_error), + ProviderError::NotFound + )); + + let http_error = synctv_media_providers::ProviderClientError::Http { + status: reqwest::StatusCode::NOT_FOUND, + url: "https://provider.example/items/missing".to_string(), + retry_after_secs: None, + body: String::new(), + }; + assert!(matches!( + ProviderError::from(http_error), + ProviderError::NotFound + )); +} diff --git a/synctv-media-providers/src/emby/client.rs b/synctv-media-providers/src/emby/client.rs index 2a7ee81c..19534f8c 100644 --- a/synctv-media-providers/src/emby/client.rs +++ b/synctv-media-providers/src/emby/client.rs @@ -487,7 +487,7 @@ impl EmbyClient { let headers = self.build_headers()?; let response: ItemsResponse = self.send_get_json(&url, &headers).await?; response.items.into_iter().next().ok_or(EmbyError::Api { - code: 0, + code: 404, message: "Item not found".to_string(), }) } diff --git a/synctv-media-providers/src/emby/client_tests.rs b/synctv-media-providers/src/emby/client_tests.rs index 06a8fcec..14eed2ea 100644 --- a/synctv-media-providers/src/emby/client_tests.rs +++ b/synctv-media-providers/src/emby/client_tests.rs @@ -26,6 +26,28 @@ fn authenticated_client(server: &MockServer) -> Result { ) } +#[tokio::test] +async fn get_item_maps_empty_result_to_not_found() -> TestResult { + let server = MockServer::start().await; + mount_api_prefix_probe(&server).await; + Mock::given(method("GET")) + .and(path("/Users/user-1/Items")) + .and(query_param("Ids", "missing-item")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "Items": [], + "TotalRecordCount": 0 + }))) + .mount(&server) + .await; + + let error = authenticated_client(&server)? + .get_item("missing-item") + .await + .expect_err("an empty item result must be reported as not found"); + assert!(matches!(error, EmbyError::Api { code: 404, .. })); + Ok(()) +} + fn items_response(item_type: &str) -> serde_json::Value { serde_json::json!({ "Items": [{