jellyfin_service.dart 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563
  1. import 'dart:async';
  2. import 'dart:convert';
  3. import 'dart:io';
  4. import 'dart:typed_data';
  5. import '../../core/app_version.dart';
  6. import '../models/jellyfin_library.dart';
  7. import '../models/jellyfin_models.dart';
  8. /// The small part of the Jellyfin API CarDash needs for Quick Connect.
  9. abstract class JellyfinGateway {
  10. Future<JellyfinServerInfo> serverInfo(Uri baseUrl);
  11. Future<JellyfinQuickConnect> startQuickConnect(
  12. Uri baseUrl, {
  13. required String deviceId,
  14. });
  15. Future<bool> isQuickConnectApproved(Uri baseUrl, String secret);
  16. Future<JellyfinSession> authenticateQuickConnect(
  17. Uri baseUrl,
  18. String secret, {
  19. required String deviceId,
  20. });
  21. Future<JellyfinUser> currentUser(Uri baseUrl, String token);
  22. void close() {}
  23. }
  24. /// The music-library and playback-metadata calls layered on the same session.
  25. /// The access token travels in the Authorization header only.
  26. abstract class JellyfinLibraryGateway {
  27. Future<List<JellyfinTrack>> playedTracks(
  28. JellyfinConnection connection, {
  29. int limit = 50,
  30. });
  31. Future<List<JellyfinAlbum>> latestAddedAlbums(
  32. JellyfinConnection connection, {
  33. int limit = 5,
  34. });
  35. Future<List<JellyfinAlbum>> randomAlbums(
  36. JellyfinConnection connection, {
  37. required List<String> genres,
  38. int limit = 5,
  39. });
  40. Future<List<JellyfinTrack>> albumTracks(
  41. JellyfinConnection connection,
  42. String albumId,
  43. );
  44. Future<List<String>> musicLibraryIds(JellyfinConnection connection);
  45. Future<List<String>> genreNames(
  46. JellyfinConnection connection,
  47. String libraryId,
  48. );
  49. Future<Uint8List?> albumArtwork(
  50. JellyfinConnection connection,
  51. String albumId, {
  52. String? tag,
  53. int maxHeight = 400,
  54. });
  55. }
  56. /// Talks to Jellyfin over HTTPS. Quick Connect, library calls and artwork all
  57. /// share one client; the token is never logged and only travels in the
  58. /// Authorization header.
  59. class HttpJellyfinGateway implements JellyfinGateway, JellyfinLibraryGateway {
  60. HttpJellyfinGateway({
  61. HttpClient Function()? clientFactory,
  62. this.timeout = const Duration(seconds: 10),
  63. }) : _clientFactory = clientFactory ?? HttpClient.new;
  64. final HttpClient Function() _clientFactory;
  65. final Duration timeout;
  66. HttpClient? _client;
  67. HttpClient get _http {
  68. return _client ??= _clientFactory()
  69. ..connectionTimeout = timeout
  70. ..idleTimeout = const Duration(seconds: 15);
  71. }
  72. @override
  73. Future<JellyfinServerInfo> serverInfo(Uri baseUrl) async {
  74. final response = await _send('GET', baseUrl.resolve('System/Info/Public'));
  75. if (response.status != HttpStatus.ok) {
  76. throw _statusError(response, notFound: 'No Jellyfin server found there.');
  77. }
  78. final json = _decode(response.body);
  79. final product = (json['ProductName'] as String?)?.toLowerCase();
  80. if (product != null && !product.contains('jellyfin')) {
  81. throw JellyfinException('That URL is not a Jellyfin server.');
  82. }
  83. final name = (json['ServerName'] as String?)?.trim();
  84. return JellyfinServerInfo(
  85. name: name == null || name.isEmpty ? 'Jellyfin' : name,
  86. version: (json['Version'] as String?) ?? '',
  87. );
  88. }
  89. @override
  90. Future<JellyfinQuickConnect> startQuickConnect(
  91. Uri baseUrl, {
  92. required String deviceId,
  93. }) async {
  94. final response = await _send(
  95. 'POST',
  96. baseUrl.resolve('QuickConnect/Initiate'),
  97. deviceId: deviceId,
  98. );
  99. if (response.status != HttpStatus.ok) {
  100. if (response.status == HttpStatus.unauthorized ||
  101. response.status == HttpStatus.forbidden ||
  102. response.status == HttpStatus.notFound) {
  103. throw JellyfinException(
  104. 'Quick Connect is disabled on this server. Enable it in Jellyfin '
  105. 'settings and try again.',
  106. );
  107. }
  108. throw _statusError(response);
  109. }
  110. final json = _decode(response.body);
  111. final code = (json['Code'] as String?)?.trim();
  112. final secret = (json['Secret'] as String?)?.trim();
  113. if (code == null || code.isEmpty || secret == null || secret.isEmpty) {
  114. throw JellyfinException(
  115. 'The server did not return a Quick Connect code.',
  116. );
  117. }
  118. return JellyfinQuickConnect(code: code, secret: secret);
  119. }
  120. @override
  121. Future<bool> isQuickConnectApproved(Uri baseUrl, String secret) async {
  122. final uri = baseUrl
  123. .resolve('QuickConnect/Connect')
  124. .replace(queryParameters: {'secret': secret});
  125. final response = await _send('GET', uri);
  126. if (response.status != HttpStatus.ok) {
  127. if (response.status == HttpStatus.notFound ||
  128. response.status == HttpStatus.unauthorized) {
  129. throw JellyfinException('The Quick Connect code expired.');
  130. }
  131. throw _statusError(response);
  132. }
  133. return _decode(response.body)['Authenticated'] == true;
  134. }
  135. @override
  136. Future<JellyfinSession> authenticateQuickConnect(
  137. Uri baseUrl,
  138. String secret, {
  139. required String deviceId,
  140. }) async {
  141. final response = await _send(
  142. 'POST',
  143. baseUrl.resolve('Users/AuthenticateWithQuickConnect'),
  144. deviceId: deviceId,
  145. body: jsonEncode({'Secret': secret}),
  146. contentType: ContentType.json,
  147. );
  148. if (response.status == HttpStatus.unauthorized ||
  149. response.status == HttpStatus.forbidden) {
  150. throw JellyfinAuthException('The Quick Connect approval was rejected.');
  151. }
  152. if (response.status != HttpStatus.ok) {
  153. throw _statusError(response);
  154. }
  155. final json = _decode(response.body);
  156. final token = (json['AccessToken'] as String?)?.trim();
  157. final user = json['User'];
  158. if (token == null || token.isEmpty || user is! Map) {
  159. throw JellyfinException('The server did not return an access token.');
  160. }
  161. final id = (user['Id'] as String?)?.trim();
  162. final name = (user['Name'] as String?)?.trim();
  163. if (id == null || id.isEmpty || name == null || name.isEmpty) {
  164. throw JellyfinException('The server did not return the user profile.');
  165. }
  166. return JellyfinSession(accessToken: token, userId: id, userName: name);
  167. }
  168. @override
  169. Future<JellyfinUser> currentUser(Uri baseUrl, String token) async {
  170. final response = await _send(
  171. 'GET',
  172. baseUrl.resolve('Users/Me'),
  173. token: token,
  174. );
  175. if (response.status == HttpStatus.unauthorized ||
  176. response.status == HttpStatus.forbidden) {
  177. throw JellyfinAuthException('The saved Jellyfin pairing is not valid.');
  178. }
  179. if (response.status != HttpStatus.ok) {
  180. throw _statusError(response);
  181. }
  182. final json = _decode(response.body);
  183. final id = (json['Id'] as String?)?.trim();
  184. final name = (json['Name'] as String?)?.trim();
  185. if (id == null || id.isEmpty || name == null || name.isEmpty) {
  186. throw JellyfinException('The server returned an unexpected profile.');
  187. }
  188. return JellyfinUser(id: id, name: name);
  189. }
  190. @override
  191. Future<List<JellyfinTrack>> playedTracks(
  192. JellyfinConnection connection, {
  193. int limit = 50,
  194. }) async {
  195. final uri = connection.baseUrl
  196. .resolve('Items')
  197. .replace(
  198. queryParameters: {
  199. 'userId': connection.userId,
  200. 'recursive': 'true',
  201. 'includeItemTypes': 'Audio',
  202. 'filters': 'IsPlayed',
  203. 'sortBy': 'DatePlayed',
  204. 'sortOrder': 'Descending',
  205. 'limit': '$limit',
  206. 'fields': 'Genres,AlbumArtist,Artists,Album,AlbumId,ParentId,RunTimeTicks',
  207. 'enableUserData': 'true',
  208. 'enableImageTypes': 'Primary',
  209. 'imageTypeLimit': '1',
  210. },
  211. );
  212. final json = await _getJson(connection, uri);
  213. return _items(json)
  214. .map(JellyfinTrack.fromJson)
  215. .where((track) => track.id.isNotEmpty)
  216. .toList(growable: false);
  217. }
  218. @override
  219. Future<List<JellyfinAlbum>> latestAddedAlbums(
  220. JellyfinConnection connection, {
  221. int limit = 5,
  222. }) async {
  223. final uri = connection.baseUrl
  224. .resolve('Items/Latest')
  225. .replace(
  226. queryParameters: {
  227. 'userId': connection.userId,
  228. 'includeItemTypes': 'MusicAlbum',
  229. 'limit': '$limit',
  230. 'fields': 'Genres,DateCreated,ChildCount,AlbumArtist,Artists,PrimaryImageAspectRatio',
  231. 'enableUserData': 'true',
  232. 'enableImageTypes': 'Primary',
  233. 'imageTypeLimit': '1',
  234. },
  235. );
  236. final list = await _getList(connection, uri);
  237. return list
  238. .whereType<Map>()
  239. .map(
  240. (entry) => JellyfinAlbum.fromJson(Map<String, dynamic>.from(entry)),
  241. )
  242. .where((album) => album.id.isNotEmpty)
  243. .toList(growable: false);
  244. }
  245. @override
  246. Future<List<JellyfinAlbum>> randomAlbums(
  247. JellyfinConnection connection, {
  248. required List<String> genres,
  249. int limit = 5,
  250. }) async {
  251. if (genres.isEmpty) return const [];
  252. final uri = connection.baseUrl
  253. .resolve('Items')
  254. .replace(
  255. queryParameters: {
  256. 'userId': connection.userId,
  257. 'recursive': 'true',
  258. 'includeItemTypes': 'MusicAlbum',
  259. 'genres': genres.join('|'),
  260. 'sortBy': 'Random',
  261. 'limit': '$limit',
  262. 'fields': 'Genres,DateCreated,ChildCount,AlbumArtist,Artists,PrimaryImageAspectRatio',
  263. 'enableUserData': 'true',
  264. 'enableImageTypes': 'Primary',
  265. 'imageTypeLimit': '1',
  266. },
  267. );
  268. final json = await _getJson(connection, uri);
  269. return _items(json)
  270. .map(JellyfinAlbum.fromJson)
  271. .where((album) => album.id.isNotEmpty)
  272. .toList(growable: false);
  273. }
  274. @override
  275. Future<List<JellyfinTrack>> albumTracks(
  276. JellyfinConnection connection,
  277. String albumId,
  278. ) async {
  279. if (albumId.isEmpty) return const [];
  280. final uri = connection.baseUrl
  281. .resolve('Items')
  282. .replace(
  283. queryParameters: {
  284. 'userId': connection.userId,
  285. 'parentId': albumId,
  286. 'recursive': 'false',
  287. 'includeItemTypes': 'Audio',
  288. 'sortBy': 'ParentIndexNumber,IndexNumber',
  289. 'fields': 'MediaSources,MediaStreams,Genres,AlbumArtist,Artists,IndexNumber,ParentIndexNumber,RunTimeTicks',
  290. 'enableUserData': 'true',
  291. },
  292. );
  293. final json = await _getJson(connection, uri);
  294. return _items(json)
  295. .map(JellyfinTrack.fromJson)
  296. .where((track) => track.id.isNotEmpty)
  297. .toList(growable: false);
  298. }
  299. @override
  300. Future<List<String>> musicLibraryIds(JellyfinConnection connection) async {
  301. final uri = connection.baseUrl
  302. .resolve('UserViews')
  303. .replace(queryParameters: {'userId': connection.userId});
  304. final json = await _getJson(connection, uri);
  305. final ids = <String>[];
  306. for (final item in _items(json)) {
  307. if (item['CollectionType'] != 'music') continue;
  308. final id = (item['Id'] as String?)?.trim();
  309. if (id == null || id.isEmpty || ids.contains(id)) continue;
  310. ids.add(id);
  311. }
  312. return ids;
  313. }
  314. @override
  315. Future<List<String>> genreNames(
  316. JellyfinConnection connection,
  317. String libraryId,
  318. ) async {
  319. if (libraryId.isEmpty) return const [];
  320. final uri = connection.baseUrl
  321. .resolve('Genres')
  322. .replace(
  323. queryParameters: {'userId': connection.userId, 'parentId': libraryId},
  324. );
  325. final json = await _getJson(connection, uri);
  326. final names = <String>[];
  327. for (final item in _items(json)) {
  328. final name = (item['Name'] as String?)?.trim();
  329. if (name == null || name.isEmpty || names.contains(name)) continue;
  330. names.add(name);
  331. }
  332. return names;
  333. }
  334. /// Album artwork is binary; the text decoder in [_send] cannot be used.
  335. @override
  336. Future<Uint8List?> albumArtwork(
  337. JellyfinConnection connection,
  338. String albumId, {
  339. String? tag,
  340. int maxHeight = 400,
  341. }) async {
  342. if (albumId.isEmpty) return null;
  343. final uri = connection.baseUrl
  344. .resolve('Items/$albumId/Images/Primary')
  345. .replace(
  346. queryParameters: {
  347. 'maxHeight': '$maxHeight',
  348. if (tag != null && tag.isNotEmpty) 'tag': tag,
  349. },
  350. );
  351. try {
  352. final request = await _http.getUrl(uri).timeout(timeout);
  353. request.headers.set(
  354. HttpHeaders.authorizationHeader,
  355. connection.authorizationHeader,
  356. );
  357. request.headers.set(HttpHeaders.acceptHeader, 'image/jpeg,image/png');
  358. final response = await request.close().timeout(timeout);
  359. if (response.statusCode != HttpStatus.ok) {
  360. await response.drain<void>();
  361. return null;
  362. }
  363. final builder = await response
  364. .fold<BytesBuilder>(BytesBuilder(copy: false), (buffer, chunk) {
  365. buffer.add(chunk);
  366. return buffer;
  367. })
  368. .timeout(timeout);
  369. final bytes = builder.takeBytes();
  370. return bytes.isEmpty ? null : bytes;
  371. } catch (_) {
  372. return null;
  373. }
  374. }
  375. @override
  376. void close() {
  377. _client?.close(force: true);
  378. _client = null;
  379. }
  380. Future<_JellyfinResponse> _send(
  381. String method,
  382. Uri uri, {
  383. String? deviceId,
  384. String? token,
  385. String? body,
  386. ContentType? contentType,
  387. }) async {
  388. try {
  389. final request = await _http.openUrl(method, uri).timeout(timeout);
  390. request.headers.set(
  391. HttpHeaders.authorizationHeader,
  392. _authorization(deviceId ?? 'cardash', token: token),
  393. );
  394. request.headers.set(HttpHeaders.acceptHeader, 'application/json');
  395. if (body != null) {
  396. request.headers.contentType = contentType ?? ContentType.text;
  397. request.write(body);
  398. }
  399. final response = await request.close().timeout(timeout);
  400. final text = await response
  401. .transform(utf8.decoder)
  402. .join()
  403. .timeout(timeout);
  404. return _JellyfinResponse(status: response.statusCode, body: text);
  405. } on JellyfinException {
  406. rethrow;
  407. } on SocketException {
  408. throw JellyfinUnreachable('Could not reach the server.');
  409. } on HandshakeException {
  410. throw JellyfinUnreachable('Could not establish a secure connection.');
  411. } on TimeoutException {
  412. throw JellyfinUnreachable('The server did not respond.');
  413. } on HttpException {
  414. throw JellyfinUnreachable('Could not reach the server.');
  415. } on FormatException {
  416. throw JellyfinException('The server returned an unexpected response.');
  417. }
  418. }
  419. String _authorization(String deviceId, {String? token}) {
  420. final parts = [
  421. 'Client="CarDash"',
  422. 'Device="CarDash"',
  423. 'DeviceId="$deviceId"',
  424. 'Version="$appVersion"',
  425. if (token != null) 'Token="$token"',
  426. ];
  427. return 'MediaBrowser ${parts.join(', ')}';
  428. }
  429. Future<Map<String, dynamic>> _getJson(
  430. JellyfinConnection connection,
  431. Uri uri,
  432. ) async {
  433. final response = await _send(
  434. 'GET',
  435. uri,
  436. deviceId: connection.deviceId,
  437. token: connection.accessToken,
  438. );
  439. if (response.status != HttpStatus.ok) {
  440. throw _statusError(response);
  441. }
  442. return _decode(response.body);
  443. }
  444. Future<List<dynamic>> _getList(JellyfinConnection connection, Uri uri) async {
  445. final response = await _send(
  446. 'GET',
  447. uri,
  448. deviceId: connection.deviceId,
  449. token: connection.accessToken,
  450. );
  451. if (response.status != HttpStatus.ok) {
  452. throw _statusError(response);
  453. }
  454. return _decodeList(response.body);
  455. }
  456. Map<String, dynamic> _decode(String body) {
  457. final decoded = jsonDecode(body);
  458. if (decoded is! Map<String, dynamic>) {
  459. throw JellyfinException('The server returned an unexpected response.');
  460. }
  461. return decoded;
  462. }
  463. List<dynamic> _decodeList(String body) {
  464. final decoded = jsonDecode(body);
  465. if (decoded is! List) {
  466. throw JellyfinException('The server returned an unexpected response.');
  467. }
  468. return decoded;
  469. }
  470. List<Map<String, dynamic>> _items(Map<String, dynamic> json) {
  471. final items = json['Items'];
  472. if (items is! List) return const [];
  473. return items
  474. .whereType<Map>()
  475. .map((entry) => Map<String, dynamic>.from(entry))
  476. .toList(growable: false);
  477. }
  478. JellyfinException _statusError(
  479. _JellyfinResponse response, {
  480. String? notFound,
  481. }) {
  482. if (response.status == HttpStatus.unauthorized ||
  483. response.status == HttpStatus.forbidden) {
  484. return JellyfinAuthException('The server rejected the credentials.');
  485. }
  486. if (response.status == HttpStatus.notFound && notFound != null) {
  487. return JellyfinException(notFound);
  488. }
  489. return JellyfinException('The server returned ${response.status}.');
  490. }
  491. }
  492. class _JellyfinResponse {
  493. const _JellyfinResponse({required this.status, required this.body});
  494. final int status;
  495. final String body;
  496. }
  497. /// Accepts `host`, `host:8096`, `https://host/jellyfin` and similar. Missing
  498. /// schemes default to HTTPS; the result always ends in `/` so relative API
  499. /// paths resolve under any configured path prefix.
  500. Uri? normalizeJellyfinUrl(String input) {
  501. var value = input.trim();
  502. if (value.isEmpty) return null;
  503. if (!value.contains('://')) value = 'https://$value';
  504. final uri = Uri.tryParse(value);
  505. if (uri == null || uri.host.isEmpty) return null;
  506. if (uri.scheme != 'http' && uri.scheme != 'https') return null;
  507. final path = uri.path.endsWith('/') ? uri.path : '${uri.path}/';
  508. return Uri(
  509. scheme: uri.scheme,
  510. host: uri.host,
  511. port: uri.hasPort ? uri.port : null,
  512. path: path,
  513. );
  514. }
  515. /// The stored form of [normalizeJellyfinUrl], without the trailing slash.
  516. String? displayJellyfinUrl(String input) {
  517. final uri = normalizeJellyfinUrl(input);
  518. if (uri == null) return null;
  519. final value = uri.toString();
  520. return value.endsWith('/') ? value.substring(0, value.length - 1) : value;
  521. }