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}