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 '../../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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(', ')}';
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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!,
|
||||
};
|
||||
|
||||
@@ -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!,
|
||||
|
||||
@@ -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/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;
|
||||
|
||||
|
||||
@@ -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