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:
@@ -1,5 +1,7 @@
|
|||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
|
||||||
|
import '../../utils/device_identity.dart';
|
||||||
|
|
||||||
class PlexConfig {
|
class PlexConfig {
|
||||||
final String baseUrl;
|
final String baseUrl;
|
||||||
final String? token;
|
final String? token;
|
||||||
@@ -8,6 +10,10 @@ class PlexConfig {
|
|||||||
final String version;
|
final String version;
|
||||||
final String platform;
|
final String platform;
|
||||||
final String? device;
|
final String? device;
|
||||||
|
|
||||||
|
/// Friendly device name — Plex dashboards and Tautulli show it as the
|
||||||
|
/// session's "Player".
|
||||||
|
final String? deviceName;
|
||||||
final bool acceptJson;
|
final bool acceptJson;
|
||||||
final String? machineIdentifier;
|
final String? machineIdentifier;
|
||||||
final String? languageCode;
|
final String? languageCode;
|
||||||
@@ -20,6 +26,7 @@ class PlexConfig {
|
|||||||
required this.version,
|
required this.version,
|
||||||
this.platform = 'Flutter',
|
this.platform = 'Flutter',
|
||||||
this.device,
|
this.device,
|
||||||
|
this.deviceName,
|
||||||
this.acceptJson = true,
|
this.acceptJson = true,
|
||||||
this.machineIdentifier,
|
this.machineIdentifier,
|
||||||
this.languageCode,
|
this.languageCode,
|
||||||
@@ -37,14 +44,16 @@ class PlexConfig {
|
|||||||
String? languageCode,
|
String? languageCode,
|
||||||
}) async {
|
}) async {
|
||||||
final packageInfo = await PackageInfo.fromPlatform();
|
final packageInfo = await PackageInfo.fromPlatform();
|
||||||
|
final identity = await DeviceIdentityService.resolve();
|
||||||
return PlexConfig(
|
return PlexConfig(
|
||||||
baseUrl: baseUrl,
|
baseUrl: baseUrl,
|
||||||
token: token,
|
token: token,
|
||||||
clientIdentifier: clientIdentifier,
|
clientIdentifier: clientIdentifier,
|
||||||
product: product ?? 'Plezy',
|
product: product ?? 'Plezy',
|
||||||
version: packageInfo.version,
|
version: packageInfo.version,
|
||||||
platform: platform ?? 'Flutter',
|
platform: platform ?? identity.platform,
|
||||||
device: device,
|
device: device ?? sanitizeHeaderValue(identity.deviceModel),
|
||||||
|
deviceName: sanitizeHeaderValue(identity.deviceName),
|
||||||
acceptJson: acceptJson,
|
acceptJson: acceptJson,
|
||||||
machineIdentifier: machineIdentifier,
|
machineIdentifier: machineIdentifier,
|
||||||
languageCode: languageCode,
|
languageCode: languageCode,
|
||||||
@@ -59,6 +68,7 @@ class PlexConfig {
|
|||||||
'X-Plex-Platform': platform,
|
'X-Plex-Platform': platform,
|
||||||
'X-Plex-Client-Profile-Name': 'Generic',
|
'X-Plex-Client-Profile-Name': 'Generic',
|
||||||
'X-Plex-Device': ?device,
|
'X-Plex-Device': ?device,
|
||||||
|
'X-Plex-Device-Name': ?deviceName,
|
||||||
if (acceptJson) 'Accept': 'application/json',
|
if (acceptJson) 'Accept': 'application/json',
|
||||||
'Accept-Charset': 'utf-8',
|
'Accept-Charset': 'utf-8',
|
||||||
'Accept-Language': ?_normalizedLanguageCode,
|
'Accept-Language': ?_normalizedLanguageCode,
|
||||||
@@ -85,6 +95,7 @@ class PlexConfig {
|
|||||||
String? version,
|
String? version,
|
||||||
String? platform,
|
String? platform,
|
||||||
String? device,
|
String? device,
|
||||||
|
String? deviceName,
|
||||||
bool? acceptJson,
|
bool? acceptJson,
|
||||||
String? machineIdentifier,
|
String? machineIdentifier,
|
||||||
String? languageCode,
|
String? languageCode,
|
||||||
@@ -97,6 +108,7 @@ class PlexConfig {
|
|||||||
version: version ?? this.version,
|
version: version ?? this.version,
|
||||||
platform: platform ?? this.platform,
|
platform: platform ?? this.platform,
|
||||||
device: device ?? this.device,
|
device: device ?? this.device,
|
||||||
|
deviceName: deviceName ?? this.deviceName,
|
||||||
acceptJson: acceptJson ?? this.acceptJson,
|
acceptJson: acceptJson ?? this.acceptJson,
|
||||||
machineIdentifier: machineIdentifier ?? this.machineIdentifier,
|
machineIdentifier: machineIdentifier ?? this.machineIdentifier,
|
||||||
languageCode: languageCode ?? this.languageCode,
|
languageCode: languageCode ?? this.languageCode,
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
|
||||||
|
|
||||||
import '../connection/connection.dart';
|
import '../connection/connection.dart';
|
||||||
import '../connection/connection_registry.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_context.dart';
|
||||||
import '../services/companion_remote/remote_auth_service.dart';
|
import '../services/companion_remote/remote_auth_service.dart';
|
||||||
import '../utils/app_logger.dart';
|
import '../utils/app_logger.dart';
|
||||||
import '../utils/platform_detector.dart';
|
import '../utils/device_identity.dart';
|
||||||
import '../mixins/disposable_change_notifier_mixin.dart';
|
import '../mixins/disposable_change_notifier_mixin.dart';
|
||||||
|
|
||||||
export '../services/companion_remote/lan_discovery_service.dart' show DiscoveredHost;
|
export '../services/companion_remote/lan_discovery_service.dart' show DiscoveredHost;
|
||||||
@@ -91,36 +89,9 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initializeDeviceInfo() async {
|
Future<void> _initializeDeviceInfo() async {
|
||||||
final deviceInfo = DeviceInfoPlugin();
|
final identity = await DeviceIdentityService.resolve();
|
||||||
|
_deviceName = identity.deviceName ?? t.companionRemote.unknownDevice;
|
||||||
try {
|
_platform = identity.platform;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
safeNotifyListeners();
|
safeNotifyListeners();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import '../../services/jellyfin_lan_discovery_service.dart';
|
|||||||
import '../../services/storage_service.dart';
|
import '../../services/storage_service.dart';
|
||||||
import '../../theme/mono_tokens.dart';
|
import '../../theme/mono_tokens.dart';
|
||||||
import '../../utils/app_logger.dart';
|
import '../../utils/app_logger.dart';
|
||||||
|
import '../../utils/device_identity.dart';
|
||||||
import '../../utils/platform_detector.dart';
|
import '../../utils/platform_detector.dart';
|
||||||
import '../../widgets/focused_scroll_scaffold.dart';
|
import '../../widgets/focused_scroll_scaffold.dart';
|
||||||
import '../profile/profile_switch_screen.dart';
|
import '../profile/profile_switch_screen.dart';
|
||||||
@@ -436,10 +437,8 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<String> _resolveDeviceName() async {
|
Future<String> _resolveDeviceName() async {
|
||||||
// PackageInfo doesn't expose a device name; fall back to a generic label.
|
final identity = await DeviceIdentityService.resolve();
|
||||||
// Jellyfin only shows this in the admin "Devices" list — fine to keep
|
return sanitizeHeaderValue(identity.deviceName) ?? 'Plezy';
|
||||||
// simple until we add proper device_info_plus integration.
|
|
||||||
return 'Plezy';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
/// Build the `MediaBrowser` Authorization header value the way the Jellyfin
|
/// Build the `MediaBrowser` Authorization header value the way the Jellyfin
|
||||||
/// SDK formats it. Used at auth time and on every authenticated request so
|
/// SDK formats it. Used at auth time and on every authenticated request so
|
||||||
/// the server sees a consistent client identity.
|
/// 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({
|
String buildJellyfinAuthHeader({
|
||||||
required String clientName,
|
required String clientName,
|
||||||
required String clientVersion,
|
required String clientVersion,
|
||||||
@@ -8,12 +12,13 @@ String buildJellyfinAuthHeader({
|
|||||||
required String deviceId,
|
required String deviceId,
|
||||||
String? accessToken,
|
String? accessToken,
|
||||||
}) {
|
}) {
|
||||||
|
String quoted(String value) => '"${value.replaceAll('"', '')}"';
|
||||||
final parts = <String>[
|
final parts = <String>[
|
||||||
'Client="$clientName"',
|
'Client=${quoted(clientName)}',
|
||||||
'Device="$deviceName"',
|
'Device=${quoted(deviceName)}',
|
||||||
'DeviceId="$deviceId"',
|
'DeviceId=${quoted(deviceId)}',
|
||||||
'Version="$clientVersion"',
|
'Version=${quoted(clientVersion)}',
|
||||||
if (accessToken != null && accessToken.isNotEmpty) 'Token="$accessToken"',
|
if (accessToken != null && accessToken.isNotEmpty) 'Token=${quoted(accessToken)}',
|
||||||
];
|
];
|
||||||
return 'MediaBrowser ${parts.join(', ')}';
|
return 'MediaBrowser ${parts.join(', ')}';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import '../models/media_subscription.dart';
|
|||||||
import '../media/media_source_info.dart';
|
import '../media/media_source_info.dart';
|
||||||
import '../media/media_sort.dart';
|
import '../media/media_sort.dart';
|
||||||
import '../utils/app_logger.dart';
|
import '../utils/app_logger.dart';
|
||||||
|
import '../utils/device_identity.dart';
|
||||||
import '../utils/failover_http_client.dart';
|
import '../utils/failover_http_client.dart';
|
||||||
import '../utils/media_server_retry.dart';
|
import '../utils/media_server_retry.dart';
|
||||||
import '../utils/media_server_timeouts.dart';
|
import '../utils/media_server_timeouts.dart';
|
||||||
@@ -125,10 +126,16 @@ class JellyfinClient
|
|||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Tests / non-platform contexts — keep the fallback version.
|
// 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(
|
final authHeader = buildJellyfinAuthHeader(
|
||||||
clientName: 'Plezy',
|
clientName: 'Plezy',
|
||||||
clientVersion: version,
|
clientVersion: version,
|
||||||
deviceName: 'Plezy',
|
deviceName: deviceName ?? 'Plezy',
|
||||||
deviceId: connection.deviceId,
|
deviceId: connection.deviceId,
|
||||||
accessToken: connection.accessToken,
|
accessToken: connection.accessToken,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import '../models/plex/plex_user_profile.dart';
|
|||||||
import '../models/plex/plex_home.dart';
|
import '../models/plex/plex_home.dart';
|
||||||
import '../models/user_switch_response.dart';
|
import '../models/user_switch_response.dart';
|
||||||
import '../utils/app_logger.dart';
|
import '../utils/app_logger.dart';
|
||||||
|
import '../utils/device_identity.dart';
|
||||||
import '../utils/endpoint_race.dart';
|
import '../utils/endpoint_race.dart';
|
||||||
import '../utils/media_server_timeouts.dart';
|
import '../utils/media_server_timeouts.dart';
|
||||||
import '../utils/media_server_http_client.dart';
|
import '../utils/media_server_http_client.dart';
|
||||||
@@ -53,8 +54,17 @@ class PlexAuthService {
|
|||||||
final String _clientIdentifier;
|
final String _clientIdentifier;
|
||||||
final String _appVersion;
|
final String _appVersion;
|
||||||
final String _platformVersion;
|
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
|
@visibleForTesting
|
||||||
PlexAuthService.forTesting({
|
PlexAuthService.forTesting({
|
||||||
@@ -62,7 +72,9 @@ class PlexAuthService {
|
|||||||
String clientIdentifier = 'test-client',
|
String clientIdentifier = 'test-client',
|
||||||
String appVersion = 'test',
|
String appVersion = 'test',
|
||||||
String platformVersion = '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
|
/// Close the underlying HTTP client. Call when the service is short-lived
|
||||||
/// (created for a single API call) to avoid leaking sockets.
|
/// (created for a single API call) to avoid leaking sockets.
|
||||||
@@ -76,7 +88,15 @@ class PlexAuthService {
|
|||||||
);
|
);
|
||||||
final clientIdentifier = await storage.getOrCreateClientIdentifier();
|
final clientIdentifier = await storage.getOrCreateClientIdentifier();
|
||||||
final packageInfo = await PackageInfo.fromPlatform();
|
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;
|
String get clientIdentifier => _clientIdentifier;
|
||||||
@@ -86,6 +106,8 @@ class PlexAuthService {
|
|||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
'X-Plex-Product': _appName,
|
'X-Plex-Product': _appName,
|
||||||
'X-Plex-Client-Identifier': _clientIdentifier,
|
'X-Plex-Client-Identifier': _clientIdentifier,
|
||||||
|
'X-Plex-Platform': _platform,
|
||||||
|
'X-Plex-Device-Name': ?_deviceName,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (authToken != null) {
|
if (authToken != null) {
|
||||||
@@ -251,7 +273,7 @@ class PlexAuthService {
|
|||||||
'X-Plex-Product': _appName,
|
'X-Plex-Product': _appName,
|
||||||
'X-Plex-Version': _appVersion,
|
'X-Plex-Version': _appVersion,
|
||||||
'X-Plex-Client-Identifier': _clientIdentifier,
|
'X-Plex-Client-Identifier': _clientIdentifier,
|
||||||
'X-Plex-Platform': 'Flutter',
|
'X-Plex-Platform': _platform,
|
||||||
'X-Plex-Platform-Version': _platformVersion,
|
'X-Plex-Platform-Version': _platformVersion,
|
||||||
'X-Plex-Token': currentToken,
|
'X-Plex-Token': currentToken,
|
||||||
'X-Plex-Language': 'en',
|
'X-Plex-Language': 'en',
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import '../utils/content_utils.dart';
|
|||||||
import '../media/media_sort.dart';
|
import '../media/media_sort.dart';
|
||||||
import '../models/plex/plex_video_playback_data.dart';
|
import '../models/plex/plex_video_playback_data.dart';
|
||||||
import '../models/transcode_quality_preset.dart';
|
import '../models/transcode_quality_preset.dart';
|
||||||
|
import '../utils/device_identity.dart';
|
||||||
import '../utils/failover_http_client.dart';
|
import '../utils/failover_http_client.dart';
|
||||||
import '../utils/app_logger.dart';
|
import '../utils/app_logger.dart';
|
||||||
import '../utils/media_server_retry.dart';
|
import '../utils/media_server_retry.dart';
|
||||||
@@ -551,6 +552,8 @@ class PlexClient
|
|||||||
Duration timeout = const Duration(seconds: 5),
|
Duration timeout = const Duration(seconds: 5),
|
||||||
String? clientIdentifier,
|
String? clientIdentifier,
|
||||||
}) async {
|
}) async {
|
||||||
|
// Memoized after the first call — resolve outside the latency window.
|
||||||
|
final identity = await DeviceIdentityService.resolve();
|
||||||
final stopwatch = Stopwatch()..start();
|
final stopwatch = Stopwatch()..start();
|
||||||
MediaServerHttpClient? client;
|
MediaServerHttpClient? client;
|
||||||
|
|
||||||
@@ -561,7 +564,7 @@ class PlexClient
|
|||||||
if (clientIdentifier != null) {
|
if (clientIdentifier != null) {
|
||||||
headers['X-Plex-Client-Identifier'] = clientIdentifier;
|
headers['X-Plex-Client-Identifier'] = clientIdentifier;
|
||||||
headers['X-Plex-Product'] = 'Plezy';
|
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);
|
final response = await client.get('/', headers: headers);
|
||||||
@@ -3138,6 +3141,7 @@ class PlexClient
|
|||||||
// [_transcodePlatformName] for the mapping.
|
// [_transcodePlatformName] for the mapping.
|
||||||
'X-Plex-Platform': _transcodePlatformName(),
|
'X-Plex-Platform': _transcodePlatformName(),
|
||||||
if (config.device != null) 'X-Plex-Device': config.device!,
|
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 (offsetMs != null) 'offset': (offsetMs ~/ 1000).toString(),
|
||||||
if (config.token != null) 'X-Plex-Token': config.token!,
|
if (config.token != null) 'X-Plex-Token': config.token!,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -999,7 +999,10 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
|
|||||||
'X-Plex-Product': config.product,
|
'X-Plex-Product': config.product,
|
||||||
'X-Plex-Version': config.version,
|
'X-Plex-Version': config.version,
|
||||||
'X-Plex-Client-Identifier': config.clientIdentifier,
|
'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',
|
'X-Plex-Client-Profile-Name': 'Plex Desktop',
|
||||||
if (offsetSeconds != null) 'offset': offsetSeconds.toString(),
|
if (offsetSeconds != null) 'offset': offsetSeconds.toString(),
|
||||||
if (config.token != null) 'X-Plex-Token': config.token!,
|
if (config.token != null) 'X-Plex-Token': config.token!,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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/models/transcode_quality_preset.dart';
|
||||||
import 'package:plezy/services/jellyfin_client.dart';
|
import 'package:plezy/services/jellyfin_client.dart';
|
||||||
import 'package:plezy/services/playback_initialization_types.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 _conn({String accessToken = 'tok-abc', String baseUrl = 'https://jf.example.com'}) =>
|
||||||
JellyfinConnection(
|
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)
|
/// tests pin the contract so the next iteration of the player (Task 8 wiring)
|
||||||
/// has something to point at.
|
/// has something to point at.
|
||||||
void main() {
|
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', () {
|
group('JellyfinClient URL builders', () {
|
||||||
late JellyfinClient client;
|
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_client.dart';
|
||||||
import 'package:plezy/services/jellyfin_trickplay_service.dart';
|
import 'package:plezy/services/jellyfin_trickplay_service.dart';
|
||||||
import 'package:plezy/services/scrub_preview_source.dart';
|
import 'package:plezy/services/scrub_preview_source.dart';
|
||||||
|
import 'package:plezy/utils/device_identity.dart';
|
||||||
|
|
||||||
JellyfinConnection _conn() => JellyfinConnection(
|
JellyfinConnection _conn() => JellyfinConnection(
|
||||||
id: 'srv-1/user-1',
|
id: 'srv-1/user-1',
|
||||||
@@ -44,6 +45,11 @@ TrickplayInfo _info({
|
|||||||
ImageProvider _fakeSheet(String _) => MemoryImage(Uint8List.fromList(const [0]));
|
ImageProvider _fakeSheet(String _) => MemoryImage(Uint8List.fromList(const [0]));
|
||||||
|
|
||||||
void main() {
|
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', () {
|
group('JellyfinTrickplayService.create — width selection', () {
|
||||||
late JellyfinClient client;
|
late JellyfinClient client;
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user