import 'dart:async'; import 'dart:convert'; import 'dart:io'; import '../../core/app_version.dart'; import '../models/jellyfin_models.dart'; /// The small part of the Jellyfin API CarDash needs for Quick Connect. abstract class JellyfinGateway { Future serverInfo(Uri baseUrl); Future startQuickConnect( Uri baseUrl, { required String deviceId, }); Future isQuickConnectApproved(Uri baseUrl, String secret); Future authenticateQuickConnect( Uri baseUrl, String secret, { required String deviceId, }); Future currentUser(Uri baseUrl, String token); void close() {} } /// Talks to Jellyfin over HTTPS using the documented Quick Connect endpoints. /// The access token is never logged; it only travels in the Authorization /// header and is stored by the caller in secure storage. class HttpJellyfinGateway implements JellyfinGateway { HttpJellyfinGateway({ HttpClient Function()? clientFactory, this.timeout = const Duration(seconds: 10), }) : _clientFactory = clientFactory ?? HttpClient.new; final HttpClient Function() _clientFactory; final Duration timeout; HttpClient? _client; HttpClient get _http { return _client ??= _clientFactory() ..connectionTimeout = timeout ..idleTimeout = const Duration(seconds: 15); } @override Future serverInfo(Uri baseUrl) async { final response = await _send('GET', baseUrl.resolve('System/Info/Public')); if (response.status != HttpStatus.ok) { throw _statusError(response, notFound: 'No Jellyfin server found there.'); } final json = _decode(response.body); final product = (json['ProductName'] as String?)?.toLowerCase(); if (product != null && !product.contains('jellyfin')) { throw JellyfinException('That URL is not a Jellyfin server.'); } final name = (json['ServerName'] as String?)?.trim(); return JellyfinServerInfo( name: name == null || name.isEmpty ? 'Jellyfin' : name, version: (json['Version'] as String?) ?? '', ); } @override Future startQuickConnect( Uri baseUrl, { required String deviceId, }) async { final response = await _send( 'POST', baseUrl.resolve('QuickConnect/Initiate'), deviceId: deviceId, ); if (response.status != HttpStatus.ok) { if (response.status == HttpStatus.unauthorized || response.status == HttpStatus.forbidden || response.status == HttpStatus.notFound) { throw JellyfinException( 'Quick Connect is disabled on this server. Enable it in Jellyfin ' 'settings and try again.', ); } throw _statusError(response); } final json = _decode(response.body); final code = (json['Code'] as String?)?.trim(); final secret = (json['Secret'] as String?)?.trim(); if (code == null || code.isEmpty || secret == null || secret.isEmpty) { throw JellyfinException( 'The server did not return a Quick Connect code.', ); } return JellyfinQuickConnect(code: code, secret: secret); } @override Future isQuickConnectApproved(Uri baseUrl, String secret) async { final uri = baseUrl .resolve('QuickConnect/Connect') .replace(queryParameters: {'secret': secret}); final response = await _send('GET', uri); if (response.status != HttpStatus.ok) { if (response.status == HttpStatus.notFound || response.status == HttpStatus.unauthorized) { throw JellyfinException('The Quick Connect code expired.'); } throw _statusError(response); } return _decode(response.body)['Authenticated'] == true; } @override Future authenticateQuickConnect( Uri baseUrl, String secret, { required String deviceId, }) async { final response = await _send( 'POST', baseUrl.resolve('Users/AuthenticateWithQuickConnect'), deviceId: deviceId, body: jsonEncode({'Secret': secret}), contentType: ContentType.json, ); if (response.status == HttpStatus.unauthorized || response.status == HttpStatus.forbidden) { throw JellyfinAuthException('The Quick Connect approval was rejected.'); } if (response.status != HttpStatus.ok) { throw _statusError(response); } final json = _decode(response.body); final token = (json['AccessToken'] as String?)?.trim(); final user = json['User']; if (token == null || token.isEmpty || user is! Map) { throw JellyfinException('The server did not return an access token.'); } final id = (user['Id'] as String?)?.trim(); final name = (user['Name'] as String?)?.trim(); if (id == null || id.isEmpty || name == null || name.isEmpty) { throw JellyfinException('The server did not return the user profile.'); } return JellyfinSession(accessToken: token, userId: id, userName: name); } @override Future currentUser(Uri baseUrl, String token) async { final response = await _send( 'GET', baseUrl.resolve('Users/Me'), token: token, ); if (response.status == HttpStatus.unauthorized || response.status == HttpStatus.forbidden) { throw JellyfinAuthException('The saved Jellyfin pairing is not valid.'); } if (response.status != HttpStatus.ok) { throw _statusError(response); } final json = _decode(response.body); final id = (json['Id'] as String?)?.trim(); final name = (json['Name'] as String?)?.trim(); if (id == null || id.isEmpty || name == null || name.isEmpty) { throw JellyfinException('The server returned an unexpected profile.'); } return JellyfinUser(id: id, name: name); } @override void close() { _client?.close(force: true); _client = null; } Future<_JellyfinResponse> _send( String method, Uri uri, { String? deviceId, String? token, String? body, ContentType? contentType, }) async { try { final request = await _http.openUrl(method, uri).timeout(timeout); request.headers.set( HttpHeaders.authorizationHeader, _authorization(deviceId ?? 'cardash', token: token), ); request.headers.set(HttpHeaders.acceptHeader, 'application/json'); if (body != null) { request.headers.contentType = contentType ?? ContentType.text; request.write(body); } final response = await request.close().timeout(timeout); final text = await response .transform(utf8.decoder) .join() .timeout(timeout); return _JellyfinResponse(status: response.statusCode, body: text); } on JellyfinException { rethrow; } on SocketException { throw JellyfinUnreachable('Could not reach the server.'); } on HandshakeException { throw JellyfinUnreachable('Could not establish a secure connection.'); } on TimeoutException { throw JellyfinUnreachable('The server did not respond.'); } on HttpException { throw JellyfinUnreachable('Could not reach the server.'); } on FormatException { throw JellyfinException('The server returned an unexpected response.'); } } String _authorization(String deviceId, {String? token}) { final parts = [ 'Client="CarDash"', 'Device="CarDash"', 'DeviceId="$deviceId"', 'Version="$appVersion"', if (token != null) 'Token="$token"', ]; return 'MediaBrowser ${parts.join(', ')}'; } Map _decode(String body) { final decoded = jsonDecode(body); if (decoded is! Map) { throw JellyfinException('The server returned an unexpected response.'); } return decoded; } JellyfinException _statusError( _JellyfinResponse response, { String? notFound, }) { if (response.status == HttpStatus.unauthorized || response.status == HttpStatus.forbidden) { return JellyfinAuthException('The server rejected the credentials.'); } if (response.status == HttpStatus.notFound && notFound != null) { return JellyfinException(notFound); } return JellyfinException('The server returned ${response.status}.'); } } class _JellyfinResponse { const _JellyfinResponse({required this.status, required this.body}); final int status; final String body; } /// Accepts `host`, `host:8096`, `https://host/jellyfin` and similar. Missing /// schemes default to HTTPS; the result always ends in `/` so relative API /// paths resolve under any configured path prefix. Uri? normalizeJellyfinUrl(String input) { var value = input.trim(); if (value.isEmpty) return null; if (!value.contains('://')) value = 'https://$value'; final uri = Uri.tryParse(value); if (uri == null || uri.host.isEmpty) return null; if (uri.scheme != 'http' && uri.scheme != 'https') return null; final path = uri.path.endsWith('/') ? uri.path : '${uri.path}/'; return Uri( scheme: uri.scheme, host: uri.host, port: uri.hasPort ? uri.port : null, path: path, ); } /// The stored form of [normalizeJellyfinUrl], without the trailing slash. String? displayJellyfinUrl(String input) { final uri = normalizeJellyfinUrl(input); if (uri == null) return null; final value = uri.toString(); return value.endsWith('/') ? value.substring(0, value.length - 1) : value; }