Skip to main content

jellytau_lib/repository/
capabilities.rs

1//! What the server on the other end of the wire can actually do.
2//!
3//! One `ServerCapabilities` value is resolved per connection, from the version
4//! the server already reports at `/System/Info/Public`, and every decision that
5//! depends on the server generation reads a **named flag** from it.
6//!
7//! # Why flags and not version comparisons
8//!
9//! A `version < N` written at the point of use re-derives a domain fact where it
10//! is consumed — the same error as a Jellyfin taxonomy in the frontend, and the
11//! reason `check:boundary` exists. It is also unreadable by its second
12//! occurrence (`< 11` says nothing about *what* changed), and it cannot express
13//! a backport, where a behaviour appears in a patch release of an older line.
14//!
15//! So the version → flags mapping lives in exactly one function
16//! ([`ServerCapabilities::for_version`]) and nothing else in the crate compares
17//! a version number.
18//!
19//! # Why an unknown version resolves forward
20//!
21//! A server newer than this build resolves to the newest capability set we know
22//! rather than being refused. Refusing would make every JellyTau release expire
23//! the moment the server upgrades, which is the failure UR-085 exists to remove.
24//! Refusal is reserved for a version *below* [`MINIMUM_SUPPORTED_MAJOR_MINOR`],
25//! where failure is certain rather than merely likely.
26//!
27//! TRACES: UR-085 | IR-035, DR-280
28
29use std::fmt;
30
31/// The oldest server this build will talk to, as `(major, minor)`.
32///
33/// This is the current target and not a researched floor: no older server has
34/// been tested against, so claiming support for one would be a guess. Lower it
35/// when a real server has been exercised, not before.
36pub const MINIMUM_SUPPORTED_MAJOR_MINOR: (u32, u32) = (10, 10);
37
38/// A parsed server version.
39///
40/// Jellyfin reports things like `10.11.5`, `10.11.5.0` and occasionally a
41/// build suffix (`10.11.5-rc1`). Only the leading numeric components are
42/// meaningful here; anything after them is preserved in `raw` for logging and
43/// otherwise ignored.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct ServerVersion {
46    pub major: u32,
47    pub minor: u32,
48    pub patch: u32,
49    pub raw: String,
50}
51
52impl ServerVersion {
53    /// Parse what `/System/Info/Public` reported.
54    ///
55    /// Returns `None` for anything without at least a numeric major, which is
56    /// treated as "unknown" rather than as an error — an unparseable version is
57    /// not a reason to refuse a server that may work perfectly well.
58    pub fn parse(raw: &str) -> Option<Self> {
59        let trimmed = raw.trim();
60        if trimmed.is_empty() {
61            return None;
62        }
63
64        // Stop at the first character that cannot begin a numeric component, so
65        // `10.11.5-rc1` and `10.11.5+build7` both yield 10.11.5.
66        let numeric_prefix: String = trimmed
67            .chars()
68            .take_while(|c| c.is_ascii_digit() || *c == '.')
69            .collect();
70
71        let mut parts = numeric_prefix.split('.').filter(|p| !p.is_empty());
72        let major = parts.next()?.parse().ok()?;
73        let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
74        let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
75
76        Some(Self {
77            major,
78            minor,
79            patch,
80            raw: trimmed.to_string(),
81        })
82    }
83
84    fn is_below_floor(&self) -> bool {
85        (self.major, self.minor) < MINIMUM_SUPPORTED_MAJOR_MINOR
86    }
87}
88
89impl fmt::Display for ServerVersion {
90    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
92    }
93}
94
95/// How this build classified the server it is talking to.
96///
97/// There are exactly two live cases, and the gap between them is not a typo:
98/// **Jellyfin 11.0 does not exist and never did.** With 12.0 the project dropped
99/// the leading `10` from its scheme, so what would have been 10.12.0 shipped as
100/// `12.0` and the server reports `Version: "12.0.0"`. 12.0 is therefore *one*
101/// release-branch step from 10.11, not two, and `major == 11` will never occur.
102///
103/// Source: <https://jellyfin.org/posts/jellyfin-release-12.0>, which explicitly
104/// flags version-string parsers as the thing to check before upgrading.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum ServerGeneration {
107    /// The 10.x line — `major == 10`. What this client was built against.
108    V10_11,
109    /// The post-rename line — `major >= 12`.
110    V12Plus,
111    /// The server did not report a parseable version. Treated as the older
112    /// generation, which is the conservative choice: its flags are the ones that
113    /// also work on 12.x.
114    Unknown,
115}
116
117/// The resolved answer, carried by `OnlineRepository` for the life of a
118/// connection.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct ServerCapabilities {
121    pub version: Option<ServerVersion>,
122    pub generation: ServerGeneration,
123
124    /// Whether item queries go to `/Users/{userId}/Items` (`true`) or to
125    /// `/Items?userId=` (`false`).
126    ///
127    /// **`true` for every generation, and deliberately so.** The whole
128    /// `/Users/{userId}/…` family still exists and still works in 12.0 — only
129    /// six routes were removed anywhere, and the only user-scoped one is
130    /// `POST /Users/{userId}/EasyPassword`, which this client never called.
131    ///
132    /// What *did* change is policy: the family has carried `[Obsolete]` and been
133    /// hidden from the OpenAPI spec since 10.11.5, and 12.0 states in writing
134    /// that unspecified endpoints "can be removed in any major release without
135    /// warning". The replacements (`/Items?userId=` and friends) already exist
136    /// on 10.11.5, so migrating is a one-generation-compatible change whenever
137    /// it is wanted — which is why the route table carries both shapes even
138    /// though nothing selects the second one yet. See DR-282.
139    pub user_scoped_item_routes: bool,
140
141    /// Whether the server honours the **audio codec** in a submitted
142    /// `DirectPlayProfile`.
143    ///
144    /// `false` on 10.11.5: it enforces the profile's container and video codec
145    /// but ignores its audio codec, so it offers direct play for an E-AC-3 track
146    /// the renderer cannot decode and the picture plays in silence. The client
147    /// therefore has to overrule the server's own direct-play offer. See
148    /// `device_profile::audio_forces_transcode` and DR-283.
149    ///
150    /// **Still `false` on 12.x, and that is an admission rather than a finding.**
151    /// A source-level diff of 12.0 could not establish whether the underlying
152    /// behaviour changed; it established only that 12.0 *reports* codec
153    /// mismatches in `TranscodeReasons` which 10.11.5 omitted, which is not the
154    /// same claim. Keeping the override on costs a transcode that might not be
155    /// needed; turning it off on a guess costs silent playback. Flip it only
156    /// against a running 12.x server.
157    pub honours_directplay_audio_codec: bool,
158
159    /// Whether a source whose container is a *manifest* (`hls`, `applehttp`,
160    /// `dash`) may be direct-played. 12.0 makes such sources ineligible; on
161    /// 10.11.x they were eligible, which is what this client has assumed.
162    pub supports_manifest_container_direct_play: bool,
163
164    /// Whether asking the image endpoint for a size larger than the stored image
165    /// returns that size. 10.11.x upscaled; 12.0 returns the original instead.
166    /// Governs layout expectation only — a smaller image is never an error.
167    pub image_endpoint_upscales: bool,
168}
169
170impl ServerCapabilities {
171    /// The single place a version becomes behaviour. Nothing else in the crate
172    /// compares a version number.
173    pub fn for_version(version: Option<ServerVersion>) -> Self {
174        let generation = match &version {
175            None => ServerGeneration::Unknown,
176            // `major >= 12` and `major == 10` are the two live cases; 11 will
177            // never occur. A hypothetical 11 sorts with the older line, which is
178            // the conservative side.
179            Some(v) if v.major >= 12 => ServerGeneration::V12Plus,
180            Some(_) => ServerGeneration::V10_11,
181        };
182
183        let v12 = generation == ServerGeneration::V12Plus;
184
185        Self {
186            version,
187            generation,
188            // Unchanged across both generations — see each flag's docs. Note the
189            // two genuinely breaking changes 12.0 introduced (the auth spelling
190            // and the `Recursive` default) are fixed by writing the request
191            // correctly for *both*, so neither appears here. A flag is a silent
192            // branch that outlives the reason it was added; keep them for
193            // genuine either/or behaviour only.
194            user_scoped_item_routes: true,
195            honours_directplay_audio_codec: false,
196            supports_manifest_container_direct_play: !v12,
197            image_endpoint_upscales: !v12,
198        }
199    }
200
201    /// Resolve straight from what the server reported.
202    pub fn from_reported(raw_version: &str) -> Self {
203        Self::for_version(ServerVersion::parse(raw_version))
204    }
205
206    /// What this build assumes with no server to ask — the current target.
207    /// Used by offline paths and by tests that do not care.
208    pub fn assumed() -> Self {
209        Self::for_version(None)
210    }
211
212    /// Whether the server is old enough that failure is certain rather than
213    /// likely. An unparseable version is never below the floor: we do not refuse
214    /// a server on the strength of not understanding its version string.
215    pub fn is_below_supported_floor(&self) -> bool {
216        self.version.as_ref().is_some_and(|v| v.is_below_floor())
217    }
218}
219
220impl Default for ServerCapabilities {
221    fn default() -> Self {
222        Self::assumed()
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    /// TRACES: UR-085 | DR-280
231    #[test]
232    fn parses_the_shapes_a_real_server_reports() {
233        assert_eq!(
234            ServerVersion::parse("10.11.5").unwrap().to_string(),
235            "10.11.5"
236        );
237        // Four components: Jellyfin reports these, the fourth is ignored.
238        assert_eq!(
239            ServerVersion::parse("10.11.5.0").unwrap().to_string(),
240            "10.11.5"
241        );
242        // A pre-release suffix must not defeat parsing.
243        assert_eq!(
244            ServerVersion::parse("10.11.5-rc1").unwrap().to_string(),
245            "10.11.5"
246        );
247        assert_eq!(
248            ServerVersion::parse("10.11.5+build7").unwrap().to_string(),
249            "10.11.5"
250        );
251        // Missing components default rather than failing.
252        assert_eq!(ServerVersion::parse("11").unwrap().to_string(), "11.0.0");
253        assert_eq!(
254            ServerVersion::parse(" 10.10 ").unwrap().to_string(),
255            "10.10.0"
256        );
257    }
258
259    /// Nonsense is "unknown", never a panic and never a refusal.
260    ///
261    /// TRACES: UR-085 | DR-280, DR-286
262    #[test]
263    fn unparseable_versions_are_unknown_not_fatal() {
264        for raw in ["", "   ", "not-a-version", "v", "-", "..."] {
265            assert!(
266                ServerVersion::parse(raw).is_none(),
267                "{raw:?} should not parse"
268            );
269        }
270        let caps = ServerCapabilities::from_reported("not-a-version");
271        assert_eq!(caps.generation, ServerGeneration::Unknown);
272        assert!(
273            !caps.is_below_supported_floor(),
274            "an unreadable version must not refuse a server that may work"
275        );
276    }
277
278    /// A server newer than this build keeps working. Refusing it would make
279    /// every release expire the moment the server upgrades.
280    ///
281    /// TRACES: UR-085 | DR-286
282    #[test]
283    fn a_newer_than_known_server_resolves_forward() {
284        let newer = ServerCapabilities::from_reported("99.0.0");
285        assert_eq!(newer.generation, ServerGeneration::V12Plus);
286        assert!(!newer.is_below_supported_floor());
287
288        // It resolves to the newest known generation's flags; only the recorded
289        // version differs.
290        let known = ServerCapabilities::from_reported("12.0.0");
291        assert_eq!(
292            newer,
293            ServerCapabilities {
294                version: newer.version.clone(),
295                ..known
296            }
297        );
298    }
299
300    /// The version scheme changed: 12.0 *is* 10.12 renamed, so 11 never occurs
301    /// and a parser must not assume a leading `10.`.
302    ///
303    /// TRACES: UR-085 | DR-280
304    #[test]
305    fn the_two_live_generations_are_10_and_12_with_no_11() {
306        assert_eq!(
307            ServerCapabilities::from_reported("10.11.5").generation,
308            ServerGeneration::V10_11
309        );
310        assert_eq!(
311            ServerCapabilities::from_reported("12.0.0").generation,
312            ServerGeneration::V12Plus
313        );
314        // 11 cannot be reported by any real server; if one somehow does, it
315        // sorts with the older line rather than being treated as newer.
316        assert_eq!(
317            ServerCapabilities::from_reported("11.0.0").generation,
318            ServerGeneration::V10_11
319        );
320    }
321
322    /// The flags that genuinely differ, and only those.
323    ///
324    /// TRACES: UR-085 | DR-283
325    #[test]
326    fn manifest_direct_play_and_upscaling_are_the_flags_that_differ() {
327        let old = ServerCapabilities::from_reported("10.11.5");
328        let new = ServerCapabilities::from_reported("12.0.0");
329
330        assert!(old.supports_manifest_container_direct_play);
331        assert!(!new.supports_manifest_container_direct_play);
332        assert!(old.image_endpoint_upscales);
333        assert!(!new.image_endpoint_upscales);
334
335        // The two breaking changes 12.0 introduced are NOT flags: they are fixed
336        // by writing the request correctly for both generations.
337        assert_eq!(old.user_scoped_item_routes, new.user_scoped_item_routes);
338        assert_eq!(
339            old.honours_directplay_audio_codec, new.honours_directplay_audio_codec,
340            "unestablished against a running 12.x server; must not be flipped on a guess"
341        );
342    }
343
344    /// Nothing may reintroduce an authentication spelling that 12.0 disables by
345    /// default. The header *value* is correct on both generations; only the
346    /// names were deprecated, so this is a structural guard.
347    ///
348    /// TRACES: UR-085 | DR-287
349    #[test]
350    fn no_deprecated_auth_spelling_reaches_a_request_builder() {
351        let sources: &[(&str, &str)] = &[
352            ("repository/online.rs", include_str!("online.rs")),
353            ("jellyfin/client.rs", include_str!("../jellyfin/client.rs")),
354            (
355                "jellyfin/http_client.rs",
356                include_str!("../jellyfin/http_client.rs"),
357            ),
358            ("auth/mod.rs", include_str!("../auth/mod.rs")),
359        ];
360
361        for (name, src) in sources {
362            assert!(
363                !src.contains(r#".header("X-Emby-Authorization""#),
364                "{name}: X-Emby-Authorization is disabled by default on Jellyfin 12.0 \
365                 (a migration flips it on upgraded servers too). Use `Authorization` \
366                 with the same MediaBrowser value — ungated on both generations."
367            );
368            assert!(
369                !src.contains(r#".header("X-Emby-Token""#)
370                    && !src.contains(r#".header("X-MediaBrowser-Token""#),
371                "{name}: token headers are gated behind EnableLegacyAuthorization on 12.0"
372            );
373            assert!(
374                !src.contains("api_key="),
375                "{name}: `api_key` as a query parameter is gated on 12.0. Use `ApiKey`, \
376                 ungated on both and what the server itself emits."
377            );
378        }
379    }
380
381    /// TRACES: UR-085 | DR-286
382    #[test]
383    fn a_server_below_the_floor_is_refused() {
384        assert!(ServerCapabilities::from_reported("10.9.11").is_below_supported_floor());
385        assert!(ServerCapabilities::from_reported("9.0.0").is_below_supported_floor());
386        assert!(!ServerCapabilities::from_reported("10.10.0").is_below_supported_floor());
387        assert!(!ServerCapabilities::from_reported("10.11.5").is_below_supported_floor());
388    }
389
390    /// The documented 10.11.5 behaviour, pinned so that flipping it later is a
391    /// deliberate act with a citation rather than a drive-by edit.
392    ///
393    /// TRACES: UR-085 | DR-283
394    #[test]
395    fn the_current_target_does_not_honour_directplay_audio_codec() {
396        let caps = ServerCapabilities::from_reported("10.11.5");
397        assert_eq!(caps.generation, ServerGeneration::V10_11);
398        assert!(
399            !caps.honours_directplay_audio_codec,
400            "10.11.5 ignores a DirectPlayProfile's audio codec; the client must overrule it"
401        );
402    }
403
404    /// No generation may quietly acquire an unverified route change.
405    ///
406    /// TRACES: UR-085 | DR-282
407    #[test]
408    fn no_generation_yet_disables_user_scoped_routes() {
409        for raw in ["10.10.0", "10.11.5", "11.0.0", "12.0.0", "99.9.9"] {
410            assert!(
411                ServerCapabilities::from_reported(raw).user_scoped_item_routes,
412                "{raw}: flipping this needs a cited upstream source (DR-282), not a guess"
413            );
414        }
415    }
416}