···11+Mozilla Public License Version 2.0
22+==================================
33+44+1. Definitions
55+--------------
66+77+1.1. "Contributor"
88+ means each individual or legal entity that creates, contributes to
99+ the creation of, or owns Covered Software.
1010+1111+1.2. "Contributor Version"
1212+ means the combination of the Contributions of others (if any) used
1313+ by a Contributor and that particular Contributor's Contribution.
1414+1515+1.3. "Contribution"
1616+ means Covered Software of a particular Contributor.
1717+1818+1.4. "Covered Software"
1919+ means Source Code Form to which the initial Contributor has attached
2020+ the notice in Exhibit A, the Executable Form of such Source Code
2121+ Form, and Modifications of such Source Code Form, in each case
2222+ including portions thereof.
2323+2424+1.5. "Incompatible With Secondary Licenses"
2525+ means
2626+2727+ (a) that the initial Contributor has attached the notice described
2828+ in Exhibit B to the Covered Software; or
2929+3030+ (b) that the Covered Software was made available under the terms of
3131+ version 1.1 or earlier of the License, but not also under the
3232+ terms of a Secondary License.
3333+3434+1.6. "Executable Form"
3535+ means any form of the work other than Source Code Form.
3636+3737+1.7. "Larger Work"
3838+ means a work that combines Covered Software with other material, in
3939+ a separate file or files, that is not Covered Software.
4040+4141+1.8. "License"
4242+ means this document.
4343+4444+1.9. "Licensable"
4545+ means having the right to grant, to the maximum extent possible,
4646+ whether at the time of the initial grant or subsequently, any and
4747+ all of the rights conveyed by this License.
4848+4949+1.10. "Modifications"
5050+ means any of the following:
5151+5252+ (a) any file in Source Code Form that results from an addition to,
5353+ deletion from, or modification of the contents of Covered
5454+ Software; or
5555+5656+ (b) any new file in Source Code Form that contains any Covered
5757+ Software.
5858+5959+1.11. "Patent Claims" of a Contributor
6060+ means any patent claim(s), including without limitation, method,
6161+ process, and apparatus claims, in any patent Licensable by such
6262+ Contributor that would be infringed, but for the grant of the
6363+ License, by the making, using, selling, offering for sale, having
6464+ made, import, or transfer of either its Contributions or its
6565+ Contributor Version.
6666+6767+1.12. "Secondary License"
6868+ means either the GNU General Public License, Version 2.0, the GNU
6969+ Lesser General Public License, Version 2.1, the GNU Affero General
7070+ Public License, Version 3.0, or any later versions of those
7171+ licenses.
7272+7373+1.13. "Source Code Form"
7474+ means the form of the work preferred for making modifications.
7575+7676+1.14. "You" (or "Your")
7777+ means an individual or a legal entity exercising rights under this
7878+ License. For legal entities, "You" includes any entity that
7979+ controls, is controlled by, or is under common control with You. For
8080+ purposes of this definition, "control" means (a) the power, direct
8181+ or indirect, to cause the direction or management of such entity,
8282+ whether by contract or otherwise, or (b) ownership of more than
8383+ fifty percent (50%) of the outstanding shares or beneficial
8484+ ownership of such entity.
8585+8686+2. License Grants and Conditions
8787+--------------------------------
8888+8989+2.1. Grants
9090+9191+Each Contributor hereby grants You a world-wide, royalty-free,
9292+non-exclusive license:
9393+9494+(a) under intellectual property rights (other than patent or trademark)
9595+ Licensable by such Contributor to use, reproduce, make available,
9696+ modify, display, perform, distribute, and otherwise exploit its
9797+ Contributions, either on an unmodified basis, with Modifications, or
9898+ as part of a Larger Work; and
9999+100100+(b) under Patent Claims of such Contributor to make, use, sell, offer
101101+ for sale, have made, import, and otherwise transfer either its
102102+ Contributions or its Contributor Version.
103103+104104+2.2. Effective Date
105105+106106+The licenses granted in Section 2.1 with respect to any Contribution
107107+become effective for each Contribution on the date the Contributor first
108108+distributes such Contribution.
109109+110110+2.3. Limitations on Grant Scope
111111+112112+The licenses granted in this Section 2 are the only rights granted under
113113+this License. No additional rights or licenses will be implied from the
114114+distribution or licensing of Covered Software under this License.
115115+Notwithstanding Section 2.1(b) above, no patent license is granted by a
116116+Contributor:
117117+118118+(a) for any code that a Contributor has removed from Covered Software;
119119+ or
120120+121121+(b) for infringements caused by: (i) Your and any other third party's
122122+ modifications of Covered Software, or (ii) the combination of its
123123+ Contributions with other software (except as part of its Contributor
124124+ Version); or
125125+126126+(c) under Patent Claims infringed by Covered Software in the absence of
127127+ its Contributions.
128128+129129+This License does not grant any rights in the trademarks, service marks,
130130+or logos of any Contributor (except as may be necessary to comply with
131131+the notice requirements in Section 3.4).
132132+133133+2.4. Subsequent Licenses
134134+135135+No Contributor makes additional grants as a result of Your choice to
136136+distribute the Covered Software under a subsequent version of this
137137+License (see Section 10.2) or under the terms of a Secondary License (if
138138+permitted under the terms of Section 3.3).
139139+140140+2.5. Representation
141141+142142+Each Contributor represents that the Contributor believes its
143143+Contributions are its original creation(s) or it has sufficient rights
144144+to grant the rights to its Contributions conveyed by this License.
145145+146146+2.6. Fair Use
147147+148148+This License is not intended to limit any rights You have under
149149+applicable copyright doctrines of fair use, fair dealing, or other
150150+equivalents.
151151+152152+2.7. Conditions
153153+154154+Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
155155+in Section 2.1.
156156+157157+3. Responsibilities
158158+-------------------
159159+160160+3.1. Distribution of Source Form
161161+162162+All distribution of Covered Software in Source Code Form, including any
163163+Modifications that You create or to which You contribute, must be under
164164+the terms of this License. You must inform recipients that the Source
165165+Code Form of the Covered Software is governed by the terms of this
166166+License, and how they can obtain a copy of this License. You may not
167167+attempt to alter or restrict the recipients' rights in the Source Code
168168+Form.
169169+170170+3.2. Distribution of Executable Form
171171+172172+If You distribute Covered Software in Executable Form then:
173173+174174+(a) such Covered Software must also be made available in Source Code
175175+ Form, as described in Section 3.1, and You must inform recipients of
176176+ the Executable Form how they can obtain a copy of such Source Code
177177+ Form by reasonable means in a timely manner, at a charge no more
178178+ than the cost of distribution to the recipient; and
179179+180180+(b) You may distribute such Executable Form under the terms of this
181181+ License, or sublicense it under different terms, provided that the
182182+ license for the Executable Form does not attempt to limit or alter
183183+ the recipients' rights in the Source Code Form under this License.
184184+185185+3.3. Distribution of a Larger Work
186186+187187+You may create and distribute a Larger Work under terms of Your choice,
188188+provided that You also comply with the requirements of this License for
189189+the Covered Software. If the Larger Work is a combination of Covered
190190+Software with a work governed by one or more Secondary Licenses, and the
191191+Covered Software is not Incompatible With Secondary Licenses, this
192192+License permits You to additionally distribute such Covered Software
193193+under the terms of such Secondary License(s), so that the recipient of
194194+the Larger Work may, at their option, further distribute the Covered
195195+Software under the terms of either this License or such Secondary
196196+License(s).
197197+198198+3.4. Notices
199199+200200+You may not remove or alter the substance of any license notices
201201+(including copyright notices, patent notices, disclaimers of warranty,
202202+or limitations of liability) contained within the Source Code Form of
203203+the Covered Software, except that You may alter any license notices to
204204+the extent required to remedy known factual inaccuracies.
205205+206206+3.5. Application of Additional Terms
207207+208208+You may choose to offer, and to charge a fee for, warranty, support,
209209+indemnity or liability obligations to one or more recipients of Covered
210210+Software. However, You may do so only on Your own behalf, and not on
211211+behalf of any Contributor. You must make it absolutely clear that any
212212+such warranty, support, indemnity, or liability obligation is offered by
213213+You alone, and You hereby agree to indemnify every Contributor for any
214214+liability incurred by such Contributor as a result of warranty, support,
215215+indemnity or liability terms You offer. You may include additional
216216+disclaimers of warranty and limitations of liability specific to any
217217+jurisdiction.
218218+219219+4. Inability to Comply Due to Statute or Regulation
220220+---------------------------------------------------
221221+222222+If it is impossible for You to comply with any of the terms of this
223223+License with respect to some or all of the Covered Software due to
224224+statute, judicial order, or regulation then You must: (a) comply with
225225+the terms of this License to the maximum extent possible; and (b)
226226+describe the limitations and the code they affect. Such description must
227227+be placed in a text file included with all distributions of the Covered
228228+Software under this License. Except to the extent prohibited by statute
229229+or regulation, such description must be sufficiently detailed for a
230230+recipient of ordinary skill to be able to understand it.
231231+232232+5. Termination
233233+--------------
234234+235235+5.1. The rights granted under this License will terminate automatically
236236+if You fail to comply with any of its terms. However, if You become
237237+compliant, then the rights granted under this License from a particular
238238+Contributor are reinstated (a) provisionally, unless and until such
239239+Contributor explicitly and finally terminates Your grants, and (b) on an
240240+ongoing basis, if such Contributor fails to notify You of the
241241+non-compliance by some reasonable means prior to 60 days after You have
242242+come back into compliance. Moreover, Your grants from a particular
243243+Contributor are reinstated on an ongoing basis if such Contributor
244244+notifies You of the non-compliance by some reasonable means, this is the
245245+first time You have received notice of non-compliance with this License
246246+from such Contributor, and You become compliant prior to 30 days after
247247+Your receipt of the notice.
248248+249249+5.2. If You initiate litigation against any entity by asserting a patent
250250+infringement claim (excluding declaratory judgment actions,
251251+counter-claims, and cross-claims) alleging that a Contributor Version
252252+directly or indirectly infringes any patent, then the rights granted to
253253+You by any and all Contributors for the Covered Software under Section
254254+2.1 of this License shall terminate.
255255+256256+5.3. In the event of termination under Sections 5.1 or 5.2 above, all
257257+end user license agreements (excluding distributors and resellers) which
258258+have been validly granted by You or Your distributors under this License
259259+prior to termination shall survive termination.
260260+261261+************************************************************************
262262+* *
263263+* 6. Disclaimer of Warranty *
264264+* ------------------------- *
265265+* *
266266+* Covered Software is provided under this License on an "as is" *
267267+* basis, without warranty of any kind, either expressed, implied, or *
268268+* statutory, including, without limitation, warranties that the *
269269+* Covered Software is free of defects, merchantable, fit for a *
270270+* particular purpose or non-infringing. The entire risk as to the *
271271+* quality and performance of the Covered Software is with You. *
272272+* Should any Covered Software prove defective in any respect, You *
273273+* (not any Contributor) assume the cost of any necessary servicing, *
274274+* repair, or correction. This disclaimer of warranty constitutes an *
275275+* essential part of this License. No use of any Covered Software is *
276276+* authorized under this License except under this disclaimer. *
277277+* *
278278+************************************************************************
279279+280280+************************************************************************
281281+* *
282282+* 7. Limitation of Liability *
283283+* -------------------------- *
284284+* *
285285+* Under no circumstances and under no legal theory, whether tort *
286286+* (including negligence), contract, or otherwise, shall any *
287287+* Contributor, or anyone who distributes Covered Software as *
288288+* permitted above, be liable to You for any direct, indirect, *
289289+* special, incidental, or consequential damages of any character *
290290+* including, without limitation, damages for lost profits, loss of *
291291+* goodwill, work stoppage, computer failure or malfunction, or any *
292292+* and all other commercial damages or losses, even if such party *
293293+* shall have been informed of the possibility of such damages. This *
294294+* limitation of liability shall not apply to liability for death or *
295295+* personal injury resulting from such party's negligence to the *
296296+* extent applicable law prohibits such limitation. Some *
297297+* jurisdictions do not allow the exclusion or limitation of *
298298+* incidental or consequential damages, so this exclusion and *
299299+* limitation may not apply to You. *
300300+* *
301301+************************************************************************
302302+303303+8. Litigation
304304+-------------
305305+306306+Any litigation relating to this License may be brought only in the
307307+courts of a jurisdiction where the defendant maintains its principal
308308+place of business and such litigation shall be governed by laws of that
309309+jurisdiction, without reference to its conflict-of-law provisions.
310310+Nothing in this Section shall prevent a party's ability to bring
311311+cross-claims or counter-claims.
312312+313313+9. Miscellaneous
314314+----------------
315315+316316+This License represents the complete agreement concerning the subject
317317+matter hereof. If any provision of this License is held to be
318318+unenforceable, such provision shall be reformed only to the extent
319319+necessary to make it enforceable. Any law or regulation which provides
320320+that the language of a contract shall be construed against the drafter
321321+shall not be used to construe this License against a Contributor.
322322+323323+10. Versions of the License
324324+---------------------------
325325+326326+10.1. New Versions
327327+328328+Mozilla Foundation is the license steward. Except as provided in Section
329329+10.3, no one other than the license steward has the right to modify or
330330+publish new versions of this License. Each version will be given a
331331+distinguishing version number.
332332+333333+10.2. Effect of New Versions
334334+335335+You may distribute the Covered Software under the terms of the version
336336+of the License under which You originally received the Covered Software,
337337+or under the terms of any subsequent version published by the license
338338+steward.
339339+340340+10.3. Modified Versions
341341+342342+If you create software not governed by this License, and you want to
343343+create a new license for such software, you may create and use a
344344+modified version of this License if you rename the license and remove
345345+any references to the name of the license steward (except to note that
346346+such modified license differs from this License).
347347+348348+10.4. Distributing Source Code Form that is Incompatible With Secondary
349349+Licenses
350350+351351+If You choose to distribute Source Code Form that is Incompatible With
352352+Secondary Licenses under the terms of this version of the License, the
353353+notice described in Exhibit B of this License must be attached.
354354+355355+Exhibit A - Source Code Form License Notice
356356+-------------------------------------------
357357+358358+ This Source Code Form is subject to the terms of the Mozilla Public
359359+ License, v. 2.0. If a copy of the MPL was not distributed with this
360360+ file, You can obtain one at http://mozilla.org/MPL/2.0/.
361361+362362+If it is not possible or desirable to put the notice in a particular
363363+file, then You may include the notice in a location (such as a LICENSE
364364+file in a relevant directory) where a recipient would be likely to look
365365+for such a notice.
366366+367367+You may add additional accurate notices of copyright ownership.
368368+369369+Exhibit B - "Incompatible With Secondary Licenses" Notice
370370+---------------------------------------------------------
371371+372372+ This Source Code Form is "Incompatible With Secondary Licenses", as
373373+ defined by the Mozilla Public License, v. 2.0.
+231
README.md
···11+# fin ⚡
22+33+> a neon-electric Jellyfin client for the terminal — powered by `mpv` and Chromecast
44+55+`fin` is a Rust TUI + one-shot CLI that talks to your Jellyfin server, searches
66+your library, manages playlists, and pushes streams to either your local **mpv**
77+window or any **Chromecast** on your network. Chromecast playback is fully
88+queued — enqueue, play-next, skip, resume, all from the terminal.
99+1010+## Features
1111+1212+- **Ratatui-based TUI** with a neon-electric palette (teal / cyan / violet).
1313+- **fzf-style instant search** — results update on every keystroke.
1414+- **Two renderers**, one interface:
1515+ - `mpv` (local) — spawned automatically and driven via its JSON IPC socket.
1616+ - `chromecast` — device discovery via mDNS, playback through the Default
1717+ Media Receiver, with a **local queue** that auto-advances on `FINISHED`.
1818+- **Real queue management** — enqueue, play next, jump between tracks, and
1919+ see the whole queue in its own tab. Works identically for both renderers.
2020+- **Playlists** — browse, open, and play the playlists you've saved on the
2121+ server.
2222+- **Now Playing bar** with title, subtitle, elapsed / total time, a neon
2323+ progress gauge, volume, and the active renderer.
2424+- **CLI shortcuts** for scripting: `fin play "kind of blue"`,
2525+ `fin queue --chromecast "Living Room" "wednesday"`, `fin devices`.
2626+- **All settings** are available as **CLI flags _or_ TOML keys** — one
2727+ workflow scales from ad-hoc invocation to per-machine config.
2828+- **Pure Rust TLS** (`rustls`) everywhere — no OpenSSL required.
2929+3030+## Install
3131+3232+`fin` requires **`mpv`** on your `$PATH` (it's used as the local renderer and
3333+as a required preflight check even when casting).
3434+3535+```bash
3636+# macOS
3737+brew install mpv
3838+3939+# Debian / Ubuntu
4040+sudo apt install mpv
4141+4242+# Arch
4343+sudo pacman -S mpv
4444+```
4545+4646+Then build fin from source:
4747+4848+```bash
4949+git clone https://github.com/tsirysndr/fin
5050+cd fin
5151+cargo install --path crates/fin
5252+```
5353+5454+### Nix
5555+5656+A flake is provided — mpv is baked into the wrapper, so no extra install
5757+step is needed:
5858+5959+```bash
6060+# One-off run:
6161+nix run github:tsirysndr/fin
6262+6363+# Install into your user profile:
6464+nix profile install github:tsirysndr/fin
6565+6666+# Dev shell (rust toolchain + mpv + clippy + rust-analyzer):
6767+nix develop
6868+```
6969+7070+## Getting started
7171+7272+```bash
7373+# 1. Sign in
7474+fin login https://media.example.com
7575+7676+# 2. Launch the TUI (default sub-command)
7777+fin
7878+7979+# 3. Or drive it entirely from the shell
8080+fin search "daft punk"
8181+fin play "kind of blue"
8282+fin queue "wednesday season 1"
8383+fin devices # list Chromecasts on your LAN
8484+fin play --chromecast "Living Room" "solaris"
8585+```
8686+8787+## Renderer selection
8888+8989+Three ways to choose a renderer — all equivalent:
9090+9191+| Shortcut flag | Long flag | Config key |
9292+|----------------------------------------|---------------------------|--------------------------------|
9393+| `--mpv` | `--renderer mpv` | `renderer = "mpv"` |
9494+| `--chromecast "Living Room"` | `--renderer chromecast` | `renderer = "chromecast"` |
9595+| _(none — falls back to mpv)_ | | |
9696+9797+When you pass `--chromecast NAME`, the renderer is switched to chromecast
9898+automatically and that device is preferred on connect. If the name is not
9999+found on the network, fin picks the first device discovered.
100100+101101+## All settings
102102+103103+Every setting exists as both a CLI flag and a TOML key. Flags win.
104104+105105+| CLI flag | Env var | TOML key | Default |
106106+|-----------------------|---------------------|---------------------------|------------------|
107107+| `--server URL` | `FIN_SERVER` | `servers[].url` | _(none)_ |
108108+| `--server-name NAME` | `FIN_SERVER_NAME` | `current_server` | _(latest login)_ |
109109+| `--token TOKEN` | `FIN_TOKEN` | `servers[].access_token` | _(from login)_ |
110110+| `--user-id ID` | `FIN_USER_ID` | `servers[].user_id` | _(from login)_ |
111111+| `--user-name NAME` | | `servers[].user_name` | _(from login)_ |
112112+| `--device-id ID` | `FIN_DEVICE_ID` | `servers[].device_id` | random UUID |
113113+| `--renderer <mpv/chromecast>` | `FIN_RENDERER` | `renderer` | `mpv` |
114114+| `--mpv` | | `renderer = "mpv"` | |
115115+| `--chromecast [NAME]` | `FIN_CHROMECAST` | `last_chromecast` | |
116116+| `-v`, `-vv` | | _(log level)_ | `warn` |
117117+118118+Find the on-disk config with `fin config --path`; print it with
119119+`fin config --show`.
120120+121121+## Multiple servers
122122+123123+fin authenticates against as many Jellyfin servers as you like and keeps
124124+their tokens side-by-side in one config file:
125125+126126+```bash
127127+fin login https://home.example.com --name home
128128+fin login https://work.example.com --name work
129129+fin login https://mom.dyndns.example --name mom
130130+131131+fin server # list all servers (▍ marks the current one)
132132+fin server switch work # make `work` the active server
133133+fin server rm mom # remove one
134134+fin server rename home casa # rename `home` → `casa`
135135+136136+# One-off — hit `work` without changing the current pointer:
137137+fin --server-name work search "spirited away"
138138+fin --server-name work play "spirited away"
139139+```
140140+141141+Inside the TUI, the Settings screen shows every saved server; **Enter** on
142142+one switches to it. **`t`** anywhere in the TUI cycles to the next server
143143+without leaving the current screen.
144144+145145+## Sub-commands
146146+147147+```
148148+fin # launch the TUI (default)
149149+fin login <url> [--name N] # sign in and save credentials for server `N`
150150+fin logout [--name N] # remove server `N` (defaults to the current one)
151151+fin server # list saved servers
152152+fin server switch <name> # change the active server
153153+fin server rm <name> # remove one
154154+fin server rename <a> <b> # rename
155155+fin search <query> # print matches from the active library
156156+fin play <query> # search + play the top hit
157157+fin queue <query> # search + append to the current queue
158158+fin devices # list Chromecasts on the local network
159159+fin playlists # list playlists
160160+fin playlists --list <id> # dump items of a playlist
161161+fin config --show|--path # inspect config
162162+```
163163+164164+## Keybindings (TUI)
165165+166166+| Key | Action |
167167+|------------------------------|-------------------------------------|
168168+| `Tab` / `Shift+Tab` | next / prev screen |
169169+| `1`…`8` | jump to Home / Search / Music / Videos / Playlists / Queue / Devices / Settings |
170170+| `/` | jump to Search & focus input |
171171+| `↑` `↓` / `k` `j` | move selection |
172172+| `PgUp` / `PgDown` | jump 10 rows |
173173+| `Enter` | play the highlighted item (or connect to the highlighted Chromecast) |
174174+| `a` | enqueue the highlighted item |
175175+| `n` | play the highlighted item **next** |
176176+| `Space` or `p` | pause / resume |
177177+| `s` | stop |
178178+| `<` / `>` or `h` / `l` | previous / next track |
179179+| `+` / `-` | volume up / down |
180180+| `m` | switch to local mpv renderer |
181181+| `t` | cycle to the next saved Jellyfin server |
182182+| `r` | refresh the current screen |
183183+| `Esc` | leave the search input / close open playlist |
184184+| `q` / `Ctrl-C` | quit |
185185+186186+## Chromecast queue
187187+188188+For Chromecasts, `fin` maintains the queue **on the client**, tracks the
189189+device's media session, and loads the next item automatically the moment
190190+the current one reports `IDLE / FINISHED`. That means:
191191+192192+- `a` (queue) and `n` (play-next) do the right thing while something is
193193+ already casting.
194194+- Skipping (`>` / `<`) triggers a `load` for the next queue item
195195+ immediately — no waiting for the current one to finish.
196196+- Stopping clears the local queue and stops the receiver's media session.
197197+198198+## Streams & transcoding
199199+200200+- Local **mpv** playback uses the original stream (`Static=true`), which
201201+ is the fastest path and lets mpv handle any container Jellyfin can
202202+ direct-stream.
203203+- **Chromecast** playback defaults to Jellyfin's HLS output (`main.m3u8`),
204204+ because the Default Media Receiver's codec matrix is much narrower than
205205+ mpv's. This means Jellyfin will transcode when it needs to.
206206+- Force one or the other from the CLI with `--hls` on `play`/`queue`.
207207+208208+## Development
209209+210210+```bash
211211+cargo check --workspace
212212+cargo build --release -p fin
213213+./target/release/fin --help
214214+```
215215+216216+The workspace layout:
217217+218218+```
219219+fin/
220220+├── crates/
221221+│ ├── fin/ # binary — clap CLI + startup
222222+│ ├── fin-config/ # TOML config file & credentials
223223+│ ├── fin-jellyfin/ # Jellyfin HTTP API client
224224+│ ├── fin-player/ # Renderer trait + mpv + Chromecast + queue
225225+│ └── fin-tui/ # Ratatui neon TUI
226226+└── Cargo.toml # workspace + shared deps (rustls only, no openssl)
227227+```
228228+229229+## License
230230+231231+`fin` is released under the [MPL-2.0](LICENSE).
···11+use std::sync::mpsc as std_mpsc;
22+use std::sync::Arc;
33+use std::thread;
44+use std::time::{Duration, Instant};
55+66+use anyhow::{anyhow, Context, Result};
77+use async_trait::async_trait;
88+use parking_lot::Mutex;
99+use tokio::sync::oneshot;
1010+use tracing::error;
1111+1212+use rust_cast::channels::media::{
1313+ IdleReason, Image, Media, Metadata, MovieMediaMetadata, MusicTrackMediaMetadata, PlayerState,
1414+ StreamType,
1515+};
1616+use rust_cast::channels::receiver::CastDeviceApp;
1717+use rust_cast::CastDevice as RcDevice;
1818+1919+use crate::discovery::CastDevice;
2020+use crate::queue::{PlaybackQueue, QueueItem};
2121+use crate::renderer::{PlaybackState, PlaybackStatus, Renderer, RendererKind};
2222+2323+const DEFAULT_DESTINATION_ID: &str = "receiver-0";
2424+2525+enum CastCommand {
2626+ Play {
2727+ items: Vec<QueueItem>,
2828+ start_index: usize,
2929+ reply: oneshot::Sender<Result<()>>,
3030+ },
3131+ Enqueue {
3232+ items: Vec<QueueItem>,
3333+ reply: oneshot::Sender<Result<()>>,
3434+ },
3535+ PlayNext {
3636+ items: Vec<QueueItem>,
3737+ reply: oneshot::Sender<Result<()>>,
3838+ },
3939+ Pause(oneshot::Sender<Result<()>>),
4040+ Resume(oneshot::Sender<Result<()>>),
4141+ Stop(oneshot::Sender<Result<()>>),
4242+ Next(oneshot::Sender<Result<()>>),
4343+ Previous(oneshot::Sender<Result<()>>),
4444+ Seek(f64, oneshot::Sender<Result<()>>),
4545+ Volume(f32, oneshot::Sender<Result<()>>),
4646+ Shutdown,
4747+}
4848+4949+pub struct ChromecastRenderer {
5050+ device: CastDevice,
5151+ tx: std_mpsc::Sender<CastCommand>,
5252+ state: Arc<Mutex<PlaybackState>>,
5353+ queue: PlaybackQueue,
5454+}
5555+5656+impl ChromecastRenderer {
5757+ /// Connect to a Chromecast and launch the Default Media Receiver.
5858+ pub async fn connect(device: CastDevice) -> Result<Self> {
5959+ let state = Arc::new(Mutex::new(PlaybackState::default()));
6060+ let queue = PlaybackQueue::new();
6161+ let (tx, rx) = std_mpsc::channel::<CastCommand>();
6262+6363+ let dev_for_thread = device.clone();
6464+ let state_for_thread = state.clone();
6565+ let queue_for_thread = queue.clone();
6666+6767+ let (ready_tx, ready_rx) = oneshot::channel();
6868+6969+ thread::spawn(move || {
7070+ if let Err(e) = cast_worker(
7171+ dev_for_thread,
7272+ rx,
7373+ state_for_thread,
7474+ queue_for_thread,
7575+ ready_tx,
7676+ ) {
7777+ error!(error=?e, "chromecast worker exited with error");
7878+ }
7979+ });
8080+8181+ ready_rx
8282+ .await
8383+ .context("chromecast worker dropped ready signal")??;
8484+8585+ Ok(Self {
8686+ device,
8787+ tx,
8888+ state,
8989+ queue,
9090+ })
9191+ }
9292+9393+ pub fn device(&self) -> &CastDevice {
9494+ &self.device
9595+ }
9696+9797+ pub fn queue_handle(&self) -> PlaybackQueue {
9898+ self.queue.clone()
9999+ }
100100+101101+ async fn send(
102102+ &self,
103103+ make: impl FnOnce(oneshot::Sender<Result<()>>) -> CastCommand,
104104+ ) -> Result<()> {
105105+ let (tx, rx) = oneshot::channel();
106106+ self.tx
107107+ .send(make(tx))
108108+ .map_err(|_| anyhow!("chromecast worker dead"))?;
109109+ rx.await.context("chromecast reply dropped")?
110110+ }
111111+}
112112+113113+impl Drop for ChromecastRenderer {
114114+ fn drop(&mut self) {
115115+ let _ = self.tx.send(CastCommand::Shutdown);
116116+ }
117117+}
118118+119119+fn build_media(item: &QueueItem) -> Media {
120120+ let images: Vec<Image> = item
121121+ .image_url
122122+ .clone()
123123+ .map(|u| vec![Image::new(u)])
124124+ .unwrap_or_default();
125125+ let metadata = if item.is_video {
126126+ Metadata::Movie(MovieMediaMetadata {
127127+ title: Some(item.title.clone()),
128128+ subtitle: if item.subtitle.is_empty() {
129129+ None
130130+ } else {
131131+ Some(item.subtitle.clone())
132132+ },
133133+ studio: None,
134134+ release_date: None,
135135+ images,
136136+ })
137137+ } else {
138138+ Metadata::MusicTrack(MusicTrackMediaMetadata {
139139+ album_name: None,
140140+ title: Some(item.title.clone()),
141141+ album_artist: None,
142142+ artist: if item.subtitle.is_empty() {
143143+ None
144144+ } else {
145145+ Some(item.subtitle.clone())
146146+ },
147147+ composer: None,
148148+ track_number: None,
149149+ disc_number: None,
150150+ images,
151151+ release_date: None,
152152+ })
153153+ };
154154+ Media {
155155+ content_id: item.stream_url.clone(),
156156+ content_type: item.content_type.clone(),
157157+ stream_type: StreamType::Buffered,
158158+ duration: item.duration_secs.map(|d| d as f32),
159159+ metadata: Some(metadata),
160160+ }
161161+}
162162+163163+struct Session {
164164+ app_transport: String,
165165+ session_id: String,
166166+ media_session_id: Option<i32>,
167167+}
168168+169169+fn cast_worker(
170170+ device: CastDevice,
171171+ rx: std_mpsc::Receiver<CastCommand>,
172172+ state: Arc<Mutex<PlaybackState>>,
173173+ queue: PlaybackQueue,
174174+ ready: oneshot::Sender<Result<()>>,
175175+) -> Result<()> {
176176+ let host = device.address.to_string();
177177+ let rc = match RcDevice::connect_without_host_verification(host.clone(), device.port) {
178178+ Ok(d) => d,
179179+ Err(e) => {
180180+ let _ = ready.send(Err(anyhow!("connect to {}: {}", device.display_name(), e)));
181181+ return Ok(());
182182+ }
183183+ };
184184+185185+ if let Err(e) = rc.connection.connect(DEFAULT_DESTINATION_ID) {
186186+ let _ = ready.send(Err(anyhow!("initial connect: {}", e)));
187187+ return Ok(());
188188+ }
189189+ if let Err(e) = rc.heartbeat.ping() {
190190+ let _ = ready.send(Err(anyhow!("heartbeat ping: {}", e)));
191191+ return Ok(());
192192+ }
193193+194194+ let app = match rc.receiver.launch_app(&CastDeviceApp::DefaultMediaReceiver) {
195195+ Ok(a) => a,
196196+ Err(e) => {
197197+ let _ = ready.send(Err(anyhow!("launching default media receiver: {}", e)));
198198+ return Ok(());
199199+ }
200200+ };
201201+ if let Err(e) = rc.connection.connect(app.transport_id.as_str()) {
202202+ let _ = ready.send(Err(anyhow!("connect to app: {}", e)));
203203+ return Ok(());
204204+ }
205205+206206+ let session = Arc::new(Mutex::new(Session {
207207+ app_transport: app.transport_id.clone(),
208208+ session_id: app.session_id.clone(),
209209+ media_session_id: None,
210210+ }));
211211+212212+ let _ = ready.send(Ok(()));
213213+214214+ let mut last_heartbeat = Instant::now();
215215+ let mut last_status = Instant::now();
216216+217217+ // We run the worker single-threaded — rust_cast's channels aren't
218218+ // thread-safe. Between commands we poll media status for progress
219219+ // updates and auto-advance on FINISHED.
220220+ loop {
221221+ match rx.recv_timeout(Duration::from_millis(300)) {
222222+ Ok(CastCommand::Shutdown) => {
223223+ let sess = session.lock();
224224+ let _ = rc.receiver.stop_app(sess.session_id.as_str());
225225+ return Ok(());
226226+ }
227227+ Ok(cmd) => handle_command(cmd, &rc, &session, &queue, &state),
228228+ Err(std_mpsc::RecvTimeoutError::Timeout) => {}
229229+ Err(std_mpsc::RecvTimeoutError::Disconnected) => return Ok(()),
230230+ }
231231+232232+ if last_heartbeat.elapsed() > Duration::from_secs(4) {
233233+ let _ = rc.heartbeat.ping();
234234+ last_heartbeat = Instant::now();
235235+ }
236236+237237+ if last_status.elapsed() > Duration::from_millis(750) {
238238+ last_status = Instant::now();
239239+ let (dest, media_id) = {
240240+ let sess = session.lock();
241241+ (sess.app_transport.clone(), sess.media_session_id)
242242+ };
243243+ if let Some(id) = media_id {
244244+ if let Ok(status) = rc.media.get_status(dest.as_str(), Some(id)) {
245245+ let mut advanced = false;
246246+ if let Some(entry) = status.entries.first() {
247247+ let mut s = state.lock();
248248+ s.position_secs = entry.current_time.unwrap_or(0.0) as f64;
249249+ if let Some(m) = &entry.media {
250250+ if let Some(d) = m.duration {
251251+ s.duration_secs = d as f64;
252252+ }
253253+ }
254254+ match entry.player_state {
255255+ PlayerState::Playing => s.status = PlaybackStatus::Playing,
256256+ PlayerState::Paused => s.status = PlaybackStatus::Paused,
257257+ PlayerState::Buffering => s.status = PlaybackStatus::Buffering,
258258+ PlayerState::Idle => {
259259+ if matches!(entry.idle_reason, Some(IdleReason::Finished)) {
260260+ advanced = true;
261261+ } else {
262262+ s.status = PlaybackStatus::Idle;
263263+ }
264264+ }
265265+ }
266266+ }
267267+ if advanced {
268268+ if queue.advance().is_some() {
269269+ if let Some(next) = queue.current() {
270270+ let _ = load_media(&rc, &session, &next, &state);
271271+ }
272272+ } else {
273273+ state.lock().status = PlaybackStatus::Idle;
274274+ }
275275+ }
276276+ }
277277+ }
278278+ }
279279+ }
280280+}
281281+282282+fn handle_command(
283283+ cmd: CastCommand,
284284+ rc: &RcDevice,
285285+ session: &Arc<Mutex<Session>>,
286286+ queue: &PlaybackQueue,
287287+ state: &Arc<Mutex<PlaybackState>>,
288288+) {
289289+ match cmd {
290290+ CastCommand::Play {
291291+ items,
292292+ start_index,
293293+ reply,
294294+ } => {
295295+ queue.replace(items.clone(), start_index);
296296+ let index = queue.current_index().unwrap_or(0);
297297+ let current = items.get(index).cloned();
298298+ {
299299+ let mut s = state.lock();
300300+ s.queue = queue.items();
301301+ s.current_index = queue.current_index();
302302+ s.now_playing = current.clone();
303303+ s.status = PlaybackStatus::Buffering;
304304+ }
305305+ let res = if let Some(item) = current {
306306+ load_media(rc, session, &item, state)
307307+ } else {
308308+ Ok(())
309309+ };
310310+ let _ = reply.send(res);
311311+ }
312312+ CastCommand::Enqueue { items, reply } => {
313313+ let was_empty = queue.is_empty();
314314+ queue.append(items);
315315+ let res = if was_empty {
316316+ if let Some(item) = queue.current() {
317317+ load_media(rc, session, &item, state)
318318+ } else {
319319+ Ok(())
320320+ }
321321+ } else {
322322+ Ok(())
323323+ };
324324+ {
325325+ let mut s = state.lock();
326326+ s.queue = queue.items();
327327+ s.current_index = queue.current_index();
328328+ s.now_playing = queue.current();
329329+ }
330330+ let _ = reply.send(res);
331331+ }
332332+ CastCommand::PlayNext { items, reply } => {
333333+ let was_empty = queue.is_empty();
334334+ queue.insert_next(items);
335335+ let res = if was_empty {
336336+ if let Some(item) = queue.current() {
337337+ load_media(rc, session, &item, state)
338338+ } else {
339339+ Ok(())
340340+ }
341341+ } else {
342342+ Ok(())
343343+ };
344344+ {
345345+ let mut s = state.lock();
346346+ s.queue = queue.items();
347347+ s.current_index = queue.current_index();
348348+ s.now_playing = queue.current();
349349+ }
350350+ let _ = reply.send(res);
351351+ }
352352+ CastCommand::Pause(reply) => {
353353+ let sess = session.lock();
354354+ let media_id = sess.media_session_id;
355355+ let dest = sess.app_transport.clone();
356356+ drop(sess);
357357+ let res = if let Some(id) = media_id {
358358+ rc.media
359359+ .pause(dest.as_str(), id)
360360+ .map(|_| ())
361361+ .map_err(|e| anyhow!("pause: {}", e))
362362+ } else {
363363+ Ok(())
364364+ };
365365+ let _ = reply.send(res);
366366+ }
367367+ CastCommand::Resume(reply) => {
368368+ let sess = session.lock();
369369+ let media_id = sess.media_session_id;
370370+ let dest = sess.app_transport.clone();
371371+ drop(sess);
372372+ let res = if let Some(id) = media_id {
373373+ rc.media
374374+ .play(dest.as_str(), id)
375375+ .map(|_| ())
376376+ .map_err(|e| anyhow!("resume: {}", e))
377377+ } else {
378378+ Ok(())
379379+ };
380380+ let _ = reply.send(res);
381381+ }
382382+ CastCommand::Stop(reply) => {
383383+ let sess = session.lock();
384384+ let media_id = sess.media_session_id;
385385+ let dest = sess.app_transport.clone();
386386+ drop(sess);
387387+ queue.clear();
388388+ {
389389+ let mut s = state.lock();
390390+ s.queue.clear();
391391+ s.now_playing = None;
392392+ s.current_index = None;
393393+ s.status = PlaybackStatus::Stopped;
394394+ }
395395+ let res = if let Some(id) = media_id {
396396+ rc.media
397397+ .stop(dest.as_str(), id)
398398+ .map(|_| ())
399399+ .map_err(|e| anyhow!("stop: {}", e))
400400+ } else {
401401+ Ok(())
402402+ };
403403+ let _ = reply.send(res);
404404+ }
405405+ CastCommand::Next(reply) => {
406406+ let res = if queue.advance().is_some() {
407407+ if let Some(item) = queue.current() {
408408+ load_media(rc, session, &item, state)
409409+ } else {
410410+ Ok(())
411411+ }
412412+ } else {
413413+ Ok(())
414414+ };
415415+ let _ = reply.send(res);
416416+ }
417417+ CastCommand::Previous(reply) => {
418418+ let res = if queue.back().is_some() {
419419+ if let Some(item) = queue.current() {
420420+ load_media(rc, session, &item, state)
421421+ } else {
422422+ Ok(())
423423+ }
424424+ } else {
425425+ Ok(())
426426+ };
427427+ let _ = reply.send(res);
428428+ }
429429+ CastCommand::Seek(pos, reply) => {
430430+ let sess = session.lock();
431431+ let media_id = sess.media_session_id;
432432+ let dest = sess.app_transport.clone();
433433+ drop(sess);
434434+ let res = if let Some(id) = media_id {
435435+ rc.media
436436+ .seek(dest.as_str(), id, Some(pos as f32), None)
437437+ .map(|_| ())
438438+ .map_err(|e| anyhow!("seek: {}", e))
439439+ } else {
440440+ Ok(())
441441+ };
442442+ let _ = reply.send(res);
443443+ }
444444+ CastCommand::Volume(v, reply) => {
445445+ let res = rc
446446+ .receiver
447447+ .set_volume(v.clamp(0.0, 1.0))
448448+ .map(|_| ())
449449+ .map_err(|e| anyhow!("volume: {}", e));
450450+ if res.is_ok() {
451451+ state.lock().volume = v.clamp(0.0, 1.0);
452452+ }
453453+ let _ = reply.send(res);
454454+ }
455455+ CastCommand::Shutdown => {
456456+ let sess = session.lock();
457457+ let _ = rc.receiver.stop_app(sess.session_id.as_str());
458458+ }
459459+ }
460460+}
461461+462462+fn load_media(
463463+ rc: &RcDevice,
464464+ session: &Arc<Mutex<Session>>,
465465+ item: &QueueItem,
466466+ state: &Arc<Mutex<PlaybackState>>,
467467+) -> Result<()> {
468468+ let media = build_media(item);
469469+ let sess = session.lock();
470470+ let dest = sess.app_transport.clone();
471471+ let sid = sess.session_id.clone();
472472+ drop(sess);
473473+ let status = rc
474474+ .media
475475+ .load(dest.as_str(), sid.as_str(), &media)
476476+ .map_err(|e| anyhow!("cast load: {}", e))?;
477477+ if let Some(entry) = status.entries.first() {
478478+ session.lock().media_session_id = Some(entry.media_session_id);
479479+ let mut s = state.lock();
480480+ s.duration_secs = entry.media.as_ref().and_then(|m| m.duration).unwrap_or(0.0) as f64;
481481+ s.now_playing = Some(item.clone());
482482+ s.status = PlaybackStatus::Playing;
483483+ }
484484+ Ok(())
485485+}
486486+487487+#[async_trait]
488488+impl Renderer for ChromecastRenderer {
489489+ fn kind(&self) -> RendererKind {
490490+ RendererKind::Chromecast
491491+ }
492492+493493+ async fn play(&self, items: Vec<QueueItem>, start_index: usize) -> Result<()> {
494494+ self.send(|reply| CastCommand::Play {
495495+ items,
496496+ start_index,
497497+ reply,
498498+ })
499499+ .await
500500+ }
501501+502502+ async fn enqueue(&self, items: Vec<QueueItem>) -> Result<()> {
503503+ self.send(|reply| CastCommand::Enqueue { items, reply })
504504+ .await
505505+ }
506506+507507+ async fn play_next(&self, items: Vec<QueueItem>) -> Result<()> {
508508+ self.send(|reply| CastCommand::PlayNext { items, reply })
509509+ .await
510510+ }
511511+512512+ async fn pause(&self) -> Result<()> {
513513+ self.send(CastCommand::Pause).await
514514+ }
515515+516516+ async fn resume(&self) -> Result<()> {
517517+ self.send(CastCommand::Resume).await
518518+ }
519519+520520+ async fn stop(&self) -> Result<()> {
521521+ self.send(CastCommand::Stop).await
522522+ }
523523+524524+ async fn next(&self) -> Result<()> {
525525+ self.send(CastCommand::Next).await
526526+ }
527527+528528+ async fn previous(&self) -> Result<()> {
529529+ self.send(CastCommand::Previous).await
530530+ }
531531+532532+ async fn seek(&self, position_secs: f64) -> Result<()> {
533533+ self.send(|reply| CastCommand::Seek(position_secs, reply))
534534+ .await
535535+ }
536536+537537+ async fn set_volume(&self, volume: f32) -> Result<()> {
538538+ self.send(|reply| CastCommand::Volume(volume, reply)).await
539539+ }
540540+541541+ fn state(&self) -> PlaybackState {
542542+ self.state.lock().clone()
543543+ }
544544+}
+89
crates/fin-player/src/discovery.rs
···11+use std::collections::HashMap;
22+use std::net::IpAddr;
33+use std::time::Duration;
44+55+use anyhow::Result;
66+use mdns_sd::{ServiceDaemon, ServiceEvent};
77+use tokio::time::timeout;
88+99+const CAST_SERVICE: &str = "_googlecast._tcp.local.";
1010+1111+#[derive(Debug, Clone)]
1212+pub struct CastDevice {
1313+ pub name: String,
1414+ pub model: String,
1515+ pub address: IpAddr,
1616+ pub port: u16,
1717+ pub uuid: String,
1818+}
1919+2020+impl CastDevice {
2121+ pub fn display_name(&self) -> String {
2222+ if self.name.is_empty() {
2323+ self.model.clone()
2424+ } else {
2525+ self.name.clone()
2626+ }
2727+ }
2828+}
2929+3030+/// Browse the local network for Chromecast-compatible receivers.
3131+pub async fn discover_chromecasts(scan_for: Duration) -> Result<Vec<CastDevice>> {
3232+ let mdns = ServiceDaemon::new()?;
3333+ let receiver = mdns.browse(CAST_SERVICE)?;
3434+ let mut devices: HashMap<String, CastDevice> = HashMap::new();
3535+3636+ let deadline = tokio::time::Instant::now() + scan_for;
3737+ while tokio::time::Instant::now() < deadline {
3838+ let remaining = deadline
3939+ .saturating_duration_since(tokio::time::Instant::now())
4040+ .min(Duration::from_millis(500));
4141+ let ev = timeout(remaining, async {
4242+ loop {
4343+ match receiver.recv_async().await {
4444+ Ok(ev) => return Some(ev),
4545+ Err(_) => return None,
4646+ }
4747+ }
4848+ })
4949+ .await;
5050+ let ev = match ev {
5151+ Ok(Some(ev)) => ev,
5252+ _ => continue,
5353+ };
5454+ if let ServiceEvent::ServiceResolved(info) = ev {
5555+ let props: HashMap<String, String> = info
5656+ .get_properties()
5757+ .iter()
5858+ .map(|p| (p.key().to_string(), p.val_str().to_string()))
5959+ .collect();
6060+ let friendly = props
6161+ .get("fn")
6262+ .cloned()
6363+ .unwrap_or_else(|| info.get_hostname().to_string());
6464+ let model = props.get("md").cloned().unwrap_or_default();
6565+ let uuid = props
6666+ .get("id")
6767+ .cloned()
6868+ .unwrap_or_else(|| info.get_fullname().to_string());
6969+ let Some(addr) = info.get_addresses().iter().next().copied() else {
7070+ continue;
7171+ };
7272+ let port = info.get_port();
7373+ devices.insert(
7474+ uuid.clone(),
7575+ CastDevice {
7676+ name: friendly,
7777+ model,
7878+ address: addr,
7979+ port,
8080+ uuid,
8181+ },
8282+ );
8383+ }
8484+ }
8585+ let _ = mdns.shutdown();
8686+ let mut list: Vec<_> = devices.into_values().collect();
8787+ list.sort_by(|a, b| a.display_name().cmp(&b.display_name()));
8888+ Ok(list)
8989+}
+11
crates/fin-player/src/lib.rs
···11+pub mod cast;
22+pub mod discovery;
33+pub mod mpv;
44+pub mod queue;
55+pub mod renderer;
66+77+pub use cast::ChromecastRenderer;
88+pub use discovery::{discover_chromecasts, CastDevice};
99+pub use mpv::MpvRenderer;
1010+pub use queue::{PlaybackQueue, QueueItem};
1111+pub use renderer::{PlaybackState, PlaybackStatus, Renderer, RendererKind};
+414
crates/fin-player/src/mpv.rs
···11+use std::path::PathBuf;
22+use std::process::Stdio;
33+use std::sync::atomic::{AtomicU32, Ordering};
44+use std::sync::Arc;
55+use std::time::Duration;
66+77+use anyhow::{anyhow, Context, Result};
88+use async_trait::async_trait;
99+use parking_lot::Mutex;
1010+use serde_json::{json, Value};
1111+use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
1212+use tokio::net::UnixStream;
1313+use tokio::process::{Child, Command};
1414+use tokio::sync::{mpsc, oneshot};
1515+use tokio::time::sleep;
1616+use tracing::{debug, error, warn};
1717+1818+use crate::queue::{PlaybackQueue, QueueItem};
1919+use crate::renderer::{PlaybackState, PlaybackStatus, Renderer, RendererKind};
2020+2121+/// mpv renderer — spawns a headless mpv (or a windowed one, for video)
2222+/// and drives it over the JSON IPC socket.
2323+pub struct MpvRenderer {
2424+ socket_path: PathBuf,
2525+ force_window: bool,
2626+ queue: PlaybackQueue,
2727+ state: Arc<Mutex<PlaybackState>>,
2828+ ipc: Arc<Mutex<Option<IpcHandle>>>,
2929+ child: Arc<Mutex<Option<Child>>>,
3030+}
3131+3232+#[derive(Clone)]
3333+struct IpcHandle {
3434+ tx: mpsc::UnboundedSender<IpcCommand>,
3535+ req_id: Arc<AtomicU32>,
3636+}
3737+3838+struct IpcCommand {
3939+ payload: Value,
4040+ reply: Option<oneshot::Sender<Result<Value>>>,
4141+}
4242+4343+impl MpvRenderer {
4444+ pub fn new(socket_path: Option<PathBuf>) -> Self {
4545+ let socket_path = socket_path.unwrap_or_else(|| {
4646+ let mut p = std::env::temp_dir();
4747+ p.push(format!("fin-mpv-{}.sock", std::process::id()));
4848+ p
4949+ });
5050+ Self {
5151+ socket_path,
5252+ force_window: true,
5353+ queue: PlaybackQueue::new(),
5454+ state: Arc::new(Mutex::new(PlaybackState::default())),
5555+ ipc: Arc::new(Mutex::new(None)),
5656+ child: Arc::new(Mutex::new(None)),
5757+ }
5858+ }
5959+6060+ pub fn queue_handle(&self) -> PlaybackQueue {
6161+ self.queue.clone()
6262+ }
6363+6464+ /// Ensures mpv is spawned and the IPC channel is ready. Idempotent —
6565+ /// calling it a second time is a no-op.
6666+ async fn ensure_running(&self) -> Result<()> {
6767+ if self.ipc.lock().is_some() {
6868+ return Ok(());
6969+ }
7070+7171+ // If a stale socket exists, remove it first.
7272+ let _ = std::fs::remove_file(&self.socket_path);
7373+7474+ let mut cmd = Command::new("mpv");
7575+ cmd.arg("--idle=yes")
7676+ .arg("--no-terminal")
7777+ .arg("--force-window=".to_string() + if self.force_window { "immediate" } else { "no" })
7878+ .arg("--keep-open=yes")
7979+ .arg("--audio-display=no")
8080+ .arg("--input-ipc-server=".to_string() + self.socket_path.to_string_lossy().as_ref())
8181+ .stdin(Stdio::null())
8282+ .stdout(Stdio::null())
8383+ .stderr(Stdio::null());
8484+8585+ let child = cmd.spawn().context(
8686+ "failed to spawn mpv — is it installed? On macOS: `brew install mpv`, on Debian/Ubuntu: `sudo apt install mpv`",
8787+ )?;
8888+ *self.child.lock() = Some(child);
8989+9090+ // Wait for mpv to create the socket. We poll with a small backoff.
9191+ let mut stream = None;
9292+ for _ in 0..40 {
9393+ match UnixStream::connect(&self.socket_path).await {
9494+ Ok(s) => {
9595+ stream = Some(s);
9696+ break;
9797+ }
9898+ Err(_) => sleep(Duration::from_millis(75)).await,
9999+ }
100100+ }
101101+ let stream = stream.context("timed out connecting to mpv IPC socket")?;
102102+103103+ let (tx, rx) = mpsc::unbounded_channel::<IpcCommand>();
104104+ let req_id = Arc::new(AtomicU32::new(1));
105105+ let handle = IpcHandle {
106106+ tx: tx.clone(),
107107+ req_id: req_id.clone(),
108108+ };
109109+ *self.ipc.lock() = Some(handle);
110110+111111+ let state = self.state.clone();
112112+ let queue = self.queue.clone();
113113+ tokio::spawn(async move {
114114+ if let Err(e) = ipc_loop(stream, rx, state, queue).await {
115115+ error!(error=?e, "mpv ipc loop exited");
116116+ }
117117+ });
118118+119119+ // Observe events we care about — send directly on the mpsc,
120120+ // bypassing `cmd()` to avoid recursion through `ensure_running`.
121121+ for prop in [
122122+ "time-pos",
123123+ "duration",
124124+ "pause",
125125+ "volume",
126126+ "playlist-pos",
127127+ "eof-reached",
128128+ ] {
129129+ let id = req_id.fetch_add(1, Ordering::SeqCst);
130130+ let _ = tx.send(IpcCommand {
131131+ payload: json!({
132132+ "command": ["observe_property", 1, prop],
133133+ "request_id": id,
134134+ }),
135135+ reply: None,
136136+ });
137137+ }
138138+ Ok(())
139139+ }
140140+141141+ async fn cmd(&self, mut payload: Value) -> Result<Value> {
142142+ self.ensure_running().await?;
143143+ // Clone the handle out of the lock so we don't hold a MutexGuard
144144+ // across the following `.await` (parking_lot guards aren't Send).
145145+ let handle: IpcHandle = {
146146+ let guard = self.ipc.lock();
147147+ guard
148148+ .as_ref()
149149+ .cloned()
150150+ .ok_or_else(|| anyhow!("mpv not running"))?
151151+ };
152152+ let id = handle.req_id.fetch_add(1, Ordering::SeqCst);
153153+ if let Value::Object(ref mut m) = payload {
154154+ m.insert("request_id".into(), Value::from(id));
155155+ }
156156+ let (reply_tx, reply_rx) = oneshot::channel();
157157+ handle
158158+ .tx
159159+ .send(IpcCommand {
160160+ payload,
161161+ reply: Some(reply_tx),
162162+ })
163163+ .map_err(|_| anyhow!("mpv ipc channel closed"))?;
164164+ reply_rx.await.context("mpv ipc reply dropped")?
165165+ }
166166+167167+ fn apply_queue_from_local(&self) {
168168+ let items = self.queue.items();
169169+ let idx = self.queue.current_index();
170170+ let mut s = self.state.lock();
171171+ s.queue = items.clone();
172172+ s.current_index = idx;
173173+ s.now_playing = idx.and_then(|i| items.get(i).cloned());
174174+ }
175175+}
176176+177177+async fn ipc_loop(
178178+ stream: UnixStream,
179179+ mut rx: mpsc::UnboundedReceiver<IpcCommand>,
180180+ state: Arc<Mutex<PlaybackState>>,
181181+ queue: PlaybackQueue,
182182+) -> Result<()> {
183183+ let (reader, mut writer) = stream.into_split();
184184+ let mut lines = BufReader::new(reader).lines();
185185+ let pending: Arc<Mutex<std::collections::HashMap<u32, oneshot::Sender<Result<Value>>>>> =
186186+ Arc::new(Mutex::new(Default::default()));
187187+188188+ let pending_read = pending.clone();
189189+ let state_r = state.clone();
190190+ let queue_r = queue.clone();
191191+ let read_task = tokio::spawn(async move {
192192+ while let Ok(Some(line)) = lines.next_line().await {
193193+ let value: Value = match serde_json::from_str(&line) {
194194+ Ok(v) => v,
195195+ Err(_) => continue,
196196+ };
197197+ if let Some(id) = value.get("request_id").and_then(|v| v.as_u64()) {
198198+ let sender = pending_read.lock().remove(&(id as u32));
199199+ if let Some(tx) = sender {
200200+ let err = value
201201+ .get("error")
202202+ .and_then(|v| v.as_str())
203203+ .unwrap_or("success");
204204+ if err == "success" {
205205+ let _ = tx.send(Ok(value.get("data").cloned().unwrap_or(Value::Null)));
206206+ } else {
207207+ let _ = tx.send(Err(anyhow!("mpv ipc error: {}", err)));
208208+ }
209209+ }
210210+ continue;
211211+ }
212212+ if let Some(event) = value.get("event").and_then(|v| v.as_str()) {
213213+ handle_event(event, &value, &state_r, &queue_r);
214214+ }
215215+ }
216216+ });
217217+218218+ while let Some(cmd) = rx.recv().await {
219219+ let id = cmd
220220+ .payload
221221+ .get("request_id")
222222+ .and_then(|v| v.as_u64())
223223+ .unwrap_or(0) as u32;
224224+ if let Some(reply) = cmd.reply {
225225+ pending.lock().insert(id, reply);
226226+ }
227227+ let mut serialized = serde_json::to_vec(&cmd.payload)?;
228228+ serialized.push(b'\n');
229229+ if let Err(e) = writer.write_all(&serialized).await {
230230+ warn!(?e, "mpv ipc write failed");
231231+ break;
232232+ }
233233+ }
234234+ read_task.abort();
235235+ Ok(())
236236+}
237237+238238+fn handle_event(
239239+ event: &str,
240240+ value: &Value,
241241+ state: &Arc<Mutex<PlaybackState>>,
242242+ queue: &PlaybackQueue,
243243+) {
244244+ match event {
245245+ "property-change" => {
246246+ let name = value.get("name").and_then(|v| v.as_str()).unwrap_or("");
247247+ let data = value.get("data").cloned().unwrap_or(Value::Null);
248248+ let mut s = state.lock();
249249+ match name {
250250+ "time-pos" => {
251251+ s.position_secs = data.as_f64().unwrap_or(s.position_secs);
252252+ }
253253+ "duration" => {
254254+ s.duration_secs = data.as_f64().unwrap_or(s.duration_secs);
255255+ }
256256+ "pause" => {
257257+ if let Some(p) = data.as_bool() {
258258+ s.status = if p {
259259+ PlaybackStatus::Paused
260260+ } else if s.now_playing.is_some() {
261261+ PlaybackStatus::Playing
262262+ } else {
263263+ PlaybackStatus::Idle
264264+ };
265265+ }
266266+ }
267267+ "volume" => {
268268+ if let Some(v) = data.as_f64() {
269269+ s.volume = (v / 100.0) as f32;
270270+ }
271271+ }
272272+ _ => {}
273273+ }
274274+ }
275275+ "start-file" => {
276276+ let mut s = state.lock();
277277+ s.status = PlaybackStatus::Buffering;
278278+ }
279279+ "file-loaded" => {
280280+ let mut s = state.lock();
281281+ s.status = PlaybackStatus::Playing;
282282+ }
283283+ "end-file" => {
284284+ queue.advance();
285285+ let items = queue.items();
286286+ let idx = queue.current_index();
287287+ let mut s = state.lock();
288288+ s.queue = items.clone();
289289+ s.current_index = idx;
290290+ s.now_playing = idx.and_then(|i| items.get(i).cloned());
291291+ if s.now_playing.is_none() {
292292+ s.status = PlaybackStatus::Idle;
293293+ s.position_secs = 0.0;
294294+ s.duration_secs = 0.0;
295295+ }
296296+ }
297297+ "shutdown" => {
298298+ let mut s = state.lock();
299299+ s.status = PlaybackStatus::Stopped;
300300+ }
301301+ _ => {
302302+ debug!(event, "mpv event");
303303+ }
304304+ }
305305+}
306306+307307+#[async_trait]
308308+impl Renderer for MpvRenderer {
309309+ fn kind(&self) -> RendererKind {
310310+ RendererKind::Mpv
311311+ }
312312+313313+ async fn play(&self, items: Vec<QueueItem>, start_index: usize) -> Result<()> {
314314+ self.ensure_running().await?;
315315+ self.queue.replace(items.clone(), start_index);
316316+ self.apply_queue_from_local();
317317+318318+ // Reset mpv's own playlist and load our items.
319319+ self.cmd(json!({"command": ["playlist-clear"]})).await.ok();
320320+ self.cmd(json!({"command": ["stop"]})).await.ok();
321321+322322+ for (i, item) in items.iter().enumerate() {
323323+ let mode = if i == 0 { "replace" } else { "append" };
324324+ self.cmd(json!({"command": ["loadfile", item.stream_url, mode]}))
325325+ .await?;
326326+ }
327327+ if start_index > 0 {
328328+ self.cmd(json!({"command": ["set_property", "playlist-pos", start_index]}))
329329+ .await?;
330330+ }
331331+ self.cmd(json!({"command": ["set_property", "pause", false]}))
332332+ .await?;
333333+ Ok(())
334334+ }
335335+336336+ async fn enqueue(&self, items: Vec<QueueItem>) -> Result<()> {
337337+ self.ensure_running().await?;
338338+ self.queue.append(items.clone());
339339+ self.apply_queue_from_local();
340340+ for item in items {
341341+ self.cmd(json!({"command": ["loadfile", item.stream_url, "append-play"]}))
342342+ .await?;
343343+ }
344344+ Ok(())
345345+ }
346346+347347+ async fn play_next(&self, items: Vec<QueueItem>) -> Result<()> {
348348+ self.ensure_running().await?;
349349+ self.queue.insert_next(items.clone());
350350+ self.apply_queue_from_local();
351351+ // mpv's `loadfile` with `insert-next` is available since mpv 0.38.
352352+ for item in items {
353353+ self.cmd(json!({"command": ["loadfile", item.stream_url, "insert-next"]}))
354354+ .await?;
355355+ }
356356+ Ok(())
357357+ }
358358+359359+ async fn pause(&self) -> Result<()> {
360360+ self.cmd(json!({"command": ["set_property", "pause", true]}))
361361+ .await
362362+ .map(|_| ())
363363+ }
364364+365365+ async fn resume(&self) -> Result<()> {
366366+ self.cmd(json!({"command": ["set_property", "pause", false]}))
367367+ .await
368368+ .map(|_| ())
369369+ }
370370+371371+ async fn stop(&self) -> Result<()> {
372372+ self.queue.clear();
373373+ self.apply_queue_from_local();
374374+ self.cmd(json!({"command": ["stop"]})).await.map(|_| ())
375375+ }
376376+377377+ async fn next(&self) -> Result<()> {
378378+ self.cmd(json!({"command": ["playlist-next", "weak"]}))
379379+ .await
380380+ .map(|_| ())
381381+ }
382382+383383+ async fn previous(&self) -> Result<()> {
384384+ self.cmd(json!({"command": ["playlist-prev", "weak"]}))
385385+ .await
386386+ .map(|_| ())
387387+ }
388388+389389+ async fn seek(&self, position_secs: f64) -> Result<()> {
390390+ self.cmd(json!({"command": ["seek", position_secs, "absolute"]}))
391391+ .await
392392+ .map(|_| ())
393393+ }
394394+395395+ async fn set_volume(&self, volume: f32) -> Result<()> {
396396+ let pct = (volume.clamp(0.0, 1.5) * 100.0) as i32;
397397+ self.cmd(json!({"command": ["set_property", "volume", pct]}))
398398+ .await
399399+ .map(|_| ())
400400+ }
401401+402402+ fn state(&self) -> PlaybackState {
403403+ self.state.lock().clone()
404404+ }
405405+}
406406+407407+impl Drop for MpvRenderer {
408408+ fn drop(&mut self) {
409409+ if let Some(mut child) = self.child.lock().take() {
410410+ let _ = child.start_kill();
411411+ }
412412+ let _ = std::fs::remove_file(&self.socket_path);
413413+ }
414414+}
+138
crates/fin-player/src/queue.rs
···11+use std::sync::Arc;
22+33+use parking_lot::RwLock;
44+use serde::{Deserialize, Serialize};
55+66+/// Everything a renderer needs to play a single item.
77+#[derive(Debug, Clone, Serialize, Deserialize)]
88+pub struct QueueItem {
99+ pub id: String,
1010+ pub title: String,
1111+ pub subtitle: String,
1212+ pub stream_url: String,
1313+ pub image_url: Option<String>,
1414+ pub duration_secs: Option<u64>,
1515+ pub is_video: bool,
1616+ pub content_type: String,
1717+}
1818+1919+/// A thread-safe playback queue. Both the TUI and renderers share the same
2020+/// queue snapshot; the renderer is the source of truth for the current index.
2121+#[derive(Debug, Clone, Default)]
2222+pub struct PlaybackQueue {
2323+ inner: Arc<RwLock<QueueInner>>,
2424+}
2525+2626+#[derive(Debug, Default)]
2727+struct QueueInner {
2828+ items: Vec<QueueItem>,
2929+ index: Option<usize>,
3030+}
3131+3232+impl PlaybackQueue {
3333+ pub fn new() -> Self {
3434+ Self::default()
3535+ }
3636+3737+ pub fn items(&self) -> Vec<QueueItem> {
3838+ self.inner.read().items.clone()
3939+ }
4040+4141+ pub fn current_index(&self) -> Option<usize> {
4242+ self.inner.read().index
4343+ }
4444+4545+ pub fn current(&self) -> Option<QueueItem> {
4646+ let g = self.inner.read();
4747+ g.index.and_then(|i| g.items.get(i).cloned())
4848+ }
4949+5050+ pub fn len(&self) -> usize {
5151+ self.inner.read().items.len()
5252+ }
5353+5454+ pub fn is_empty(&self) -> bool {
5555+ self.inner.read().items.is_empty()
5656+ }
5757+5858+ pub fn replace(&self, items: Vec<QueueItem>, index: usize) {
5959+ let mut g = self.inner.write();
6060+ g.items = items;
6161+ g.index = if g.items.is_empty() {
6262+ None
6363+ } else {
6464+ Some(index.min(g.items.len().saturating_sub(1)))
6565+ };
6666+ }
6767+6868+ pub fn append(&self, items: Vec<QueueItem>) {
6969+ let mut g = self.inner.write();
7070+ let was_empty = g.items.is_empty();
7171+ g.items.extend(items);
7272+ if was_empty && !g.items.is_empty() {
7373+ g.index = Some(0);
7474+ }
7575+ }
7676+7777+ pub fn insert_next(&self, items: Vec<QueueItem>) {
7878+ let mut g = self.inner.write();
7979+ let at = g.index.map(|i| i + 1).unwrap_or(0);
8080+ for (offset, item) in items.into_iter().enumerate() {
8181+ g.items.insert(at + offset, item);
8282+ }
8383+ if g.index.is_none() && !g.items.is_empty() {
8484+ g.index = Some(0);
8585+ }
8686+ }
8787+8888+ pub fn advance(&self) -> Option<usize> {
8989+ let mut g = self.inner.write();
9090+ let next = match g.index {
9191+ Some(i) if i + 1 < g.items.len() => Some(i + 1),
9292+ None if !g.items.is_empty() => Some(0),
9393+ _ => None,
9494+ };
9595+ g.index = next;
9696+ next
9797+ }
9898+9999+ pub fn back(&self) -> Option<usize> {
100100+ let mut g = self.inner.write();
101101+ let prev = match g.index {
102102+ Some(i) if i > 0 => Some(i - 1),
103103+ _ => g.index,
104104+ };
105105+ g.index = prev;
106106+ prev
107107+ }
108108+109109+ pub fn set_index(&self, i: usize) {
110110+ let mut g = self.inner.write();
111111+ if i < g.items.len() {
112112+ g.index = Some(i);
113113+ }
114114+ }
115115+116116+ pub fn clear(&self) {
117117+ let mut g = self.inner.write();
118118+ g.items.clear();
119119+ g.index = None;
120120+ }
121121+122122+ pub fn remove(&self, i: usize) {
123123+ let mut g = self.inner.write();
124124+ if i >= g.items.len() {
125125+ return;
126126+ }
127127+ g.items.remove(i);
128128+ if g.items.is_empty() {
129129+ g.index = None;
130130+ } else if let Some(cur) = g.index {
131131+ if i < cur {
132132+ g.index = Some(cur - 1);
133133+ } else if i == cur && cur >= g.items.len() {
134134+ g.index = Some(g.items.len() - 1);
135135+ }
136136+ }
137137+ }
138138+}
···11+use clap::builder::styling::{AnsiColor, Color, RgbColor, Style, Styles};
22+use clap::{Args, Parser, Subcommand, ValueEnum};
33+use fin_config::RendererPref;
44+55+/// Neon-electric palette applied to clap's help & error output.
66+/// Mirrors the ratatui theme in `fin_tui::theme`.
77+pub fn neon_styles() -> Styles {
88+ let primary = Color::Rgb(RgbColor(0, 232, 198)); // electric teal — section titles
99+ let sky = Color::Rgb(RgbColor(0, 210, 255)); // sky blue — flag literals
1010+ let accent = Color::Rgb(RgbColor(130, 100, 255)); // violet — "Usage:" line
1111+ let highlight = Color::Rgb(RgbColor(100, 232, 130)); // mint — valid values
1212+ let link = Color::Rgb(RgbColor(255, 160, 100)); // orange — <PLACEHOLDER> tokens
1313+ let error = Color::Rgb(RgbColor(255, 100, 100)); // red — "error:" prefix
1414+1515+ Styles::styled()
1616+ .header(Style::new().bold().underline().fg_color(Some(primary)))
1717+ .usage(Style::new().bold().fg_color(Some(accent)))
1818+ .literal(Style::new().bold().fg_color(Some(sky)))
1919+ .placeholder(Style::new().fg_color(Some(link)))
2020+ .valid(Style::new().bold().fg_color(Some(highlight)))
2121+ .invalid(
2222+ Style::new()
2323+ .bold()
2424+ .fg_color(Some(Color::Ansi(AnsiColor::Yellow))),
2525+ )
2626+ .error(Style::new().bold().fg_color(Some(error)))
2727+}
2828+2929+/// A clap-friendly mirror of `fin_config::RendererPref`. Kept out of
3030+/// `fin-config` so that library doesn't pull in clap.
3131+#[derive(Debug, Clone, Copy, ValueEnum)]
3232+pub enum RendererArg {
3333+ Mpv,
3434+ Chromecast,
3535+}
3636+3737+impl From<RendererArg> for RendererPref {
3838+ fn from(a: RendererArg) -> Self {
3939+ match a {
4040+ RendererArg::Mpv => RendererPref::Mpv,
4141+ RendererArg::Chromecast => RendererPref::Chromecast,
4242+ }
4343+ }
4444+}
4545+4646+/// fin — a neon-electric TUI Jellyfin client for mpv & Chromecast.
4747+///
4848+/// Every setting is exposed as both a CLI flag and a TOML key:
4949+///
5050+/// fin --server https://media.example --renderer chromecast --chromecast "Living Room"
5151+///
5252+/// or persist the equivalent settings in the config file (see `fin config --path`).
5353+#[derive(Debug, Parser, Clone)]
5454+#[command(
5555+ author,
5656+ version,
5757+ about,
5858+ long_about,
5959+ propagate_version = true,
6060+ arg_required_else_help = false,
6161+ styles = neon_styles(),
6262+)]
6363+pub struct Cli {
6464+ /// Increase log verbosity (-v, -vv).
6565+ #[arg(short, long, action = clap::ArgAction::Count, global = true)]
6666+ pub verbose: u8,
6767+6868+ /// Jellyfin server URL. Overrides the TOML `server.url`.
6969+ #[arg(long, global = true, env = "FIN_SERVER")]
7070+ pub server: Option<String>,
7171+7272+ /// Use a saved server by its name (see `fin server`). Overrides `current_server`.
7373+ #[arg(long = "server-name", global = true, env = "FIN_SERVER_NAME")]
7474+ pub server_name: Option<String>,
7575+7676+ /// Access token (skip `login` if you already have one). Overrides `server.access_token`.
7777+ #[arg(long, global = true, env = "FIN_TOKEN")]
7878+ pub token: Option<String>,
7979+8080+ /// User id. Overrides `server.user_id`.
8181+ #[arg(long, global = true, env = "FIN_USER_ID")]
8282+ pub user_id: Option<String>,
8383+8484+ /// User name (display only). Overrides `server.user_name`.
8585+ #[arg(long, global = true)]
8686+ pub user_name: Option<String>,
8787+8888+ /// Client device id. Overrides `server.device_id`.
8989+ #[arg(long, global = true, env = "FIN_DEVICE_ID")]
9090+ pub device_id: Option<String>,
9191+9292+ /// Renderer to use. Advanced form; usually `--mpv` / `--chromecast NAME` is enough.
9393+ #[arg(long, global = true, value_enum, env = "FIN_RENDERER")]
9494+ pub renderer: Option<RendererArg>,
9595+9696+ /// Force the local mpv renderer.
9797+ #[arg(long, global = true, conflicts_with_all = ["chromecast", "renderer"])]
9898+ pub mpv: bool,
9999+100100+ /// Cast to a Chromecast by display name. Implies `--renderer chromecast`.
101101+ /// Pass an empty string to auto-pick the first device found.
102102+ #[arg(long, global = true, env = "FIN_CHROMECAST", num_args = 0..=1, default_missing_value = "")]
103103+ pub chromecast: Option<String>,
104104+105105+ #[command(subcommand)]
106106+ pub command: Option<Command>,
107107+}
108108+109109+#[derive(Debug, Subcommand, Clone)]
110110+pub enum Command {
111111+ /// (default) launch the neon TUI.
112112+ Tui,
113113+114114+ /// Authenticate against a Jellyfin server and save credentials.
115115+ /// Servers are stored by name — pass `--name` to keep more than one.
116116+ Login {
117117+ /// Server URL — e.g. https://media.example
118118+ url: String,
119119+ /// Username (prompted if omitted).
120120+ #[arg(long, short = 'u')]
121121+ username: Option<String>,
122122+ /// Password (prompted if omitted, hidden while typing).
123123+ #[arg(long, short = 'p')]
124124+ password: Option<String>,
125125+ /// Short name for this server. Defaults to the URL host
126126+ /// (e.g. `media.example.com`). Reusing an existing name updates it.
127127+ #[arg(long, short = 'n')]
128128+ name: Option<String>,
129129+ },
130130+131131+ /// Remove a saved server. If no `--name` is given, the current server is removed.
132132+ Logout {
133133+ #[arg(long, short = 'n')]
134134+ name: Option<String>,
135135+ },
136136+137137+ /// Manage saved Jellyfin servers.
138138+ #[command(subcommand)]
139139+ Server(ServerCmd),
140140+141141+ /// Search the server from the shell.
142142+ Search {
143143+ /// Query string.
144144+ query: String,
145145+ /// Restrict to a kind: audio | music | video | movies (default: all).
146146+ #[arg(long, short = 'k')]
147147+ kind: Option<String>,
148148+ /// Max results.
149149+ #[arg(long, default_value_t = 25)]
150150+ limit: u32,
151151+ },
152152+153153+ /// Search + play the first hit on the configured renderer.
154154+ Play(PlayArgs),
155155+156156+ /// Search + append to the current renderer queue.
157157+ Queue(PlayArgs),
158158+159159+ /// List Chromecasts on the local network.
160160+ Devices {
161161+ /// mDNS scan duration in seconds.
162162+ #[arg(long, default_value_t = 4)]
163163+ scan_seconds: u64,
164164+ },
165165+166166+ /// List playlists, or dump one when given `--list <id>`.
167167+ Playlists {
168168+ /// Playlist id to dump.
169169+ #[arg(long)]
170170+ list: Option<String>,
171171+ },
172172+173173+ /// Inspect or locate the config file.
174174+ Config {
175175+ /// Print the current config.
176176+ #[arg(long)]
177177+ show: bool,
178178+ /// Print the config file path.
179179+ #[arg(long)]
180180+ path: bool,
181181+ },
182182+}
183183+184184+#[derive(Debug, Subcommand, Clone)]
185185+pub enum ServerCmd {
186186+ /// List all saved servers. The current one is marked ▍.
187187+ #[command(alias = "ls")]
188188+ List,
189189+ /// Switch the active server by name.
190190+ Switch {
191191+ /// Server name (see `fin server list`).
192192+ name: String,
193193+ },
194194+ /// Remove a saved server.
195195+ #[command(alias = "remove")]
196196+ Rm {
197197+ /// Server name (see `fin server list`).
198198+ name: String,
199199+ },
200200+ /// Rename a saved server.
201201+ Rename {
202202+ /// Old name.
203203+ from: String,
204204+ /// New name.
205205+ to: String,
206206+ },
207207+}
208208+209209+#[derive(Debug, Args, Clone)]
210210+pub struct PlayArgs {
211211+ /// Search query. The first hit wins.
212212+ pub query: String,
213213+ /// Restrict to a kind: audio | music | video | movies.
214214+ #[arg(long, short = 'k')]
215215+ pub kind: Option<String>,
216216+ /// Force HLS transcoded stream (default for Chromecast).
217217+ #[arg(long)]
218218+ pub hls: bool,
219219+}
220220+221221+// `Queue` reuses `PlayArgs` semantics. Introducing a distinct type keeps the
222222+// derive-based subcommand parsing simple.
223223+#[derive(Debug, Args, Clone)]
224224+pub struct QueueArgs {
225225+ /// Search query. The first hit wins.
226226+ pub query: String,
227227+ /// Restrict to a kind: audio | music | video | movies.
228228+ #[arg(long, short = 'k')]
229229+ pub kind: Option<String>,
230230+ /// Force HLS transcoded stream (default for Chromecast).
231231+ #[arg(long)]
232232+ pub hls: bool,
233233+}
234234+235235+impl From<PlayArgs> for QueueArgs {
236236+ fn from(p: PlayArgs) -> Self {
237237+ Self {
238238+ query: p.query,
239239+ kind: p.kind,
240240+ hls: p.hls,
241241+ }
242242+ }
243243+}
+478
crates/fin/src/main.rs
···11+mod cli;
22+mod preflight;
33+44+use std::io::{self, Write};
55+use std::sync::Arc;
66+use std::time::Duration;
77+88+use anyhow::{Context, Result};
99+use clap::Parser;
1010+use fin_config::{derive_server_name, Config, RendererPref, ServerConfig};
1111+use fin_jellyfin::{ItemKind, JellyfinClient, StreamFormat};
1212+use fin_player::{
1313+ discover_chromecasts, CastDevice, ChromecastRenderer, MpvRenderer, QueueItem, Renderer,
1414+ RendererKind,
1515+};
1616+use fin_tui::{run_tui, App};
1717+use tracing_subscriber::EnvFilter;
1818+1919+use crate::cli::{Cli, Command, PlayArgs, ServerCmd};
2020+2121+#[tokio::main]
2222+async fn main() -> Result<()> {
2323+ let cli = Cli::parse();
2424+ init_tracing(cli.verbose);
2525+2626+ // preflight: mpv must be installed no matter what renderer the user picks
2727+ preflight::ensure_mpv()?;
2828+2929+ // Merge CLI flags into the on-disk config so any setting can be overridden inline.
3030+ let mut config = load_and_merge(&cli)?;
3131+3232+ match cli.command.clone().unwrap_or(Command::Tui) {
3333+ Command::Tui => run_tui_cmd(config).await,
3434+ Command::Login {
3535+ url,
3636+ username,
3737+ password,
3838+ name,
3939+ } => cmd_login(&mut config, url, username, password, name).await,
4040+ Command::Logout { name } => cmd_logout(&mut config, name),
4141+ Command::Server(cmd) => cmd_server(&mut config, cmd),
4242+ Command::Search { query, kind, limit } => cmd_search(&config, &query, kind, limit).await,
4343+ Command::Play(args) => cmd_play(&config, args, false).await,
4444+ Command::Queue(args) => cmd_play(&config, args, true).await,
4545+ Command::Devices { scan_seconds } => cmd_devices(scan_seconds).await,
4646+ Command::Playlists { list } => cmd_playlists(&config, list).await,
4747+ Command::Config { show, path } => cmd_config(&config, show, path),
4848+ }
4949+}
5050+5151+fn init_tracing(verbose: u8) {
5252+ let level = match verbose {
5353+ 0 => "warn,fin=info",
5454+ 1 => "info,fin=debug",
5555+ _ => "debug",
5656+ };
5757+ let _ = tracing_subscriber::fmt()
5858+ .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| level.into()))
5959+ .with_target(false)
6060+ .with_writer(io::stderr)
6161+ .try_init();
6262+}
6363+6464+fn load_and_merge(cli: &Cli) -> Result<Config> {
6565+ let mut cfg = Config::load()?;
6666+6767+ // 1) Pick which server this invocation should use.
6868+ if let Some(name) = cli.server_name.as_deref() {
6969+ // Non-fatal: if the name doesn't exist yet, we let the actual
7070+ // command (e.g. `login`) create it.
7171+ if cfg.find_server(name).is_some() {
7272+ cfg.switch_to(name)?;
7373+ } else {
7474+ cfg.current_server = Some(name.to_string());
7575+ }
7676+ }
7777+7878+ // 2) Layer inline flag overrides onto the *current* server, if any.
7979+ // `--server URL` on its own (no `--server-name`) points to an
8080+ // ephemeral server used for this run only, so we upsert one under
8181+ // the URL host as its name.
8282+ if let Some(url) = &cli.server {
8383+ let name = cli
8484+ .server_name
8585+ .clone()
8686+ .unwrap_or_else(|| derive_server_name(url));
8787+ let existing = cfg.find_server(&name).cloned();
8888+ let merged = ServerConfig {
8989+ name: name.clone(),
9090+ url: url.clone(),
9191+ user_id: cli
9292+ .user_id
9393+ .clone()
9494+ .or_else(|| existing.as_ref().map(|s| s.user_id.clone()))
9595+ .unwrap_or_default(),
9696+ user_name: cli
9797+ .user_name
9898+ .clone()
9999+ .or_else(|| existing.as_ref().map(|s| s.user_name.clone()))
100100+ .unwrap_or_default(),
101101+ access_token: cli
102102+ .token
103103+ .clone()
104104+ .or_else(|| existing.as_ref().map(|s| s.access_token.clone()))
105105+ .unwrap_or_default(),
106106+ device_id: cli
107107+ .device_id
108108+ .clone()
109109+ .or_else(|| existing.as_ref().map(|s| s.device_id.clone()))
110110+ .unwrap_or_default(),
111111+ };
112112+ cfg.add_or_update_server(merged);
113113+ } else {
114114+ // No URL override — still let per-field flags patch the current server.
115115+ if let Some(cur_name) = cfg.current_server.clone() {
116116+ if let Some(server) = cfg.servers.iter_mut().find(|s| s.name == cur_name) {
117117+ if let Some(t) = &cli.token {
118118+ server.access_token = t.clone();
119119+ }
120120+ if let Some(u) = &cli.user_id {
121121+ server.user_id = u.clone();
122122+ }
123123+ if let Some(u) = &cli.user_name {
124124+ server.user_name = u.clone();
125125+ }
126126+ if let Some(d) = &cli.device_id {
127127+ server.device_id = d.clone();
128128+ }
129129+ }
130130+ }
131131+ }
132132+133133+ // Shortcut flags win over `--renderer` and the on-disk pref.
134134+ // Default (when nothing is set) → mpv.
135135+ if cli.mpv {
136136+ cfg.renderer = RendererPref::Mpv;
137137+ } else if let Some(name) = &cli.chromecast {
138138+ cfg.renderer = RendererPref::Chromecast;
139139+ if !name.is_empty() {
140140+ cfg.last_chromecast = Some(name.clone());
141141+ }
142142+ } else if let Some(r) = cli.renderer {
143143+ cfg.renderer = r.into();
144144+ }
145145+ Ok(cfg)
146146+}
147147+148148+fn make_client(cfg: &Config) -> Result<JellyfinClient> {
149149+ let server = cfg.require_current()?;
150150+ JellyfinClient::with_credentials(
151151+ &server.url,
152152+ &server.device_id,
153153+ &server.user_id,
154154+ &server.access_token,
155155+ )
156156+}
157157+158158+async fn build_renderer(cfg: &Config) -> Result<(Arc<dyn Renderer>, String)> {
159159+ match cfg.renderer {
160160+ RendererPref::Mpv => {
161161+ let r = MpvRenderer::new(None);
162162+ Ok((Arc::new(r), "this machine".to_string()))
163163+ }
164164+ RendererPref::Chromecast => {
165165+ // Try to reconnect to the previously used device, otherwise
166166+ // pick the first one we discover.
167167+ let devices = discover_chromecasts(Duration::from_secs(3)).await?;
168168+ let target = pick_chromecast(&devices, cfg.last_chromecast.as_deref())?;
169169+ let name = target.display_name();
170170+ let r = ChromecastRenderer::connect(target).await?;
171171+ Ok((Arc::new(r), name))
172172+ }
173173+ }
174174+}
175175+176176+fn pick_chromecast(devices: &[CastDevice], preferred: Option<&str>) -> Result<CastDevice> {
177177+ if devices.is_empty() {
178178+ anyhow::bail!("no Chromecasts found on the local network");
179179+ }
180180+ if let Some(name) = preferred {
181181+ if let Some(d) = devices
182182+ .iter()
183183+ .find(|d| d.display_name().eq_ignore_ascii_case(name))
184184+ {
185185+ return Ok(d.clone());
186186+ }
187187+ }
188188+ Ok(devices[0].clone())
189189+}
190190+191191+async fn run_tui_cmd(cfg: Config) -> Result<()> {
192192+ let client = make_client(&cfg)?;
193193+ let (renderer, _) = build_renderer(&cfg).await?;
194194+ let app = App::new(cfg, client, renderer);
195195+ run_tui(app).await
196196+}
197197+198198+async fn cmd_login(
199199+ config: &mut Config,
200200+ url: String,
201201+ username: Option<String>,
202202+ password: Option<String>,
203203+ name: Option<String>,
204204+) -> Result<()> {
205205+ let url = normalize_url(&url);
206206+ let server_name = name.unwrap_or_else(|| derive_server_name(&url));
207207+ let username = match username {
208208+ Some(u) => u,
209209+ None => prompt_line("Username: ")?,
210210+ };
211211+ let password = match password {
212212+ Some(p) => p,
213213+ None => prompt_password("Password: ")?,
214214+ };
215215+ let mut client = JellyfinClient::new(&url)?;
216216+ let auth = client.login(&username, &password).await?;
217217+ let server = ServerConfig {
218218+ name: server_name.clone(),
219219+ url,
220220+ user_id: auth.user.id.clone(),
221221+ user_name: auth.user.name.clone(),
222222+ access_token: auth.access_token.clone(),
223223+ device_id: client.device_id().to_string(),
224224+ };
225225+ config.add_or_update_server(server);
226226+ config.save()?;
227227+ println!(
228228+ "✓ signed in as {} on `{}` — now the active server",
229229+ auth.user.name, server_name
230230+ );
231231+ if config.servers.len() > 1 {
232232+ println!(
233233+ " ({} servers saved — switch anytime with `fin server switch <name>`)",
234234+ config.servers.len()
235235+ );
236236+ }
237237+ Ok(())
238238+}
239239+240240+fn cmd_logout(config: &mut Config, name: Option<String>) -> Result<()> {
241241+ let target = name
242242+ .or_else(|| config.current_server.clone())
243243+ .context("no server to log out of")?;
244244+ config.remove_server(&target)?;
245245+ config.save()?;
246246+ println!("✓ removed `{}`", target);
247247+ if let Some(cur) = &config.current_server {
248248+ println!(" now active: `{}`", cur);
249249+ } else {
250250+ println!(" no servers left — run `fin login <url>` to add one");
251251+ }
252252+ Ok(())
253253+}
254254+255255+fn cmd_server(config: &mut Config, sub: ServerCmd) -> Result<()> {
256256+ match sub {
257257+ ServerCmd::List => {
258258+ if config.servers.is_empty() {
259259+ println!("no servers yet — run `fin login <url>` to add one");
260260+ return Ok(());
261261+ }
262262+ let cur = config.current_server.as_deref();
263263+ for s in &config.servers {
264264+ let marker = if Some(s.name.as_str()) == cur {
265265+ "▍"
266266+ } else {
267267+ " "
268268+ };
269269+ println!(
270270+ "{} {:<20} {:<45} as {}",
271271+ marker, s.name, s.url, s.user_name
272272+ );
273273+ }
274274+ }
275275+ ServerCmd::Switch { name } => {
276276+ config.switch_to(&name)?;
277277+ config.save()?;
278278+ let s = config.current().unwrap();
279279+ println!(
280280+ "✓ active server → `{}` ({}, as {})",
281281+ s.name, s.url, s.user_name
282282+ );
283283+ }
284284+ ServerCmd::Rm { name } => {
285285+ config.remove_server(&name)?;
286286+ config.save()?;
287287+ println!("✓ removed `{}`", name);
288288+ }
289289+ ServerCmd::Rename { from, to } => {
290290+ let Some(idx) = config.servers.iter().position(|s| s.name == from) else {
291291+ anyhow::bail!("no server named `{}`", from);
292292+ };
293293+ if config.servers.iter().any(|s| s.name == to) {
294294+ anyhow::bail!("a server named `{}` already exists", to);
295295+ }
296296+ config.servers[idx].name = to.clone();
297297+ if config.current_server.as_deref() == Some(from.as_str()) {
298298+ config.current_server = Some(to.clone());
299299+ }
300300+ config.save()?;
301301+ println!("✓ `{}` → `{}`", from, to);
302302+ }
303303+ }
304304+ Ok(())
305305+}
306306+307307+async fn cmd_search(cfg: &Config, query: &str, kind: Option<String>, limit: u32) -> Result<()> {
308308+ let client = make_client(cfg)?;
309309+ let types: Vec<&str> = match kind.as_deref() {
310310+ Some("audio") | Some("music") => vec!["Audio", "MusicAlbum", "MusicArtist"],
311311+ Some("video") | Some("movies") => vec!["Movie", "Series", "Episode"],
312312+ _ => vec![
313313+ "Audio",
314314+ "MusicAlbum",
315315+ "MusicArtist",
316316+ "Movie",
317317+ "Series",
318318+ "Episode",
319319+ ],
320320+ };
321321+ let items = client.search(query, &types, limit).await?;
322322+ for it in &items {
323323+ println!("{} {} {}", it.kind().icon(), it.name, it.subtitle());
324324+ }
325325+ if items.is_empty() {
326326+ eprintln!("no matches");
327327+ }
328328+ Ok(())
329329+}
330330+331331+async fn cmd_play(cfg: &Config, args: PlayArgs, queue: bool) -> Result<()> {
332332+ let client = make_client(cfg)?;
333333+ let format = if args.hls || cfg.renderer == RendererPref::Chromecast {
334334+ StreamFormat::Hls
335335+ } else {
336336+ StreamFormat::Direct
337337+ };
338338+ let types: Vec<&str> = match args.kind.as_deref() {
339339+ Some("audio") | Some("music") => vec!["Audio", "MusicAlbum"],
340340+ Some("video") | Some("movie") | Some("movies") => vec!["Movie", "Episode"],
341341+ _ => vec!["Audio", "MusicAlbum", "Movie", "Episode"],
342342+ };
343343+ let hits = client.search(&args.query, &types, 10).await?;
344344+ let Some(hit) = hits.into_iter().next() else {
345345+ anyhow::bail!("nothing matched '{}'", args.query);
346346+ };
347347+ let items = match hit.kind() {
348348+ ItemKind::MusicAlbum => {
349349+ client
350350+ .items(
351351+ Some(&hit.id),
352352+ &["Audio"],
353353+ false,
354354+ Some("SortName"),
355355+ Some(500),
356356+ )
357357+ .await?
358358+ }
359359+ _ => vec![hit],
360360+ };
361361+ let mut queue_items = Vec::new();
362362+ for it in items {
363363+ let url = client.stream_url(&it, format)?;
364364+ let is_video = it.kind().is_video();
365365+ queue_items.push(QueueItem {
366366+ id: it.id.clone(),
367367+ title: it.name.clone(),
368368+ subtitle: it.subtitle(),
369369+ stream_url: url,
370370+ image_url: None,
371371+ duration_secs: it.duration_secs(),
372372+ is_video,
373373+ content_type: if is_video {
374374+ "video/mp4".into()
375375+ } else {
376376+ "audio/mpeg".into()
377377+ },
378378+ });
379379+ }
380380+ let (renderer, label) = build_renderer(cfg).await?;
381381+ if queue {
382382+ renderer.enqueue(queue_items.clone()).await?;
383383+ println!("+ queued {} item(s) on {}", queue_items.len(), label);
384384+ } else {
385385+ renderer.play(queue_items.clone(), 0).await?;
386386+ println!(
387387+ "▶ playing “{}” on {}",
388388+ queue_items.first().map(|q| q.title.as_str()).unwrap_or(""),
389389+ label
390390+ );
391391+ }
392392+ // For chromecast, keep the process alive so playback keeps flowing.
393393+ if renderer.kind() == RendererKind::Chromecast {
394394+ println!("Ctrl+C to disconnect.");
395395+ tokio::signal::ctrl_c().await?;
396396+ }
397397+ Ok(())
398398+}
399399+400400+async fn cmd_devices(scan_seconds: u64) -> Result<()> {
401401+ println!("scanning for Chromecasts ({}s)…", scan_seconds);
402402+ let devices = discover_chromecasts(Duration::from_secs(scan_seconds)).await?;
403403+ if devices.is_empty() {
404404+ eprintln!("no devices found");
405405+ return Ok(());
406406+ }
407407+ for d in devices {
408408+ println!(
409409+ " {} [{}] {}:{}",
410410+ d.display_name(),
411411+ d.model,
412412+ d.address,
413413+ d.port
414414+ );
415415+ }
416416+ Ok(())
417417+}
418418+419419+async fn cmd_playlists(cfg: &Config, list_id: Option<String>) -> Result<()> {
420420+ let client = make_client(cfg)?;
421421+ match list_id {
422422+ None => {
423423+ let pls = client.playlists().await?;
424424+ for p in pls {
425425+ println!("▤ {} ({})", p.name, p.id);
426426+ }
427427+ }
428428+ Some(id) => {
429429+ let items = client.playlist_items(&id).await?;
430430+ for it in items {
431431+ println!("{} {} {}", it.kind().icon(), it.name, it.subtitle());
432432+ }
433433+ }
434434+ }
435435+ Ok(())
436436+}
437437+438438+fn cmd_config(cfg: &Config, show: bool, path: bool) -> Result<()> {
439439+ if path {
440440+ println!("{}", fin_config::config_path()?.display());
441441+ return Ok(());
442442+ }
443443+ if show {
444444+ let text = toml::to_string_pretty(cfg).context("serialize config")?;
445445+ print!("{}", text);
446446+ return Ok(());
447447+ }
448448+ println!(
449449+ "config file: {}\nuse --show to print, --path to print just the path",
450450+ fin_config::config_path()?.display()
451451+ );
452452+ Ok(())
453453+}
454454+455455+fn normalize_url(url: &str) -> String {
456456+ let trimmed = url.trim().trim_end_matches('/');
457457+ if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
458458+ trimmed.to_string()
459459+ } else {
460460+ format!("http://{}", trimmed)
461461+ }
462462+}
463463+464464+fn prompt_line(msg: &str) -> Result<String> {
465465+ print!("{}", msg);
466466+ io::stdout().flush().ok();
467467+ let mut buf = String::new();
468468+ io::stdin().read_line(&mut buf)?;
469469+ Ok(buf.trim().to_string())
470470+}
471471+472472+/// Read a secret from the TTY without echoing it. Falls back to the standard
473473+/// prompt only when stdin is not a TTY (e.g. piped input from a script) so
474474+/// automation still works.
475475+fn prompt_password(msg: &str) -> Result<String> {
476476+ let secret = rpassword::prompt_password(msg).context("reading password")?;
477477+ Ok(secret)
478478+}
+31
crates/fin/src/preflight.rs
···11+use std::process::Command;
22+33+use anyhow::{bail, Result};
44+55+/// Verify that mpv is on `$PATH`. fin uses mpv both as its local renderer and
66+/// as a fallback probe, so we refuse to start without it.
77+pub fn ensure_mpv() -> Result<()> {
88+ let ok = Command::new("mpv")
99+ .arg("--version")
1010+ .stdout(std::process::Stdio::null())
1111+ .stderr(std::process::Stdio::null())
1212+ .status()
1313+ .map(|s| s.success())
1414+ .unwrap_or(false);
1515+ if ok {
1616+ return Ok(());
1717+ }
1818+ let hint = if cfg!(target_os = "macos") {
1919+ "brew install mpv"
2020+ } else if cfg!(target_os = "linux") {
2121+ "sudo apt install mpv # or: sudo pacman -S mpv"
2222+ } else if cfg!(target_os = "windows") {
2323+ "winget install mpv # or: scoop install mpv"
2424+ } else {
2525+ "install mpv from https://mpv.io"
2626+ };
2727+ bail!(
2828+ "mpv is required but was not found on PATH.\n\nInstall it and try again:\n {}\n",
2929+ hint
3030+ );
3131+}