feat: report real device identity to media servers

Resolve platform, hardware model, and friendly device name once via a
shared DeviceIdentityService and send them to both backends: Plex gets
a real X-Plex-Platform plus X-Plex-Device/X-Plex-Device-Name (shown as
Player in dashboards/Tautulli), Jellyfin gets the device name in the
MediaBrowser auth header. Transcode and live-TV decision requests keep
their pinned platform names, which Plex validates server-side.

close #1270
This commit is contained in:
edde746
2026-07-05 01:22:20 +02:00
parent f1422feff1
commit d80a1ed15a
14 changed files with 324 additions and 51 deletions
+14 -2
View File
@@ -1,5 +1,7 @@
import 'package:package_info_plus/package_info_plus.dart';
import '../../utils/device_identity.dart';
class PlexConfig {
final String baseUrl;
final String? token;
@@ -8,6 +10,10 @@ class PlexConfig {
final String version;
final String platform;
final String? device;
/// Friendly device name — Plex dashboards and Tautulli show it as the
/// session's "Player".
final String? deviceName;
final bool acceptJson;
final String? machineIdentifier;
final String? languageCode;
@@ -20,6 +26,7 @@ class PlexConfig {
required this.version,
this.platform = 'Flutter',
this.device,
this.deviceName,
this.acceptJson = true,
this.machineIdentifier,
this.languageCode,
@@ -37,14 +44,16 @@ class PlexConfig {
String? languageCode,
}) async {
final packageInfo = await PackageInfo.fromPlatform();
final identity = await DeviceIdentityService.resolve();
return PlexConfig(
baseUrl: baseUrl,
token: token,
clientIdentifier: clientIdentifier,
product: product ?? 'Plezy',
version: packageInfo.version,
platform: platform ?? 'Flutter',
device: device,
platform: platform ?? identity.platform,
device: device ?? sanitizeHeaderValue(identity.deviceModel),
deviceName: sanitizeHeaderValue(identity.deviceName),
acceptJson: acceptJson,
machineIdentifier: machineIdentifier,
languageCode: languageCode,
@@ -59,6 +68,7 @@ class PlexConfig {
'X-Plex-Platform': platform,
'X-Plex-Client-Profile-Name': 'Generic',
'X-Plex-Device': ?device,
'X-Plex-Device-Name': ?deviceName,
if (acceptJson) 'Accept': 'application/json',
'Accept-Charset': 'utf-8',
'Accept-Language': ?_normalizedLanguageCode,
@@ -85,6 +95,7 @@ class PlexConfig {
String? version,
String? platform,
String? device,
String? deviceName,
bool? acceptJson,
String? machineIdentifier,
String? languageCode,
@@ -97,6 +108,7 @@ class PlexConfig {
version: version ?? this.version,
platform: platform ?? this.platform,
device: device ?? this.device,
deviceName: deviceName ?? this.deviceName,
acceptJson: acceptJson ?? this.acceptJson,
machineIdentifier: machineIdentifier ?? this.machineIdentifier,
languageCode: languageCode ?? this.languageCode,
+4 -33
View File
@@ -1,8 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:device_info_plus/device_info_plus.dart';
import '../connection/connection.dart';
import '../connection/connection_registry.dart';
@@ -20,7 +18,7 @@ import '../services/companion_remote/lan_discovery_service.dart';
import '../services/companion_remote/remote_auth_context.dart';
import '../services/companion_remote/remote_auth_service.dart';
import '../utils/app_logger.dart';
import '../utils/platform_detector.dart';
import '../utils/device_identity.dart';
import '../mixins/disposable_change_notifier_mixin.dart';
export '../services/companion_remote/lan_discovery_service.dart' show DiscoveredHost;
@@ -91,36 +89,9 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
}
Future<void> _initializeDeviceInfo() async {
final deviceInfo = DeviceInfoPlugin();
try {
if (Platform.isAndroid) {
final androidInfo = await deviceInfo.androidInfo;
final osName = await TvDetectionService.getAndroidDeviceName();
_deviceName = osName ?? '${androidInfo.brand} ${androidInfo.model}';
_platform = 'Android';
} else if (Platform.isIOS) {
final iosInfo = await deviceInfo.iosInfo;
_deviceName = iosInfo.name;
_platform = 'iOS';
} else if (Platform.isMacOS) {
final macInfo = await deviceInfo.macOsInfo;
_deviceName = macInfo.computerName;
_platform = 'macOS';
} else if (Platform.isWindows) {
final windowsInfo = await deviceInfo.windowsInfo;
_deviceName = windowsInfo.computerName;
_platform = 'Windows';
} else if (Platform.isLinux) {
final host = Platform.localHostname.trim();
_deviceName = (host.isNotEmpty && host != 'localhost') ? host : (await deviceInfo.linuxInfo).name;
_platform = 'Linux';
}
} catch (e) {
appLogger.e('CompanionRemote: Failed to get device info', error: e);
_deviceName = t.companionRemote.unknownDevice;
_platform = Platform.operatingSystem;
}
final identity = await DeviceIdentityService.resolve();
_deviceName = identity.deviceName ?? t.companionRemote.unknownDevice;
_platform = identity.platform;
safeNotifyListeners();
}
@@ -26,6 +26,7 @@ import '../../services/jellyfin_lan_discovery_service.dart';
import '../../services/storage_service.dart';
import '../../theme/mono_tokens.dart';
import '../../utils/app_logger.dart';
import '../../utils/device_identity.dart';
import '../../utils/platform_detector.dart';
import '../../widgets/focused_scroll_scaffold.dart';
import '../profile/profile_switch_screen.dart';
@@ -436,10 +437,8 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
}
Future<String> _resolveDeviceName() async {
// PackageInfo doesn't expose a device name; fall back to a generic label.
// Jellyfin only shows this in the admin "Devices" list — fine to keep
// simple until we add proper device_info_plus integration.
return 'Plezy';
final identity = await DeviceIdentityService.resolve();
return sanitizeHeaderValue(identity.deviceName) ?? 'Plezy';
}
@override
+10 -5
View File
@@ -1,6 +1,10 @@
/// Build the `MediaBrowser` Authorization header value the way the Jellyfin
/// SDK formats it. Used at auth time and on every authenticated request so
/// the server sees a consistent client identity.
///
/// Values are quote-stripped: the header grammar has no escape for `"`, so a
/// device name like `My "cool" TV` would otherwise corrupt every field after
/// it.
String buildJellyfinAuthHeader({
required String clientName,
required String clientVersion,
@@ -8,12 +12,13 @@ String buildJellyfinAuthHeader({
required String deviceId,
String? accessToken,
}) {
String quoted(String value) => '"${value.replaceAll('"', '')}"';
final parts = <String>[
'Client="$clientName"',
'Device="$deviceName"',
'DeviceId="$deviceId"',
'Version="$clientVersion"',
if (accessToken != null && accessToken.isNotEmpty) 'Token="$accessToken"',
'Client=${quoted(clientName)}',
'Device=${quoted(deviceName)}',
'DeviceId=${quoted(deviceId)}',
'Version=${quoted(clientVersion)}',
if (accessToken != null && accessToken.isNotEmpty) 'Token=${quoted(accessToken)}',
];
return 'MediaBrowser ${parts.join(', ')}';
}
+8 -1
View File
@@ -41,6 +41,7 @@ import '../models/media_subscription.dart';
import '../media/media_source_info.dart';
import '../media/media_sort.dart';
import '../utils/app_logger.dart';
import '../utils/device_identity.dart';
import '../utils/failover_http_client.dart';
import '../utils/media_server_retry.dart';
import '../utils/media_server_timeouts.dart';
@@ -125,10 +126,16 @@ class JellyfinClient
} catch (_) {
// Tests / non-platform contexts — keep the fallback version.
}
String? deviceName;
try {
deviceName = sanitizeHeaderValue((await DeviceIdentityService.resolve()).deviceName);
} catch (_) {
// Tests / non-platform contexts — keep the fallback name.
}
final authHeader = buildJellyfinAuthHeader(
clientName: 'Plezy',
clientVersion: version,
deviceName: 'Plezy',
deviceName: deviceName ?? 'Plezy',
deviceId: connection.deviceId,
accessToken: connection.accessToken,
);
+26 -4
View File
@@ -9,6 +9,7 @@ import '../models/plex/plex_user_profile.dart';
import '../models/plex/plex_home.dart';
import '../models/user_switch_response.dart';
import '../utils/app_logger.dart';
import '../utils/device_identity.dart';
import '../utils/endpoint_race.dart';
import '../utils/media_server_timeouts.dart';
import '../utils/media_server_http_client.dart';
@@ -53,8 +54,17 @@ class PlexAuthService {
final String _clientIdentifier;
final String _appVersion;
final String _platformVersion;
final String _platform;
final String? _deviceName;
PlexAuthService._(this._http, this._clientIdentifier, this._appVersion, this._platformVersion);
PlexAuthService._(
this._http,
this._clientIdentifier,
this._appVersion,
this._platformVersion,
this._platform,
this._deviceName,
);
@visibleForTesting
PlexAuthService.forTesting({
@@ -62,7 +72,9 @@ class PlexAuthService {
String clientIdentifier = 'test-client',
String appVersion = 'test',
String platformVersion = 'test',
}) : this._(http, clientIdentifier, appVersion, platformVersion);
String platform = 'Flutter',
String? deviceName,
}) : this._(http, clientIdentifier, appVersion, platformVersion, platform, deviceName);
/// Close the underlying HTTP client. Call when the service is short-lived
/// (created for a single API call) to avoid leaking sockets.
@@ -76,7 +88,15 @@ class PlexAuthService {
);
final clientIdentifier = await storage.getOrCreateClientIdentifier();
final packageInfo = await PackageInfo.fromPlatform();
return PlexAuthService._(http, clientIdentifier, packageInfo.version, Platform.operatingSystemVersion);
final identity = await DeviceIdentityService.resolve();
return PlexAuthService._(
http,
clientIdentifier,
packageInfo.version,
Platform.operatingSystemVersion,
identity.platform,
sanitizeHeaderValue(identity.deviceName),
);
}
String get clientIdentifier => _clientIdentifier;
@@ -86,6 +106,8 @@ class PlexAuthService {
'Accept': 'application/json',
'X-Plex-Product': _appName,
'X-Plex-Client-Identifier': _clientIdentifier,
'X-Plex-Platform': _platform,
'X-Plex-Device-Name': ?_deviceName,
};
if (authToken != null) {
@@ -251,7 +273,7 @@ class PlexAuthService {
'X-Plex-Product': _appName,
'X-Plex-Version': _appVersion,
'X-Plex-Client-Identifier': _clientIdentifier,
'X-Plex-Platform': 'Flutter',
'X-Plex-Platform': _platform,
'X-Plex-Platform-Version': _platformVersion,
'X-Plex-Token': currentToken,
'X-Plex-Language': 'en',
+5 -1
View File
@@ -52,6 +52,7 @@ import '../utils/content_utils.dart';
import '../media/media_sort.dart';
import '../models/plex/plex_video_playback_data.dart';
import '../models/transcode_quality_preset.dart';
import '../utils/device_identity.dart';
import '../utils/failover_http_client.dart';
import '../utils/app_logger.dart';
import '../utils/media_server_retry.dart';
@@ -551,6 +552,8 @@ class PlexClient
Duration timeout = const Duration(seconds: 5),
String? clientIdentifier,
}) async {
// Memoized after the first call — resolve outside the latency window.
final identity = await DeviceIdentityService.resolve();
final stopwatch = Stopwatch()..start();
MediaServerHttpClient? client;
@@ -561,7 +564,7 @@ class PlexClient
if (clientIdentifier != null) {
headers['X-Plex-Client-Identifier'] = clientIdentifier;
headers['X-Plex-Product'] = 'Plezy';
headers['X-Plex-Device-Name'] = 'Plezy';
headers['X-Plex-Device-Name'] = sanitizeHeaderValue(identity.deviceName) ?? 'Plezy';
}
final response = await client.get('/', headers: headers);
@@ -3138,6 +3141,7 @@ class PlexClient
// [_transcodePlatformName] for the mapping.
'X-Plex-Platform': _transcodePlatformName(),
if (config.device != null) 'X-Plex-Device': config.device!,
if (config.deviceName != null) 'X-Plex-Device-Name': config.deviceName!,
if (offsetMs != null) 'offset': (offsetMs ~/ 1000).toString(),
if (config.token != null) 'X-Plex-Token': config.token!,
};
+4 -1
View File
@@ -999,7 +999,10 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
'X-Plex-Product': config.product,
'X-Plex-Version': config.version,
'X-Plex-Client-Identifier': config.clientIdentifier,
'X-Plex-Platform': config.platform,
// Pinned rather than config.platform: this decision request is only
// known to work with the 'Plex Desktop' profile + 'Flutter' platform
// pairing, and real OS names map to server-side preset profiles.
'X-Plex-Platform': 'Flutter',
'X-Plex-Client-Profile-Name': 'Plex Desktop',
if (offsetSeconds != null) 'offset': offsetSeconds.toString(),
if (config.token != null) 'X-Plex-Token': config.token!,
+113
View File
@@ -0,0 +1,113 @@
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/foundation.dart';
import 'app_logger.dart';
import 'platform_detector.dart';
/// What this install should call itself when talking to media servers and
/// companion peers: a real platform name, the hardware model, and the
/// user-facing device name (Plex dashboards show it as the "Player";
/// Jellyfin as the "Device").
class DeviceIdentity {
/// 'Android' | 'iOS' | 'tvOS' | 'macOS' | 'Windows' | 'Linux', falling back
/// to [Platform.operatingSystem] when detection fails.
final String platform;
/// Hardware model for `X-Plex-Device`, e.g. 'AFTKM' (Fire TV), 'iPhone',
/// 'Apple TV'. Null when unresolvable.
final String? deviceModel;
/// Friendly, usually user-assigned name (Settings > About > Device name on
/// Android, computer name on desktop). Null when unresolvable — callers
/// pick their own fallback. May contain characters that are not valid in
/// HTTP headers; pass through [sanitizeHeaderValue] before sending.
final String? deviceName;
final bool isTv;
const DeviceIdentity({required this.platform, this.deviceModel, this.deviceName, this.isTv = false});
}
/// Resolves the device identity once per process and memoizes it. Never
/// throws — platform-channel failures (tests, exotic platforms) degrade to
/// [Platform.operatingSystem] with null name/model.
class DeviceIdentityService {
DeviceIdentityService._();
static Future<DeviceIdentity>? _cached;
static Future<DeviceIdentity> resolve() => _cached ??= _resolve();
@visibleForTesting
static void debugOverride(DeviceIdentity? identity) {
_cached = identity == null ? null : Future.value(identity);
}
static Future<DeviceIdentity> _resolve() async {
final deviceInfo = DeviceInfoPlugin();
final isTv = TvDetectionService.isTVSync();
try {
if (Platform.isAndroid) {
final androidInfo = await deviceInfo.androidInfo;
final assignedName = await TvDetectionService.getAndroidDeviceName();
return DeviceIdentity(
platform: 'Android',
deviceModel: androidInfo.model,
deviceName: assignedName ?? '${androidInfo.brand} ${androidInfo.model}',
isTv: isTv,
);
}
if (Platform.isIOS) {
final iosInfo = await deviceInfo.iosInfo;
if (TvDetectionService.isAppleTVSync()) {
return DeviceIdentity(platform: 'tvOS', deviceModel: 'Apple TV', deviceName: iosInfo.name, isTv: true);
}
return DeviceIdentity(platform: 'iOS', deviceModel: iosInfo.model, deviceName: iosInfo.name, isTv: isTv);
}
if (Platform.isMacOS) {
final macInfo = await deviceInfo.macOsInfo;
return DeviceIdentity(
platform: 'macOS',
deviceModel: macInfo.model,
deviceName: macInfo.computerName,
isTv: isTv,
);
}
if (Platform.isWindows) {
final windowsInfo = await deviceInfo.windowsInfo;
return DeviceIdentity(
platform: 'Windows',
deviceModel: 'Windows',
deviceName: windowsInfo.computerName,
isTv: isTv,
);
}
if (Platform.isLinux) {
final host = Platform.localHostname.trim();
final name = (host.isNotEmpty && host != 'localhost') ? host : (await deviceInfo.linuxInfo).name;
return DeviceIdentity(platform: 'Linux', deviceModel: 'Linux', deviceName: name, isTv: isTv);
}
} catch (e) {
appLogger.w('DeviceIdentity: failed to resolve device info', error: e);
}
return DeviceIdentity(platform: Platform.operatingSystem, isTv: isTv);
}
}
/// Makes a free-form device name safe to send as an HTTP header value:
/// drops CR/LF and any code unit above 0xFF (dart:io's HttpHeaders throws a
/// FormatException on non-latin-1 — an emoji in an iPhone name would
/// otherwise kill every request), trims, and returns null when nothing
/// usable remains.
String? sanitizeHeaderValue(String? value) {
if (value == null) return null;
final filtered = String.fromCharCodes(
value.codeUnits.where((unit) => unit != 0x0D && unit != 0x0A && unit <= 0xFF),
);
final trimmed = filtered.trim();
return trimmed.isEmpty ? null : trimmed;
}
+34
View File
@@ -0,0 +1,34 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/models/plex/plex_config.dart';
void main() {
group('PlexConfig.headers', () {
test('includes X-Plex-Device-Name when deviceName is set', () {
final config = PlexConfig(
baseUrl: 'https://plex.example.com',
clientIdentifier: 'client-1',
product: 'Plezy',
version: '1.0',
platform: 'Windows',
device: 'Windows',
deviceName: 'Living Room PC',
);
expect(config.headers['X-Plex-Platform'], 'Windows');
expect(config.headers['X-Plex-Device'], 'Windows');
expect(config.headers['X-Plex-Device-Name'], 'Living Room PC');
});
test('omits X-Plex-Device-Name and X-Plex-Device when unset', () {
final config = PlexConfig(
baseUrl: 'https://plex.example.com',
clientIdentifier: 'client-1',
product: 'Plezy',
version: '1.0',
);
expect(config.headers.containsKey('X-Plex-Device-Name'), isFalse);
expect(config.headers.containsKey('X-Plex-Device'), isFalse);
// Raw-constructor default is unchanged for tests that rely on it.
expect(config.headers['X-Plex-Platform'], 'Flutter');
});
});
}
@@ -0,0 +1,44 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/services/jellyfin_auth_header.dart';
void main() {
group('buildJellyfinAuthHeader', () {
test('formats the SDK-style MediaBrowser header', () {
final header = buildJellyfinAuthHeader(
clientName: 'Plezy',
clientVersion: '1.2.3',
deviceName: 'Living Room TV',
deviceId: 'dev-1',
accessToken: 'tok',
);
expect(
header,
'MediaBrowser Client="Plezy", Device="Living Room TV", DeviceId="dev-1", Version="1.2.3", Token="tok"',
);
});
test('omits Token when access token is null or empty', () {
for (final token in [null, '']) {
final header = buildJellyfinAuthHeader(
clientName: 'Plezy',
clientVersion: '1.2.3',
deviceName: 'Plezy',
deviceId: 'dev-1',
accessToken: token,
);
expect(header, isNot(contains('Token=')));
}
});
test('strips embedded quotes so a device name cannot corrupt the header', () {
final header = buildJellyfinAuthHeader(
clientName: 'Plezy',
clientVersion: '1.2.3',
deviceName: 'My "cool" TV',
deviceId: 'dev-1',
accessToken: 'tok',
);
expect(header, contains('Device="My cool TV"'));
});
});
}
@@ -12,6 +12,7 @@ import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/models/transcode_quality_preset.dart';
import 'package:plezy/services/jellyfin_client.dart';
import 'package:plezy/services/playback_initialization_types.dart';
import 'package:plezy/utils/device_identity.dart';
JellyfinConnection _conn({String accessToken = 'tok-abc', String baseUrl = 'https://jf.example.com'}) =>
JellyfinConnection(
@@ -32,6 +33,11 @@ JellyfinConnection _conn({String accessToken = 'tok-abc', String baseUrl = 'http
/// tests pin the contract so the next iteration of the player (Task 8 wiring)
/// has something to point at.
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')));
tearDownAll(() => DeviceIdentityService.debugOverride(null));
group('JellyfinClient URL builders', () {
late JellyfinClient client;
@@ -8,6 +8,7 @@ import 'package:plezy/media/media_source_info.dart';
import 'package:plezy/services/jellyfin_client.dart';
import 'package:plezy/services/jellyfin_trickplay_service.dart';
import 'package:plezy/services/scrub_preview_source.dart';
import 'package:plezy/utils/device_identity.dart';
JellyfinConnection _conn() => JellyfinConnection(
id: 'srv-1/user-1',
@@ -44,6 +45,11 @@ TrickplayInfo _info({
ImageProvider _fakeSheet(String _) => MemoryImage(Uint8List.fromList(const [0]));
void main() {
// Pin device identity so JellyfinClient.create doesn't resolve the host
// machine's name.
setUpAll(() => DeviceIdentityService.debugOverride(const DeviceIdentity(platform: 'Test')));
tearDownAll(() => DeviceIdentityService.debugOverride(null));
group('JellyfinTrickplayService.create — width selection', () {
late JellyfinClient client;
+47
View File
@@ -0,0 +1,47 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/utils/device_identity.dart';
void main() {
group('sanitizeHeaderValue', () {
test('passes plain latin-1 names through trimmed', () {
expect(sanitizeHeaderValue(' Living Room TV '), 'Living Room TV');
expect(sanitizeHeaderValue("Édouard's Mac"), "Édouard's Mac");
});
test('strips code units above latin-1 (emoji would break dart:io headers)', () {
expect(sanitizeHeaderValue('📱 Bob\'s iPhone'), "Bob's iPhone");
expect(sanitizeHeaderValue('电视'), isNull);
});
test('strips CR/LF', () {
expect(sanitizeHeaderValue('evil\r\nX-Injected: 1'), 'evilX-Injected: 1');
});
test('returns null for null, empty, and whitespace-only input', () {
expect(sanitizeHeaderValue(null), isNull);
expect(sanitizeHeaderValue(''), isNull);
expect(sanitizeHeaderValue(' '), isNull);
});
});
group('DeviceIdentityService.debugOverride', () {
tearDown(() => DeviceIdentityService.debugOverride(null));
test('resolve returns the overridden identity', () async {
const identity = DeviceIdentity(platform: 'TestOS', deviceModel: 'Model-X', deviceName: 'Unit Test', isTv: true);
DeviceIdentityService.debugOverride(identity);
final resolved = await DeviceIdentityService.resolve();
expect(resolved.platform, 'TestOS');
expect(resolved.deviceModel, 'Model-X');
expect(resolved.deviceName, 'Unit Test');
expect(resolved.isTv, isTrue);
});
test('a later override replaces the memoized value', () async {
DeviceIdentityService.debugOverride(const DeviceIdentity(platform: 'First'));
expect((await DeviceIdentityService.resolve()).platform, 'First');
DeviceIdentityService.debugOverride(const DeviceIdentity(platform: 'Second'));
expect((await DeviceIdentityService.resolve()).platform, 'Second');
});
});
}