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.

Fix Chromecast playback, add release workflow + install docs

Chromecast:
- Content-Type follows the URL that stream_url() built — new
content_type_for_url() sniffs the extension (m3u8, mp4, mp3, flac,
opus, mkv, webm, …). Fixes the "metadata shows then stops" pattern
where we were labeling .m3u8 URLs as video/mp4 and the receiver
refused to play them.
- Queue advance fixed: get_status() is now called without a media
session id (None). Chromecast invalidates the previous session id
the instant playback ends, so polling with Some(id) returned an
empty list and we never saw the FINISHED signal. Empty entries
after having been Playing is now treated as end-of-track too.
- Idle-with-error is also treated as end-of-track so a single bad
stream can't hang the whole queue.

Streams:
- No more forced mp3/mp4 URLs. BaseItem now carries `container`
(fetched via Fields=Container,MediaSources), and stream_url(Direct)
uses it: FLAC stays .flac, MKV stays .mkv, Opus stays .opus.
Static=true keeps Jellyfin from unnecessarily transcoding.
- Both mpv and Chromecast now default to Direct; Chromecast decodes
H.264/MP3/AAC/FLAC/Opus/Vorbis/WebM/WAV natively so this is faster
and more reliable than the old HLS pipeline. `--hls` still forces
transcoding for edge codecs.

Release / packaging:
- .github/workflows/release.yml — cross-arch matrix for
linux amd64/aarch64 and darwin amd64/aarch64. Builds tarballs
(with checksums), .deb for both Linux arches, and .rpm for x86_64.
Publishes a GitHub release on `v*` tags and pushes .deb/.rpm to
Gemfury when FURY_TOKEN + FURY_ACCOUNT secrets are set.
- dist/debian/{amd64,arm64}/DEBIAN/control and dist/rpm/amd64/fin.spec
declare `mpv` as a runtime dependency so `apt install fin` /
`dnf install fin` pull it in automatically.

Docs:
- README: brew tap, apt/dnf via GitHub release AND via Gemfury repo,
Arch source install, prebuilt tarballs.
- Preview screenshot reference at .github/assets/preview.png.

+537 -62
.github/assets/preview.png

This is a binary file and will not be displayed.

