| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648 |
- 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<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() {}
- }
- /// 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<List<JellyfinTrack>> playedTracks(
- JellyfinConnection connection, {
- int limit = 50,
- });
- Future<List<JellyfinAlbum>> latestAddedAlbums(
- JellyfinConnection connection, {
- int limit = 5,
- });
- Future<List<JellyfinAlbum>> randomAlbums(
- JellyfinConnection connection, {
- required List<String> genres,
- int limit = 5,
- });
- Future<List<JellyfinAlbum>> favoriteAlbums(JellyfinConnection connection);
- Future<bool> isAlbumFavorite(JellyfinConnection connection, String albumId);
- Future<void> setAlbumFavorite(
- JellyfinConnection connection,
- String albumId, {
- required bool favorite,
- });
- Future<List<JellyfinTrack>> albumTracks(
- JellyfinConnection connection,
- String albumId,
- );
- Future<List<String>> musicLibraryIds(JellyfinConnection connection);
- Future<List<String>> genreNames(
- JellyfinConnection connection,
- String libraryId,
- );
- Future<Uint8List?> 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<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
- Future<List<JellyfinTrack>> 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<List<JellyfinAlbum>> 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>()
- .map(
- (entry) => JellyfinAlbum.fromJson(Map<String, dynamic>.from(entry)),
- )
- .where((album) => album.id.isNotEmpty)
- .toList(growable: false);
- }
- @override
- Future<List<JellyfinAlbum>> randomAlbums(
- JellyfinConnection connection, {
- required List<String> 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<List<JellyfinAlbum>> favoriteAlbums(
- JellyfinConnection connection,
- ) async {
- final uri = connection.baseUrl
- .resolve('Items')
- .replace(
- queryParameters: {
- 'userId': connection.userId,
- 'recursive': 'true',
- 'includeItemTypes': 'MusicAlbum',
- 'filters': 'IsFavorite',
- 'sortBy': 'SortName',
- 'sortOrder': 'Ascending',
- '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<bool> isAlbumFavorite(
- JellyfinConnection connection,
- String albumId,
- ) async {
- if (albumId.isEmpty) return false;
- final uri = connection.baseUrl
- .resolve('Items')
- .replace(
- queryParameters: {
- 'userId': connection.userId,
- 'ids': albumId,
- 'includeItemTypes': 'MusicAlbum',
- 'enableUserData': 'true',
- 'limit': '1',
- },
- );
- final json = await _getJson(connection, uri);
- final items = _items(json);
- if (items.isEmpty) return false;
- final userData = items.first['UserData'];
- return userData is Map && userData['IsFavorite'] == true;
- }
- @override
- Future<void> setAlbumFavorite(
- JellyfinConnection connection,
- String albumId, {
- required bool favorite,
- }) async {
- if (albumId.isEmpty) {
- throw JellyfinException('Unknown Jellyfin album.');
- }
- final uri = connection.baseUrl
- .resolve('UserFavoriteItems/$albumId')
- .replace(queryParameters: {'userId': connection.userId});
- final response = await _send(
- favorite ? 'POST' : 'DELETE',
- uri,
- deviceId: connection.deviceId,
- token: connection.accessToken,
- );
- if (response.status != HttpStatus.ok &&
- response.status != HttpStatus.noContent) {
- throw _statusError(response);
- }
- }
- @override
- Future<List<JellyfinTrack>> 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<List<String>> musicLibraryIds(JellyfinConnection connection) async {
- final uri = connection.baseUrl
- .resolve('UserViews')
- .replace(queryParameters: {'userId': connection.userId});
- final json = await _getJson(connection, uri);
- final ids = <String>[];
- 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<List<String>> 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 = <String>[];
- 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<Uint8List?> 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<void>();
- return null;
- }
- final builder = await response
- .fold<BytesBuilder>(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<Map<String, dynamic>> _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<List<dynamic>> _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<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;
- }
- List<dynamic> _decodeList(String body) {
- final decoded = jsonDecode(body);
- if (decoded is! List) {
- throw JellyfinException('The server returned an unexpected response.');
- }
- return decoded;
- }
- List<Map<String, dynamic>> _items(Map<String, dynamic> json) {
- final items = json['Items'];
- if (items is! List) return const [];
- return items
- .whereType<Map>()
- .map((entry) => Map<String, dynamic>.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;
- }
|