From 56b990c04c285dca54cd378f2381d5ae07e507ca Mon Sep 17 00:00:00 2001 From: l3gitpanda Date: Sun, 6 Sep 2026 16:39:07 -0700 Subject: [PATCH] feat(jellyfin): report the platform in the MediaBrowser client name (#2261) Jellyfin and Emby sessions expose Client and DeviceName but no platform, so dashboards and session trackers (Tracearr's normalizeClient, for one) keyword-match the Client string the way they do for "Jellyfin Android TV" and "Swiftfin tvOS". Plezy sent Client="Plezy" on every platform, so every install showed up as platform "Plezy", and an unresolvable device name became Device="Plezy" as well. Add jellyfinClientName, which appends the platform ("Plezy Android TV", "Plezy tvOS", "Plezy iOS", ...), and jellyfinDeviceName, which falls back through the hardware model and the platform before the app name, and use both at the two Jellyfin/Emby header call sites. Every emitted client string was checked against Tracearr's matcher. The Plex headers and the DisplayPreferences client key are unchanged. Claude-Session: https://claude.ai/code/session_0134eXvhukgvG6Szg5NTwAim Co-authored-by: Claude Fable 5.1 --- lib/screens/settings/add_jellyfin_screen.dart | 15 +--- lib/services/jellyfin_auth_header.dart | 26 ++++++ lib/services/jellyfin_client.dart | 15 ++-- test/services/jellyfin_auth_header_test.dart | 82 +++++++++++++++++++ test/services/jellyfin_client_urls_test.dart | 39 +++++++-- 5 files changed, 151 insertions(+), 26 deletions(-) diff --git a/lib/screens/settings/add_jellyfin_screen.dart b/lib/screens/settings/add_jellyfin_screen.dart index 093f101d7..3c32f45e1 100644 --- a/lib/screens/settings/add_jellyfin_screen.dart +++ b/lib/screens/settings/add_jellyfin_screen.dart @@ -21,6 +21,7 @@ import '../../profiles/active_profile_binder.dart'; import '../../profiles/active_profile_provider.dart'; import '../../profiles/profile.dart'; import '../../profiles/profile_connection.dart'; +import '../../services/jellyfin_auth_header.dart'; import '../../services/jellyfin_auth_service.dart'; import '../../services/jellyfin_endpoint_discovery.dart'; import '../../services/jellyfin_lan_discovery_service.dart'; @@ -447,23 +448,15 @@ class _AddJellyfinScreenState extends State final authServiceFactory = widget._authServiceFactory; if (authServiceFactory != null) return await authServiceFactory(); final clientVersion = await resolveJellyfinClientVersion(); - final deviceName = await _resolveDeviceName(); + final identity = await DeviceIdentityService.resolve(); return JellyfinConnectionAuthService( - clientName: 'Plezy', + clientName: jellyfinClientName(identity), clientVersion: clientVersion, - deviceName: deviceName, + deviceName: jellyfinDeviceName(identity), dialect: widget.dialect, ); } - /// The raw name, not a header-sanitized one: the Jellyfin `MediaBrowser` - /// header percent-encodes it, so the device list shows it verbatim. - Future _resolveDeviceName() async { - final identity = await DeviceIdentityService.resolve(); - final name = identity.deviceName?.trim(); - return name == null || name.isEmpty ? 'Plezy' : name; - } - @override Widget build(BuildContext context) { final theme = Theme.of(context); diff --git a/lib/services/jellyfin_auth_header.dart b/lib/services/jellyfin_auth_header.dart index fec4e14e0..1eb0177cd 100644 --- a/lib/services/jellyfin_auth_header.dart +++ b/lib/services/jellyfin_auth_header.dart @@ -44,6 +44,32 @@ String buildJellyfinAuthHeader({ return 'MediaBrowser ${parts.join(', ')}'; } +/// The `Client` name for the same header, with the platform appended the way +/// the first-party apps do (`Jellyfin Android TV`, `Swiftfin tvOS`). Neither +/// Jellyfin nor Emby exposes a platform field on a session, so dashboards and +/// session trackers derive the platform from this string by keyword; a bare +/// `Plezy` on every platform makes every install look alike. +String jellyfinClientName(DeviceIdentity identity) { + final platform = _meaningful(identity.platform); + if (platform.isEmpty) return 'Plezy'; + if (identity.isTv && platform.toLowerCase() == 'android') return 'Plezy Android TV'; + return 'Plezy $platform'; +} + +/// The `Device` name for the same header: the user-facing device name, else +/// the hardware model, else the platform. An Apple TV whose name lookup failed +/// should appear in the server's device list as `Apple TV`, not as a second +/// `Plezy` next to the client name. Raw, not header-sanitized: +/// [buildJellyfinAuthHeader] percent-encodes it, so the server shows the name +/// verbatim. +String jellyfinDeviceName(DeviceIdentity identity) { + for (final candidate in [identity.deviceName, identity.deviceModel, identity.platform].nonNulls) { + final name = _meaningful(candidate); + if (name.isNotEmpty) return name; + } + return 'Plezy'; +} + final RegExp _controlCharacters = RegExp(r'[\x00-\x1f\x7f-\x9f]'); /// Percent-encoding makes any byte transportable, so the only values worth diff --git a/lib/services/jellyfin_client.dart b/lib/services/jellyfin_client.dart index 5ce6d9b77..e50de577c 100644 --- a/lib/services/jellyfin_client.dart +++ b/lib/services/jellyfin_client.dart @@ -282,18 +282,13 @@ class JellyfinClient } catch (_) { // Tests / non-platform contexts — keep the fallback version. } - // Raw, not header-sanitized: [buildJellyfinAuthHeader] percent-encodes it. - String? deviceName; - try { - final resolved = (await DeviceIdentityService.resolve()).deviceName?.trim(); - if (resolved != null && resolved.isNotEmpty) deviceName = resolved; - } catch (_) { - // Tests / non-platform contexts — keep the fallback name. - } + // Never throws: tests and non-platform contexts degrade to the bare OS + // name, which the helpers below turn into a usable client and device. + final identity = await DeviceIdentityService.resolve(); final authHeader = buildJellyfinAuthHeader( - clientName: 'Plezy', + clientName: jellyfinClientName(identity), clientVersion: version, - deviceName: deviceName ?? 'Plezy', + deviceName: jellyfinDeviceName(identity), deviceId: connection.deviceId, accessToken: connection.accessToken, ); diff --git a/test/services/jellyfin_auth_header_test.dart b/test/services/jellyfin_auth_header_test.dart index cf310fa64..1f0374a47 100644 --- a/test/services/jellyfin_auth_header_test.dart +++ b/test/services/jellyfin_auth_header_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/services/jellyfin_auth_header.dart'; +import 'package:plezy/utils/device_identity.dart'; /// Every field value Jellyfin reads back out of the header, mirroring the /// server's own parse: split on the top-level commas, strip the quotes, then @@ -111,4 +112,85 @@ void main() { expect(requireJellyfinDeviceId('dev-1'), 'dev-1'); }); }); + + // Jellyfin and Emby sessions carry no platform field, so session trackers + // (Tracearr's `normalizeClient`, for one) keyword-match the Client string + // the way they do for `Jellyfin Android TV` and `Swiftfin tvOS`. Each + // expected value below was checked against that matcher. + group('jellyfinClientName', () { + test('appends the platform the way the first-party apps do', () { + for (final platform in ['iOS', 'Android', 'macOS', 'Windows', 'Linux']) { + expect(jellyfinClientName(DeviceIdentity(platform: platform)), 'Plezy $platform'); + } + }); + + test('names the Android TV variant without repeating TV for tvOS', () { + expect(jellyfinClientName(const DeviceIdentity(platform: 'Android', isTv: true)), 'Plezy Android TV'); + expect(jellyfinClientName(const DeviceIdentity(platform: 'tvOS', isTv: true)), 'Plezy tvOS'); + }); + + test('keeps the TV suffix on the degraded lowercase OS name', () { + // DeviceIdentityService falls back to Platform.operatingSystem when the + // platform plugin fails, and that is lowercase. + expect(jellyfinClientName(const DeviceIdentity(platform: 'android', isTv: true)), 'Plezy Android TV'); + }); + + test('only Android gets a TV suffix', () { + expect(jellyfinClientName(const DeviceIdentity(platform: 'Linux', isTv: true)), 'Plezy Linux'); + }); + + test('falls back to the bare app name without a platform', () { + expect(jellyfinClientName(const DeviceIdentity(platform: '')), 'Plezy'); + expect(jellyfinClientName(const DeviceIdentity(platform: ' \u0000 ')), 'Plezy'); + }); + + test('survives the header round trip', () { + final header = buildJellyfinAuthHeader( + clientName: jellyfinClientName(const DeviceIdentity(platform: 'Android', isTv: true)), + clientVersion: '2.19.0', + deviceName: 'Living Room Shield', + deviceId: 'dev-1', + ); + expect(parseAsJellyfinWould(header)['Client'], 'Plezy Android TV'); + }); + }); + + group('jellyfinDeviceName', () { + test('prefers the user-facing device name', () { + const identity = DeviceIdentity(platform: 'iOS', deviceModel: 'iPhone', deviceName: ' Bob\'s iPhone '); + expect(jellyfinDeviceName(identity), "Bob's iPhone"); + }); + + test('falls back to the hardware model when the name lookup failed', () { + // An Apple TV without a resolvable name should be listed as `Apple TV`, + // not as a second `Plezy` next to the client name. + const identity = DeviceIdentity(platform: 'tvOS', deviceModel: 'Apple TV', isTv: true); + expect(jellyfinDeviceName(identity), 'Apple TV'); + expect( + jellyfinDeviceName(const DeviceIdentity(platform: 'tvOS', deviceModel: 'Apple TV', deviceName: ' ')), + 'Apple TV', + ); + }); + + test('falls back to the platform when there is no model either', () { + expect(jellyfinDeviceName(const DeviceIdentity(platform: 'Linux')), 'Linux'); + expect(jellyfinDeviceName(const DeviceIdentity(platform: 'Linux', deviceModel: '\u0000')), 'Linux'); + }); + + test('falls back to the app name when nothing about the device is known', () { + expect(jellyfinDeviceName(const DeviceIdentity(platform: '')), 'Plezy'); + }); + }); + + group('jellyfinClientName and jellyfinDeviceName', () { + test('derive from the same resolved identity', () async { + // The production call sites resolve one identity and derive both names + // from it, so an override must steer both. + DeviceIdentityService.debugOverride(const DeviceIdentity(platform: 'Android', deviceModel: 'AFTKM', isTv: true)); + addTearDown(() => DeviceIdentityService.debugOverride(null)); + final identity = await DeviceIdentityService.resolve(); + expect(jellyfinClientName(identity), 'Plezy Android TV'); + expect(jellyfinDeviceName(identity), 'AFTKM'); + }); + }); } diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index 67124b176..378fc43a8 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -135,12 +135,14 @@ _initializeJellyfinAudioCarry({int? selectedAudioStreamId, AudioTrack? preferred return (client: client, requests: requests); } +const _testIdentity = DeviceIdentity(platform: 'Test'); + /// URL-builder smoke tests. Without a live Jellyfin server, pin query keys and /// authentication parameters directly. void main() { - // Pin device identity so JellyfinClient.create's MediaBrowser header falls - // back to Device="Plezy" instead of resolving the host machine's name. - setUpAll(() => DeviceIdentityService.debugOverride(const DeviceIdentity(platform: 'Test'))); + // Pin device identity so JellyfinClient.create's MediaBrowser header names + // the test platform instead of resolving the host machine's name. + setUpAll(() => DeviceIdentityService.debugOverride(_testIdentity)); tearDownAll(() => DeviceIdentityService.debugOverride(null)); group('JellyfinClient URL builders', () { @@ -3054,8 +3056,8 @@ void main() { final auth = headers['Authorization']; expect(auth, isNotNull); expect(auth, startsWith('MediaBrowser ')); - expect(auth, contains('Client="Plezy"')); - expect(auth, contains('Device="Plezy"')); + expect(auth, contains('Client="Plezy%20Test"')); + expect(auth, contains('Device="Test"')); expect(auth, contains('DeviceId="dev-xyz"')); expect(auth, contains(RegExp(r'Version="[^"]+"'))); expect(auth, contains('Token="tok-abc"')); @@ -3066,6 +3068,33 @@ void main() { expect(headers['Accept'], 'application/json'); }); + test('names the platform in Client and the device in Device', () async { + // Jellyfin sessions have no platform field: dashboards and session + // trackers keyword-match the Client string, the way they already do for + // `Jellyfin Android TV` and `Swiftfin tvOS`. + DeviceIdentityService.debugOverride( + const DeviceIdentity(platform: 'Android', deviceModel: 'SHIELD', deviceName: 'Living Room Shield', isTv: true), + ); + addTearDown(() => DeviceIdentityService.debugOverride(_testIdentity)); + final scoped = await JellyfinClient.create(_conn()); + addTearDown(scoped.close); + + final auth = scoped.defaultHeadersForTesting['Authorization']; + expect(auth, contains('Client="Plezy%20Android%20TV"')); + expect(auth, contains('Device="Living%20Room%20Shield"')); + }); + + test('falls back to the hardware model for Device when the name lookup failed', () async { + DeviceIdentityService.debugOverride(const DeviceIdentity(platform: 'tvOS', deviceModel: 'Apple TV', isTv: true)); + addTearDown(() => DeviceIdentityService.debugOverride(_testIdentity)); + final scoped = await JellyfinClient.create(_conn()); + addTearDown(scoped.close); + + final auth = scoped.defaultHeadersForTesting['Authorization']; + expect(auth, contains('Client="Plezy%20tvOS"')); + expect(auth, contains('Device="Apple%20TV"')); + }); + test('fetchLibraryPagedContent sends a bounded paged Items request', () async { Uri? captured; final scoped = JellyfinClient.forTesting(