+212
.github/workflows/release.yml
··· 1 + name: Release 2 + 3 + on: 4 + push: 5 + tags: 6 + - "v*" 7 + workflow_dispatch: 8 + inputs: 9 + tag: 10 + description: "Tag to release (e.g. v0.1.0)" 11 + required: true 12 + type: string 13 + 14 + permissions: 15 + contents: write 16 + 17 + jobs: 18 + build: 19 + name: ${{ matrix.label }} 20 + runs-on: ${{ matrix.os }} 21 + strategy: 22 + fail-fast: false 23 + matrix: 24 + include: 25 + - label: macos-amd64 26 + os: macos-13 27 + target: x86_64-apple-darwin 28 + use_cross: false 29 + - label: macos-aarch64 30 + os: macos-14 31 + target: aarch64-apple-darwin 32 + use_cross: false 33 + - label: linux-amd64 34 + os: ubuntu-latest 35 + target: x86_64-unknown-linux-gnu 36 + use_cross: false 37 + - label: linux-aarch64 38 + os: ubuntu-latest 39 + target: aarch64-unknown-linux-gnu 40 + use_cross: true 41 + 42 + steps: 43 + - name: Checkout 44 + uses: actions/checkout@v4 45 + 46 + - name: Install Rust toolchain 47 + uses: dtolnay/rust-toolchain@stable 48 + with: 49 + targets: ${{ matrix.target }} 50 + 51 + - name: Cache cargo 52 + uses: Swatinem/rust-cache@v2 53 + with: 54 + key: ${{ matrix.target }} 55 + 56 + - name: Install cross 57 + if: matrix.use_cross 58 + run: cargo install cross --locked 59 + 60 + - name: Build (cargo) 61 + if: ${{ !matrix.use_cross }} 62 + run: cargo build --release --locked --target ${{ matrix.target }} --bin fin 63 + 64 + - name: Build (cross) 65 + if: matrix.use_cross 66 + run: cross build --release --locked --target ${{ matrix.target }} --bin fin 67 + 68 + - name: Package tarball 69 + shell: bash 70 + run: | 71 + set -euo pipefail 72 + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then 73 + version="${{ inputs.tag }}" 74 + else 75 + version="${GITHUB_REF_NAME}" 76 + fi 77 + name="fin-${version}-${{ matrix.label }}" 78 + src="target/${{ matrix.target }}/release/fin" 79 + mkdir -p dist/staging 80 + cp "$src" dist/staging/fin 81 + cp README.md LICENSE dist/staging/ 82 + tar -C dist/staging -czf "dist/${name}.tar.gz" . 83 + (cd dist && shasum -a 256 "${name}.tar.gz" > "${name}.tar.gz.sha256") 84 + ls -lh dist 85 + 86 + - name: Install dpkg-deb and rpmbuild 87 + if: startsWith(matrix.os, 'ubuntu') 88 + run: | 89 + sudo apt-get update 90 + sudo apt-get install -y dpkg-dev rpm 91 + 92 + - name: Build .deb 93 + if: startsWith(matrix.os, 'ubuntu') 94 + shell: bash 95 + run: | 96 + set -euo pipefail 97 + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then 98 + version="${{ inputs.tag }}" 99 + else 100 + version="${GITHUB_REF_NAME}" 101 + fi 102 + version_no_v="${version#v}" 103 + case "${{ matrix.label }}" in 104 + linux-amd64) deb_arch=amd64 ;; 105 + linux-aarch64) deb_arch=arm64 ;; 106 + esac 107 + pkg_root="dist/debian/${deb_arch}" 108 + mkdir -p "$pkg_root/usr/local/bin" 109 + install -m 0755 "target/${{ matrix.target }}/release/fin" "$pkg_root/usr/local/bin/fin" 110 + sed -i "s/^Version: .*/Version: ${version_no_v}/" "$pkg_root/DEBIAN/control" 111 + dpkg-deb --build --root-owner-group "$pkg_root" "dist/fin_${version_no_v}_${deb_arch}.deb" 112 + 113 + - name: Build .rpm 114 + if: matrix.label == 'linux-amd64' 115 + shell: bash 116 + run: | 117 + set -euo pipefail 118 + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then 119 + version="${{ inputs.tag }}" 120 + else 121 + version="${GITHUB_REF_NAME}" 122 + fi 123 + version_no_v="${version#v}" 124 + rpm_arch=amd64 125 + rpm_target=x86_64 126 + src_root="dist/rpm/${rpm_arch}" 127 + mkdir -p "${src_root}/usr/local/bin" 128 + install -m 0755 "target/${{ matrix.target }}/release/fin" "${src_root}/usr/local/bin/fin" 129 + sed -i "s/^Version: .*/Version: ${version_no_v}/" "${src_root}/fin.spec" 130 + 131 + rpm_top="$(pwd)/dist/rpmbuild" 132 + mkdir -p "${rpm_top}"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS} 133 + cp -r "${src_root}" "${rpm_top}/SOURCES/${rpm_arch}" 134 + rpmbuild --define "_topdir ${rpm_top}" \ 135 + --define "_sourcedir ${rpm_top}/SOURCES" \ 136 + --target "${rpm_target}" \ 137 + -bb "${src_root}/fin.spec" 138 + find "${rpm_top}/RPMS" -name '*.rpm' -exec mv {} dist/ \; 139 + 140 + - name: Checksum packages 141 + if: startsWith(matrix.os, 'ubuntu') 142 + shell: bash 143 + run: | 144 + set -euo pipefail 145 + cd dist 146 + for f in *.deb *.rpm; do 147 + [ -e "$f" ] || continue 148 + shasum -a 256 "$f" > "$f.sha256" 149 + done 150 + ls -lh 151 + 152 + - name: Upload artifact 153 + uses: actions/upload-artifact@v4 154 + with: 155 + name: ${{ matrix.label }} 156 + path: | 157 + dist/*.tar.gz* 158 + dist/*.deb* 159 + dist/*.rpm* 160 + if-no-files-found: error 161 + 162 + release: 163 + name: Publish GitHub release 164 + needs: build 165 + runs-on: ubuntu-latest 166 + steps: 167 + - name: Download all artifacts 168 + uses: actions/download-artifact@v4 169 + with: 170 + path: dist 171 + merge-multiple: true 172 + 173 + - name: Show artifacts 174 + run: ls -lh dist 175 + 176 + - name: Create release 177 + uses: softprops/action-gh-release@v2 178 + with: 179 + tag_name: ${{ github.event.inputs.tag || github.ref_name }} 180 + files: dist/* 181 + generate_release_notes: true 182 + draft: false 183 + prerelease: false 184 + 185 + - name: Check Gemfury secrets 186 + id: gemfury 187 + env: 188 + FURY_TOKEN: ${{ secrets.FURY_TOKEN }} 189 + FURY_ACCOUNT: ${{ secrets.FURY_ACCOUNT }} 190 + run: | 191 + if [ -n "$FURY_TOKEN" ] && [ -n "$FURY_ACCOUNT" ]; then 192 + echo "available=true" >> "$GITHUB_OUTPUT" 193 + else 194 + echo "available=false" >> "$GITHUB_OUTPUT" 195 + echo "Gemfury secrets not set; skipping push" 196 + fi 197 + 198 + - name: Push .deb and .rpm to Gemfury 199 + if: steps.gemfury.outputs.available == 'true' 200 + env: 201 + FURY_TOKEN: ${{ secrets.FURY_TOKEN }} 202 + FURY_ACCOUNT: ${{ secrets.FURY_ACCOUNT }} 203 + shell: bash 204 + run: | 205 + set -euo pipefail 206 + shopt -s nullglob 207 + for pkg in dist/*.deb dist/*.rpm; do 208 + echo "Pushing $pkg to Gemfury (${FURY_ACCOUNT})" 209 + curl -fsS -F package=@"${pkg}" \ 210 + "https://${FURY_TOKEN}@push.fury.io/${FURY_ACCOUNT}/" 211 + echo 212 + done
+79 -5
README.md
··· 2 2 3 3 > a neon-electric Jellyfin client for the terminal — powered by `mpv` and Chromecast 4 4 5 + ![fin — neon-electric Jellyfin TUI](.github/assets/preview.png) 6 + 5 7 `fin` is a Rust TUI + one-shot CLI that talks to your Jellyfin server, searches 6 8 your library, manages playlists, and pushes streams to either your local **mpv** 7 9 window or any **Chromecast** on your network. Chromecast playback is fully ··· 34 36 35 37 ## Install 36 38 37 - `fin` requires **`mpv`** on your `$PATH` (it's used as the local renderer and 38 - as a required preflight check even when casting). 39 + `fin` needs **`mpv`** on your `$PATH` at runtime. Every install path below 40 + either bundles it or pulls it in as a dependency. 41 + 42 + ### macOS / Linux — Homebrew 43 + 44 + ```bash 45 + brew install tsirysndr/tap/fin 46 + ``` 47 + 48 + The formula pulls in `mpv` automatically. 49 + 50 + ### Debian / Ubuntu — `.deb` 51 + 52 + Download the `.deb` for your architecture from the 53 + [latest release](https://github.com/tsirysndr/fin/releases/latest) and: 54 + 55 + ```bash 56 + # amd64 57 + curl -LO https://github.com/tsirysndr/fin/releases/latest/download/fin_0.1.0_amd64.deb 58 + sudo apt install ./fin_0.1.0_amd64.deb 59 + 60 + # arm64 (Raspberry Pi 4/5, Apple-silicon VM, …) 61 + curl -LO https://github.com/tsirysndr/fin/releases/latest/download/fin_0.1.0_arm64.deb 62 + sudo apt install ./fin_0.1.0_arm64.deb 63 + ``` 64 + 65 + `apt` will pull in `mpv` automatically as a dependency. 66 + 67 + Or add the Gemfury apt repo once and `apt install` normally: 68 + 69 + ```bash 70 + echo "deb [trusted=yes] https://apt.fury.io/tsirysndr/ /" \ 71 + | sudo tee /etc/apt/sources.list.d/tsirysndr.list 72 + sudo apt update && sudo apt install fin 73 + ``` 74 + 75 + ### Fedora / RHEL / openSUSE — `.rpm` 76 + 77 + ```bash 78 + sudo dnf install \ 79 + https://github.com/tsirysndr/fin/releases/latest/download/fin-0.1.0-1.x86_64.rpm 80 + ``` 81 + 82 + Or via the Gemfury yum repo: 83 + 84 + ```bash 85 + sudo tee /etc/yum.repos.d/tsirysndr.repo <<'EOF' 86 + [tsirysndr] 87 + name=tsirysndr 88 + baseurl=https://yum.fury.io/tsirysndr/ 89 + enabled=1 90 + gpgcheck=0 91 + EOF 92 + sudo dnf install fin 93 + ``` 94 + 95 + ### Arch — from AUR / source 96 + 97 + `mpv` from the official repos, then: 98 + 99 + ```bash 100 + sudo pacman -S mpv 101 + cargo install --git https://github.com/tsirysndr/fin --bin fin 102 + ``` 103 + 104 + ### Prebuilt tarballs 105 + 106 + For any other platform, grab the tarball for your arch from the 107 + [releases page](https://github.com/tsirysndr/fin/releases/latest): 108 + 109 + - `fin-<version>-linux-amd64.tar.gz` 110 + - `fin-<version>-linux-aarch64.tar.gz` 111 + - `fin-<version>-macos-amd64.tar.gz` 112 + - `fin-<version>-macos-aarch64.tar.gz` 113 + 114 + Each includes the `fin` binary + README + LICENSE. Install `mpv` yourself: 39 115 40 116 ```bash 41 117 # macOS 42 118 brew install mpv 43 - 44 119 # Debian / Ubuntu 45 120 sudo apt install mpv 46 - 47 121 # Arch 48 122 sudo pacman -S mpv 49 123 ``` 50 124 51 - Then build fin from source: 125 + ### From source 52 126 53 127 ```bash 54 128 git clone https://github.com/tsirysndr/fin
+90 -16
crates/fin-jellyfin/src/client.rs
··· 8 8 9 9 use crate::models::{AuthResult, BaseItem, SearchHint, SearchResult, UserViewsResult}; 10 10 11 + /// Pick the URL extension for a direct stream. 12 + /// 13 + /// Preference: whatever Jellyfin reported in `Container`. When that's absent 14 + /// (e.g. items came from `/Search/Hints`, which doesn't include container 15 + /// info), fall back to a receiver-friendly default. We deliberately do NOT 16 + /// hardcode `mp3` / `mp4` — Jellyfin honors any container extension it 17 + /// supports (`flac`, `ogg`, `opus`, `webm`, `mkv`, …). 18 + fn source_container(item: &BaseItem, is_audio: bool) -> String { 19 + if let Some(c) = item.container.as_deref() { 20 + // Jellyfin sometimes returns a comma-separated list ("mp3,mpeg"), 21 + // pick the first entry. 22 + if let Some(first) = c.split(',').next() { 23 + let first = first.trim(); 24 + if !first.is_empty() { 25 + return first.to_ascii_lowercase(); 26 + } 27 + } 28 + } 29 + if is_audio { 30 + "mp3".into() 31 + } else { 32 + "mp4".into() 33 + } 34 + } 35 + 11 36 /// Normalize every JSON object key to PascalCase in place. 12 37 /// 13 38 /// Different Jellyfin versions ship different casings — 10.8 sends PascalCase ··· 232 257 let mut q: Vec<(String, String)> = vec![ 233 258 ( 234 259 "Fields".into(), 235 - "PrimaryImageAspectRatio,ProductionYear,Overview".into(), 260 + "PrimaryImageAspectRatio,ProductionYear,Overview,Container,MediaSources".into(), 236 261 ), 237 262 ("Recursive".into(), recursive.to_string()), 238 263 ]; ··· 280 305 let user_id = self.user_id.as_ref().context("not authenticated")?; 281 306 let mut q: Vec<(String, String)> = vec![ 282 307 ("Limit".into(), limit.to_string()), 283 - ("Fields".into(), "ProductionYear".into()), 308 + ("Fields".into(), "ProductionYear,Container".into()), 284 309 ]; 285 310 if let Some(p) = parent_id { 286 311 q.push(("ParentId".into(), p.into())); ··· 358 383 ("limit".into(), limit.to_string()), 359 384 ( 360 385 "fields".into(), 361 - "PrimaryImageAspectRatio,ProductionYear,Overview".into(), 386 + "PrimaryImageAspectRatio,ProductionYear,Overview,Container,MediaSources".into(), 362 387 ), 363 388 ]; 364 389 if !include_types.is_empty() { ··· 458 483 Ok(()) 459 484 } 460 485 461 - /// Build a direct-playback stream URL. `format` chooses between original stream and HLS. 486 + /// Build a stream URL for `item`. 487 + /// 488 + /// - `Direct` — uses `item.container` (`.mp3` / `.flac` / `.mp4` / `.mkv` 489 + /// / …) so the URL matches the source and no unnecessary transcoding 490 + /// is triggered. Falls back to a sensible generic when Jellyfin 491 + /// didn't send a container. 492 + /// - `Hls` — `main.m3u8` served by Jellyfin's HLS transcoder. 462 493 pub fn stream_url(&self, item: &BaseItem, format: StreamFormat) -> Result<String> { 463 494 let token = self.access_token.as_deref().context("no access token")?; 464 495 let kind = item.kind(); 465 496 let is_audio = kind.is_audio() || item.media_type.as_deref() == Some("Audio"); 466 - let container = if is_audio { "mp3" } else { "mp4" }; 467 - let path = match (format, is_audio) { 468 - (StreamFormat::Hls, true) => format!("/Audio/{}/main.m3u8", item.id), 469 - (StreamFormat::Hls, false) => format!("/Videos/{}/main.m3u8", item.id), 470 - (StreamFormat::Direct, true) => { 471 - format!("/Audio/{}/stream.{}", item.id, container) 472 - } 473 - (StreamFormat::Direct, false) => { 474 - format!("/Videos/{}/stream.{}", item.id, container) 497 + 498 + let path = match format { 499 + StreamFormat::Hls if is_audio => format!("/Audio/{}/main.m3u8", item.id), 500 + StreamFormat::Hls => format!("/Videos/{}/main.m3u8", item.id), 501 + StreamFormat::Direct => { 502 + let container = source_container(item, is_audio); 503 + if is_audio { 504 + format!("/Audio/{}/stream.{}", item.id, container) 505 + } else { 506 + format!("/Videos/{}/stream.{}", item.id, container) 507 + } 475 508 } 476 509 }; 510 + 477 511 let mut params: Vec<(&str, String)> = vec![ 478 512 ("api_key", token.to_string()), 479 513 ("DeviceId", self.device_id.clone()), 480 - ("Static", "true".into()), 514 + ("PlaySessionId", Uuid::new_v4().to_string()), 481 515 ]; 482 - if matches!(format, StreamFormat::Hls) { 483 - params.push(("PlaySessionId", Uuid::new_v4().to_string())); 516 + // `Static=true` skips server-side transcoding — safe because the 517 + // URL extension already matches the source container. 518 + if matches!(format, StreamFormat::Direct) { 519 + params.push(("Static", "true".into())); 520 + params.push(("MediaSourceId", item.id.clone())); 484 521 } 522 + 485 523 let qs = params 486 524 .into_iter() 487 525 .map(|(k, v)| { ··· 494 532 .collect::<Vec<_>>() 495 533 .join("&"); 496 534 Ok(format!("{}{}?{}", self.base_url, path, qs)) 535 + } 536 + 537 + /// Determine the MIME type from the URL a stream URL points at. 538 + /// Single source of truth: whatever `stream_url()` produced, 539 + /// `content_type_for_url()` labels it correctly for the receiver. 540 + pub fn content_type_for_url(url: &str) -> &'static str { 541 + let path = url.split('?').next().unwrap_or(url); 542 + let ext = path 543 + .rsplit('.') 544 + .next() 545 + .unwrap_or("") 546 + .to_ascii_lowercase(); 547 + match ext.as_str() { 548 + "m3u8" => "application/vnd.apple.mpegurl", 549 + "mpd" => "application/dash+xml", 550 + "mp4" | "m4v" => "video/mp4", 551 + "mkv" => "video/x-matroska", 552 + "webm" => "video/webm", 553 + "mov" => "video/quicktime", 554 + "ts" => "video/mp2t", 555 + "avi" => "video/x-msvideo", 556 + "mp3" => "audio/mpeg", 557 + "m4a" | "aac" => "audio/aac", 558 + "ogg" | "oga" => "audio/ogg", 559 + "opus" => "audio/opus", 560 + "flac" => "audio/flac", 561 + "wav" => "audio/wav", 562 + "wma" => "audio/x-ms-wma", 563 + _ => { 564 + if url.contains("/Videos/") { 565 + "video/mp4" 566 + } else { 567 + "audio/mpeg" 568 + } 569 + } 570 + } 497 571 } 498 572 499 573 /// Primary image URL for an item, suitable for tile previews.
+6
crates/fin-jellyfin/src/models.rs
··· 105 105 pub run_time_ticks: Option<i64>, 106 106 #[serde(default)] 107 107 pub media_type: Option<String>, 108 + /// Source container reported by Jellyfin — e.g. `"mp3"`, `"flac"`, 109 + /// `"mkv"`, `"mp4"`. Used to build stream URLs that match the source 110 + /// so no unnecessary transcoding happens. 111 + #[serde(default)] 112 + pub container: Option<String>, 108 113 #[serde(default)] 109 114 pub index_number: Option<i32>, 110 115 #[serde(default)] ··· 233 238 production_year: self.production_year, 234 239 run_time_ticks: self.run_time_ticks, 235 240 media_type: self.media_type, 241 + container: None, 236 242 index_number: None, 237 243 parent_index_number: None, 238 244 image_tags: None,
+68 -23
crates/fin-player/src/cast.rs
··· 7 7 use async_trait::async_trait; 8 8 use parking_lot::Mutex; 9 9 use tokio::sync::oneshot; 10 - use tracing::error; 10 + use tracing::{debug, error, warn}; 11 11 12 12 use rust_cast::channels::media::{ 13 13 IdleReason, Image, Media, Metadata, MovieMediaMetadata, MusicTrackMediaMetadata, PlayerState, ··· 214 214 let mut last_heartbeat = Instant::now(); 215 215 let mut last_status = Instant::now(); 216 216 217 - // We run the worker single-threaded — rust_cast's channels aren't 218 - // thread-safe. Between commands we poll media status for progress 219 - // updates and auto-advance on FINISHED. 217 + // Auto-advance state — tracks whether the receiver was in the middle of 218 + // playing our media *last time we polled*. This is the signal we use to 219 + // detect end-of-track: when the receiver's active media disappears (or 220 + // reports IDLE/FINISHED) after having been Playing/Paused/Buffering, we 221 + // treat that as end-of-track and load the next queue item. 222 + // 223 + // The previous implementation polled with `Some(media_session_id)`. 224 + // Chromecast invalidates that session id the instant playback ends, so 225 + // subsequent calls returned an empty `entries` list and we never saw 226 + // the FINISHED signal — playback silently stopped after one track. 227 + let mut was_active_media = false; 228 + 220 229 loop { 221 230 match rx.recv_timeout(Duration::from_millis(300)) { 222 231 Ok(CastCommand::Shutdown) => { ··· 234 243 last_heartbeat = Instant::now(); 235 244 } 236 245 237 - if last_status.elapsed() > Duration::from_millis(750) { 246 + if last_status.elapsed() > Duration::from_millis(500) { 238 247 last_status = Instant::now(); 239 - let (dest, media_id) = { 240 - let sess = session.lock(); 241 - (sess.app_transport.clone(), sess.media_session_id) 242 - }; 243 - if let Some(id) = media_id { 244 - if let Ok(status) = rc.media.get_status(dest.as_str(), Some(id)) { 245 - let mut advanced = false; 248 + let dest = session.lock().app_transport.clone(); 249 + 250 + // Query WITHOUT a session id — returns all active media on the 251 + // receiver. Empty entries means "nothing playing" which is the 252 + // end-of-track signal we care about. 253 + match rc.media.get_status(dest.as_str(), None) { 254 + Ok(status) => { 255 + let mut ended = false; 246 256 if let Some(entry) = status.entries.first() { 257 + // Keep our tracked session id in sync — Chromecast 258 + // hands out a new one for each `load()`. 259 + session.lock().media_session_id = Some(entry.media_session_id); 260 + 247 261 let mut s = state.lock(); 248 262 s.position_secs = entry.current_time.unwrap_or(0.0) as f64; 249 263 if let Some(m) = &entry.media { ··· 252 266 } 253 267 } 254 268 match entry.player_state { 255 - PlayerState::Playing => s.status = PlaybackStatus::Playing, 256 - PlayerState::Paused => s.status = PlaybackStatus::Paused, 257 - PlayerState::Buffering => s.status = PlaybackStatus::Buffering, 258 - PlayerState::Idle => { 259 - if matches!(entry.idle_reason, Some(IdleReason::Finished)) { 260 - advanced = true; 261 - } else { 269 + PlayerState::Playing => { 270 + s.status = PlaybackStatus::Playing; 271 + was_active_media = true; 272 + } 273 + PlayerState::Paused => { 274 + s.status = PlaybackStatus::Paused; 275 + was_active_media = true; 276 + } 277 + PlayerState::Buffering => { 278 + s.status = PlaybackStatus::Buffering; 279 + was_active_media = true; 280 + } 281 + PlayerState::Idle => match entry.idle_reason { 282 + Some(IdleReason::Finished) => { 283 + ended = true; 284 + } 285 + Some(IdleReason::Error) => { 286 + // Skip on error too so a single bad 287 + // stream doesn't hang the whole queue. 288 + ended = true; 289 + } 290 + _ => { 262 291 s.status = PlaybackStatus::Idle; 263 292 } 264 - } 293 + }, 265 294 } 295 + } else if was_active_media { 296 + // No active media *right now*, but we had media 297 + // playing the last time we looked → the receiver 298 + // finished the track and dropped the session. 299 + ended = true; 266 300 } 267 - if advanced { 301 + 302 + if ended { 303 + was_active_media = false; 268 304 if queue.advance().is_some() { 269 305 if let Some(next) = queue.current() { 270 - let _ = load_media(&rc, &session, &next, &state); 306 + if let Err(e) = load_media(&rc, &session, &next, &state) { 307 + warn!(?e, "chromecast: failed to load next queue item"); 308 + state.lock().status = PlaybackStatus::Idle; 309 + } 271 310 } 272 311 } else { 273 - state.lock().status = PlaybackStatus::Idle; 312 + let mut s = state.lock(); 313 + s.status = PlaybackStatus::Idle; 314 + s.now_playing = None; 315 + s.current_index = None; 274 316 } 275 317 } 318 + } 319 + Err(e) => { 320 + debug!(?e, "chromecast: get_status failed"); 276 321 } 277 322 } 278 323 }
+10 -9
crates/fin-tui/src/app.rs
··· 154 154 production_year: None, 155 155 run_time_ticks: q.duration_secs.map(|s| (s * 10_000_000) as i64), 156 156 media_type: None, 157 + container: None, 157 158 index_number: None, 158 159 parent_index_number: None, 159 160 image_tags: None, ··· 303 304 fn base_item_to_queue(&self, item: &BaseItem, format: StreamFormat) -> Result<QueueItem> { 304 305 let url = self.jf().stream_url(item, format)?; 305 306 let is_video = item.kind().is_video(); 306 - let content_type = if is_video { 307 - "video/mp4".to_string() 308 - } else { 309 - "audio/mpeg".to_string() 310 - }; 307 + // Content type derives from the URL that stream_url() actually built, 308 + // so it always matches what the receiver will be handed. 309 + let content_type = JellyfinClient::content_type_for_url(&url).to_string(); 311 310 let image_url = item 312 311 .image_tags 313 312 .as_ref() ··· 398 397 self.set_status("Nothing playable here."); 399 398 return; 400 399 } 401 - let format = match *self.renderer_kind.lock() { 402 - RendererKind::Mpv => StreamFormat::Direct, 403 - RendererKind::Chromecast => StreamFormat::Hls, 404 - }; 400 + // Both renderers get a Direct URL — Jellyfin serves the source 401 + // container as-is so no unnecessary transcoding happens. Chromecast 402 + // handles MP3/AAC/FLAC/Opus audio and H.264 MP4 video natively; 403 + // for anything else you can force HLS transcoding with `--hls` 404 + // from the CLI (or by switching the renderer's format). 405 + let format = StreamFormat::Direct; 405 406 let mut queue_items = Vec::with_capacity(items.len()); 406 407 for it in &items { 407 408 match self.base_item_to_queue(it, format) {
+9 -9
crates/fin/src/main.rs
··· 341 341 342 342 async fn cmd_play(cfg: &Config, args: PlayArgs, queue: bool) -> Result<()> { 343 343 let client = make_client(cfg)?; 344 - let format = if args.hls || cfg.renderer == RendererPref::Chromecast { 344 + // Direct stream by default — Jellyfin serves the source container as-is, 345 + // so no unnecessary transcoding happens. `--hls` forces the m3u8 path. 346 + let format = if args.hls { 345 347 StreamFormat::Hls 346 348 } else { 347 349 StreamFormat::Direct ··· 362 364 Some(&hit.id), 363 365 &["Audio"], 364 366 false, 365 - Some("SortName"), 366 - Some(500), 367 + Some("ParentIndexNumber,IndexNumber,SortName"), 368 + None, 367 369 ) 368 370 .await? 369 371 } ··· 377 379 id: it.id.clone(), 378 380 title: it.name.clone(), 379 381 subtitle: it.subtitle(), 380 - stream_url: url, 382 + stream_url: url.clone(), 381 383 image_url: None, 382 384 duration_secs: it.duration_secs(), 383 385 is_video, 384 - content_type: if is_video { 385 - "video/mp4".into() 386 - } else { 387 - "audio/mpeg".into() 388 - }, 386 + // Content-Type follows the URL we just built — the single source 387 + // of truth so the receiver knows exactly what it's getting. 388 + content_type: JellyfinClient::content_type_for_url(&url).to_string(), 389 389 }); 390 390 } 391 391 let (renderer, label) = build_renderer(cfg).await?;
+14
dist/debian/amd64/DEBIAN/control
··· 1 + Package: fin 2 + Version: 0.1.0 3 + Section: sound 4 + Priority: optional 5 + Architecture: amd64 6 + Maintainer: Tsiry Sandratraina <tsiry.sndr@rocksky.app> 7 + Homepage: https://github.com/tsirysndr/fin 8 + Depends: libc6, mpv 9 + Description: A neon-electric Jellyfin TUI client for mpv & Chromecast 10 + fin is a Rust TUI + one-shot CLI that talks to your Jellyfin server, 11 + searches your library, manages playlists, and pushes streams to either 12 + your local mpv window or any Chromecast on your network. Chromecast 13 + playback is fully queued — enqueue, play-next, skip, resume, all from 14 + the terminal.
+14
dist/debian/arm64/DEBIAN/control
··· 1 + Package: fin 2 + Version: 0.1.0 3 + Section: sound 4 + Priority: optional 5 + Architecture: arm64 6 + Maintainer: Tsiry Sandratraina <tsiry.sndr@rocksky.app> 7 + Homepage: https://github.com/tsirysndr/fin 8 + Depends: libc6, mpv 9 + Description: A neon-electric Jellyfin TUI client for mpv & Chromecast 10 + fin is a Rust TUI + one-shot CLI that talks to your Jellyfin server, 11 + searches your library, manages playlists, and pushes streams to either 12 + your local mpv window or any Chromecast on your network. Chromecast 13 + playback is fully queued — enqueue, play-next, skip, resume, all from 14 + the terminal.
+35
dist/rpm/amd64/fin.spec
··· 1 + Name: fin 2 + Version: 0.1.0 3 + Release: 1%{?dist} 4 + Summary: A neon-electric Jellyfin TUI client for mpv & Chromecast 5 + 6 + License: MPL-2.0 7 + URL: https://github.com/tsirysndr/fin 8 + 9 + BuildArch: x86_64 10 + 11 + Requires: glibc, mpv 12 + 13 + %description 14 + fin is a Rust TUI + one-shot CLI that talks to your Jellyfin server, searches 15 + your library, manages playlists, and pushes streams to either your local mpv 16 + window or any Chromecast on your network. Chromecast playback is fully queued 17 + — enqueue, play-next, skip, resume, all from the terminal. 18 + 19 + %prep 20 + # Nothing to prep — the binary is prebuilt. 21 + 22 + %build 23 + # Nothing to build — the binary is prebuilt. 24 + 25 + %install 26 + mkdir -p %{buildroot}/usr/local/bin 27 + cp -r %{_sourcedir}/amd64/usr %{buildroot}/ 28 + 29 + %files 30 + /usr/local/bin/fin 31 + 32 + %post 33 + if [ "$1" -eq 1 ]; then 34 + echo "fin: installed. Sign in with: fin login <your-jellyfin-url>" 35 + fi