fix(emby): tolerate hidden browse ancestors (#443)

## Summary

- classify empty Emby item lookups and provider 404 responses as not
found
- stop dynamic browse breadcrumb traversal after a visible item when an
ancestor is hidden
- preserve errors for missing target items and non-not-found provider
failures

## Validation

- `cargo test -p synctv-media-providers`
- `cargo test -p synctv-core`
- `cargo test -p synctv-api-common --lib`
- `cargo clippy -p synctv-media-providers -p synctv-core --all-targets
-- -D warnings`
- `cargo fmt --all -- --check`
- real Emby dynamic-playlist browse and playback validation with two
media items
pull/444/head
zijiren 4 weeks ago committed by GitHub
parent 0633b83b4e
commit c77f42243d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -3030,9 +3030,16 @@ impl DynamicPlaylistProvider for EmbyProvider {
break;
}
let item = self
let item = match self
.fetch_item(&resolved, &current_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(&current_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);

@ -148,12 +148,18 @@ impl From<synctv_media_providers::ProviderClientError> 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,

@ -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
));
}

@ -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(),
})
}

@ -26,6 +26,28 @@ fn authenticated_client(server: &MockServer) -> Result<EmbyClient, EmbyError> {
)
}
#[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": [{

Loading…
Cancel
Save