a Jellyfin & Subsonic client for the terminal — powered by mpv, Chromecast and UPnP MediaRenderer
mpv chromecast mpris navidrome jellyfin upnp tui
0

Configure Feed

Select the types of activity you want to include in your feed.

Paginate the Items endpoint so lists actually load everything

Passing Limit=<omitted> to /Users/{id}/Items still let Jellyfin apply its
own silent cap on some deployments, so Music / Videos / Playlists / album
tracks / series episodes were being truncated at whatever that cap was.

Now items() paginates internally with Limit=500 + StartIndex, driven by
the server-reported TotalRecordCount. It keeps calling until either:
* a partial page comes back (we've hit the tail), or
* the collected count equals TotalRecordCount.

Same treatment for /Playlists/{id}/Items so long playlists load fully.

Also bump the http timeout to 120s so a very large single page can't
time out mid-response.

+112 -16
+112 -16
crates/fin-jellyfin/src/client.rs
··· 92 92 serde_json::from_value::<Vec<SearchHint>>(hints.clone()).unwrap_or_default() 93 93 } 94 94 95 + /// Pull `TotalRecordCount` out of a `/Users/{id}/Items` response (or 0 if 96 + /// the field isn't there). Used by paginated `items()` to know when to stop. 97 + fn parse_total_record_count(body: &str) -> i64 { 98 + let Some(v) = parse_json_lenient(body) else { 99 + return 0; 100 + }; 101 + v.get("TotalRecordCount") 102 + .and_then(|n| n.as_i64()) 103 + .unwrap_or(0) 104 + } 105 + 95 106 /// Same forgiving parse for `/Users/{id}/Items` responses. 96 107 fn parse_items_body(body: &str) -> Vec<BaseItem> { 97 108 let Some(v) = parse_json_lenient(body) else { ··· 140 151 let http = reqwest::Client::builder() 141 152 .user_agent(format!("{}/{}", CLIENT_NAME, CLIENT_VERSION)) 142 153 .connect_timeout(std::time::Duration::from_secs(5)) 143 - .timeout(std::time::Duration::from_secs(15)) 154 + // A single request can be a full library listing on a big 155 + // server — give it plenty of headroom. Pagination keeps any 156 + // individual page well under this. 157 + .timeout(std::time::Duration::from_secs(120)) 144 158 .build()?; 145 159 let device_name = whoami::fallible::hostname().unwrap_or_else(|_| "fin-cli".to_string()); 146 160 Ok(Self { ··· 245 259 } 246 260 247 261 /// Fetch children of a parent (library, folder, album, playlist, …). 262 + /// 263 + /// When `limit` is `None` the endpoint is paginated internally with 264 + /// `StartIndex` + `Limit=500`, driven by the server-reported 265 + /// `TotalRecordCount`. This is the difference between "we asked without 266 + /// a limit and Jellyfin capped us at its silent default" and "we got 267 + /// everything". Passing `Some(l)` requests exactly one page of that size. 248 268 pub async fn items( 249 269 &self, 250 270 parent_id: Option<&str>, ··· 253 273 sort_by: Option<&str>, 254 274 limit: Option<u32>, 255 275 ) -> Result<Vec<BaseItem>> { 276 + // Single-page mode — caller wants exactly N results. 277 + if let Some(l) = limit { 278 + let (items, _) = self 279 + .items_page(parent_id, include_types, recursive, sort_by, l, 0) 280 + .await?; 281 + return Ok(items); 282 + } 283 + 284 + // Paginated mode — page through until we've collected every item. 285 + const PAGE: u32 = 500; 286 + let mut all: Vec<BaseItem> = Vec::new(); 287 + let mut start: u32 = 0; 288 + loop { 289 + let (items, total) = self 290 + .items_page(parent_id, include_types, recursive, sort_by, PAGE, start) 291 + .await?; 292 + let got = items.len() as u32; 293 + all.extend(items); 294 + // Stop when the server ran out of matches … 295 + if got == 0 || got < PAGE { 296 + break; 297 + } 298 + // … or when we've collected everything the server said existed. 299 + if total > 0 && (all.len() as i64) >= total { 300 + break; 301 + } 302 + start += got; 303 + // Belt-and-braces guard against a runaway server response. 304 + if all.len() > 1_000_000 { 305 + break; 306 + } 307 + } 308 + Ok(all) 309 + } 310 + 311 + /// One page of `/Users/{id}/Items`. Returns `(items, total_record_count)` 312 + /// so the caller can drive pagination. 313 + async fn items_page( 314 + &self, 315 + parent_id: Option<&str>, 316 + include_types: &[&str], 317 + recursive: bool, 318 + sort_by: Option<&str>, 319 + limit: u32, 320 + start_index: u32, 321 + ) -> Result<(Vec<BaseItem>, i64)> { 256 322 let user_id = self.user_id.as_ref().context("not authenticated")?; 257 323 let mut q: Vec<(String, String)> = vec![ 258 324 ( ··· 260 326 "PrimaryImageAspectRatio,ProductionYear,Overview,Container,MediaSources".into(), 261 327 ), 262 328 ("Recursive".into(), recursive.to_string()), 329 + ("Limit".into(), limit.to_string()), 330 + ("StartIndex".into(), start_index.to_string()), 263 331 ]; 264 332 if let Some(p) = parent_id { 265 333 q.push(("ParentId".into(), p.into())); ··· 271 339 q.push(("SortBy".into(), s.into())); 272 340 q.push(("SortOrder".into(), "Ascending".into())); 273 341 } 274 - if let Some(l) = limit { 275 - q.push(("Limit".into(), l.to_string())); 276 - } 277 342 let url = self.url(&format!("/Users/{}/Items", user_id)); 278 - let resp = self 343 + debug!(?url, start_index, limit, "items page"); 344 + let body = self 279 345 .http 280 346 .get(&url) 281 347 .headers(self.headers()?) 282 348 .query(&q) 283 349 .send() 350 + .await? 351 + .error_for_status()? 352 + .text() 284 353 .await?; 285 - let res: SearchResult = resp.error_for_status()?.json().await?; 286 - Ok(res.items) 354 + let items = parse_items_body(&body); 355 + let total = parse_total_record_count(&body); 356 + Ok((items, total)) 287 357 } 288 358 289 359 /// Recent / resume items on the home screen. ··· 410 480 .await 411 481 } 412 482 483 + /// All items in a playlist, paginated by `StartIndex` + `Limit=500`. 413 484 pub async fn playlist_items(&self, playlist_id: &str) -> Result<Vec<BaseItem>> { 414 485 let user_id = self.user_id.as_ref().context("not authenticated")?; 415 486 let url = self.url(&format!("/Playlists/{}/Items", playlist_id)); 416 - let resp = self 417 - .http 418 - .get(&url) 419 - .headers(self.headers()?) 420 - .query(&[("UserId", user_id.as_str())]) 421 - .send() 422 - .await?; 423 - let res: SearchResult = resp.error_for_status()?.json().await?; 424 - Ok(res.items) 487 + const PAGE: u32 = 500; 488 + let mut all: Vec<BaseItem> = Vec::new(); 489 + let mut start: u32 = 0; 490 + loop { 491 + let body = self 492 + .http 493 + .get(&url) 494 + .headers(self.headers()?) 495 + .query(&[ 496 + ("UserId", user_id.as_str()), 497 + ("Limit", &PAGE.to_string()), 498 + ("StartIndex", &start.to_string()), 499 + ]) 500 + .send() 501 + .await? 502 + .error_for_status()? 503 + .text() 504 + .await?; 505 + let page = parse_items_body(&body); 506 + let got = page.len() as u32; 507 + let total = parse_total_record_count(&body); 508 + all.extend(page); 509 + if got == 0 || got < PAGE { 510 + break; 511 + } 512 + if total > 0 && (all.len() as i64) >= total { 513 + break; 514 + } 515 + start += got; 516 + if all.len() > 1_000_000 { 517 + break; 518 + } 519 + } 520 + Ok(all) 425 521 } 426 522 427 523 pub async fn create_playlist(