| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284 |
- 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<JellyfinServerInfo> serverInfo(Uri baseUrl);
- Future<JellyfinQuickConnect> startQuickConnect(
- Uri baseUrl, {
- required String deviceId,
- });
- Future<bool> isQuickConnectApproved(Uri baseUrl, String secret);
- Future<JellyfinSession> authenticateQuickConnect(
- Uri baseUrl,
- String secret, {
- required String deviceId,
- });
- Future<JellyfinUser> 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<JellyfinServerInfo> 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<JellyfinQuickConnect> 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<bool> 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<JellyfinSession> 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<JellyfinUser> 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<String, dynamic> _decode(String body) {
- final decoded = jsonDecode(body);
- if (decoded is! Map<String, dynamic>) {
- 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;
- }
|