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.

Album details: disc grouping, track numbers, year, client-side sort

Album drill-in now uses its own renderer instead of the generic list.
Improvements:
- Album title includes the production year in parentheses when
Jellyfin has one (e.g. "17 (2017)").
- Every track row shows its track number, padded to the widest number
in the album so "1." and "12." line up. The subtitle / duration
columns follow the same layout math as the standard list, so they
stay column-aligned with sibling views.
- Multi-disc albums render a "▤ Disc N" header before each disc's
tracks — non-selectable, so Up/Down navigation walks track-index
space and the cursor never lands on a header. Single-disc albums
render as a flat list (no header noise).
- After fetching the tracks, sort_album_tracks() re-orders by
(disc, track number, name) as a safety net. Jellyfin already applies
ParentIndexNumber,IndexNumber,SortName server-side, but a single
mis-tagged track was enough to leak into the disc grouping downstream.

Under the hood the app still tracks a TRACK-index cursor; the album
renderer builds visual rows + a track_to_visual map and drives a local
ListState so the highlight lands on the right visible line even when
disc headers push the visual index above the track index.

Four unit tests cover sort_album_tracks: disc-then-track ordering,
tracks with missing metadata sink to the tail, ties break on name,
and already-sorted input is preserved.

+331 -20
+331 -20
crates/fin-tui/src/app.rs
··· 635 635 ) 636 636 .await 637 637 { 638 - Ok(v) => { 638 + Ok(mut v) => { 639 + // Belt-and-braces: Jellyfin already applies the sort 640 + // above, but some libraries have stale metadata and we 641 + // don't want a single mis-tagged track to break disc 642 + // grouping downstream. 643 + sort_album_tracks(&mut v); 639 644 *status.lock() = 640 645 Some((format!("◈ {} — {} track(s)", name, v.len()), Instant::now())); 641 646 *out.lock() = v; ··· 1350 1355 draw_list(f, area, app, " ♪ Music — Albums ") 1351 1356 } 1352 1357 Screen::Music => { 1353 - let name = app 1354 - .open_album 1355 - .lock() 1356 - .as_ref() 1357 - .map(|a| a.name.clone()) 1358 - .unwrap_or_default(); 1359 - let sub = app 1360 - .open_album 1361 - .lock() 1362 - .as_ref() 1363 - .map(|a| a.subtitle()) 1364 - .unwrap_or_default(); 1365 - let title = format!( 1366 - " ◈ {} {} — {} track(s) (Esc to go back) ", 1367 - name, 1368 - sub, 1369 - app.album_tracks.lock().len() 1370 - ); 1371 - draw_list_with_title(f, area, app, &title); 1358 + let album = app.open_album.lock().clone(); 1359 + draw_album_tracks(f, area, app, album.as_ref()); 1372 1360 } 1373 1361 Screen::Videos if app.open_series.lock().is_none() => { 1374 1362 draw_list(f, area, app, " ▶ Videos — Movies & Series ") ··· 1467 1455 f.render_stateful_widget(list, area, &mut app.list_state); 1468 1456 } 1469 1457 1458 + /// Client-side sort as a safety net over Jellyfin's server-side ordering. 1459 + /// Sorts by (disc, track, name); tracks missing metadata sink below tracks 1460 + /// with known indices instead of scattering through the list. 1461 + fn sort_album_tracks(items: &mut [BaseItem]) { 1462 + items.sort_by(|a, b| { 1463 + let disc_a = a.parent_index_number.unwrap_or(i32::MAX); 1464 + let disc_b = b.parent_index_number.unwrap_or(i32::MAX); 1465 + let track_a = a.index_number.unwrap_or(i32::MAX); 1466 + let track_b = b.index_number.unwrap_or(i32::MAX); 1467 + disc_a 1468 + .cmp(&disc_b) 1469 + .then(track_a.cmp(&track_b)) 1470 + .then_with(|| a.name.cmp(&b.name)) 1471 + }); 1472 + } 1473 + 1474 + /// Album drill-in view. Groups tracks by disc when the album has more than 1475 + /// one, prepends every row with its track number, and includes the album's 1476 + /// production year in the title if present. Headers are rendered as 1477 + /// non-selectable rows — navigation transparently steps over them. 1478 + fn draw_album_tracks(f: &mut Frame<'_>, area: Rect, app: &mut App, album: Option<&BaseItem>) { 1479 + let tracks = app.album_tracks.lock().clone(); 1480 + let name = album.map(|a| a.name.clone()).unwrap_or_default(); 1481 + let sub = album.map(|a| a.subtitle()).unwrap_or_default(); 1482 + let year = album 1483 + .and_then(|a| a.production_year) 1484 + .map(|y| format!(" ({y})")) 1485 + .unwrap_or_default(); 1486 + let title = format!( 1487 + " ◈ {}{} {} — {} track(s) (Esc to go back) ", 1488 + name, 1489 + year, 1490 + sub, 1491 + tracks.len() 1492 + ); 1493 + 1494 + let block = neon_block(&title, true); 1495 + let inner = block.inner(area); 1496 + 1497 + if tracks.is_empty() { 1498 + f.render_widget(block, area); 1499 + f.render_widget( 1500 + Paragraph::new(Line::from(Span::styled( 1501 + " Loading tracks…", 1502 + muted_style(), 1503 + ))) 1504 + .alignment(Alignment::Center), 1505 + inner.inner(Margin::new(2, 1)), 1506 + ); 1507 + return; 1508 + } 1509 + 1510 + // How wide is a track-number column? Widest track number governs 1511 + // padding so single-digit and triple-digit tracks line up. 1512 + let widest_track = tracks 1513 + .iter() 1514 + .filter_map(|t| t.index_number) 1515 + .max() 1516 + .unwrap_or(1) 1517 + .max(1); 1518 + let tn_width = widest_track.to_string().len().max(2); 1519 + 1520 + // Show disc headers only when the album actually has more than one 1521 + // disc — single-disc albums render as a plain list. 1522 + let discs: std::collections::BTreeSet<i32> = tracks 1523 + .iter() 1524 + .map(|t| t.parent_index_number.unwrap_or(1)) 1525 + .collect(); 1526 + let show_disc_headers = discs.len() > 1; 1527 + 1528 + // Row layout matches the standard list widths so track columns line up 1529 + // with the rest of the app. The 3 cols reserved for the highlight 1530 + // symbol (" ▍ ") come off the top before layout compute. 1531 + let row_width = inner.width.saturating_sub(3); 1532 + let layout = RowLayout::compute(row_width); 1533 + 1534 + // Build visual rows + map every TRACK index → visual row index so the 1535 + // list cursor lands on the right visible line. 1536 + let mut items: Vec<ListItem> = Vec::with_capacity(tracks.len() + discs.len()); 1537 + let mut track_to_visual: Vec<usize> = Vec::with_capacity(tracks.len()); 1538 + let mut header_visual_indices: Vec<usize> = Vec::new(); 1539 + let mut current_disc: Option<i32> = None; 1540 + 1541 + for (ti, track) in tracks.iter().enumerate() { 1542 + let disc = track.parent_index_number.unwrap_or(1); 1543 + if show_disc_headers && current_disc != Some(disc) { 1544 + header_visual_indices.push(items.len()); 1545 + items.push(ListItem::new(Line::from(vec![ 1546 + Span::styled( 1547 + format!(" ▤ Disc {}", disc), 1548 + Style::default() 1549 + .fg(Palette::ACCENT) 1550 + .add_modifier(Modifier::BOLD), 1551 + ), 1552 + ]))); 1553 + current_disc = Some(disc); 1554 + } 1555 + track_to_visual.push(items.len()); 1556 + items.push(ListItem::new(album_track_row( 1557 + track, 1558 + Some(ti) == app.list_state.selected(), 1559 + layout, 1560 + tn_width, 1561 + ))); 1562 + } 1563 + 1564 + // Map the app's TRACK-index selection into the visual list. 1565 + let sel_track = app 1566 + .list_state 1567 + .selected() 1568 + .unwrap_or(0) 1569 + .min(tracks.len().saturating_sub(1)); 1570 + let sel_visual = track_to_visual.get(sel_track).copied(); 1571 + 1572 + let mut local_state = ListState::default(); 1573 + local_state.select(sel_visual); 1574 + 1575 + let list = List::new(items) 1576 + .block(block) 1577 + .highlight_style( 1578 + Style::default() 1579 + .bg(Palette::SURFACE) 1580 + .fg(Palette::PRIMARY) 1581 + .add_modifier(Modifier::BOLD), 1582 + ) 1583 + .highlight_symbol(" ▍ "); 1584 + f.render_stateful_widget(list, area, &mut local_state); 1585 + } 1586 + 1587 + /// Build a track row for the album drill-in — same layout as `item_row_line` 1588 + /// but with the track number spliced in ahead of the title. Selection state 1589 + /// still colors the icon + main text. 1590 + fn album_track_row<'a>( 1591 + track: &'a BaseItem, 1592 + selected: bool, 1593 + layout: RowLayout, 1594 + tn_width: usize, 1595 + ) -> Line<'a> { 1596 + use unicode_width::UnicodeWidthStr; 1597 + 1598 + let (icon_fg, main_style) = if selected { 1599 + ( 1600 + Palette::PRIMARY, 1601 + Style::default() 1602 + .fg(Palette::FG) 1603 + .add_modifier(Modifier::BOLD), 1604 + ) 1605 + } else { 1606 + (Palette::ACCENT, Style::default().fg(Palette::FG)) 1607 + }; 1608 + 1609 + let icon_text = format!(" ♪ "); 1610 + let tn_text = match track.index_number { 1611 + Some(n) => format!("{:>tn_width$}. ", n, tn_width = tn_width), 1612 + None => format!("{:>tn_width$} ", "", tn_width = tn_width), 1613 + }; 1614 + // The track number eats into what was the title column, so recompute 1615 + // the title budget so subtitles and times stay aligned with sibling 1616 + // rows. 1617 + let tn_len = UnicodeWidthStr::width(tn_text.as_str()); 1618 + let title_budget = layout.title_col.saturating_sub(tn_len); 1619 + let title = truncate_to_width(&track.name, title_budget); 1620 + let title_padded = pad_right(&title, title_budget); 1621 + let sub = track.subtitle(); 1622 + let sub_text = if layout.sub_col > 0 { 1623 + pad_right(&truncate_to_width(&sub, layout.sub_col), layout.sub_col) 1624 + } else { 1625 + String::new() 1626 + }; 1627 + let time = track 1628 + .duration_secs() 1629 + .map(fmt_dur_local) 1630 + .unwrap_or_default(); 1631 + let time_pad = layout 1632 + .time_col 1633 + .saturating_sub(UnicodeWidthStr::width(time.as_str())); 1634 + let time_text = format!("{}{}", " ".repeat(time_pad), time); 1635 + 1636 + let gap1 = " ".repeat(layout.gap1); 1637 + let gap2 = " ".repeat(layout.gap2); 1638 + 1639 + Line::from(vec![ 1640 + Span::styled( 1641 + icon_text, 1642 + Style::default().fg(icon_fg).add_modifier(Modifier::BOLD), 1643 + ), 1644 + Span::styled(tn_text, muted_style()), 1645 + Span::styled(title_padded, main_style), 1646 + Span::raw(gap1), 1647 + Span::styled(sub_text, Style::default().fg(Palette::MUTED)), 1648 + Span::raw(gap2), 1649 + Span::styled(time_text, Style::default().fg(Palette::SKY)), 1650 + ]) 1651 + } 1652 + 1653 + // --- small local formatting helpers (mirror screens/mod.rs privates so we 1654 + // don't have to make them pub; the album view is the only extra caller) 1655 + 1656 + fn fmt_dur_local(secs: u64) -> String { 1657 + let (h, rem) = (secs / 3600, secs % 3600); 1658 + let (m, s) = (rem / 60, rem % 60); 1659 + if h > 0 { 1660 + format!("{}:{:02}:{:02}", h, m, s) 1661 + } else { 1662 + format!("{}:{:02}", m, s) 1663 + } 1664 + } 1665 + 1666 + fn truncate_to_width(s: &str, max_cols: usize) -> String { 1667 + use unicode_width::UnicodeWidthChar; 1668 + use unicode_width::UnicodeWidthStr; 1669 + 1670 + if UnicodeWidthStr::width(s) <= max_cols { 1671 + return s.to_string(); 1672 + } 1673 + if max_cols <= 1 { 1674 + return "…".into(); 1675 + } 1676 + let target = max_cols - 1; 1677 + let mut acc = String::new(); 1678 + let mut w = 0usize; 1679 + for ch in s.chars() { 1680 + let cw = ch.width().unwrap_or(0); 1681 + if w + cw > target { 1682 + break; 1683 + } 1684 + acc.push(ch); 1685 + w += cw; 1686 + } 1687 + acc.push('…'); 1688 + acc 1689 + } 1690 + 1691 + fn pad_right(s: &str, cols: usize) -> String { 1692 + use unicode_width::UnicodeWidthStr; 1693 + let w = UnicodeWidthStr::width(s); 1694 + if w >= cols { 1695 + s.to_string() 1696 + } else { 1697 + format!("{}{}", s, " ".repeat(cols - w)) 1698 + } 1699 + } 1700 + 1470 1701 fn draw_search(f: &mut Frame<'_>, area: Rect, app: &mut App) { 1471 1702 let chunks = Layout::default() 1472 1703 .direction(Direction::Vertical) ··· 1866 2097 self.config.lock().save().context("saving config") 1867 2098 } 1868 2099 } 2100 + 2101 + #[cfg(test)] 2102 + mod tests { 2103 + use super::*; 2104 + 2105 + fn track(name: &str, disc: Option<i32>, tn: Option<i32>) -> BaseItem { 2106 + BaseItem { 2107 + id: name.into(), 2108 + name: name.into(), 2109 + type_: "Audio".into(), 2110 + album: None, 2111 + album_id: None, 2112 + album_artist: None, 2113 + artists: None, 2114 + series_name: None, 2115 + production_year: None, 2116 + run_time_ticks: None, 2117 + media_type: None, 2118 + container: None, 2119 + index_number: tn, 2120 + parent_index_number: disc, 2121 + image_tags: None, 2122 + is_folder: None, 2123 + overview: None, 2124 + } 2125 + } 2126 + 2127 + fn names(items: &[BaseItem]) -> Vec<String> { 2128 + items.iter().map(|i| i.name.clone()).collect() 2129 + } 2130 + 2131 + #[test] 2132 + fn sort_by_disc_then_track_number() { 2133 + let mut v = vec![ 2134 + track("d2-t1", Some(2), Some(1)), 2135 + track("d1-t2", Some(1), Some(2)), 2136 + track("d1-t1", Some(1), Some(1)), 2137 + track("d2-t2", Some(2), Some(2)), 2138 + ]; 2139 + sort_album_tracks(&mut v); 2140 + assert_eq!( 2141 + names(&v), 2142 + vec!["d1-t1", "d1-t2", "d2-t1", "d2-t2"] 2143 + ); 2144 + } 2145 + 2146 + #[test] 2147 + fn tracks_missing_metadata_sink_to_the_end() { 2148 + let mut v = vec![ 2149 + track("orphan", None, None), 2150 + track("t2", Some(1), Some(2)), 2151 + track("t1", Some(1), Some(1)), 2152 + ]; 2153 + sort_album_tracks(&mut v); 2154 + assert_eq!(names(&v), vec!["t1", "t2", "orphan"]); 2155 + } 2156 + 2157 + #[test] 2158 + fn ties_break_on_name() { 2159 + let mut v = vec![ 2160 + track("Z", Some(1), Some(3)), 2161 + track("A", Some(1), Some(3)), 2162 + track("M", Some(1), Some(3)), 2163 + ]; 2164 + sort_album_tracks(&mut v); 2165 + assert_eq!(names(&v), vec!["A", "M", "Z"]); 2166 + } 2167 + 2168 + #[test] 2169 + fn stable_when_already_sorted() { 2170 + let mut v = vec![ 2171 + track("t1", Some(1), Some(1)), 2172 + track("t2", Some(1), Some(2)), 2173 + track("t3", Some(1), Some(3)), 2174 + ]; 2175 + let before = names(&v); 2176 + sort_album_tracks(&mut v); 2177 + assert_eq!(names(&v), before); 2178 + } 2179 + }