-
Notifications
You must be signed in to change notification settings - Fork 124
Address ABS importer failures #1802
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
ad296de
beb4d12
7c0bc0c
71c2f16
6d53cd1
e2984e3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,8 @@ | ||
| use std::{result::Result as StdResult, sync::Arc}; | ||
| use std::{collections::HashSet, result::Result as StdResult, sync::Arc, time::Duration}; | ||
|
|
||
| use anyhow::Result; | ||
| use anyhow::{Context, Result}; | ||
| use application_utils::get_podcast_episode_number_by_name; | ||
| use common_utils::{get_http_client_with_tls_config, ryot_log}; | ||
| use common_utils::{USER_AGENT_STR, ryot_log, sleep_for_n_seconds}; | ||
| use data_encoding::BASE64; | ||
| use dependent_entity_utils::commit_metadata; | ||
| use dependent_models::{ImportCompletedItem, ImportOrExportMetadataItem, ImportResult}; | ||
|
|
@@ -18,8 +18,8 @@ use media_models::{ | |
| }; | ||
| use openlibrary_provider::OpenlibraryService; | ||
| use reqwest::{ | ||
| Client, | ||
| header::{AUTHORIZATION, HeaderValue}, | ||
| Client, ClientBuilder, | ||
| header::{AUTHORIZATION, HeaderMap, HeaderValue, USER_AGENT}, | ||
| }; | ||
| use supporting_service::SupportingService; | ||
|
|
||
|
|
@@ -28,6 +28,7 @@ struct ImportServices<'a> { | |
| hardcover_service: &'a HardcoverService, | ||
| google_books_service: &'a GoogleBooksService, | ||
| open_library_service: &'a OpenlibraryService, | ||
| finished_podcast_episodes: &'a HashSet<(String, String)>, | ||
| } | ||
|
|
||
| pub async fn import( | ||
|
|
@@ -40,19 +41,31 @@ pub async fn import( | |
| let mut completed = vec![]; | ||
| let mut failed = vec![]; | ||
| let url = format!("{}/api", input.api_url); | ||
| let client = get_http_client_with_tls_config( | ||
| Some(vec![( | ||
| AUTHORIZATION, | ||
| HeaderValue::from_str(&format!("Bearer {}", input.api_key)).unwrap(), | ||
| )]), | ||
| let client = build_client( | ||
| &input.api_key, | ||
| input.allow_insecure_connections.unwrap_or(false), | ||
| ); | ||
|
|
||
| let me = client | ||
| .get(format!("{url}/me")) | ||
| .send() | ||
| .await? | ||
| .json::<audiobookshelf_models::MeResponse>() | ||
| .await | ||
| .unwrap(); | ||
| let finished_podcast_episodes = me | ||
| .media_progress | ||
| .into_iter() | ||
| .filter(|progress| progress.is_finished) | ||
| .filter_map(|progress| Some((progress.library_item_id?, progress.episode_id?))) | ||
| .collect::<HashSet<_>>(); | ||
|
|
||
| let services = ImportServices { | ||
| ss, | ||
| hardcover_service, | ||
| google_books_service, | ||
| open_library_service, | ||
| finished_podcast_episodes: &finished_podcast_episodes, | ||
| }; | ||
|
|
||
| let libraries_resp = client | ||
|
|
@@ -80,7 +93,7 @@ pub async fn import( | |
|
|
||
| let results: Vec<_> = stream::iter(finished_items.results.into_iter().enumerate()) | ||
| .map(|(idx, item)| process_item(idx, item, len, &client, &url, &services)) | ||
| .buffer_unordered(5) | ||
| .buffer_unordered(1) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🌐 Web query:
💡 Result: There is no documented requirement or technical advantage to fully serializing Citations:
🏁 Script executed: #!/bin/bash
set -euo pipefail
# Locate and inspect the relevant importer implementation and retry flow.
fd -a 'lib.rs' . | sed 's#^\./##' | rg 'importer/audiobookshelf/src/lib.rs|audiobookshelf/src/lib.rs' || true
echo '--- outline ---'
ast-grep outline crates/services/importer/audiobookshelf/src/lib.rs --view compact || true
echo '--- relevant lines ---'
cat -n crates/services/importer/audiobookshelf/src/lib.rs | sed -n '1,180p'
echo '--- search get_item_details and retry/backoff symbols ---'
rg -n "get_item_details|buffer_unordered|retry|backoff|try|Result|async|match" crates/services/importer/audiobookshelf/src/lib.rsRepository: IgnisDa/ryot Length of output: 9866 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo '--- read slice around item processing continuation and get_item_details ---'
cat -n crates/services/importer/audiobookshelf/src/lib.rs | sed -n '150,320p'
echo '--- get_item_details usages and concurrent limits in audiobookshelf importer ---'
rg -n "get_item_details|buffer_unordered|buffer_max|Semaphore|semaphore|mutex|lock|finished_podcast|podcast" crates/services/importer/audiobookshelf/src/lib.rs crates -g '*.rs' | head -200
echo '--- determine whether get_item_details loop is exponential backoff and how many sleeps ---'
python3 - <<'PY'
from pathlib import Path
s = Path('crates/services/importer/audiobookshelf/src/lib.rs').read_text().splitlines()
for i, line in enumerate(s, 1):
if i < 250 or i > 310:
continue
print(f"{i:04d}: {line}")
PYRepository: IgnisDa/ryot Length of output: 32688 Avoid serializing the entire importer with Each podcast imports per-episode commits, while item-details fetching can retry forever with exponential sleeps ( 🤖 Prompt for AI Agents |
||
| .collect() | ||
| .await; | ||
|
|
||
|
|
@@ -120,73 +133,71 @@ async fn process_item( | |
| _ => { | ||
| return Err(ImportFailedItem { | ||
| identifier: title, | ||
| lot: Some(MediaLot::Book), | ||
| step: ImportFailStep::InputTransformation, | ||
| error: Some("No Google Books ID found".to_string()), | ||
| ..Default::default() | ||
| }); | ||
| } | ||
| }, | ||
| _ => { | ||
| return Err(ImportFailedItem { | ||
| identifier: title, | ||
| lot: Some(MediaLot::Book), | ||
| error: Some("No ISBN found".to_string()), | ||
| step: ImportFailStep::InputTransformation, | ||
|
Comment on lines
143
to
147
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win Reorder struct fields by ascending line length. The changed Also applies to: 143-148, 160-169, 184-189, 213-220 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| ..Default::default() | ||
| }); | ||
|
Comment on lines
134
to
148
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Include all source identifiers in failure messages. The book failures at Lines 125 and 133 should include Also applies to: 184-189 🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
| } else if let Some(asin) = metadata.asin.clone() { | ||
| (asin, MediaLot::AudioBook, MediaSource::Audible, None) | ||
| } else if let Some(itunes_id) = metadata.itunes_id.clone() { | ||
| let item_details = get_item_details(client, url, &item.id, None) | ||
| .await | ||
| .map_err(|e| ImportFailedItem { | ||
| identifier: title.clone(), | ||
| error: Some(e.to_string()), | ||
| step: ImportFailStep::ItemDetailsFromSource, | ||
| ..Default::default() | ||
| })?; | ||
| let item_details = | ||
| get_item_details(client, url, &item.id) | ||
| .await | ||
| .map_err(|e| ImportFailedItem { | ||
| identifier: title.clone(), | ||
| error: Some(format!("item_id={}: {e:#}", item.id)), | ||
| lot: Some(MediaLot::Podcast), | ||
| step: ImportFailStep::ItemDetailsFromSource, | ||
| })?; | ||
| match item_details.media.and_then(|m| m.episodes) { | ||
| Some(episodes) => { | ||
| let lot = MediaLot::Podcast; | ||
| let source = MediaSource::Itunes; | ||
| let mut to_return = vec![]; | ||
| for episode in episodes { | ||
| ryot_log!(debug, "Importing episode {:?}", episode.title); | ||
| let episode_details = | ||
| get_item_details(client, url, &item.id, Some(episode.id.unwrap())) | ||
| .await | ||
| .map_err(|e| ImportFailedItem { | ||
| identifier: title.clone(), | ||
| error: Some(e.to_string()), | ||
| step: ImportFailStep::ItemDetailsFromSource, | ||
| ..Default::default() | ||
| })?; | ||
| if let Some(true) = | ||
| episode_details.user_media_progress.map(|u| u.is_finished) | ||
| let Some(episode_id) = episode.id.clone() else { | ||
| continue; | ||
| }; | ||
| if !services | ||
| .finished_podcast_episodes | ||
| .contains(&(item.id.clone(), episode_id)) | ||
| { | ||
| let (podcast, _) = commit_metadata( | ||
| PartialMetadataWithoutId { | ||
| lot, | ||
| source, | ||
| identifier: itunes_id.clone(), | ||
| ..Default::default() | ||
| }, | ||
| services.ss, | ||
| Some(true), | ||
| ) | ||
| .await | ||
| .map_err(|e| ImportFailedItem { | ||
| identifier: title.clone(), | ||
| error: Some(e.to_string()), | ||
| step: ImportFailStep::ItemDetailsFromSource, | ||
| continue; | ||
| } | ||
| ryot_log!(debug, "Importing episode {:?}", episode.title); | ||
| let (podcast, _) = commit_metadata( | ||
| PartialMetadataWithoutId { | ||
| lot, | ||
| source, | ||
| identifier: itunes_id.clone(), | ||
| ..Default::default() | ||
| })?; | ||
| if let Some(pe) = podcast.podcast_specifics.and_then(|p| { | ||
| get_podcast_episode_number_by_name(&p, &episode.title) | ||
| }) { | ||
| to_return.push(pe); | ||
| } | ||
| }, | ||
| services.ss, | ||
| Some(true), | ||
| ) | ||
| .await | ||
| .map_err(|e| ImportFailedItem { | ||
| identifier: title.clone(), | ||
| error: Some(format!("item_id={}: {e:#}", item.id)), | ||
| lot: Some(MediaLot::Podcast), | ||
| step: ImportFailStep::DatabaseCommit, | ||
| })?; | ||
| if let Some(pe) = podcast | ||
| .podcast_specifics | ||
| .and_then(|p| get_podcast_episode_number_by_name(&p, &episode.title)) | ||
| { | ||
| to_return.push(pe); | ||
| } | ||
| } | ||
| (itunes_id, lot, source, Some(to_return)) | ||
|
|
@@ -196,7 +207,10 @@ async fn process_item( | |
| identifier: title, | ||
| lot: Some(MediaLot::Podcast), | ||
| step: ImportFailStep::ItemDetailsFromSource, | ||
| error: Some("No episodes found for podcast".to_string()), | ||
| error: Some(format!( | ||
| "item_id={}: No episodes found for podcast", | ||
| item.id | ||
| )), | ||
| }); | ||
| } | ||
| } | ||
|
|
@@ -205,8 +219,11 @@ async fn process_item( | |
| return Err(ImportFailedItem { | ||
| identifier: title, | ||
| step: ImportFailStep::InputTransformation, | ||
| error: Some("No ASIN, ISBN or iTunes ID found".to_string()), | ||
| ..Default::default() | ||
| lot: None, | ||
| error: Some(format!( | ||
| "item_id={}: No ASIN, ISBN or iTunes ID found", | ||
| item.id | ||
| )), | ||
| }); | ||
| }; | ||
| let mut seen_history = vec![]; | ||
|
|
@@ -234,22 +251,72 @@ async fn process_item( | |
| })) | ||
| } | ||
|
|
||
| const MAX_ITEM_DETAILS_ATTEMPTS: u32 = 3; | ||
|
|
||
| async fn get_item_details( | ||
| client: &Client, | ||
| url: &str, | ||
| id: &str, | ||
| episode: Option<String>, | ||
| ) -> Result<audiobookshelf_models::Item> { | ||
| let mut query = serde_json::json!({ "expanded": "1", "include": "progress" }); | ||
| if let Some(episode) = episode { | ||
| query["episode"] = serde_json::json!(episode); | ||
| let query = serde_json::json!({ "expanded": "1" }); | ||
| let request_url = format!("{url}/items/{id}"); | ||
| let mut last_error = None; | ||
| for attempt in 0..MAX_ITEM_DETAILS_ATTEMPTS { | ||
| match fetch_item_details(client, &request_url, &query).await { | ||
| Ok(item) => return Ok(item), | ||
| Err(error) => { | ||
| last_error = Some(error); | ||
| if attempt + 1 < MAX_ITEM_DETAILS_ATTEMPTS { | ||
| let sleep_time = u64::pow(2, attempt + 1); | ||
| ryot_log!( | ||
| debug, | ||
| "Attempt {}/{} failed for {}, retrying in {}s", | ||
| attempt + 1, | ||
| MAX_ITEM_DETAILS_ATTEMPTS, | ||
| request_url, | ||
| sleep_time | ||
| ); | ||
| sleep_for_n_seconds(sleep_time).await; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| let item = client | ||
| .get(format!("{url}/items/{id}")) | ||
| .query(&query) | ||
| Err(last_error.unwrap()) | ||
| } | ||
|
Comment on lines
+254
to
+285
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift Retry loop retries non-transient failures (parse errors, 4xx) the same as transient ones.
[reliability_and_resilience] 🤖 Prompt for AI Agents |
||
|
|
||
| async fn fetch_item_details( | ||
| client: &Client, | ||
| request_url: &str, | ||
| query: &serde_json::Value, | ||
| ) -> Result<audiobookshelf_models::Item> { | ||
| let response = client | ||
| .get(request_url) | ||
| .query(query) | ||
| .send() | ||
| .await? | ||
| .json::<audiobookshelf_models::Item>() | ||
| .await?; | ||
| Ok(item) | ||
| .await | ||
| .with_context(|| format!("Network error requesting audiobookshelf API at {request_url}"))?; | ||
| let status = response.status(); | ||
| let body = response.text().await.unwrap_or_default(); | ||
| if !status.is_success() { | ||
| anyhow::bail!("Audiobookshelf returned HTTP {status} (url: {request_url}): {body}"); | ||
| } | ||
| serde_json::from_str::<audiobookshelf_models::Item>(&body).with_context(|| { | ||
| format!("Failed to parse JSON from Audiobookshelf response for {request_url}") | ||
| }) | ||
| } | ||
|
|
||
| // Longer timeout than the shared client since podcast item lookups can be slow. | ||
| fn build_client(bearer_token: &str, danger_accept_invalid_certs: bool) -> Client { | ||
| let mut headers = HeaderMap::new(); | ||
| headers.insert(USER_AGENT, HeaderValue::from_static(USER_AGENT_STR)); | ||
| headers.insert( | ||
| AUTHORIZATION, | ||
| HeaderValue::from_str(&format!("Bearer {bearer_token}")).unwrap(), | ||
| ); | ||
| ClientBuilder::new() | ||
| .default_headers(headers) | ||
| .timeout(Duration::from_secs(60)) | ||
| .danger_accept_invalid_certs(danger_accept_invalid_certs) | ||
| .build() | ||
| .unwrap() | ||
| } | ||
|
Comment on lines
+309
to
322
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🌐 Web query:
💡 Result: Yes, the HeaderValue::from_str method in the http crate (which reqwest uses) rejects strings containing leading or trailing whitespace [1][2]. When you attempt to create a HeaderValue from a string that starts or ends with whitespace (such as a space or tab), the operation will return an error [1][2]. This design choice follows the HTTP specification (RFC 7230/9110), which dictates that header field values should not include leading or trailing whitespace, as such characters are meant to be excluded by parsers [1][2]. In addition to rejecting leading and trailing whitespace, HeaderValue::from_str validates that the input contains only visible ASCII characters (bytes 32–126, where 32 is a space) [3][4][5]. Although the horizontal tab (byte 9) is technically allowed in header values by the underlying logic, the specific restriction against leading/trailing whitespace prevents its use at the beginning or end of the string [1][5]. If you are dealing with API tokens that may contain accidental whitespace, you should trim the string (e.g., using.trim in Rust) before passing it to HeaderValue::from_str [2]. Citations:
🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== locate file =="
git ls-files | rg 'crates/services/importer/audiobookshelf/src/lib\.rs|Cargo\.toml|Cargo.lock' || true
echo "== inspect relevant lines =="
cat -n crates/services/importer/audiobookshelf/src/lib.rs | sed -n '260,350p'
echo "== imports and surrounding config/token usage =="
sed -n '1,120p' crates/services/importer/audiobookshelf/src/lib.rs
rg -n "api_key|bearer_token|build_client|ImportResult|ImportFailedItem" crates/services/importer/audiobookshelf/src/lib.rs
echo "== reqwest/http dependency versions =="
rg -n 'reqwest|http \||http =|http ' Cargo.toml Cargo.lockRepository: IgnisDa/ryot Length of output: 15699 🌐 Web query:
💡 Result: In the http crate, the HeaderValue::from_str method (and related constructors like from_bytes) strictly prohibits leading or trailing whitespace, as well as control characters (including tabs and other non-visible ASCII characters), by design [1][2][3]. 1. Strict Validation: The http crate enforces that a HeaderValue contains only visible ASCII characters (defined as the range 32-127 inclusive, excluding the DEL character 127) [1][4][5]. Because space (0x20) and horizontal tabs (0x09) are either outside this range or classified as non-visible/control characters in the context of this implementation, any input starting or ending with these characters, or containing them, will trigger a validation error [1][3]. 2. Motivation: This behavior is intentional. While HTTP specifications (such as RFC 7230/9110) note that parsers should ignore "Observed Whitespace" (OWS) around field values when extracting them from a raw byte stream, it is considered a requirement that a valid HeaderValue type itself should represent the "sanitized" or "trimmed" value [2][3]. Therefore, the library forces developers to ensure that the data provided to these constructors is already compliant and trimmed of leading/trailing whitespace [2][3]. 3. Historical Context: This behavior was formalized to prevent issues where invalid or ambiguous header values could be constructed [2][3]. Previous discussions and pull requests (e.g., Citations:
🌐 Web query:
💡 Result: In the Rust http crate (version 1.4.0), the Citations:
🌐 Web query:
💡 Result: In the http crate (including version 1.4.0), the HeaderValue::from_str method enforces strict validation rules on input strings [1][2]. It explicitly permits only visible ASCII characters, defined as the range 32–127 (inclusive) [1][3]. Regarding your specific concerns: 1. Leading and Trailing Whitespace: The http crate prohibits leading and trailing whitespace in HeaderValue objects [4]. This was implemented to align with HTTP specifications (RFC 7230), which expect parsers to ignore whitespace around header values rather than including them as part of the value itself [4]. Attempting to construct a HeaderValue with leading or trailing spaces (ASCII 32 or horizontal tab, ASCII 9) will result in an error [4][2]. 2. Control Characters and Tabs: The implementation restricts HeaderValue to visible ASCII characters (32–127) [1][3]. Because it explicitly excludes characters below 32, control characters—including horizontal tabs (ASCII 9)—are disallowed [1][3]. While some earlier discussions and GitHub issues noted potential nuances regarding how "visible ASCII" is interpreted versus broader HTTP spec requirements (which historically allowed HTAB), the current implementation maintains this strict restriction [1][5]. If you need to include opaque octets (bytes outside the 32–127 range, such as 128–255), you should use HeaderValue::from_bytes instead of from_str [1][2]. Unlike from_str, from_bytes provides a path to construct values with bytes that do not fit the visible ASCII requirement [1][3]. Citations:
Return a failed import item when the bearer token is not a valid
🤖 Prompt for AI Agents |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
/meresponse parsing panics on failure, unlike the rest of this PR..json::<MeResponse>().await.unwrap()panics the entire import job if the/meendpoint returns malformed JSON or an unexpected shape — a much larger blast radius than the per-item failures this PR otherwise carefully categorizes (network vs. status vs. parse errors) inget_item_details. Sinceimport()already returnsResult<ImportResult>, propagate this with?andwith_contextinstead.🛠️ Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents