jellyfin_service.dart 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. import 'dart:async';
  2. import 'dart:convert';
  3. import 'dart:io';
  4. import '../../core/app_version.dart';
  5. import '../models/jellyfin_models.dart';
  6. /// The small part of the Jellyfin API CarDash needs for Quick Connect.
  7. abstract class JellyfinGateway {
  8. Future<JellyfinServerInfo> serverInfo(Uri baseUrl);
  9. Future<JellyfinQuickConnect> startQuickConnect(
  10. Uri baseUrl, {
  11. required String deviceId,
  12. });
  13. Future<bool> isQuickConnectApproved(Uri baseUrl, String secret);
  14. Future<JellyfinSession> authenticateQuickConnect(
  15. Uri baseUrl,
  16. String secret, {
  17. required String deviceId,
  18. });
  19. Future<JellyfinUser> currentUser(Uri baseUrl, String token);
  20. void close() {}
  21. }
  22. /// Talks to Jellyfin over HTTPS using the documented Quick Connect endpoints.
  23. /// The access token is never logged; it only travels in the Authorization
  24. /// header and is stored by the caller in secure storage.
  25. class HttpJellyfinGateway implements JellyfinGateway {
  26. HttpJellyfinGateway({
  27. HttpClient Function()? clientFactory,
  28. this.timeout = const Duration(seconds: 10),
  29. }) : _clientFactory = clientFactory ?? HttpClient.new;
  30. final HttpClient Function() _clientFactory;
  31. final Duration timeout;
  32. HttpClient? _client;
  33. HttpClient get _http {
  34. return _client ??= _clientFactory()
  35. ..connectionTimeout = timeout
  36. ..idleTimeout = const Duration(seconds: 15);
  37. }
  38. @override
  39. Future<JellyfinServerInfo> serverInfo(Uri baseUrl) async {
  40. final response = await _send('GET', baseUrl.resolve('System/Info/Public'));
  41. if (response.status != HttpStatus.ok) {
  42. throw _statusError(response, notFound: 'No Jellyfin server found there.');
  43. }
  44. final json = _decode(response.body);
  45. final product = (json['ProductName'] as String?)?.toLowerCase();
  46. if (product != null && !product.contains('jellyfin')) {
  47. throw JellyfinException('That URL is not a Jellyfin server.');
  48. }
  49. final name = (json['ServerName'] as String?)?.trim();
  50. return JellyfinServerInfo(
  51. name: name == null || name.isEmpty ? 'Jellyfin' : name,
  52. version: (json['Version'] as String?) ?? '',
  53. );
  54. }
  55. @override
  56. Future<JellyfinQuickConnect> startQuickConnect(
  57. Uri baseUrl, {
  58. required String deviceId,
  59. }) async {
  60. final response = await _send(
  61. 'POST',
  62. baseUrl.resolve('QuickConnect/Initiate'),
  63. deviceId: deviceId,
  64. );
  65. if (response.status != HttpStatus.ok) {
  66. if (response.status == HttpStatus.unauthorized ||
  67. response.status == HttpStatus.forbidden ||
  68. response.status == HttpStatus.notFound) {
  69. throw JellyfinException(
  70. 'Quick Connect is disabled on this server. Enable it in Jellyfin '
  71. 'settings and try again.',
  72. );
  73. }
  74. throw _statusError(response);
  75. }
  76. final json = _decode(response.body);
  77. final code = (json['Code'] as String?)?.trim();
  78. final secret = (json['Secret'] as String?)?.trim();
  79. if (code == null || code.isEmpty || secret == null || secret.isEmpty) {
  80. throw JellyfinException(
  81. 'The server did not return a Quick Connect code.',
  82. );
  83. }
  84. return JellyfinQuickConnect(code: code, secret: secret);
  85. }
  86. @override
  87. Future<bool> isQuickConnectApproved(Uri baseUrl, String secret) async {
  88. final uri = baseUrl
  89. .resolve('QuickConnect/Connect')
  90. .replace(queryParameters: {'secret': secret});
  91. final response = await _send('GET', uri);
  92. if (response.status != HttpStatus.ok) {
  93. if (response.status == HttpStatus.notFound ||
  94. response.status == HttpStatus.unauthorized) {
  95. throw JellyfinException('The Quick Connect code expired.');
  96. }
  97. throw _statusError(response);
  98. }
  99. return _decode(response.body)['Authenticated'] == true;
  100. }
  101. @override
  102. Future<JellyfinSession> authenticateQuickConnect(
  103. Uri baseUrl,
  104. String secret, {
  105. required String deviceId,
  106. }) async {
  107. final response = await _send(
  108. 'POST',
  109. baseUrl.resolve('Users/AuthenticateWithQuickConnect'),
  110. deviceId: deviceId,
  111. body: jsonEncode({'Secret': secret}),
  112. contentType: ContentType.json,
  113. );
  114. if (response.status == HttpStatus.unauthorized ||
  115. response.status == HttpStatus.forbidden) {
  116. throw JellyfinAuthException('The Quick Connect approval was rejected.');
  117. }
  118. if (response.status != HttpStatus.ok) {
  119. throw _statusError(response);
  120. }
  121. final json = _decode(response.body);
  122. final token = (json['AccessToken'] as String?)?.trim();
  123. final user = json['User'];
  124. if (token == null || token.isEmpty || user is! Map) {
  125. throw JellyfinException('The server did not return an access token.');
  126. }
  127. final id = (user['Id'] as String?)?.trim();
  128. final name = (user['Name'] as String?)?.trim();
  129. if (id == null || id.isEmpty || name == null || name.isEmpty) {
  130. throw JellyfinException('The server did not return the user profile.');
  131. }
  132. return JellyfinSession(accessToken: token, userId: id, userName: name);
  133. }
  134. @override
  135. Future<JellyfinUser> currentUser(Uri baseUrl, String token) async {
  136. final response = await _send(
  137. 'GET',
  138. baseUrl.resolve('Users/Me'),
  139. token: token,
  140. );
  141. if (response.status == HttpStatus.unauthorized ||
  142. response.status == HttpStatus.forbidden) {
  143. throw JellyfinAuthException('The saved Jellyfin pairing is not valid.');
  144. }
  145. if (response.status != HttpStatus.ok) {
  146. throw _statusError(response);
  147. }
  148. final json = _decode(response.body);
  149. final id = (json['Id'] as String?)?.trim();
  150. final name = (json['Name'] as String?)?.trim();
  151. if (id == null || id.isEmpty || name == null || name.isEmpty) {
  152. throw JellyfinException('The server returned an unexpected profile.');
  153. }
  154. return JellyfinUser(id: id, name: name);
  155. }
  156. @override
  157. void close() {
  158. _client?.close(force: true);
  159. _client = null;
  160. }
  161. Future<_JellyfinResponse> _send(
  162. String method,
  163. Uri uri, {
  164. String? deviceId,
  165. String? token,
  166. String? body,
  167. ContentType? contentType,
  168. }) async {
  169. try {
  170. final request = await _http.openUrl(method, uri).timeout(timeout);
  171. request.headers.set(
  172. HttpHeaders.authorizationHeader,
  173. _authorization(deviceId ?? 'cardash', token: token),
  174. );
  175. request.headers.set(HttpHeaders.acceptHeader, 'application/json');
  176. if (body != null) {
  177. request.headers.contentType = contentType ?? ContentType.text;
  178. request.write(body);
  179. }
  180. final response = await request.close().timeout(timeout);
  181. final text = await response
  182. .transform(utf8.decoder)
  183. .join()
  184. .timeout(timeout);
  185. return _JellyfinResponse(status: response.statusCode, body: text);
  186. } on JellyfinException {
  187. rethrow;
  188. } on SocketException {
  189. throw JellyfinUnreachable('Could not reach the server.');
  190. } on HandshakeException {
  191. throw JellyfinUnreachable('Could not establish a secure connection.');
  192. } on TimeoutException {
  193. throw JellyfinUnreachable('The server did not respond.');
  194. } on HttpException {
  195. throw JellyfinUnreachable('Could not reach the server.');
  196. } on FormatException {
  197. throw JellyfinException('The server returned an unexpected response.');
  198. }
  199. }
  200. String _authorization(String deviceId, {String? token}) {
  201. final parts = [
  202. 'Client="CarDash"',
  203. 'Device="CarDash"',
  204. 'DeviceId="$deviceId"',
  205. 'Version="$appVersion"',
  206. if (token != null) 'Token="$token"',
  207. ];
  208. return 'MediaBrowser ${parts.join(', ')}';
  209. }
  210. Map<String, dynamic> _decode(String body) {
  211. final decoded = jsonDecode(body);
  212. if (decoded is! Map<String, dynamic>) {
  213. throw JellyfinException('The server returned an unexpected response.');
  214. }
  215. return decoded;
  216. }
  217. JellyfinException _statusError(
  218. _JellyfinResponse response, {
  219. String? notFound,
  220. }) {
  221. if (response.status == HttpStatus.unauthorized ||
  222. response.status == HttpStatus.forbidden) {
  223. return JellyfinAuthException('The server rejected the credentials.');
  224. }
  225. if (response.status == HttpStatus.notFound && notFound != null) {
  226. return JellyfinException(notFound);
  227. }
  228. return JellyfinException('The server returned ${response.status}.');
  229. }
  230. }
  231. class _JellyfinResponse {
  232. const _JellyfinResponse({required this.status, required this.body});
  233. final int status;
  234. final String body;
  235. }
  236. /// Accepts `host`, `host:8096`, `https://host/jellyfin` and similar. Missing
  237. /// schemes default to HTTPS; the result always ends in `/` so relative API
  238. /// paths resolve under any configured path prefix.
  239. Uri? normalizeJellyfinUrl(String input) {
  240. var value = input.trim();
  241. if (value.isEmpty) return null;
  242. if (!value.contains('://')) value = 'https://$value';
  243. final uri = Uri.tryParse(value);
  244. if (uri == null || uri.host.isEmpty) return null;
  245. if (uri.scheme != 'http' && uri.scheme != 'https') return null;
  246. final path = uri.path.endsWith('/') ? uri.path : '${uri.path}/';
  247. return Uri(
  248. scheme: uri.scheme,
  249. host: uri.host,
  250. port: uri.hasPort ? uri.port : null,
  251. path: path,
  252. );
  253. }
  254. /// The stored form of [normalizeJellyfinUrl], without the trailing slash.
  255. String? displayJellyfinUrl(String input) {
  256. final uri = normalizeJellyfinUrl(input);
  257. if (uri == null) return null;
  258. final value = uri.toString();
  259. return value.endsWith('/') ? value.substring(0, value.length - 1) : value;
  260. }