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

Configure Feed

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

Add built-in UPnP MediaRenderer — cast to fin from any control point

New fin-mediarenderer crate advertises the machine as a UPnP AV
MediaRenderer while the TUI runs: SSDP presence (alive/byebye +
M-SEARCH answers), device/SCPD description XML over a tiny embedded
HTTP server, AVTransport / RenderingControl / ConnectionManager SOAP
control, and GENA LastChange eventing.

Pushed streams drive the shared LocalRenderer, so audio decodes
in-process via symphonia and video opens in mpv. Cast-in tracks are
badged "⇊ UPnP" in the Now Playing bar with a one-shot status line,
and are excluded from scrobbling (their ids are foreign to the media
server).

Enabled by default; disable with --no-media-renderer,
FIN_NO_MEDIA_RENDERER, or media_renderer.enabled = false. The
friendly name and port are configurable, and the device UDN is
generated once and persisted so control points recognize the machine
across restarts.

Also: LocalRenderer now reports the audio player's real volume while
idle (UPnP GetVolume exposed the 100% snap-back), and a pre-existing
clippy never_loop error in discovery.rs is fixed.

Tsiry Sandratraina (Jul 7, 2026, 12:18 PM +0300) 4e88c9af 90dc271e

+2288 -13
+23
CHANGELOG.md
··· 5 5 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 6 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 7 8 + ## [Unreleased] 9 + 10 + ### Added 11 + - **Built-in UPnP MediaRenderer** (`fin-mediarenderer`) — fin now advertises 12 + *itself* as a cast target on the LAN while the TUI runs. Any UPnP control 13 + point (BubbleUPnP, Kodi, Jellyfin's "Play On", …) can push streams at 14 + this machine: audio decodes in-process via symphonia, video is handed to 15 + mpv, and the pushed track shows up in the Now Playing bar with a 16 + `⇊ UPnP` badge plus a status-line notice naming what arrived. Includes 17 + SSDP presence (alive/byebye + M-SEARCH answers), AVTransport / 18 + RenderingControl / ConnectionManager SOAP control, and GENA `LastChange` 19 + eventing. On by default; disable with `--no-media-renderer`, 20 + `FIN_NO_MEDIA_RENDERER=1`, or `media_renderer.enabled = false`. 21 + `media_renderer.friendly_name` / `.port` are also configurable, and the 22 + device UDN is generated once and persisted so control points recognize 23 + the machine across restarts. 24 + 25 + ### Fixed 26 + - **Idle volume readback** — `LocalRenderer` reported 100% volume while 27 + idle even after a volume change; it now mirrors the audio player's 28 + actual volume, so the TUI slider and UPnP `GetVolume` stay truthful 29 + between tracks. 30 + 8 31 ## [0.3.1] - 2026-07-05 9 32 10 33 ### Added
+20
Cargo.lock
··· 605 605 "fin-config", 606 606 "fin-jellyfin", 607 607 "fin-media", 608 + "fin-mediarenderer", 608 609 "fin-player", 609 610 "fin-tui", 610 611 "rpassword", ··· 615 616 "toml", 616 617 "tracing", 617 618 "tracing-subscriber", 619 + "uuid", 620 + "whoami", 618 621 ] 619 622 620 623 [[package]] ··· 660 663 "serde_json", 661 664 "tokio", 662 665 "tracing", 666 + ] 667 + 668 + [[package]] 669 + name = "fin-mediarenderer" 670 + version = "0.3.1" 671 + dependencies = [ 672 + "anyhow", 673 + "chrono", 674 + "fin-player", 675 + "parking_lot", 676 + "percent-encoding", 677 + "reqwest", 678 + "socket2 0.5.10", 679 + "tokio", 680 + "tracing", 681 + "tracing-subscriber", 682 + "uuid", 663 683 ] 664 684 665 685 [[package]]
+2
Cargo.toml
··· 33 33 mdns-sd = "0.11" 34 34 rust_cast = "0.20" 35 35 parking_lot = "0.12" 36 + socket2 = "0.5" 36 37 unicode-width = "0.2" 37 38 percent-encoding = "2" 38 39 whoami = "1" ··· 41 42 fin-config = { path = "crates/fin-config" } 42 43 fin-jellyfin = { path = "crates/fin-jellyfin" } 43 44 fin-media = { path = "crates/fin-media" } 45 + fin-mediarenderer = { path = "crates/fin-mediarenderer" } 44 46 fin-player = { path = "crates/fin-player" } 45 47 fin-subsonic = { path = "crates/fin-subsonic" } 46 48 fin-tui = { path = "crates/fin-tui" }
+58
README.md
··· 29 29 - [Nix](#nix) 30 30 - [Getting started](#getting-started) 31 31 - [Renderer selection](#renderer-selection) 32 + - [UPnP MediaRenderer — casting *to* fin](#upnp-mediarenderer--casting-to-fin) 32 33 - [All settings](#all-settings) 33 34 - [Multiple servers](#multiple-servers) 34 35 - [Sub-commands](#sub-commands) ··· 66 67 - **upnp** — SSDP discovery of any UPnP AV MediaRenderer, playback via 67 68 AVTransport (`SetAVTransportURI` / `Play` / `Pause` / `Stop` / `Seek`) 68 69 and volume via RenderingControl. Same auto-advancing queue. 70 + - **Cast *to* fin** — while the TUI runs, fin advertises itself as a 71 + **UPnP MediaRenderer** on the LAN. Push streams at it from BubbleUPnP, 72 + Kodi, Jellyfin's "Play On", or any other control point: audio decodes 73 + in-process via symphonia, video opens in mpv, and the pushed track lands 74 + in the Now Playing bar with a `⇊ UPnP` badge. On by default; opt out with 75 + `--no-media-renderer` or `media_renderer.enabled = false` 76 + (see [UPnP MediaRenderer](#upnp-mediarenderer--casting-to-fin)). 69 77 - **Playback modes** — shuffle, repeat-off/all/one, [ReplayGain](#replaygain) 70 78 (track / album), [crossfade](#crossfade) between adjacent tracks 71 79 (traditional cosine curves *or* additive DJ-mixed), and a ··· 242 250 to that protocol automatically and the named device is preferred on connect. 243 251 If the name is not found on the network, fin picks the first device discovered. 244 252 253 + ## UPnP MediaRenderer — casting *to* fin 254 + 255 + The renderer table above is about fin *sending* streams elsewhere. This is 256 + the opposite direction: while the TUI runs, fin also shows up on the LAN as 257 + a **UPnP AV MediaRenderer** device, so any control point — BubbleUPnP, 258 + Kodi, Jellyfin's "Play On", gupnp tools, another fin casting with 259 + `--upnp`, … — can push media at this machine. 260 + 261 + Incoming streams take the same local path as everything else: **audio is 262 + decoded in-process by symphonia** (never mpv), **video is handed to mpv**. 263 + The pushed track appears in the Now Playing bar with a violet `⇊ UPnP` 264 + badge and a one-shot status line (`⇊ receiving UPnP cast — <title>`), joins 265 + the queue like any other item, and answers the normal transport keys — 266 + `Space` pauses it, `s` stops it, `+`/`-` change volume. The control point 267 + sees those changes reflected back through AVTransport/RenderingControl 268 + state and GENA `LastChange` events. 269 + 270 + It's **on by default**. Three equivalent ways to turn it off: 271 + 272 + ```bash 273 + fin --no-media-renderer # this run only 274 + FIN_NO_MEDIA_RENDERER=1 fin # environment 275 + ``` 276 + 277 + ```toml 278 + # config.toml — persistent 279 + [media_renderer] 280 + enabled = false 281 + ``` 282 + 283 + Optional keys under the same table: 284 + 285 + ```toml 286 + [media_renderer] 287 + enabled = true 288 + friendly_name = "office fin" # picker name; default: fin (<hostname>) 289 + port = 47899 # description/control port; 0 = ephemeral 290 + # uuid is generated on first launch and persisted so control points 291 + # recognize the device across restarts — no need to set it by hand. 292 + ``` 293 + 294 + Playback pushed by a control point is **not scrobbled** — the item ids are 295 + foreign to your media server, so session reports/scrobbles are skipped for 296 + cast-in tracks. 297 + 245 298 ## All settings 246 299 247 300 Every setting exists as both a CLI flag and a TOML key. Flags win. ··· 258 311 | `--mpv` | | `renderer = "mpv"` | | 259 312 | `--chromecast [NAME]` | `FIN_CHROMECAST` | `last_chromecast` | | 260 313 | `--upnp [NAME]` | `FIN_UPNP` | `last_upnp` | | 314 + | `--no-media-renderer` | `FIN_NO_MEDIA_RENDERER` | `media_renderer.enabled` | `true` | 315 + | `--media-renderer` | | `media_renderer.enabled = true` | | 316 + | | | `media_renderer.friendly_name` | `fin (<hostname>)` | 317 + | | | `media_renderer.port` | `0` _(ephemeral)_ | 318 + | | | `media_renderer.uuid` | _(generated once)_ | 261 319 | `-v`, `-vv` | | _(log level)_ | `warn` | 262 320 263 321 Audio-side playback settings live under TOML sub-tables and are toggled from
+38
crates/fin-config/src/lib.rs
··· 52 52 /// Treble shelf cutoff in Hz. `0` = Rockbox default 3500 Hz. 53 53 #[serde(default)] 54 54 pub treble_cutoff: i32, 55 + /// UPnP MediaRenderer device settings — fin advertising *itself* as a 56 + /// cast target on the LAN (the receiving side; `renderer`/`last_upnp` 57 + /// above are the sending side). 58 + #[serde(default)] 59 + pub media_renderer: MediaRendererSettings, 60 + } 61 + 62 + /// Settings for the built-in UPnP MediaRenderer device (`fin-mediarenderer`). 63 + /// On by default; disable with `--no-media-renderer` or `enabled = false`. 64 + #[derive(Debug, Clone, Serialize, Deserialize)] 65 + pub struct MediaRendererSettings { 66 + #[serde(default = "default_media_renderer_enabled")] 67 + pub enabled: bool, 68 + /// Name shown in control-point pickers. Default: `fin (<hostname>)`. 69 + #[serde(default)] 70 + pub friendly_name: Option<String>, 71 + /// TCP port for the description/control endpoints. `0` = ephemeral. 72 + #[serde(default)] 73 + pub port: u16, 74 + /// Stable device UDN, generated and persisted on first launch so 75 + /// control points recognize the device across restarts. 76 + #[serde(default)] 77 + pub uuid: Option<String>, 78 + } 79 + 80 + fn default_media_renderer_enabled() -> bool { 81 + true 82 + } 83 + 84 + impl Default for MediaRendererSettings { 85 + fn default() -> Self { 86 + Self { 87 + enabled: true, 88 + friendly_name: None, 89 + port: 0, 90 + uuid: None, 91 + } 92 + } 55 93 } 56 94 57 95 /// The ISO-octave 10-band flat preset used when a fresh config has no
+22
crates/fin-mediarenderer/Cargo.toml
··· 1 + [package] 2 + name = "fin-mediarenderer" 3 + version.workspace = true 4 + edition.workspace = true 5 + authors.workspace = true 6 + license.workspace = true 7 + repository.workspace = true 8 + 9 + [dependencies] 10 + anyhow.workspace = true 11 + tokio.workspace = true 12 + tracing.workspace = true 13 + parking_lot.workspace = true 14 + uuid.workspace = true 15 + chrono.workspace = true 16 + reqwest.workspace = true 17 + percent-encoding.workspace = true 18 + socket2.workspace = true 19 + fin-player.workspace = true 20 + 21 + [dev-dependencies] 22 + tracing-subscriber.workspace = true
+37
crates/fin-mediarenderer/examples/standalone.rs
··· 1 + //! Run the MediaRenderer on its own for protocol debugging: 2 + //! 3 + //! cargo run --release -p fin-mediarenderer --example standalone 4 + //! 5 + //! Then probe it with a UPnP control point (BubbleUPnP, Kodi, `gupnp-av-cp`) 6 + //! or plain curl against the printed description URL. 7 + 8 + use std::sync::Arc; 9 + 10 + use fin_mediarenderer::{MediaRendererServer, Options, RendererCell}; 11 + use fin_player::{LocalRenderer, Renderer}; 12 + use parking_lot::Mutex; 13 + 14 + #[tokio::main] 15 + async fn main() -> anyhow::Result<()> { 16 + tracing_subscriber::fmt() 17 + .with_env_filter("debug") 18 + .with_writer(std::io::stderr) 19 + .init(); 20 + 21 + let renderer: Arc<dyn Renderer> = Arc::new(LocalRenderer::new()); 22 + let cell: RendererCell = Arc::new(Mutex::new(renderer)); 23 + let server = MediaRendererServer::start( 24 + Options { 25 + friendly_name: "fin standalone".into(), 26 + uuid: "f1a2b3c4-d5e6-4788-99aa-bbccddeeff00".into(), 27 + port: 47899, 28 + }, 29 + cell, 30 + ) 31 + .await?; 32 + 33 + println!("description: http://{}/description.xml", server.http_addr()); 34 + tokio::signal::ctrl_c().await?; 35 + server.shutdown().await; 36 + Ok(()) 37 + }
+343
crates/fin-mediarenderer/src/desc.rs
··· 1 + //! UPnP description documents: the root device description plus the three 2 + //! service SCPDs. Static except for friendlyName/UDN interpolation. 3 + 4 + use crate::xml::encode_entities; 5 + 6 + pub fn device_description(friendly_name: &str, uuid: &str) -> String { 7 + let name = encode_entities(friendly_name); 8 + let version = env!("CARGO_PKG_VERSION"); 9 + format!( 10 + r#"<?xml version="1.0" encoding="utf-8"?> 11 + <root xmlns="urn:schemas-upnp-org:device-1-0"> 12 + <specVersion><major>1</major><minor>0</minor></specVersion> 13 + <device> 14 + <deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType> 15 + <friendlyName>{name}</friendlyName> 16 + <manufacturer>tsirysndr</manufacturer> 17 + <manufacturerURL>https://github.com/tsirysndr/fin</manufacturerURL> 18 + <modelDescription>fin terminal media player — symphonia audio, mpv video</modelDescription> 19 + <modelName>fin</modelName> 20 + <modelNumber>{version}</modelNumber> 21 + <modelURL>https://github.com/tsirysndr/fin</modelURL> 22 + <UDN>uuid:{uuid}</UDN> 23 + <dlna:X_DLNADOC xmlns:dlna="urn:schemas-dlna-org:device-1-0">DMR-1.50</dlna:X_DLNADOC> 24 + <serviceList> 25 + <service> 26 + <serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType> 27 + <serviceId>urn:upnp-org:serviceId:AVTransport</serviceId> 28 + <SCPDURL>/scpd/AVTransport.xml</SCPDURL> 29 + <controlURL>/control/AVTransport</controlURL> 30 + <eventSubURL>/event/AVTransport</eventSubURL> 31 + </service> 32 + <service> 33 + <serviceType>urn:schemas-upnp-org:service:RenderingControl:1</serviceType> 34 + <serviceId>urn:upnp-org:serviceId:RenderingControl</serviceId> 35 + <SCPDURL>/scpd/RenderingControl.xml</SCPDURL> 36 + <controlURL>/control/RenderingControl</controlURL> 37 + <eventSubURL>/event/RenderingControl</eventSubURL> 38 + </service> 39 + <service> 40 + <serviceType>urn:schemas-upnp-org:service:ConnectionManager:1</serviceType> 41 + <serviceId>urn:upnp-org:serviceId:ConnectionManager</serviceId> 42 + <SCPDURL>/scpd/ConnectionManager.xml</SCPDURL> 43 + <controlURL>/control/ConnectionManager</controlURL> 44 + <eventSubURL>/event/ConnectionManager</eventSubURL> 45 + </service> 46 + </serviceList> 47 + </device> 48 + </root>"# 49 + ) 50 + } 51 + 52 + // SCPD helpers — the arg/var XML is highly repetitive, so build it from 53 + // short tables instead of one wall of literal text. 54 + 55 + fn action(name: &str, args: &[(&str, &str, &str)]) -> String { 56 + let list: String = args 57 + .iter() 58 + .map(|(n, dir, var)| { 59 + format!( 60 + "<argument><name>{n}</name><direction>{dir}</direction>\ 61 + <relatedStateVariable>{var}</relatedStateVariable></argument>" 62 + ) 63 + }) 64 + .collect(); 65 + format!("<action><name>{name}</name><argumentList>{list}</argumentList></action>") 66 + } 67 + 68 + fn var(name: &str, ty: &str, send_events: bool, allowed: &[&str]) -> String { 69 + let values: String = if allowed.is_empty() { 70 + String::new() 71 + } else { 72 + let items: String = allowed 73 + .iter() 74 + .map(|v| format!("<allowedValue>{v}</allowedValue>")) 75 + .collect(); 76 + format!("<allowedValueList>{items}</allowedValueList>") 77 + }; 78 + format!( 79 + "<stateVariable sendEvents=\"{}\"><name>{name}</name><dataType>{ty}</dataType>{values}</stateVariable>", 80 + if send_events { "yes" } else { "no" } 81 + ) 82 + } 83 + 84 + fn scpd(actions: String, vars: String) -> String { 85 + format!( 86 + r#"<?xml version="1.0" encoding="utf-8"?> 87 + <scpd xmlns="urn:schemas-upnp-org:service-1-0"> 88 + <specVersion><major>1</major><minor>0</minor></specVersion> 89 + <actionList>{actions}</actionList> 90 + <serviceStateTable>{vars}</serviceStateTable> 91 + </scpd>"# 92 + ) 93 + } 94 + 95 + pub fn av_transport_scpd() -> String { 96 + let id = ("InstanceID", "in", "A_ARG_TYPE_InstanceID"); 97 + let actions = [ 98 + action( 99 + "SetAVTransportURI", 100 + &[ 101 + id, 102 + ("CurrentURI", "in", "AVTransportURI"), 103 + ("CurrentURIMetaData", "in", "AVTransportURIMetaData"), 104 + ], 105 + ), 106 + action( 107 + "SetNextAVTransportURI", 108 + &[ 109 + id, 110 + ("NextURI", "in", "NextAVTransportURI"), 111 + ("NextURIMetaData", "in", "NextAVTransportURIMetaData"), 112 + ], 113 + ), 114 + action( 115 + "GetMediaInfo", 116 + &[ 117 + id, 118 + ("NrTracks", "out", "NumberOfTracks"), 119 + ("MediaDuration", "out", "CurrentMediaDuration"), 120 + ("CurrentURI", "out", "AVTransportURI"), 121 + ("CurrentURIMetaData", "out", "AVTransportURIMetaData"), 122 + ("NextURI", "out", "NextAVTransportURI"), 123 + ("NextURIMetaData", "out", "NextAVTransportURIMetaData"), 124 + ("PlayMedium", "out", "PlaybackStorageMedium"), 125 + ("RecordMedium", "out", "RecordStorageMedium"), 126 + ("WriteStatus", "out", "RecordMediumWriteStatus"), 127 + ], 128 + ), 129 + action( 130 + "GetTransportInfo", 131 + &[ 132 + id, 133 + ("CurrentTransportState", "out", "TransportState"), 134 + ("CurrentTransportStatus", "out", "TransportStatus"), 135 + ("CurrentSpeed", "out", "TransportPlaySpeed"), 136 + ], 137 + ), 138 + action( 139 + "GetPositionInfo", 140 + &[ 141 + id, 142 + ("Track", "out", "CurrentTrack"), 143 + ("TrackDuration", "out", "CurrentTrackDuration"), 144 + ("TrackMetaData", "out", "CurrentTrackMetaData"), 145 + ("TrackURI", "out", "CurrentTrackURI"), 146 + ("RelTime", "out", "RelativeTimePosition"), 147 + ("AbsTime", "out", "AbsoluteTimePosition"), 148 + ("RelCount", "out", "RelativeCounterPosition"), 149 + ("AbsCount", "out", "AbsoluteCounterPosition"), 150 + ], 151 + ), 152 + action( 153 + "GetDeviceCapabilities", 154 + &[ 155 + id, 156 + ("PlayMedia", "out", "PossiblePlaybackStorageMedia"), 157 + ("RecMedia", "out", "PossibleRecordStorageMedia"), 158 + ("RecQualityModes", "out", "PossibleRecordQualityModes"), 159 + ], 160 + ), 161 + action( 162 + "GetTransportSettings", 163 + &[ 164 + id, 165 + ("PlayMode", "out", "CurrentPlayMode"), 166 + ("RecQualityMode", "out", "CurrentRecordQualityMode"), 167 + ], 168 + ), 169 + action("Stop", &[id]), 170 + action("Play", &[id, ("Speed", "in", "TransportPlaySpeed")]), 171 + action("Pause", &[id]), 172 + action( 173 + "Seek", 174 + &[ 175 + id, 176 + ("Unit", "in", "A_ARG_TYPE_SeekMode"), 177 + ("Target", "in", "A_ARG_TYPE_SeekTarget"), 178 + ], 179 + ), 180 + action("Next", &[id]), 181 + action("Previous", &[id]), 182 + ] 183 + .concat(); 184 + 185 + let vars = [ 186 + var( 187 + "TransportState", 188 + "string", 189 + false, 190 + &[ 191 + "STOPPED", 192 + "PAUSED_PLAYBACK", 193 + "PLAYING", 194 + "TRANSITIONING", 195 + "NO_MEDIA_PRESENT", 196 + ], 197 + ), 198 + var( 199 + "TransportStatus", 200 + "string", 201 + false, 202 + &["OK", "ERROR_OCCURRED"], 203 + ), 204 + var("PlaybackStorageMedium", "string", false, &[]), 205 + var("RecordStorageMedium", "string", false, &[]), 206 + var("PossiblePlaybackStorageMedia", "string", false, &[]), 207 + var("PossibleRecordStorageMedia", "string", false, &[]), 208 + var("CurrentPlayMode", "string", false, &["NORMAL"]), 209 + var("TransportPlaySpeed", "string", false, &["1"]), 210 + var("RecordMediumWriteStatus", "string", false, &[]), 211 + var("CurrentRecordQualityMode", "string", false, &[]), 212 + var("PossibleRecordQualityModes", "string", false, &[]), 213 + var("NumberOfTracks", "ui4", false, &[]), 214 + var("CurrentTrack", "ui4", false, &[]), 215 + var("CurrentTrackDuration", "string", false, &[]), 216 + var("CurrentMediaDuration", "string", false, &[]), 217 + var("CurrentTrackMetaData", "string", false, &[]), 218 + var("CurrentTrackURI", "string", false, &[]), 219 + var("AVTransportURI", "string", false, &[]), 220 + var("AVTransportURIMetaData", "string", false, &[]), 221 + var("NextAVTransportURI", "string", false, &[]), 222 + var("NextAVTransportURIMetaData", "string", false, &[]), 223 + var("RelativeTimePosition", "string", false, &[]), 224 + var("AbsoluteTimePosition", "string", false, &[]), 225 + var("RelativeCounterPosition", "i4", false, &[]), 226 + var("AbsoluteCounterPosition", "i4", false, &[]), 227 + var( 228 + "A_ARG_TYPE_SeekMode", 229 + "string", 230 + false, 231 + &["REL_TIME", "ABS_TIME"], 232 + ), 233 + var("A_ARG_TYPE_SeekTarget", "string", false, &[]), 234 + var("A_ARG_TYPE_InstanceID", "ui4", false, &[]), 235 + var("LastChange", "string", true, &[]), 236 + ] 237 + .concat(); 238 + 239 + scpd(actions, vars) 240 + } 241 + 242 + pub fn rendering_control_scpd() -> String { 243 + let id = ("InstanceID", "in", "A_ARG_TYPE_InstanceID"); 244 + let ch = ("Channel", "in", "A_ARG_TYPE_Channel"); 245 + let actions = [ 246 + action("GetVolume", &[id, ch, ("CurrentVolume", "out", "Volume")]), 247 + action("SetVolume", &[id, ch, ("DesiredVolume", "in", "Volume")]), 248 + action("GetMute", &[id, ch, ("CurrentMute", "out", "Mute")]), 249 + action("SetMute", &[id, ch, ("DesiredMute", "in", "Mute")]), 250 + ] 251 + .concat(); 252 + let vars = [ 253 + var("Volume", "ui2", false, &[]), 254 + var("Mute", "boolean", false, &[]), 255 + var("A_ARG_TYPE_Channel", "string", false, &["Master"]), 256 + var("A_ARG_TYPE_InstanceID", "ui4", false, &[]), 257 + var("LastChange", "string", true, &[]), 258 + ] 259 + .concat(); 260 + scpd(actions, vars) 261 + } 262 + 263 + pub fn connection_manager_scpd() -> String { 264 + let actions = [ 265 + action( 266 + "GetProtocolInfo", 267 + &[ 268 + ("Source", "out", "SourceProtocolInfo"), 269 + ("Sink", "out", "SinkProtocolInfo"), 270 + ], 271 + ), 272 + action( 273 + "GetCurrentConnectionIDs", 274 + &[("ConnectionIDs", "out", "CurrentConnectionIDs")], 275 + ), 276 + action( 277 + "GetCurrentConnectionInfo", 278 + &[ 279 + ("ConnectionID", "in", "A_ARG_TYPE_ConnectionID"), 280 + ("RcsID", "out", "A_ARG_TYPE_RcsID"), 281 + ("AVTransportID", "out", "A_ARG_TYPE_AVTransportID"), 282 + ("ProtocolInfo", "out", "A_ARG_TYPE_ProtocolInfo"), 283 + ( 284 + "PeerConnectionManager", 285 + "out", 286 + "A_ARG_TYPE_ConnectionManager", 287 + ), 288 + ("PeerConnectionID", "out", "A_ARG_TYPE_ConnectionID"), 289 + ("Direction", "out", "A_ARG_TYPE_Direction"), 290 + ("Status", "out", "A_ARG_TYPE_ConnectionStatus"), 291 + ], 292 + ), 293 + ] 294 + .concat(); 295 + let vars = [ 296 + var("SourceProtocolInfo", "string", true, &[]), 297 + var("SinkProtocolInfo", "string", true, &[]), 298 + var("CurrentConnectionIDs", "string", true, &[]), 299 + var("A_ARG_TYPE_ConnectionID", "i4", false, &[]), 300 + var("A_ARG_TYPE_RcsID", "i4", false, &[]), 301 + var("A_ARG_TYPE_AVTransportID", "i4", false, &[]), 302 + var("A_ARG_TYPE_ProtocolInfo", "string", false, &[]), 303 + var("A_ARG_TYPE_ConnectionManager", "string", false, &[]), 304 + var( 305 + "A_ARG_TYPE_Direction", 306 + "string", 307 + false, 308 + &["Input", "Output"], 309 + ), 310 + var("A_ARG_TYPE_ConnectionStatus", "string", false, &["OK"]), 311 + ] 312 + .concat(); 313 + scpd(actions, vars) 314 + } 315 + 316 + /// Everything symphonia (audio) or mpv (video) can realistically take. 317 + /// Control points filter their "cast to" pickers on this list. 318 + pub const SINK_PROTOCOL_INFO: &str = "http-get:*:audio/mpeg:*,\ 319 + http-get:*:audio/mp3:*,\ 320 + http-get:*:audio/flac:*,\ 321 + http-get:*:audio/x-flac:*,\ 322 + http-get:*:audio/ogg:*,\ 323 + http-get:*:application/ogg:*,\ 324 + http-get:*:audio/opus:*,\ 325 + http-get:*:audio/vorbis:*,\ 326 + http-get:*:audio/mp4:*,\ 327 + http-get:*:audio/m4a:*,\ 328 + http-get:*:audio/aac:*,\ 329 + http-get:*:audio/x-m4a:*,\ 330 + http-get:*:audio/wav:*,\ 331 + http-get:*:audio/x-wav:*,\ 332 + http-get:*:audio/aiff:*,\ 333 + http-get:*:audio/L16:*,\ 334 + http-get:*:video/mp4:*,\ 335 + http-get:*:video/x-matroska:*,\ 336 + http-get:*:video/webm:*,\ 337 + http-get:*:video/mpeg:*,\ 338 + http-get:*:video/avi:*,\ 339 + http-get:*:video/x-msvideo:*,\ 340 + http-get:*:video/quicktime:*,\ 341 + http-get:*:video/mp2t:*,\ 342 + http-get:*:video/x-ms-wmv:*,\ 343 + http-get:*:application/x-mpegURL:*";
+273
crates/fin-mediarenderer/src/gena.rs
··· 1 + //! GENA eventing — SUBSCRIBE / UNSUBSCRIBE plus `LastChange` NOTIFYs. 2 + //! 3 + //! Control points subscribe to AVTransport / RenderingControl events to keep 4 + //! their UI in sync without polling. We poll the renderer once a second and 5 + //! push a full `LastChange` snapshot whenever anything in it moved — simpler 6 + //! than per-variable diffing and every control point accepts it. 7 + 8 + use std::sync::Arc; 9 + use std::time::{Duration, Instant}; 10 + 11 + use tracing::{debug, warn}; 12 + 13 + use crate::http::{Request, Response}; 14 + use crate::soap::Service; 15 + use crate::xml::encode_entities; 16 + use crate::{desc, Inner}; 17 + 18 + const TIMEOUT_SECS: u64 = 1800; 19 + 20 + pub(crate) struct Subscription { 21 + pub sid: String, 22 + pub service: Service, 23 + pub callbacks: Vec<String>, 24 + pub expires: Instant, 25 + pub seq: u32, 26 + } 27 + 28 + pub(crate) fn handle(inner: &Arc<Inner>, svc: Service, req: &Request) -> Response { 29 + match req.method.as_str() { 30 + "SUBSCRIBE" => subscribe(inner, svc, req), 31 + "UNSUBSCRIBE" => unsubscribe(inner, req), 32 + _ => Response::empty(405, "Method Not Allowed"), 33 + } 34 + } 35 + 36 + fn subscribe(inner: &Arc<Inner>, svc: Service, req: &Request) -> Response { 37 + // Renewal — the control point sends its SID back, no CALLBACK. 38 + if let Some(sid) = req.header("sid") { 39 + let mut subs = inner.subs.lock(); 40 + match subs.iter_mut().find(|s| s.sid == sid) { 41 + Some(sub) => { 42 + sub.expires = Instant::now() + Duration::from_secs(TIMEOUT_SECS); 43 + return sub_ok(&sub.sid); 44 + } 45 + None => return Response::empty(412, "Precondition Failed"), 46 + } 47 + } 48 + 49 + let Some(callback) = req.header("callback") else { 50 + return Response::empty(412, "Precondition Failed"); 51 + }; 52 + let callbacks: Vec<String> = callback 53 + .split('>') 54 + .filter_map(|part| { 55 + let url = part 56 + .trim() 57 + .trim_start_matches(',') 58 + .trim() 59 + .strip_prefix('<')?; 60 + url.starts_with("http").then(|| url.to_string()) 61 + }) 62 + .collect(); 63 + if callbacks.is_empty() { 64 + return Response::empty(412, "Precondition Failed"); 65 + } 66 + 67 + let sid = format!("uuid:{}", uuid::Uuid::new_v4()); 68 + inner.subs.lock().push(Subscription { 69 + sid: sid.clone(), 70 + service: svc, 71 + callbacks: callbacks.clone(), 72 + expires: Instant::now() + Duration::from_secs(TIMEOUT_SECS), 73 + seq: 1, // 0 goes out with the initial state NOTIFY below 74 + }); 75 + debug!(%sid, ?svc, "GENA subscribe"); 76 + 77 + // The spec requires an initial NOTIFY carrying the full current state, 78 + // SEQ 0, sent right after the subscribe response. 79 + let inner2 = inner.clone(); 80 + let sid2 = sid.clone(); 81 + tokio::spawn(async move { 82 + let body = match svc { 83 + Service::AvTransport => av_propertyset(&inner2), 84 + Service::RenderingControl => rc_propertyset(&inner2), 85 + Service::ConnectionManager => cm_propertyset(), 86 + }; 87 + send_notify(&inner2, &callbacks, &sid2, 0, &body).await; 88 + }); 89 + 90 + sub_ok(&sid) 91 + } 92 + 93 + fn sub_ok(sid: &str) -> Response { 94 + let mut r = Response::empty(200, "OK"); 95 + r.headers.push(("SID".into(), sid.to_string())); 96 + r.headers 97 + .push(("TIMEOUT".into(), format!("Second-{TIMEOUT_SECS}"))); 98 + r 99 + } 100 + 101 + fn unsubscribe(inner: &Arc<Inner>, req: &Request) -> Response { 102 + let Some(sid) = req.header("sid") else { 103 + return Response::empty(412, "Precondition Failed"); 104 + }; 105 + let mut subs = inner.subs.lock(); 106 + let before = subs.len(); 107 + subs.retain(|s| s.sid != sid); 108 + if subs.len() < before { 109 + Response::empty(200, "OK") 110 + } else { 111 + Response::empty(412, "Precondition Failed") 112 + } 113 + } 114 + 115 + /// What the AVTransport LastChange is built from — one comparable snapshot 116 + /// per poll tick. 117 + #[derive(PartialEq, Clone, Default)] 118 + struct AvSnapshot { 119 + transport: &'static str, 120 + uri: String, 121 + duration: String, 122 + n_tracks: usize, 123 + track: usize, 124 + } 125 + 126 + #[derive(PartialEq, Clone, Default)] 127 + struct RcSnapshot { 128 + volume: i32, 129 + muted: bool, 130 + } 131 + 132 + pub(crate) async fn notify_loop(inner: Arc<Inner>) { 133 + let mut last_av = AvSnapshot::default(); 134 + let mut last_rc = RcSnapshot::default(); 135 + let mut interval = tokio::time::interval(Duration::from_secs(1)); 136 + loop { 137 + interval.tick().await; 138 + // Drop expired subscriptions first so we don't notify ghosts. 139 + inner.subs.lock().retain(|s| s.expires > Instant::now()); 140 + 141 + let av = av_snapshot(&inner); 142 + if av != last_av { 143 + last_av = av; 144 + let body = av_propertyset(&inner); 145 + notify_service(&inner, Service::AvTransport, &body).await; 146 + } 147 + let rc = rc_snapshot(&inner); 148 + if rc != last_rc { 149 + last_rc = rc; 150 + let body = rc_propertyset(&inner); 151 + notify_service(&inner, Service::RenderingControl, &body).await; 152 + } 153 + } 154 + } 155 + 156 + fn av_snapshot(inner: &Inner) -> AvSnapshot { 157 + let state = inner.renderer().state(); 158 + AvSnapshot { 159 + transport: crate::soap::transport_state(state.status), 160 + uri: state 161 + .now_playing 162 + .as_ref() 163 + .map(|i| i.stream_url.clone()) 164 + .unwrap_or_default(), 165 + duration: crate::xml::fmt_hms(state.duration_secs), 166 + n_tracks: state.queue.len(), 167 + track: state.current_index.map(|i| i + 1).unwrap_or(0), 168 + } 169 + } 170 + 171 + fn rc_snapshot(inner: &Inner) -> RcSnapshot { 172 + RcSnapshot { 173 + volume: (inner.renderer().state().volume.clamp(0.0, 1.0) * 100.0).round() as i32, 174 + muted: inner.session.lock().pre_mute_volume.is_some(), 175 + } 176 + } 177 + 178 + /// `<e:propertyset>` wrapper with the LastChange event doc entity-escaped 179 + /// inside, as GENA requires. 180 + fn propertyset(last_change_event: &str) -> String { 181 + format!( 182 + r#"<?xml version="1.0" encoding="utf-8"?> 183 + <e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0"><e:property><LastChange>{}</LastChange></e:property></e:propertyset>"#, 184 + encode_entities(last_change_event) 185 + ) 186 + } 187 + 188 + fn av_propertyset(inner: &Inner) -> String { 189 + let snap = av_snapshot(inner); 190 + let (uri, meta) = { 191 + let s = inner.session.lock(); 192 + (s.current_uri.clone(), s.current_meta.clone()) 193 + }; 194 + let track_uri = if snap.uri.is_empty() { 195 + uri 196 + } else { 197 + snap.uri.clone() 198 + }; 199 + let event = format!( 200 + r#"<Event xmlns="urn:schemas-upnp-org:metadata-1-0/AVT/"><InstanceID val="0"><TransportState val="{}"/><TransportStatus val="OK"/><CurrentPlayMode val="NORMAL"/><NumberOfTracks val="{}"/><CurrentTrack val="{}"/><CurrentTrackDuration val="{}"/><CurrentTrackURI val="{}"/><CurrentTrackMetaData val="{}"/><AVTransportURI val="{}"/><AVTransportURIMetaData val="{}"/></InstanceID></Event>"#, 201 + snap.transport, 202 + snap.n_tracks, 203 + snap.track, 204 + snap.duration, 205 + encode_entities(&track_uri), 206 + encode_entities(&meta), 207 + encode_entities(&track_uri), 208 + encode_entities(&meta), 209 + ); 210 + propertyset(&event) 211 + } 212 + 213 + fn rc_propertyset(inner: &Inner) -> String { 214 + let snap = rc_snapshot(inner); 215 + let event = format!( 216 + r#"<Event xmlns="urn:schemas-upnp-org:metadata-1-0/RCS/"><InstanceID val="0"><Volume channel="Master" val="{}"/><Mute channel="Master" val="{}"/></InstanceID></Event>"#, 217 + snap.volume, 218 + u8::from(snap.muted), 219 + ); 220 + propertyset(&event) 221 + } 222 + 223 + /// ConnectionManager events its plain variables directly (no LastChange). 224 + fn cm_propertyset() -> String { 225 + format!( 226 + r#"<?xml version="1.0" encoding="utf-8"?> 227 + <e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0"><e:property><SourceProtocolInfo></SourceProtocolInfo></e:property><e:property><SinkProtocolInfo>{}</SinkProtocolInfo></e:property><e:property><CurrentConnectionIDs>0</CurrentConnectionIDs></e:property></e:propertyset>"#, 228 + encode_entities(desc::SINK_PROTOCOL_INFO) 229 + ) 230 + } 231 + 232 + async fn notify_service(inner: &Arc<Inner>, svc: Service, body: &str) { 233 + // Snapshot targets under the lock, send without it. 234 + let targets: Vec<(String, Vec<String>, u32)> = { 235 + let mut subs = inner.subs.lock(); 236 + subs.iter_mut() 237 + .filter(|s| s.service == svc) 238 + .map(|s| { 239 + let seq = s.seq; 240 + s.seq = s.seq.wrapping_add(1); 241 + (s.sid.clone(), s.callbacks.clone(), seq) 242 + }) 243 + .collect() 244 + }; 245 + for (sid, callbacks, seq) in targets { 246 + if !send_notify(inner, &callbacks, &sid, seq, body).await { 247 + // Callback gone — the control point exited without unsubscribing. 248 + inner.subs.lock().retain(|s| s.sid != sid); 249 + } 250 + } 251 + } 252 + 253 + async fn send_notify(inner: &Inner, callbacks: &[String], sid: &str, seq: u32, body: &str) -> bool { 254 + let method = reqwest::Method::from_bytes(b"NOTIFY").expect("static method name"); 255 + for cb in callbacks { 256 + let res = inner 257 + .client 258 + .request(method.clone(), cb) 259 + .header("CONTENT-TYPE", "text/xml; charset=\"utf-8\"") 260 + .header("NT", "upnp:event") 261 + .header("NTS", "upnp:propchange") 262 + .header("SID", sid) 263 + .header("SEQ", seq.to_string()) 264 + .body(body.to_string()) 265 + .send() 266 + .await; 267 + match res { 268 + Ok(_) => return true, 269 + Err(e) => warn!(?e, %cb, "GENA notify failed"), 270 + } 271 + } 272 + false 273 + }
+204
crates/fin-mediarenderer/src/http.rs
··· 1 + //! Minimal HTTP/1.1 server for the UPnP endpoints. One request per 2 + //! connection, `Connection: close` — control points open short-lived 3 + //! connections for SOAP calls, so keep-alive buys nothing here. 4 + 5 + use std::sync::Arc; 6 + use std::time::Duration; 7 + 8 + use anyhow::{bail, Context, Result}; 9 + use tokio::io::{AsyncReadExt, AsyncWriteExt}; 10 + use tokio::net::{TcpListener, TcpStream}; 11 + use tracing::{debug, warn}; 12 + 13 + use crate::soap::Service; 14 + use crate::{desc, gena, soap, Inner}; 15 + 16 + const MAX_HEAD: usize = 32 * 1024; 17 + const MAX_BODY: usize = 256 * 1024; 18 + 19 + pub(crate) struct Request { 20 + pub method: String, 21 + pub path: String, 22 + pub headers: Vec<(String, String)>, 23 + pub body: Vec<u8>, 24 + } 25 + 26 + impl Request { 27 + pub fn header(&self, name: &str) -> Option<&str> { 28 + self.headers 29 + .iter() 30 + .find(|(k, _)| k.eq_ignore_ascii_case(name)) 31 + .map(|(_, v)| v.as_str()) 32 + } 33 + } 34 + 35 + pub(crate) struct Response { 36 + pub status: u16, 37 + pub reason: &'static str, 38 + pub headers: Vec<(String, String)>, 39 + pub body: Vec<u8>, 40 + } 41 + 42 + impl Response { 43 + pub fn new(status: u16, reason: &'static str) -> Self { 44 + Self { 45 + status, 46 + reason, 47 + headers: Vec::new(), 48 + body: Vec::new(), 49 + } 50 + } 51 + 52 + pub fn xml(status: u16, reason: &'static str, body: String) -> Self { 53 + let mut r = Self::new(status, reason); 54 + r.headers 55 + .push(("Content-Type".into(), "text/xml; charset=\"utf-8\"".into())); 56 + r.body = body.into_bytes(); 57 + r 58 + } 59 + 60 + pub fn empty(status: u16, reason: &'static str) -> Self { 61 + Self::new(status, reason) 62 + } 63 + } 64 + 65 + pub(crate) async fn serve(listener: TcpListener, inner: Arc<Inner>) { 66 + loop { 67 + let (stream, peer) = match listener.accept().await { 68 + Ok(c) => c, 69 + Err(e) => { 70 + warn!(?e, "MediaRenderer accept failed"); 71 + continue; 72 + } 73 + }; 74 + let inner = inner.clone(); 75 + tokio::spawn(async move { 76 + match tokio::time::timeout(Duration::from_secs(30), handle(stream, &inner)).await { 77 + Ok(Err(e)) => debug!(?e, %peer, "MediaRenderer request failed"), 78 + Err(_) => debug!(%peer, "MediaRenderer request timed out"), 79 + Ok(Ok(())) => {} 80 + } 81 + }); 82 + } 83 + } 84 + 85 + async fn handle(mut stream: TcpStream, inner: &Arc<Inner>) -> Result<()> { 86 + let req = read_request(&mut stream).await?; 87 + debug!(method = %req.method, path = %req.path, "mediarenderer request"); 88 + let resp = route(inner, &req).await; 89 + write_response(&mut stream, &req.method, resp).await 90 + } 91 + 92 + async fn read_request(stream: &mut TcpStream) -> Result<Request> { 93 + let mut buf = Vec::with_capacity(1024); 94 + let mut chunk = [0u8; 1024]; 95 + let head_end = loop { 96 + if let Some(pos) = find_head_end(&buf) { 97 + break pos; 98 + } 99 + if buf.len() > MAX_HEAD { 100 + bail!("request head too large"); 101 + } 102 + let n = stream.read(&mut chunk).await?; 103 + if n == 0 { 104 + bail!("connection closed mid-request"); 105 + } 106 + buf.extend_from_slice(&chunk[..n]); 107 + }; 108 + 109 + let head = String::from_utf8_lossy(&buf[..head_end]).to_string(); 110 + let mut lines = head.lines(); 111 + let request_line = lines.next().context("empty request")?; 112 + let mut parts = request_line.split_whitespace(); 113 + let method = parts.next().context("missing method")?.to_uppercase(); 114 + let target = parts.next().context("missing path")?.to_string(); 115 + // Strip any absolute-URI form and query string down to a plain path. 116 + let path = target 117 + .split_once("://") 118 + .map(|(_, rest)| { 119 + rest.find('/') 120 + .map(|i| rest[i..].to_string()) 121 + .unwrap_or_else(|| "/".into()) 122 + }) 123 + .unwrap_or(target); 124 + let path = path.split(['?', '#']).next().unwrap_or("/").to_string(); 125 + 126 + let headers: Vec<(String, String)> = lines 127 + .filter_map(|l| l.split_once(':')) 128 + .map(|(k, v)| (k.trim().to_string(), v.trim().to_string())) 129 + .collect(); 130 + 131 + let content_length: usize = headers 132 + .iter() 133 + .find(|(k, _)| k.eq_ignore_ascii_case("content-length")) 134 + .and_then(|(_, v)| v.parse().ok()) 135 + .unwrap_or(0); 136 + if content_length > MAX_BODY { 137 + bail!("request body too large"); 138 + } 139 + 140 + let mut body = buf[head_end + 4..].to_vec(); 141 + while body.len() < content_length { 142 + let n = stream.read(&mut chunk).await?; 143 + if n == 0 { 144 + break; 145 + } 146 + body.extend_from_slice(&chunk[..n]); 147 + } 148 + body.truncate(content_length); 149 + 150 + Ok(Request { 151 + method, 152 + path, 153 + headers, 154 + body, 155 + }) 156 + } 157 + 158 + fn find_head_end(buf: &[u8]) -> Option<usize> { 159 + buf.windows(4).position(|w| w == b"\r\n\r\n") 160 + } 161 + 162 + async fn write_response(stream: &mut TcpStream, method: &str, resp: Response) -> Result<()> { 163 + let mut out = format!("HTTP/1.1 {} {}\r\n", resp.status, resp.reason); 164 + for (k, v) in &resp.headers { 165 + out.push_str(&format!("{k}: {v}\r\n")); 166 + } 167 + out.push_str(&format!("Server: {}\r\n", Inner::server_header())); 168 + out.push_str(&format!("Content-Length: {}\r\n", resp.body.len())); 169 + out.push_str("Connection: close\r\n\r\n"); 170 + stream.write_all(out.as_bytes()).await?; 171 + if method != "HEAD" { 172 + stream.write_all(&resp.body).await?; 173 + } 174 + stream.flush().await?; 175 + Ok(()) 176 + } 177 + 178 + async fn route(inner: &Arc<Inner>, req: &Request) -> Response { 179 + match (req.method.as_str(), req.path.as_str()) { 180 + ("GET" | "HEAD", "/description.xml") => Response::xml( 181 + 200, 182 + "OK", 183 + desc::device_description(&inner.opts.friendly_name, &inner.opts.uuid), 184 + ), 185 + ("GET" | "HEAD", "/scpd/AVTransport.xml") => { 186 + Response::xml(200, "OK", desc::av_transport_scpd()) 187 + } 188 + ("GET" | "HEAD", "/scpd/RenderingControl.xml") => { 189 + Response::xml(200, "OK", desc::rendering_control_scpd()) 190 + } 191 + ("GET" | "HEAD", "/scpd/ConnectionManager.xml") => { 192 + Response::xml(200, "OK", desc::connection_manager_scpd()) 193 + } 194 + ("POST", path) => match Service::from_control_path(path) { 195 + Some(svc) => soap::handle(inner, svc, req).await, 196 + None => Response::empty(404, "Not Found"), 197 + }, 198 + ("SUBSCRIBE" | "UNSUBSCRIBE", path) => match Service::from_event_path(path) { 199 + Some(svc) => gena::handle(inner, svc, req), 200 + None => Response::empty(404, "Not Found"), 201 + }, 202 + _ => Response::empty(404, "Not Found"), 203 + } 204 + }
+196
crates/fin-mediarenderer/src/lib.rs
··· 1 + //! fin as a UPnP AV **MediaRenderer device** — the receiving side. 2 + //! 3 + //! `fin_player::upnp` casts *to* other renderers; this crate is the mirror 4 + //! image: it advertises this machine on the LAN (SSDP), serves the device / 5 + //! service description XML plus SOAP control endpoints over a tiny embedded 6 + //! HTTP server, and routes whatever a control point pushes at us into the 7 + //! local playback stack — audio lands in the in-process symphonia player, 8 + //! video is handed to mpv. The split happens for free by driving the shared 9 + //! [`fin_player::LocalRenderer`] through the same `Renderer` trait the TUI 10 + //! uses, so incoming casts show up in the Now Playing bar and respond to the 11 + //! normal transport keys. 12 + //! 13 + //! Like the UPnP client code, all XML/SSDP parsing is hand-rolled: the 14 + //! documents involved are small and well-shaped, and a full XML/HTTP-server 15 + //! dependency would blow up the tree for no real gain. 16 + 17 + mod desc; 18 + mod gena; 19 + mod http; 20 + mod soap; 21 + mod ssdp; 22 + mod xml; 23 + 24 + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; 25 + use std::sync::Arc; 26 + 27 + use anyhow::{Context, Result}; 28 + use parking_lot::Mutex; 29 + use tokio::net::TcpListener; 30 + use tokio::task::JoinHandle; 31 + use tracing::{info, warn}; 32 + 33 + use fin_player::{QueueItem, Renderer}; 34 + 35 + /// Shared, swappable handle to whatever renderer the TUI is currently 36 + /// driving. The TUI stores the same cell, so a `m` (switch to local) or a 37 + /// Devices-screen connect is immediately reflected in where incoming casts 38 + /// land. 39 + pub type RendererCell = Arc<Mutex<Arc<dyn Renderer>>>; 40 + 41 + #[derive(Debug, Clone)] 42 + pub struct Options { 43 + /// Name shown in control-point device pickers. 44 + pub friendly_name: String, 45 + /// Bare UUID (no `uuid:` prefix). Should be stable across restarts so 46 + /// control points remember the device. 47 + pub uuid: String, 48 + /// TCP port for the description/control/event server. `0` = ephemeral. 49 + pub port: u16, 50 + } 51 + 52 + /// AVTransport session state pushed at us by the control point. The 53 + /// renderer owns actual playback; this only remembers what the last 54 + /// `SetAVTransportURI` said so `GetMediaInfo`/`GetPositionInfo` can echo it. 55 + pub(crate) struct Session { 56 + /// Item staged by `SetAVTransportURI`, consumed by the next `Play`. 57 + pub pending: Option<QueueItem>, 58 + /// Last item we actually pushed into the renderer. 59 + pub current: Option<QueueItem>, 60 + pub current_uri: String, 61 + pub current_meta: String, 62 + pub next_uri: String, 63 + pub next_meta: String, 64 + /// Volume before mute — `Some` means we're muted. 65 + pub pre_mute_volume: Option<f32>, 66 + /// Monotonic counter feeding the `upnp-cast:<n>` queue-item ids. 67 + pub item_seq: u64, 68 + } 69 + 70 + pub(crate) struct Inner { 71 + pub opts: Options, 72 + pub renderer: RendererCell, 73 + pub http_addr: SocketAddr, 74 + pub location: String, 75 + pub session: Mutex<Session>, 76 + pub subs: Mutex<Vec<gena::Subscription>>, 77 + pub client: reqwest::Client, 78 + } 79 + 80 + impl Inner { 81 + pub fn renderer(&self) -> Arc<dyn Renderer> { 82 + self.renderer.lock().clone() 83 + } 84 + 85 + pub fn udn(&self) -> String { 86 + format!("uuid:{}", self.opts.uuid) 87 + } 88 + 89 + pub fn server_header() -> String { 90 + format!( 91 + "{} UPnP/1.0 fin/{}", 92 + std::env::consts::OS, 93 + env!("CARGO_PKG_VERSION") 94 + ) 95 + } 96 + } 97 + 98 + pub struct MediaRendererServer { 99 + inner: Arc<Inner>, 100 + tasks: Vec<JoinHandle<()>>, 101 + } 102 + 103 + impl MediaRendererServer { 104 + /// Bind the HTTP endpoint, join the SSDP multicast group, and start 105 + /// advertising. Returns as soon as everything is listening; playback 106 + /// commands arrive on background tasks from then on. 107 + pub async fn start(opts: Options, renderer: RendererCell) -> Result<Self> { 108 + let host_ip = local_ip().unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST)); 109 + let listener = bind_http(opts.port).await?; 110 + let http_addr = SocketAddr::new(host_ip, listener.local_addr()?.port()); 111 + let location = format!("http://{http_addr}/description.xml"); 112 + 113 + let inner = Arc::new(Inner { 114 + opts, 115 + renderer, 116 + http_addr, 117 + location, 118 + session: Mutex::new(Session { 119 + pending: None, 120 + current: None, 121 + current_uri: String::new(), 122 + current_meta: String::new(), 123 + next_uri: String::new(), 124 + next_meta: String::new(), 125 + pre_mute_volume: None, 126 + item_seq: 0, 127 + }), 128 + subs: Mutex::new(Vec::new()), 129 + client: reqwest::Client::builder() 130 + .timeout(std::time::Duration::from_secs(3)) 131 + .build() 132 + .context("build GENA notify client")?, 133 + }); 134 + 135 + let mut tasks = vec![ 136 + tokio::spawn(http::serve(listener, inner.clone())), 137 + tokio::spawn(gena::notify_loop(inner.clone())), 138 + ]; 139 + // SSDP needs port 1900 with address reuse — if another UPnP stack on 140 + // this machine grabbed it exclusively, the device stays reachable by 141 + // direct URL but won't be discoverable. Degrade with a warning. 142 + match ssdp::socket() { 143 + Ok(sock) => tasks.push(tokio::spawn(ssdp::run(sock, inner.clone()))), 144 + Err(e) => warn!(?e, "SSDP bind failed — MediaRenderer not discoverable"), 145 + } 146 + 147 + info!( 148 + name = %inner.opts.friendly_name, 149 + location = %inner.location, 150 + "UPnP MediaRenderer up" 151 + ); 152 + Ok(Self { inner, tasks }) 153 + } 154 + 155 + pub fn http_addr(&self) -> SocketAddr { 156 + self.inner.http_addr 157 + } 158 + 159 + pub fn friendly_name(&self) -> &str { 160 + &self.inner.opts.friendly_name 161 + } 162 + 163 + /// Multicast `ssdp:byebye` so control points drop us immediately, then 164 + /// stop all background tasks. 165 + pub async fn shutdown(self) { 166 + ssdp::byebye(&self.inner).await; 167 + for t in &self.tasks { 168 + t.abort(); 169 + } 170 + } 171 + } 172 + 173 + async fn bind_http(port: u16) -> Result<TcpListener> { 174 + match TcpListener::bind(("0.0.0.0", port)).await { 175 + Ok(l) => Ok(l), 176 + Err(e) if port != 0 => { 177 + warn!( 178 + ?e, 179 + port, "MediaRenderer port busy — falling back to ephemeral" 180 + ); 181 + TcpListener::bind(("0.0.0.0", 0)) 182 + .await 183 + .context("bind MediaRenderer HTTP listener") 184 + } 185 + Err(e) => Err(e).context("bind MediaRenderer HTTP listener"), 186 + } 187 + } 188 + 189 + /// The IPv4 address of the default-route interface — what goes into the 190 + /// SSDP `LOCATION` header. A connected UDP socket never sends a packet; 191 + /// the OS just resolves which source address it *would* use. 192 + fn local_ip() -> Option<IpAddr> { 193 + let sock = std::net::UdpSocket::bind("0.0.0.0:0").ok()?; 194 + sock.connect("8.8.8.8:80").ok()?; 195 + sock.local_addr().ok().map(|a| a.ip()) 196 + }
+458
crates/fin-mediarenderer/src/soap.rs
··· 1 + //! SOAP control endpoints — AVTransport, RenderingControl, ConnectionManager. 2 + //! 3 + //! This is where an external control point's actions turn into calls on the 4 + //! shared `Renderer`: `SetAVTransportURI` + `Play` build a `QueueItem` whose 5 + //! `is_video` flag makes `LocalRenderer` route audio to symphonia and video 6 + //! to mpv; the transport/volume queries read the same live `PlaybackState` 7 + //! the TUI shows. 8 + 9 + use std::sync::Arc; 10 + 11 + use tracing::{debug, info, warn}; 12 + 13 + use fin_player::{PlaybackStatus, QueueItem, UPNP_CAST_ID_PREFIX}; 14 + 15 + use crate::http::{Request, Response}; 16 + use crate::xml::{ 17 + content_type, encode_entities, fmt_hms, is_video, parse_didl, parse_hms, tag_text, 18 + title_from_uri, 19 + }; 20 + use crate::{desc, Inner}; 21 + 22 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 23 + pub(crate) enum Service { 24 + AvTransport, 25 + RenderingControl, 26 + ConnectionManager, 27 + } 28 + 29 + impl Service { 30 + pub fn service_type(&self) -> &'static str { 31 + match self { 32 + Self::AvTransport => "urn:schemas-upnp-org:service:AVTransport:1", 33 + Self::RenderingControl => "urn:schemas-upnp-org:service:RenderingControl:1", 34 + Self::ConnectionManager => "urn:schemas-upnp-org:service:ConnectionManager:1", 35 + } 36 + } 37 + 38 + pub fn from_control_path(path: &str) -> Option<Self> { 39 + match path { 40 + "/control/AVTransport" => Some(Self::AvTransport), 41 + "/control/RenderingControl" => Some(Self::RenderingControl), 42 + "/control/ConnectionManager" => Some(Self::ConnectionManager), 43 + _ => None, 44 + } 45 + } 46 + 47 + pub fn from_event_path(path: &str) -> Option<Self> { 48 + match path { 49 + "/event/AVTransport" => Some(Self::AvTransport), 50 + "/event/RenderingControl" => Some(Self::RenderingControl), 51 + "/event/ConnectionManager" => Some(Self::ConnectionManager), 52 + _ => None, 53 + } 54 + } 55 + } 56 + 57 + struct SoapError { 58 + code: u16, 59 + desc: &'static str, 60 + } 61 + 62 + impl SoapError { 63 + const INVALID_ACTION: Self = Self { 64 + code: 401, 65 + desc: "Invalid Action", 66 + }; 67 + const INVALID_ARGS: Self = Self { 68 + code: 402, 69 + desc: "Invalid Args", 70 + }; 71 + const ACTION_FAILED: Self = Self { 72 + code: 501, 73 + desc: "Action Failed", 74 + }; 75 + const SEEK_MODE_NOT_SUPPORTED: Self = Self { 76 + code: 710, 77 + desc: "Seek mode not supported", 78 + }; 79 + } 80 + 81 + pub(crate) async fn handle(inner: &Arc<Inner>, svc: Service, req: &Request) -> Response { 82 + // SOAPACTION: "urn:schemas-upnp-org:service:AVTransport:1#Play" 83 + let action = req 84 + .header("soapaction") 85 + .and_then(|v| v.trim_matches('"').rsplit('#').next().map(str::to_string)) 86 + .unwrap_or_default(); 87 + let body = String::from_utf8_lossy(&req.body).to_string(); 88 + match dispatch(inner, svc, &action, &body).await { 89 + Ok(out_args) => { 90 + let envelope = format!( 91 + r#"<?xml version="1.0" encoding="utf-8"?> 92 + <s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"> 93 + <s:Body><u:{action}Response xmlns:u="{}">{out_args}</u:{action}Response></s:Body> 94 + </s:Envelope>"#, 95 + svc.service_type() 96 + ); 97 + Response::xml(200, "OK", envelope) 98 + } 99 + Err(e) => { 100 + debug!(?action, code = e.code, "SOAP fault"); 101 + let envelope = format!( 102 + r#"<?xml version="1.0" encoding="utf-8"?> 103 + <s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"> 104 + <s:Body><s:Fault><faultcode>s:Client</faultcode><faultstring>UPnPError</faultstring> 105 + <detail><UPnPError xmlns="urn:schemas-upnp-org:control-1-0"><errorCode>{}</errorCode><errorDescription>{}</errorDescription></UPnPError></detail> 106 + </s:Fault></s:Body> 107 + </s:Envelope>"#, 108 + e.code, e.desc 109 + ); 110 + Response::xml(500, "Internal Server Error", envelope) 111 + } 112 + } 113 + } 114 + 115 + async fn dispatch( 116 + inner: &Arc<Inner>, 117 + svc: Service, 118 + action: &str, 119 + body: &str, 120 + ) -> Result<String, SoapError> { 121 + match svc { 122 + Service::AvTransport => av_transport(inner, action, body).await, 123 + Service::RenderingControl => rendering_control(inner, action, body).await, 124 + Service::ConnectionManager => connection_manager(action), 125 + } 126 + } 127 + 128 + fn arg(body: &str, name: &str) -> Option<String> { 129 + tag_text(body, name) 130 + } 131 + 132 + /// Build the queue item a cast URI turns into. The `upnp-cast:` id prefix 133 + /// is what the TUI keys its "cast-in" badge off, and what keeps scrobbling 134 + /// from reporting foreign ids to the media server. 135 + fn build_item(inner: &Inner, uri: &str, meta_didl: &str) -> QueueItem { 136 + let meta = parse_didl(meta_didl); 137 + let video = is_video(&meta, uri); 138 + let seq = { 139 + let mut session = inner.session.lock(); 140 + session.item_seq += 1; 141 + session.item_seq 142 + }; 143 + QueueItem { 144 + id: format!("{UPNP_CAST_ID_PREFIX}{seq}"), 145 + title: meta.title.clone().unwrap_or_else(|| title_from_uri(uri)), 146 + subtitle: meta.artist.clone().unwrap_or_default(), 147 + stream_url: uri.to_string(), 148 + image_url: meta.album_art.clone(), 149 + duration_secs: meta.duration_secs, 150 + is_video: video, 151 + content_type: content_type(&meta, uri, video), 152 + } 153 + } 154 + 155 + pub(crate) fn transport_state(status: PlaybackStatus) -> &'static str { 156 + match status { 157 + PlaybackStatus::Playing => "PLAYING", 158 + PlaybackStatus::Paused => "PAUSED_PLAYBACK", 159 + PlaybackStatus::Buffering => "TRANSITIONING", 160 + PlaybackStatus::Stopped | PlaybackStatus::Idle => "STOPPED", 161 + } 162 + } 163 + 164 + async fn av_transport(inner: &Arc<Inner>, action: &str, body: &str) -> Result<String, SoapError> { 165 + let renderer = inner.renderer(); 166 + match action { 167 + "SetAVTransportURI" => { 168 + let uri = arg(body, "CurrentURI").ok_or(SoapError::INVALID_ARGS)?; 169 + let meta = arg(body, "CurrentURIMetaData").unwrap_or_default(); 170 + let item = build_item(inner, &uri, &meta); 171 + info!( 172 + title = %item.title, 173 + video = item.is_video, 174 + "UPnP cast-in: SetAVTransportURI" 175 + ); 176 + let playing = matches!( 177 + renderer.state().status, 178 + PlaybackStatus::Playing | PlaybackStatus::Buffering 179 + ); 180 + { 181 + let mut s = inner.session.lock(); 182 + s.current_uri = uri; 183 + s.current_meta = meta; 184 + s.next_uri.clear(); 185 + s.next_meta.clear(); 186 + if playing { 187 + s.current = Some(item.clone()); 188 + s.pending = None; 189 + } else { 190 + s.pending = Some(item.clone()); 191 + } 192 + } 193 + // Mid-playback URI swap means "switch tracks now" — most control 194 + // points otherwise send Stop → SetAVTransportURI → Play. 195 + if playing { 196 + renderer 197 + .play(vec![item], 0) 198 + .await 199 + .map_err(|e| action_failed(e, "play"))?; 200 + } 201 + Ok(String::new()) 202 + } 203 + "SetNextAVTransportURI" => { 204 + let uri = arg(body, "NextURI").ok_or(SoapError::INVALID_ARGS)?; 205 + let meta = arg(body, "NextURIMetaData").unwrap_or_default(); 206 + let item = build_item(inner, &uri, &meta); 207 + { 208 + let mut s = inner.session.lock(); 209 + s.next_uri = uri; 210 + s.next_meta = meta; 211 + } 212 + // Enqueue locally — the renderer's own auto-advance gives the 213 + // control point its gapless transition. 214 + renderer 215 + .enqueue(vec![item]) 216 + .await 217 + .map_err(|e| action_failed(e, "enqueue"))?; 218 + Ok(String::new()) 219 + } 220 + "Play" => { 221 + let (pending, current) = { 222 + let mut s = inner.session.lock(); 223 + let p = s.pending.take(); 224 + if let Some(item) = &p { 225 + s.current = Some(item.clone()); 226 + } 227 + (p, s.current.clone()) 228 + }; 229 + if let Some(item) = pending { 230 + renderer 231 + .play(vec![item], 0) 232 + .await 233 + .map_err(|e| action_failed(e, "play"))?; 234 + } else { 235 + match renderer.state().status { 236 + PlaybackStatus::Paused => renderer 237 + .resume() 238 + .await 239 + .map_err(|e| action_failed(e, "resume"))?, 240 + PlaybackStatus::Stopped | PlaybackStatus::Idle => { 241 + if let Some(item) = current { 242 + renderer 243 + .play(vec![item], 0) 244 + .await 245 + .map_err(|e| action_failed(e, "play"))?; 246 + } 247 + } 248 + _ => {} 249 + } 250 + } 251 + Ok(String::new()) 252 + } 253 + "Pause" => { 254 + renderer 255 + .pause() 256 + .await 257 + .map_err(|e| action_failed(e, "pause"))?; 258 + Ok(String::new()) 259 + } 260 + "Stop" => { 261 + renderer 262 + .stop() 263 + .await 264 + .map_err(|e| action_failed(e, "stop"))?; 265 + Ok(String::new()) 266 + } 267 + "Seek" => { 268 + let unit = arg(body, "Unit").unwrap_or_default(); 269 + if unit != "REL_TIME" && unit != "ABS_TIME" { 270 + return Err(SoapError::SEEK_MODE_NOT_SUPPORTED); 271 + } 272 + let target = arg(body, "Target") 273 + .as_deref() 274 + .and_then(parse_hms) 275 + .ok_or(SoapError::INVALID_ARGS)?; 276 + renderer 277 + .seek(target) 278 + .await 279 + .map_err(|e| action_failed(e, "seek"))?; 280 + Ok(String::new()) 281 + } 282 + "Next" => { 283 + renderer 284 + .next() 285 + .await 286 + .map_err(|e| action_failed(e, "next"))?; 287 + Ok(String::new()) 288 + } 289 + "Previous" => { 290 + renderer 291 + .previous() 292 + .await 293 + .map_err(|e| action_failed(e, "previous"))?; 294 + Ok(String::new()) 295 + } 296 + "GetTransportInfo" => { 297 + let state = renderer.state(); 298 + Ok(format!( 299 + "<CurrentTransportState>{}</CurrentTransportState>\ 300 + <CurrentTransportStatus>OK</CurrentTransportStatus>\ 301 + <CurrentSpeed>1</CurrentSpeed>", 302 + transport_state(state.status) 303 + )) 304 + } 305 + "GetPositionInfo" => { 306 + let state = renderer.state(); 307 + let (uri, meta) = { 308 + let s = inner.session.lock(); 309 + (s.current_uri.clone(), s.current_meta.clone()) 310 + }; 311 + // Prefer live renderer info (covers TUI-initiated playback the 312 + // control point is merely observing), fall back to the session. 313 + let (track_uri, duration) = match &state.now_playing { 314 + Some(item) => ( 315 + item.stream_url.clone(), 316 + if state.duration_secs > 0.0 { 317 + state.duration_secs 318 + } else { 319 + item.duration_secs.unwrap_or(0) as f64 320 + }, 321 + ), 322 + None => (uri, 0.0), 323 + }; 324 + let track = state 325 + .current_index 326 + .map(|i| i + 1) 327 + .unwrap_or(usize::from(state.now_playing.is_some())); 328 + Ok(format!( 329 + "<Track>{track}</Track>\ 330 + <TrackDuration>{}</TrackDuration>\ 331 + <TrackMetaData>{}</TrackMetaData>\ 332 + <TrackURI>{}</TrackURI>\ 333 + <RelTime>{}</RelTime>\ 334 + <AbsTime>NOT_IMPLEMENTED</AbsTime>\ 335 + <RelCount>2147483647</RelCount>\ 336 + <AbsCount>2147483647</AbsCount>", 337 + fmt_hms(duration), 338 + encode_entities(&meta), 339 + encode_entities(&track_uri), 340 + fmt_hms(state.position_secs), 341 + )) 342 + } 343 + "GetMediaInfo" => { 344 + let state = renderer.state(); 345 + let s = inner.session.lock(); 346 + Ok(format!( 347 + "<NrTracks>{}</NrTracks>\ 348 + <MediaDuration>{}</MediaDuration>\ 349 + <CurrentURI>{}</CurrentURI>\ 350 + <CurrentURIMetaData>{}</CurrentURIMetaData>\ 351 + <NextURI>{}</NextURI>\ 352 + <NextURIMetaData>{}</NextURIMetaData>\ 353 + <PlayMedium>NETWORK</PlayMedium>\ 354 + <RecordMedium>NOT_IMPLEMENTED</RecordMedium>\ 355 + <WriteStatus>NOT_IMPLEMENTED</WriteStatus>", 356 + state 357 + .queue 358 + .len() 359 + .max(usize::from(!s.current_uri.is_empty())), 360 + fmt_hms(state.duration_secs), 361 + encode_entities(&s.current_uri), 362 + encode_entities(&s.current_meta), 363 + encode_entities(&s.next_uri), 364 + encode_entities(&s.next_meta), 365 + )) 366 + } 367 + "GetDeviceCapabilities" => Ok("<PlayMedia>NETWORK</PlayMedia>\ 368 + <RecMedia>NOT_IMPLEMENTED</RecMedia>\ 369 + <RecQualityModes>NOT_IMPLEMENTED</RecQualityModes>" 370 + .to_string()), 371 + "GetTransportSettings" => Ok("<PlayMode>NORMAL</PlayMode>\ 372 + <RecQualityMode>NOT_IMPLEMENTED</RecQualityMode>" 373 + .to_string()), 374 + _ => Err(SoapError::INVALID_ACTION), 375 + } 376 + } 377 + 378 + async fn rendering_control( 379 + inner: &Arc<Inner>, 380 + action: &str, 381 + body: &str, 382 + ) -> Result<String, SoapError> { 383 + let renderer = inner.renderer(); 384 + match action { 385 + "GetVolume" => { 386 + let vol = (renderer.state().volume.clamp(0.0, 1.0) * 100.0).round() as i32; 387 + Ok(format!("<CurrentVolume>{vol}</CurrentVolume>")) 388 + } 389 + "SetVolume" => { 390 + let vol: f32 = arg(body, "DesiredVolume") 391 + .and_then(|v| v.parse().ok()) 392 + .ok_or(SoapError::INVALID_ARGS)?; 393 + inner.session.lock().pre_mute_volume = None; 394 + renderer 395 + .set_volume((vol / 100.0).clamp(0.0, 1.0)) 396 + .await 397 + .map_err(|e| action_failed(e, "set_volume"))?; 398 + Ok(String::new()) 399 + } 400 + "GetMute" => { 401 + let muted = inner.session.lock().pre_mute_volume.is_some(); 402 + Ok(format!("<CurrentMute>{}</CurrentMute>", u8::from(muted))) 403 + } 404 + "SetMute" => { 405 + let want_mute = matches!( 406 + arg(body, "DesiredMute").as_deref(), 407 + Some("1") | Some("true") | Some("True") | Some("yes") 408 + ); 409 + let (apply, target) = { 410 + let mut s = inner.session.lock(); 411 + match (want_mute, s.pre_mute_volume) { 412 + (true, None) => { 413 + let cur = renderer.state().volume; 414 + s.pre_mute_volume = Some(cur); 415 + (true, 0.0) 416 + } 417 + (false, Some(saved)) => { 418 + s.pre_mute_volume = None; 419 + (true, saved) 420 + } 421 + _ => (false, 0.0), 422 + } 423 + }; 424 + if apply { 425 + renderer 426 + .set_volume(target) 427 + .await 428 + .map_err(|e| action_failed(e, "set_volume"))?; 429 + } 430 + Ok(String::new()) 431 + } 432 + _ => Err(SoapError::INVALID_ACTION), 433 + } 434 + } 435 + 436 + fn connection_manager(action: &str) -> Result<String, SoapError> { 437 + match action { 438 + "GetProtocolInfo" => Ok(format!( 439 + "<Source></Source><Sink>{}</Sink>", 440 + desc::SINK_PROTOCOL_INFO 441 + )), 442 + "GetCurrentConnectionIDs" => Ok("<ConnectionIDs>0</ConnectionIDs>".to_string()), 443 + "GetCurrentConnectionInfo" => Ok("<RcsID>0</RcsID>\ 444 + <AVTransportID>0</AVTransportID>\ 445 + <ProtocolInfo></ProtocolInfo>\ 446 + <PeerConnectionManager></PeerConnectionManager>\ 447 + <PeerConnectionID>-1</PeerConnectionID>\ 448 + <Direction>Input</Direction>\ 449 + <Status>OK</Status>" 450 + .to_string()), 451 + _ => Err(SoapError::INVALID_ACTION), 452 + } 453 + } 454 + 455 + fn action_failed(e: anyhow::Error, what: &str) -> SoapError { 456 + warn!(?e, what, "renderer call failed for UPnP control point"); 457 + SoapError::ACTION_FAILED 458 + }
+190
crates/fin-mediarenderer/src/ssdp.rs
··· 1 + //! SSDP presence — periodic `ssdp:alive` NOTIFYs, unicast M-SEARCH answers, 2 + //! and a parting `ssdp:byebye`. This is the discovery half of being a 3 + //! MediaRenderer; the LOCATION header points control points at our 4 + //! description XML. 5 + 6 + use std::net::{Ipv4Addr, SocketAddr}; 7 + use std::sync::Arc; 8 + use std::time::Duration; 9 + 10 + use anyhow::{Context, Result}; 11 + use socket2::{Domain, Protocol, Socket, Type}; 12 + use tokio::net::UdpSocket; 13 + use tracing::{debug, warn}; 14 + 15 + use crate::Inner; 16 + 17 + const SSDP_ADDR: &str = "239.255.255.250:1900"; 18 + const SSDP_MULTICAST: Ipv4Addr = Ipv4Addr::new(239, 255, 255, 250); 19 + const MAX_AGE_SECS: u64 = 1800; 20 + /// Re-advertise well inside the max-age window so caches never lapse. 21 + const ALIVE_INTERVAL: Duration = Duration::from_secs(300); 22 + 23 + const DEVICE_TYPE: &str = "urn:schemas-upnp-org:device:MediaRenderer:1"; 24 + const SERVICE_TYPES: [&str; 3] = [ 25 + "urn:schemas-upnp-org:service:AVTransport:1", 26 + "urn:schemas-upnp-org:service:RenderingControl:1", 27 + "urn:schemas-upnp-org:service:ConnectionManager:1", 28 + ]; 29 + 30 + /// Bind 0.0.0.0:1900 with address reuse and join the SSDP multicast group. 31 + /// Reuse matters — Chromecast/mDNS stacks and other UPnP apps commonly 32 + /// share the port. 33 + pub(crate) fn socket() -> Result<UdpSocket> { 34 + let sock = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP)) 35 + .context("create SSDP socket")?; 36 + sock.set_reuse_address(true)?; 37 + #[cfg(unix)] 38 + sock.set_reuse_port(true)?; 39 + let bind_addr: SocketAddr = "0.0.0.0:1900".parse().expect("static addr"); 40 + sock.bind(&bind_addr.into()).context("bind SSDP :1900")?; 41 + sock.join_multicast_v4(&SSDP_MULTICAST, &Ipv4Addr::UNSPECIFIED) 42 + .context("join SSDP multicast group")?; 43 + sock.set_multicast_ttl_v4(2)?; 44 + sock.set_nonblocking(true)?; 45 + UdpSocket::from_std(sock.into()).context("wrap SSDP socket for tokio") 46 + } 47 + 48 + /// The (NT, USN) pairs a root MediaRenderer must announce and answer for. 49 + fn targets(udn: &str) -> Vec<(String, String)> { 50 + let mut list = vec![ 51 + ( 52 + "upnp:rootdevice".to_string(), 53 + format!("{udn}::upnp:rootdevice"), 54 + ), 55 + (udn.to_string(), udn.to_string()), 56 + (DEVICE_TYPE.to_string(), format!("{udn}::{DEVICE_TYPE}")), 57 + ]; 58 + for svc in SERVICE_TYPES { 59 + list.push((svc.to_string(), format!("{udn}::{svc}"))); 60 + } 61 + list 62 + } 63 + 64 + pub(crate) async fn run(sock: UdpSocket, inner: Arc<Inner>) { 65 + // Double initial burst — first packets routinely get dropped while 66 + // switches update their multicast tables. 67 + send_alive(&sock, &inner).await; 68 + tokio::time::sleep(Duration::from_millis(300)).await; 69 + send_alive(&sock, &inner).await; 70 + 71 + let mut interval = tokio::time::interval(ALIVE_INTERVAL); 72 + interval.tick().await; // consume the immediate first tick 73 + 74 + let mut buf = vec![0u8; 4096]; 75 + loop { 76 + tokio::select! { 77 + _ = interval.tick() => send_alive(&sock, &inner).await, 78 + recv = sock.recv_from(&mut buf) => { 79 + if let Ok((n, src)) = recv { 80 + let text = String::from_utf8_lossy(&buf[..n]).to_string(); 81 + handle_msearch(&sock, &inner, &text, src).await; 82 + } 83 + } 84 + } 85 + } 86 + } 87 + 88 + async fn send_alive(sock: &UdpSocket, inner: &Inner) { 89 + let udn = inner.udn(); 90 + for (nt, usn) in targets(&udn) { 91 + let msg = format!( 92 + "NOTIFY * HTTP/1.1\r\n\ 93 + HOST: {SSDP_ADDR}\r\n\ 94 + CACHE-CONTROL: max-age={MAX_AGE_SECS}\r\n\ 95 + LOCATION: {}\r\n\ 96 + NT: {nt}\r\n\ 97 + NTS: ssdp:alive\r\n\ 98 + SERVER: {}\r\n\ 99 + USN: {usn}\r\n\ 100 + BOOTID.UPNP.ORG: 1\r\n\ 101 + CONFIGID.UPNP.ORG: 1\r\n\ 102 + \r\n", 103 + inner.location, 104 + Inner::server_header(), 105 + ); 106 + if let Err(e) = sock.send_to(msg.as_bytes(), SSDP_ADDR).await { 107 + debug!(?e, "SSDP alive send failed"); 108 + } 109 + } 110 + } 111 + 112 + async fn handle_msearch(sock: &UdpSocket, inner: &Inner, text: &str, src: SocketAddr) { 113 + if !text.starts_with("M-SEARCH") { 114 + return; 115 + } 116 + let Some(st) = header(text, "ST") else { return }; 117 + if !header(text, "MAN").is_some_and(|m| m.contains("ssdp:discover")) { 118 + return; 119 + } 120 + 121 + let udn = inner.udn(); 122 + let matched: Vec<(String, String)> = targets(&udn) 123 + .into_iter() 124 + .filter(|(nt, _)| st == "ssdp:all" || st == *nt) 125 + .collect(); 126 + if matched.is_empty() { 127 + return; 128 + } 129 + debug!(%st, %src, "answering M-SEARCH"); 130 + 131 + // A short fixed delay spreads replies without holding the receive loop 132 + // long enough to matter (the spec asks for 0..MX seconds of jitter). 133 + tokio::time::sleep(Duration::from_millis(40)).await; 134 + let date = chrono::Utc::now().format("%a, %d %b %Y %H:%M:%S GMT"); 135 + for (nt, usn) in matched { 136 + let st_out = if st == "ssdp:all" { &nt } else { &st }; 137 + let msg = format!( 138 + "HTTP/1.1 200 OK\r\n\ 139 + CACHE-CONTROL: max-age={MAX_AGE_SECS}\r\n\ 140 + DATE: {date}\r\n\ 141 + EXT: \r\n\ 142 + LOCATION: {}\r\n\ 143 + SERVER: {}\r\n\ 144 + ST: {st_out}\r\n\ 145 + USN: {usn}\r\n\ 146 + BOOTID.UPNP.ORG: 1\r\n\ 147 + CONFIGID.UPNP.ORG: 1\r\n\ 148 + \r\n", 149 + inner.location, 150 + Inner::server_header(), 151 + ); 152 + if let Err(e) = sock.send_to(msg.as_bytes(), src).await { 153 + debug!(?e, %src, "M-SEARCH reply failed"); 154 + } 155 + } 156 + } 157 + 158 + /// Multicast `ssdp:byebye` for every advertised target so control points 159 + /// drop the device immediately instead of waiting out max-age. 160 + pub(crate) async fn byebye(inner: &Inner) { 161 + let Ok(sock) = UdpSocket::bind("0.0.0.0:0").await else { 162 + warn!("SSDP byebye skipped — no socket"); 163 + return; 164 + }; 165 + let udn = inner.udn(); 166 + for (nt, usn) in targets(&udn) { 167 + let msg = format!( 168 + "NOTIFY * HTTP/1.1\r\n\ 169 + HOST: {SSDP_ADDR}\r\n\ 170 + NT: {nt}\r\n\ 171 + NTS: ssdp:byebye\r\n\ 172 + USN: {usn}\r\n\ 173 + BOOTID.UPNP.ORG: 1\r\n\ 174 + CONFIGID.UPNP.ORG: 1\r\n\ 175 + \r\n" 176 + ); 177 + let _ = sock.send_to(msg.as_bytes(), SSDP_ADDR).await; 178 + } 179 + } 180 + 181 + fn header(text: &str, name: &str) -> Option<String> { 182 + for line in text.lines() { 183 + if let Some((k, v)) = line.split_once(':') { 184 + if k.trim().eq_ignore_ascii_case(name) { 185 + return Some(v.trim().to_string()); 186 + } 187 + } 188 + } 189 + None 190 + }
+286
crates/fin-mediarenderer/src/xml.rs
··· 1 + //! Small XML + DIDL-Lite helpers. Same hand-rolled approach as 2 + //! `fin_player::upnp` — the documents are tiny and predictable. 3 + 4 + pub fn encode_entities(s: &str) -> String { 5 + // `&` first so we don't double-encode our own replacements. 6 + s.replace('&', "&amp;") 7 + .replace('<', "&lt;") 8 + .replace('>', "&gt;") 9 + .replace('"', "&quot;") 10 + .replace('\'', "&apos;") 11 + } 12 + 13 + pub fn decode_entities(s: &str) -> String { 14 + s.replace("&lt;", "<") 15 + .replace("&gt;", ">") 16 + .replace("&quot;", "\"") 17 + .replace("&apos;", "'") 18 + .replace("&amp;", "&") 19 + } 20 + 21 + /// Extract the text content of the first `<tag>…</tag>` pair, matching on 22 + /// the *local* name only (namespace prefixes vary between control points). 23 + /// Returns entity-decoded text. 24 + pub fn tag_text(xml: &str, local_name: &str) -> Option<String> { 25 + let lower = xml.to_ascii_lowercase(); 26 + let needle = local_name.to_ascii_lowercase(); 27 + let mut cursor = 0; 28 + while cursor < lower.len() { 29 + let lt = lower[cursor..].find('<')?; 30 + let abs = cursor + lt + 1; 31 + let rest = &lower[abs..]; 32 + if rest.starts_with('/') || rest.starts_with('!') || rest.starts_with('?') { 33 + cursor = abs; 34 + continue; 35 + } 36 + let name_end = rest 37 + .find(|c: char| c == ' ' || c == '>' || c == '/' || c == '\t' || c == '\n') 38 + .unwrap_or(rest.len()); 39 + let tag_name = &rest[..name_end]; 40 + let local = tag_name.rsplit(':').next().unwrap_or(tag_name); 41 + if local == needle { 42 + let gt = rest.find('>')?; 43 + if rest[..gt].ends_with('/') { 44 + // Self-closing tag — empty content. 45 + return Some(String::new()); 46 + } 47 + let content_start = abs + gt + 1; 48 + let close_needle = format!("</{tag_name}>"); 49 + let close_rel = lower[content_start..].find(&close_needle)?; 50 + return Some(decode_entities( 51 + xml[content_start..content_start + close_rel].trim(), 52 + )); 53 + } 54 + cursor = abs + name_end; 55 + } 56 + None 57 + } 58 + 59 + /// First occurrence of `name="value"` anywhere in the document — good 60 + /// enough for the two attributes we care about (`protocolInfo`, `duration`) 61 + /// since a cast payload holds exactly one `<res>`. 62 + pub fn attr_anywhere(xml: &str, name: &str) -> Option<String> { 63 + let lower = xml.to_ascii_lowercase(); 64 + let needle = format!("{}=\"", name.to_ascii_lowercase()); 65 + let start = lower.find(&needle)? + needle.len(); 66 + let end = start + lower[start..].find('"')?; 67 + Some(decode_entities(&xml[start..end])) 68 + } 69 + 70 + pub fn fmt_hms(secs: f64) -> String { 71 + let s = if secs.is_finite() && secs > 0.0 { 72 + secs as u64 73 + } else { 74 + 0 75 + }; 76 + format!("{}:{:02}:{:02}", s / 3600, (s % 3600) / 60, s % 60) 77 + } 78 + 79 + /// Parse `H:MM:SS[.fff]` (also tolerates `MM:SS`). 80 + pub fn parse_hms(s: &str) -> Option<f64> { 81 + let s = s.trim(); 82 + if s.is_empty() || s == "NOT_IMPLEMENTED" { 83 + return None; 84 + } 85 + let parts: Vec<f64> = s 86 + .split(':') 87 + .map(|p| p.parse::<f64>().ok()) 88 + .collect::<Option<_>>()?; 89 + match parts[..] { 90 + [h, m, sec] => Some(h * 3600.0 + m * 60.0 + sec), 91 + [m, sec] => Some(m * 60.0 + sec), 92 + [sec] => Some(sec), 93 + _ => None, 94 + } 95 + } 96 + 97 + /// What we could pull out of the control point's DIDL-Lite metadata. 98 + #[derive(Debug, Default)] 99 + pub struct CastMeta { 100 + pub title: Option<String>, 101 + pub artist: Option<String>, 102 + pub upnp_class: Option<String>, 103 + pub album_art: Option<String>, 104 + pub duration_secs: Option<u64>, 105 + /// MIME from `<res protocolInfo="http-get:*:MIME:*">`. 106 + pub mime: Option<String>, 107 + } 108 + 109 + pub fn parse_didl(didl: &str) -> CastMeta { 110 + let mime = attr_anywhere(didl, "protocolInfo") 111 + .and_then(|p| p.split(':').nth(2).map(|m| m.trim().to_string())) 112 + .filter(|m| !m.is_empty() && m != "*"); 113 + CastMeta { 114 + title: tag_text(didl, "title").filter(|t| !t.is_empty()), 115 + artist: tag_text(didl, "artist") 116 + .or_else(|| tag_text(didl, "creator")) 117 + .filter(|t| !t.is_empty()), 118 + upnp_class: tag_text(didl, "class").filter(|t| !t.is_empty()), 119 + album_art: tag_text(didl, "albumArtURI").filter(|t| !t.is_empty()), 120 + duration_secs: attr_anywhere(didl, "duration") 121 + .and_then(|d| parse_hms(&d)) 122 + .map(|d| d as u64), 123 + mime, 124 + } 125 + } 126 + 127 + /// Audio vs video decides the whole downstream path (symphonia vs mpv), so 128 + /// check every signal in confidence order: DIDL `upnp:class`, then the 129 + /// protocolInfo MIME, then the URL extension. Unknown defaults to audio — 130 + /// symphonia fails fast and loud, mpv would pop a window. 131 + pub fn is_video(meta: &CastMeta, uri: &str) -> bool { 132 + if let Some(class) = &meta.upnp_class { 133 + if class.contains("videoItem") { 134 + return true; 135 + } 136 + if class.contains("audioItem") || class.contains("musicTrack") { 137 + return false; 138 + } 139 + } 140 + if let Some(mime) = &meta.mime { 141 + if mime.starts_with("video/") { 142 + return true; 143 + } 144 + if mime.starts_with("audio/") { 145 + return false; 146 + } 147 + } 148 + matches!( 149 + extension(uri).as_deref(), 150 + Some( 151 + "mp4" 152 + | "m4v" 153 + | "mkv" 154 + | "webm" 155 + | "avi" 156 + | "mov" 157 + | "ts" 158 + | "mpg" 159 + | "mpeg" 160 + | "wmv" 161 + | "m3u8" 162 + | "flv" 163 + ) 164 + ) 165 + } 166 + 167 + pub fn content_type(meta: &CastMeta, uri: &str, video: bool) -> String { 168 + if let Some(mime) = &meta.mime { 169 + if mime.contains('/') { 170 + return mime.clone(); 171 + } 172 + } 173 + let ext = extension(uri).unwrap_or_default(); 174 + let guessed = match ext.as_str() { 175 + "mp3" => "audio/mpeg", 176 + "flac" => "audio/flac", 177 + "ogg" | "oga" => "audio/ogg", 178 + "opus" => "audio/opus", 179 + "m4a" | "m4b" => "audio/mp4", 180 + "aac" => "audio/aac", 181 + "wav" => "audio/wav", 182 + "aif" | "aiff" => "audio/aiff", 183 + "wma" => "audio/x-ms-wma", 184 + "mp4" | "m4v" => "video/mp4", 185 + "mkv" => "video/x-matroska", 186 + "webm" => "video/webm", 187 + "avi" => "video/x-msvideo", 188 + "mov" => "video/quicktime", 189 + "ts" => "video/mp2t", 190 + "mpg" | "mpeg" => "video/mpeg", 191 + "wmv" => "video/x-ms-wmv", 192 + "m3u8" => "application/x-mpegURL", 193 + _ => { 194 + if video { 195 + "video/mp4" 196 + } else { 197 + "audio/mpeg" 198 + } 199 + } 200 + }; 201 + guessed.to_string() 202 + } 203 + 204 + /// Lowercased extension of the URL path, query string stripped. 205 + fn extension(uri: &str) -> Option<String> { 206 + let path = uri.split(['?', '#']).next().unwrap_or(uri); 207 + let seg = path.rsplit('/').next()?; 208 + let (_, ext) = seg.rsplit_once('.')?; 209 + if ext.is_empty() || ext.len() > 5 { 210 + return None; 211 + } 212 + Some(ext.to_ascii_lowercase()) 213 + } 214 + 215 + /// Fallback display title: the last URL path segment, percent-decoded. 216 + pub fn title_from_uri(uri: &str) -> String { 217 + let no_query = uri.split(['?', '#']).next().unwrap_or(uri); 218 + // Cut `scheme://host` first so a bare-root URL doesn't yield the 219 + // hostname as a "title". 220 + let path = no_query 221 + .split_once("://") 222 + .and_then(|(_, rest)| rest.find('/').map(|i| &rest[i + 1..])) 223 + .unwrap_or(no_query); 224 + let seg = path.trim_end_matches('/').rsplit('/').next().unwrap_or(""); 225 + let decoded = percent_encoding::percent_decode_str(seg) 226 + .decode_utf8() 227 + .map(|c| c.to_string()) 228 + .unwrap_or_else(|_| seg.to_string()); 229 + if decoded.is_empty() { 230 + "UPnP stream".to_string() 231 + } else { 232 + decoded 233 + } 234 + } 235 + 236 + #[cfg(test)] 237 + mod tests { 238 + use super::*; 239 + 240 + const DIDL: &str = r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/"><item id="1" parentID="0" restricted="1"><dc:title>So What</dc:title><upnp:artist>Miles Davis</upnp:artist><upnp:class>object.item.audioItem.musicTrack</upnp:class><res duration="0:09:22.000" protocolInfo="http-get:*:audio/flac:*">http://srv/track.flac</res></item></DIDL-Lite>"#; 241 + 242 + #[test] 243 + fn didl_roundtrip() { 244 + let m = parse_didl(DIDL); 245 + assert_eq!(m.title.as_deref(), Some("So What")); 246 + assert_eq!(m.artist.as_deref(), Some("Miles Davis")); 247 + assert_eq!(m.mime.as_deref(), Some("audio/flac")); 248 + assert_eq!(m.duration_secs, Some(562)); 249 + assert!(!is_video(&m, "http://srv/track.flac")); 250 + assert_eq!( 251 + content_type(&m, "http://srv/track.flac", false), 252 + "audio/flac" 253 + ); 254 + } 255 + 256 + #[test] 257 + fn video_class_wins_over_extension() { 258 + let didl = DIDL.replace("object.item.audioItem.musicTrack", "object.item.videoItem"); 259 + let m = parse_didl(&didl); 260 + assert!(is_video(&m, "http://srv/track.flac")); 261 + } 262 + 263 + #[test] 264 + fn bare_uri_classification() { 265 + let m = CastMeta::default(); 266 + assert!(is_video(&m, "http://srv/movie.mkv?token=a.b")); 267 + assert!(!is_video(&m, "http://srv/song.mp3")); 268 + assert_eq!( 269 + content_type(&m, "http://srv/movie.mkv", true), 270 + "video/x-matroska" 271 + ); 272 + } 273 + 274 + #[test] 275 + fn hms_helpers() { 276 + assert_eq!(fmt_hms(562.9), "0:09:22"); 277 + assert_eq!(parse_hms("1:02:03"), Some(3723.0)); 278 + assert_eq!(parse_hms("NOT_IMPLEMENTED"), None); 279 + } 280 + 281 + #[test] 282 + fn title_fallback_decodes_percent_escapes() { 283 + assert_eq!(title_from_uri("http://s/a/My%20Song.mp3"), "My Song.mp3"); 284 + assert_eq!(title_from_uri("http://s/"), "UPnP stream"); 285 + } 286 + }
+1 -9
crates/fin-player/src/discovery.rs
··· 38 38 let remaining = deadline 39 39 .saturating_duration_since(tokio::time::Instant::now()) 40 40 .min(Duration::from_millis(500)); 41 - let ev = timeout(remaining, async { 42 - loop { 43 - match receiver.recv_async().await { 44 - Ok(ev) => return Some(ev), 45 - Err(_) => return None, 46 - } 47 - } 48 - }) 49 - .await; 41 + let ev = timeout(remaining, async { receiver.recv_async().await.ok() }).await; 50 42 let ev = match ev { 51 43 Ok(Some(ev)) => ev, 52 44 _ => continue,
+1 -1
crates/fin-player/src/lib.rs
··· 17 17 pub use local::LocalRenderer; 18 18 pub use mpv::MpvRenderer; 19 19 pub use persist::{load as load_persisted_queue, PersistedQueue}; 20 - pub use queue::{PlaybackQueue, QueueItem, RepeatMode}; 20 + pub use queue::{PlaybackQueue, QueueItem, RepeatMode, UPNP_CAST_ID_PREFIX}; 21 21 pub use renderer::{PlaybackState, PlaybackStatus, Renderer, RendererKind}; 22 22 pub use replaygain::{ReplayGainInfo, ReplayGainMode, ReplayGainSettings}; 23 23 pub use symphonia_player::SymphoniaPlayer;
+5
crates/fin-player/src/local.rs
··· 325 325 match self.get_active() { 326 326 Active::Audio => self.audio.state(), 327 327 Active::Video => self.video.state(), 328 + // Idle still reports the audio player's volume — it survives 329 + // across stop/start, and volume queries (TUI slider, UPnP 330 + // GetVolume) shouldn't snap back to 100% just because nothing 331 + // is playing. 328 332 Active::None => PlaybackState { 329 333 status: PlaybackStatus::Idle, 334 + volume: self.audio.state().volume, 330 335 ..Default::default() 331 336 }, 332 337 }
+14
crates/fin-player/src/queue.rs
··· 4 4 use rand::seq::SliceRandom; 5 5 use serde::{Deserialize, Serialize}; 6 6 7 + /// Queue-item id prefix for media pushed at us by an external UPnP control 8 + /// point (fin acting as a MediaRenderer device via `fin-mediarenderer`). 9 + /// These ids are synthetic — they don't exist on the media server — so the 10 + /// TUI badges them as an incoming cast and scrobbling skips them. 11 + pub const UPNP_CAST_ID_PREFIX: &str = "upnp-cast:"; 12 + 7 13 /// Everything a renderer needs to play a single item. 8 14 #[derive(Debug, Clone, Serialize, Deserialize)] 9 15 pub struct QueueItem { ··· 15 21 pub duration_secs: Option<u64>, 16 22 pub is_video: bool, 17 23 pub content_type: String, 24 + } 25 + 26 + impl QueueItem { 27 + /// Whether this item was pushed by an external UPnP control point 28 + /// rather than picked from the library in the TUI/CLI. 29 + pub fn is_upnp_cast(&self) -> bool { 30 + self.id.starts_with(UPNP_CAST_ID_PREFIX) 31 + } 18 32 } 19 33 20 34 /// How the queue behaves once the last item finishes.
+33 -1
crates/fin-tui/src/app.rs
··· 121 121 /// the time the queue has already advanced past the finished track. 122 122 scrobble_last_position: u64, 123 123 scrobble_session_id: String, 124 + /// Id of the incoming UPnP cast we last flashed a status line for, so 125 + /// the notice fires once per pushed track instead of every tick. 126 + cast_notice_id: Option<String>, 124 127 should_quit: bool, 125 128 logo_pulse: u8, 126 129 } ··· 172 175 // One session id per fin process — Jellyfin uses it to correlate 173 176 // Start / Progress / Stopped events, Subsonic ignores it. 174 177 scrobble_session_id: uuid::Uuid::new_v4().to_string(), 178 + cast_notice_id: None, 175 179 should_quit: false, 176 180 logo_pulse: 0, 177 181 } ··· 1055 1059 // Refresh live playback state each tick. 1056 1060 *app.playback_state.lock() = app.renderer.lock().state(); 1057 1061 1062 + notice_upnp_cast(app); 1058 1063 emit_scrobble_events(app).await; 1059 1064 1060 1065 terminal.draw(|f| draw(f, app))?; ··· 1074 1079 Ok(()) 1075 1080 } 1076 1081 1082 + /// Flash a status line when an external UPnP control point pushes media at 1083 + /// us (fin acting as a MediaRenderer device). Playback starting with no 1084 + /// user action in the TUI deserves an attribution; the Now Playing bar 1085 + /// additionally shows a persistent `⇊ UPnP` badge for these items. 1086 + fn notice_upnp_cast(app: &mut App) { 1087 + let cast = { 1088 + let state = app.playback_state.lock(); 1089 + state 1090 + .now_playing 1091 + .as_ref() 1092 + .filter(|item| item.is_upnp_cast()) 1093 + .map(|item| (item.id.clone(), item.title.clone())) 1094 + }; 1095 + match cast { 1096 + Some((id, title)) if app.cast_notice_id.as_deref() != Some(id.as_str()) => { 1097 + app.cast_notice_id = Some(id); 1098 + app.set_status(format!("⇊ receiving UPnP cast — {}", title)); 1099 + } 1100 + None => app.cast_notice_id = None, 1101 + _ => {} 1102 + } 1103 + } 1104 + 1077 1105 /// Emit Jellyfin session events / Subsonic scrobbles for track transitions. 1078 1106 /// Detects three edges from `playback_state`: 1079 1107 /// - `now_playing` changed to a new item → `report_stopped` on the old one, ··· 1096 1124 return; 1097 1125 } 1098 1126 1127 + // UPnP cast-in items carry synthetic `upnp-cast:` ids that don't exist 1128 + // on the media server — reporting them would 404. Treating them as 1129 + // "nothing playing" also fires the report_stopped edge for whatever 1130 + // library track the incoming cast displaced. 1099 1131 let now_playing_id = state 1100 1132 .now_playing 1101 1133 .as_ref() 1102 - .filter(|it| !it.is_video) 1134 + .filter(|it| !it.is_video && !it.is_upnp_cast()) 1103 1135 .map(|it| it.id.clone()); 1104 1136 1105 1137 // Snapshot the CURRENT track's position on every tick — the queue may
+14
crates/fin-tui/src/widgets/player_bar.rs
··· 90 90 None => ("Nothing playing".to_string(), String::new()), 91 91 }; 92 92 93 + // Incoming-cast badge — this track was pushed at us by an external 94 + // UPnP control point (fin acting as a MediaRenderer device), not 95 + // picked in the TUI. Flag it so unexpected audio is attributable. 96 + let cast_in_span = match &self.state.now_playing { 97 + Some(item) if item.is_upnp_cast() => Span::styled( 98 + "⇊ UPnP ", 99 + Style::default() 100 + .fg(Palette::ACCENT) 101 + .add_modifier(Modifier::BOLD), 102 + ), 103 + _ => Span::raw(""), 104 + }; 105 + 93 106 // Row 1: status icon + track title + right-aligned renderer + volume 94 107 let title_line = Line::from(vec![ 95 108 Span::styled(format!("{} ", icon), icon_style), 109 + cast_in_span, 96 110 Span::styled( 97 111 title_text, 98 112 Style::default()
+3
crates/fin/Cargo.toml
··· 23 23 toml.workspace = true 24 24 rpassword.workspace = true 25 25 rustls.workspace = true 26 + uuid.workspace = true 27 + whoami.workspace = true 26 28 fin-config.workspace = true 27 29 fin-jellyfin.workspace = true 28 30 fin-media.workspace = true 31 + fin-mediarenderer.workspace = true 29 32 fin-player.workspace = true 30 33 fin-tui.workspace = true
+18
crates/fin/src/cli.rs
··· 109 109 #[arg(long, global = true, env = "FIN_UPNP", num_args = 0..=1, default_missing_value = "", conflicts_with = "chromecast")] 110 110 pub upnp: Option<String>, 111 111 112 + /// Don't advertise this machine as a UPnP MediaRenderer (cast target). 113 + /// Persistent form: `media_renderer.enabled = false` in the config. 114 + #[arg( 115 + long = "no-media-renderer", 116 + global = true, 117 + env = "FIN_NO_MEDIA_RENDERER" 118 + )] 119 + pub no_media_renderer: bool, 120 + 121 + /// Advertise this machine as a UPnP MediaRenderer even when the config 122 + /// disables it. (It is on by default — this only overrides the TOML.) 123 + #[arg( 124 + long = "media-renderer", 125 + global = true, 126 + conflicts_with = "no_media_renderer" 127 + )] 128 + pub media_renderer: bool, 129 + 112 130 #[command(subcommand)] 113 131 pub command: Option<Command>, 114 132 }
+49 -2
crates/fin/src/main.rs
··· 167 167 } else if let Some(r) = cli.renderer { 168 168 cfg.renderer = r.into(); 169 169 } 170 + 171 + // Inline switches for the built-in MediaRenderer device. On by default; 172 + // the flags only override the TOML for this run. 173 + if cli.no_media_renderer { 174 + cfg.media_renderer.enabled = false; 175 + } else if cli.media_renderer { 176 + cfg.media_renderer.enabled = true; 177 + } 170 178 Ok(cfg) 171 179 } 172 180 ··· 271 279 Ok(devices[0].clone()) 272 280 } 273 281 274 - async fn run_tui_cmd(cfg: Config) -> Result<()> { 282 + async fn run_tui_cmd(mut cfg: Config) -> Result<()> { 275 283 let client = make_client(&cfg)?; 276 284 let (renderer, _) = build_renderer(&cfg).await?; 285 + 286 + // Give the advertised MediaRenderer a stable UDN so control points 287 + // recognize this machine across restarts. 288 + if cfg.media_renderer.enabled && cfg.media_renderer.uuid.is_none() { 289 + cfg.media_renderer.uuid = Some(uuid::Uuid::new_v4().to_string()); 290 + if let Err(e) = cfg.save() { 291 + tracing::warn!(?e, "could not persist media_renderer.uuid"); 292 + } 293 + } 294 + let mr = cfg.media_renderer.clone(); 295 + 277 296 let app = App::new(cfg, client, renderer); 278 - run_tui(app).await 297 + 298 + // The MediaRenderer device drives the same renderer cell the TUI holds, 299 + // so an incoming cast plays through symphonia (audio) / mpv (video) and 300 + // shows up in the Now Playing bar like any other queue item. 301 + let server = if mr.enabled { 302 + let opts = fin_mediarenderer::Options { 303 + friendly_name: mr 304 + .friendly_name 305 + .clone() 306 + .unwrap_or_else(|| format!("fin ({})", whoami::devicename())), 307 + uuid: mr.uuid.clone().unwrap_or_default(), 308 + port: mr.port, 309 + }; 310 + match fin_mediarenderer::MediaRendererServer::start(opts, app.renderer.clone()).await { 311 + Ok(s) => Some(s), 312 + Err(e) => { 313 + tracing::warn!(?e, "UPnP MediaRenderer failed to start"); 314 + None 315 + } 316 + } 317 + } else { 318 + None 319 + }; 320 + 321 + let result = run_tui(app).await; 322 + if let Some(s) = server { 323 + s.shutdown().await; 324 + } 325 + result 279 326 } 280 327 281 328 async fn cmd_login(