jellyfin_service.dart 20 KB

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