import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import '../../core/app_version.dart'; import '../models/jellyfin_library.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() {} } /// The music-library and playback-metadata calls layered on the same session. /// The access token travels in the Authorization header only. abstract class JellyfinLibraryGateway { Future> playedTracks( JellyfinConnection connection, { int limit = 50, }); Future> latestAddedAlbums( JellyfinConnection connection, { int limit = 5, }); Future> randomAlbums( JellyfinConnection connection, { required List genres, int limit = 5, }); Future> albumTracks( JellyfinConnection connection, String albumId, ); Future> musicLibraryIds(JellyfinConnection connection); Future> genreNames( JellyfinConnection connection, String libraryId, ); Future albumArtwork( JellyfinConnection connection, String albumId, { String? tag, int maxHeight = 400, }); } /// Talks to Jellyfin over HTTPS. Quick Connect, library calls and artwork all /// share one client; the token is never logged and only travels in the /// Authorization header. class HttpJellyfinGateway implements JellyfinGateway, JellyfinLibraryGateway { 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 Future> playedTracks( JellyfinConnection connection, { int limit = 50, }) async { final uri = connection.baseUrl .resolve('Items') .replace( queryParameters: { 'userId': connection.userId, 'recursive': 'true', 'includeItemTypes': 'Audio', 'filters': 'IsPlayed', 'sortBy': 'DatePlayed', 'sortOrder': 'Descending', 'limit': '$limit', 'fields': 'Genres,AlbumArtist,Artists,Album,AlbumId,ParentId,RunTimeTicks', 'enableUserData': 'true', 'enableImageTypes': 'Primary', 'imageTypeLimit': '1', }, ); final json = await _getJson(connection, uri); return _items(json) .map(JellyfinTrack.fromJson) .where((track) => track.id.isNotEmpty) .toList(growable: false); } @override Future> latestAddedAlbums( JellyfinConnection connection, { int limit = 5, }) async { final uri = connection.baseUrl .resolve('Items/Latest') .replace( queryParameters: { 'userId': connection.userId, 'includeItemTypes': 'MusicAlbum', 'limit': '$limit', 'fields': 'Genres,DateCreated,ChildCount,AlbumArtist,Artists,PrimaryImageAspectRatio', 'enableUserData': 'true', 'enableImageTypes': 'Primary', 'imageTypeLimit': '1', }, ); final list = await _getList(connection, uri); return list .whereType() .map( (entry) => JellyfinAlbum.fromJson(Map.from(entry)), ) .where((album) => album.id.isNotEmpty) .toList(growable: false); } @override Future> randomAlbums( JellyfinConnection connection, { required List genres, int limit = 5, }) async { if (genres.isEmpty) return const []; final uri = connection.baseUrl .resolve('Items') .replace( queryParameters: { 'userId': connection.userId, 'recursive': 'true', 'includeItemTypes': 'MusicAlbum', 'genres': genres.join('|'), 'sortBy': 'Random', 'limit': '$limit', 'fields': 'Genres,DateCreated,ChildCount,AlbumArtist,Artists,PrimaryImageAspectRatio', 'enableUserData': 'true', 'enableImageTypes': 'Primary', 'imageTypeLimit': '1', }, ); final json = await _getJson(connection, uri); return _items(json) .map(JellyfinAlbum.fromJson) .where((album) => album.id.isNotEmpty) .toList(growable: false); } @override Future> albumTracks( JellyfinConnection connection, String albumId, ) async { if (albumId.isEmpty) return const []; final uri = connection.baseUrl .resolve('Items') .replace( queryParameters: { 'userId': connection.userId, 'parentId': albumId, 'recursive': 'false', 'includeItemTypes': 'Audio', 'sortBy': 'ParentIndexNumber,IndexNumber', 'fields': 'MediaSources,MediaStreams,Genres,AlbumArtist,Artists,IndexNumber,ParentIndexNumber,RunTimeTicks', 'enableUserData': 'true', }, ); final json = await _getJson(connection, uri); return _items(json) .map(JellyfinTrack.fromJson) .where((track) => track.id.isNotEmpty) .toList(growable: false); } @override Future> musicLibraryIds(JellyfinConnection connection) async { final uri = connection.baseUrl .resolve('UserViews') .replace(queryParameters: {'userId': connection.userId}); final json = await _getJson(connection, uri); final ids = []; for (final item in _items(json)) { if (item['CollectionType'] != 'music') continue; final id = (item['Id'] as String?)?.trim(); if (id == null || id.isEmpty || ids.contains(id)) continue; ids.add(id); } return ids; } @override Future> genreNames( JellyfinConnection connection, String libraryId, ) async { if (libraryId.isEmpty) return const []; final uri = connection.baseUrl .resolve('Genres') .replace( queryParameters: {'userId': connection.userId, 'parentId': libraryId}, ); final json = await _getJson(connection, uri); final names = []; for (final item in _items(json)) { final name = (item['Name'] as String?)?.trim(); if (name == null || name.isEmpty || names.contains(name)) continue; names.add(name); } return names; } /// Album artwork is binary; the text decoder in [_send] cannot be used. @override Future albumArtwork( JellyfinConnection connection, String albumId, { String? tag, int maxHeight = 400, }) async { if (albumId.isEmpty) return null; final uri = connection.baseUrl .resolve('Items/$albumId/Images/Primary') .replace( queryParameters: { 'maxHeight': '$maxHeight', if (tag != null && tag.isNotEmpty) 'tag': tag, }, ); try { final request = await _http.getUrl(uri).timeout(timeout); request.headers.set( HttpHeaders.authorizationHeader, connection.authorizationHeader, ); request.headers.set(HttpHeaders.acceptHeader, 'image/jpeg,image/png'); final response = await request.close().timeout(timeout); if (response.statusCode != HttpStatus.ok) { await response.drain(); return null; } final builder = await response .fold(BytesBuilder(copy: false), (buffer, chunk) { buffer.add(chunk); return buffer; }) .timeout(timeout); final bytes = builder.takeBytes(); return bytes.isEmpty ? null : bytes; } catch (_) { return null; } } @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(', ')}'; } Future> _getJson( JellyfinConnection connection, Uri uri, ) async { final response = await _send( 'GET', uri, deviceId: connection.deviceId, token: connection.accessToken, ); if (response.status != HttpStatus.ok) { throw _statusError(response); } return _decode(response.body); } Future> _getList(JellyfinConnection connection, Uri uri) async { final response = await _send( 'GET', uri, deviceId: connection.deviceId, token: connection.accessToken, ); if (response.status != HttpStatus.ok) { throw _statusError(response); } return _decodeList(response.body); } Map _decode(String body) { final decoded = jsonDecode(body); if (decoded is! Map) { throw JellyfinException('The server returned an unexpected response.'); } return decoded; } List _decodeList(String body) { final decoded = jsonDecode(body); if (decoded is! List) { throw JellyfinException('The server returned an unexpected response.'); } return decoded; } List> _items(Map json) { final items = json['Items']; if (items is! List) return const []; return items .whereType() .map((entry) => Map.from(entry)) .toList(growable: false); } 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; }