diff --git a/packages/server_core/lib/server_core.dart b/packages/server_core/lib/server_core.dart index 163c3ca7c..477fc59fc 100644 --- a/packages/server_core/lib/server_core.dart +++ b/packages/server_core/lib/server_core.dart @@ -50,4 +50,18 @@ export 'src/diagnostics/server_log_sink.dart'; export 'src/network/configure_server_dio.dart'; export 'src/network/auth_header.dart'; export 'src/network/redirect_interceptor.dart'; +export 'src/network/server_interceptors.dart'; export 'src/network/server_probe.dart'; +export 'src/server_dialect.dart'; +export 'src/impl/server_auth_api.dart'; +export 'src/impl/server_display_preferences_api.dart'; +export 'src/impl/server_image_api.dart'; +export 'src/impl/server_instant_mix_api.dart'; +export 'src/impl/server_items_api.dart'; +export 'src/impl/server_live_tv_api.dart'; +export 'src/impl/server_playback_api.dart'; +export 'src/impl/server_session_api.dart'; +export 'src/impl/server_system_api.dart'; +export 'src/impl/server_user_library_api.dart'; +export 'src/impl/server_user_views_api.dart'; +export 'src/impl/server_users_api.dart'; diff --git a/packages/server_jellyfin/lib/src/api/jellyfin_auth_api.dart b/packages/server_core/lib/src/impl/server_auth_api.dart similarity index 76% rename from packages/server_jellyfin/lib/src/api/jellyfin_auth_api.dart rename to packages/server_core/lib/src/impl/server_auth_api.dart index 18f004a5a..0fdc0941b 100644 --- a/packages/server_jellyfin/lib/src/api/jellyfin_auth_api.dart +++ b/packages/server_core/lib/src/impl/server_auth_api.dart @@ -1,10 +1,21 @@ import 'package:dio/dio.dart'; -import 'package:server_core/server_core.dart'; -class JellyfinAuthApi implements AuthApi { +import '../api/auth_api.dart'; +import '../server_dialect.dart'; + +class ServerAuthApi implements AuthApi { final Dio _dio; + final ServerDialect _dialect; + + ServerAuthApi(this._dio, this._dialect); - JellyfinAuthApi(this._dio); + void _requireQuickConnect() { + if (!_dialect.supportsQuickConnect) { + throw UnsupportedError( + 'QuickConnect is not supported on this server', + ); + } + } @override Future> authenticateByName( @@ -20,12 +31,14 @@ class JellyfinAuthApi implements AuthApi { @override Future> initiateQuickConnect() async { + _requireQuickConnect(); final response = await _dio.post('/QuickConnect/Initiate'); return response.data as Map; } @override Future> checkQuickConnect(String secret) async { + _requireQuickConnect(); final response = await _dio.get( '/QuickConnect/Connect', queryParameters: {'Secret': secret}, @@ -35,6 +48,7 @@ class JellyfinAuthApi implements AuthApi { @override Future authorizeQuickConnect(String code, {String? userId}) async { + _requireQuickConnect(); final response = await _dio.post( '/QuickConnect/Authorize', queryParameters: { @@ -49,6 +63,7 @@ class JellyfinAuthApi implements AuthApi { Future> authenticateWithQuickConnect( String secret, ) async { + _requireQuickConnect(); final response = await _dio.post( '/Users/AuthenticateWithQuickConnect', data: {'Secret': secret}, diff --git a/packages/server_emby/lib/src/api/emby_display_preferences_api.dart b/packages/server_core/lib/src/impl/server_display_preferences_api.dart similarity index 65% rename from packages/server_emby/lib/src/api/emby_display_preferences_api.dart rename to packages/server_core/lib/src/impl/server_display_preferences_api.dart index cc930427d..7f147fc2a 100644 --- a/packages/server_emby/lib/src/api/emby_display_preferences_api.dart +++ b/packages/server_core/lib/src/impl/server_display_preferences_api.dart @@ -1,19 +1,25 @@ import 'package:dio/dio.dart'; -import 'package:server_core/server_core.dart'; -class EmbyDisplayPreferencesApi implements DisplayPreferencesApi { +import '../api/display_preferences_api.dart'; +import '../models/system_models.dart'; +import '../server_dialect.dart'; + +class ServerDisplayPreferencesApi implements DisplayPreferencesApi { final Dio _dio; + final ServerDialect _dialect; final Map _cache = {}; static const _cacheDuration = Duration(minutes: 5); - EmbyDisplayPreferencesApi(this._dio); + ServerDisplayPreferencesApi(this._dio, this._dialect); + + String _client(String? client) => client ?? _dialect.displayPrefsClient; @override Future getDisplayPreferences( String id, { String? client, }) async { - final cacheKey = '$id:${client ?? 'emby'}'; + final cacheKey = '$id:${_client(client)}'; final entry = _cache[cacheKey]; if (entry != null && DateTime.now().difference(entry.cachedAt) < _cacheDuration) { @@ -22,9 +28,7 @@ class EmbyDisplayPreferencesApi implements DisplayPreferencesApi { final response = await _dio.get( '/DisplayPreferences/$id', - queryParameters: { - 'client': client ?? 'emby', - }, + queryParameters: {'client': _client(client)}, ); final prefs = DisplayPreferences.fromJson(response.data as Map); @@ -41,12 +45,9 @@ class EmbyDisplayPreferencesApi implements DisplayPreferencesApi { await _dio.post( '/DisplayPreferences/$id', data: prefs.toJson(), - queryParameters: { - 'client': client ?? 'emby', - }, + queryParameters: {'client': _client(client)}, ); - final cacheKey = '$id:${client ?? 'emby'}'; - _cache[cacheKey] = _CacheEntry(prefs, DateTime.now()); + _cache['$id:${_client(client)}'] = _CacheEntry(prefs, DateTime.now()); } void invalidateCache() => _cache.clear(); diff --git a/packages/server_emby/lib/src/api/emby_image_api.dart b/packages/server_core/lib/src/impl/server_image_api.dart similarity index 88% rename from packages/server_emby/lib/src/api/emby_image_api.dart rename to packages/server_core/lib/src/impl/server_image_api.dart index c52069274..6d3765c45 100644 --- a/packages/server_emby/lib/src/api/emby_image_api.dart +++ b/packages/server_core/lib/src/impl/server_image_api.dart @@ -1,14 +1,18 @@ -import 'package:server_core/server_core.dart'; +import '../api/image_api.dart'; +import '../server_dialect.dart'; -class EmbyImageApi implements ImageApi { +class ServerImageApi implements ImageApi { final String Function() _getBaseUrl; final String? Function() _getApiKey; + final ServerDialect _dialect; - EmbyImageApi(this._getBaseUrl, this._getApiKey); + ServerImageApi(this._getBaseUrl, this._getApiKey, this._dialect); String _buildQuery(Map params) { - final apiKey = _getApiKey(); - if (apiKey != null) params['api_key'] = apiKey; + if (_dialect.includeApiKeyInImageUrls) { + final apiKey = _getApiKey(); + if (apiKey != null) params['api_key'] = apiKey; + } if (params.isEmpty) return ''; return '?${params.entries.map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}').join('&')}'; } diff --git a/packages/server_jellyfin/lib/src/api/jellyfin_instant_mix_api.dart b/packages/server_core/lib/src/impl/server_instant_mix_api.dart similarity index 60% rename from packages/server_jellyfin/lib/src/api/jellyfin_instant_mix_api.dart rename to packages/server_core/lib/src/impl/server_instant_mix_api.dart index 3576bba38..4442b7b0e 100644 --- a/packages/server_jellyfin/lib/src/api/jellyfin_instant_mix_api.dart +++ b/packages/server_core/lib/src/impl/server_instant_mix_api.dart @@ -1,10 +1,13 @@ import 'package:dio/dio.dart'; -import 'package:server_core/server_core.dart'; -class JellyfinInstantMixApi implements InstantMixApi { +import '../api/instant_mix_api.dart'; + +/// Shared instant-mix implementation; the endpoint is identical on Jellyfin +/// and Emby. +class ServerInstantMixApi implements InstantMixApi { final Dio _dio; - JellyfinInstantMixApi(this._dio); + ServerInstantMixApi(this._dio); @override Future> getInstantMix( diff --git a/packages/server_jellyfin/lib/src/api/jellyfin_items_api.dart b/packages/server_core/lib/src/impl/server_items_api.dart similarity index 89% rename from packages/server_jellyfin/lib/src/api/jellyfin_items_api.dart rename to packages/server_core/lib/src/impl/server_items_api.dart index 25b30f933..26dd72796 100644 --- a/packages/server_jellyfin/lib/src/api/jellyfin_items_api.dart +++ b/packages/server_core/lib/src/impl/server_items_api.dart @@ -1,12 +1,14 @@ import 'package:dio/dio.dart'; -import 'package:server_core/server_core.dart'; -import 'jellyfin_item_fields.dart'; -class JellyfinItemsApi implements ItemsApi { +import '../api/items_api.dart'; +import '../server_dialect.dart'; + +class ServerItemsApi implements ItemsApi { final Dio _dio; + final ServerDialect _dialect; final String Function() _getUserId; - JellyfinItemsApi(this._dio, this._getUserId); + ServerItemsApi(this._dio, this._dialect, this._getUserId); bool _shouldRetryCollectionFallback(int statusCode) { return statusCode == 400 || @@ -126,11 +128,13 @@ class JellyfinItemsApi implements ItemsApi { String? fields, }) async { final userId = _getUserId(); + final effectiveFields = fields ?? _dialect.defaultItemFields; final response = await _dio.get( '/Users/$userId/Items/$itemId', queryParameters: { - 'Fields': fields ?? kItemFields, - if (mediaSourceId != null) 'mediaSourceId': mediaSourceId, + if (effectiveFields != null && effectiveFields.isNotEmpty) + 'Fields': effectiveFields, + 'mediaSourceId': ?mediaSourceId, }, ); return response.data as Map; @@ -138,11 +142,15 @@ class JellyfinItemsApi implements ItemsApi { @override Future>> getAncestors(String itemId) async { - final response = await _dio.get('/Items/$itemId/Ancestors'); - return ((response.data as List?) ?? const []) - .whereType() - .map((e) => e.cast()) - .toList(growable: false); + try { + final response = await _dio.get('/Items/$itemId/Ancestors'); + return ((response.data as List?) ?? const []) + .whereType() + .map((e) => e.cast()) + .toList(growable: false); + } catch (_) { + return const []; + } } @override @@ -194,7 +202,7 @@ class JellyfinItemsApi implements ItemsApi { int? imageTypeLimit, }) async { final response = await _dio.get( - '/UserItems/Resume', + _dialect.resumePath(_getUserId()), queryParameters: { 'ParentId': ?parentId, if (includeItemTypes != null) @@ -218,7 +226,7 @@ class JellyfinItemsApi implements ItemsApi { int? imageTypeLimit, }) async { final response = await _dio.get( - '/Items/Latest', + _dialect.latestPath(_getUserId()), queryParameters: { 'ParentId': ?parentId, if (includeItemTypes != null) @@ -229,6 +237,8 @@ class JellyfinItemsApi implements ItemsApi { 'ImageTypeLimit': ?imageTypeLimit, }, ); + // /Items/Latest returns a bare array, so normalize it into the + // same shape as /Items so all callers stay unchanged. final data = response.data; if (data is List) return {'Items': data, 'TotalRecordCount': data.length}; return data as Map; @@ -243,9 +253,8 @@ class JellyfinItemsApi implements ItemsApi { String? enableImageTypes, int? imageTypeLimit, }) async { - final userId = _getUserId(); final response = await _dio.get( - '/Users/$userId/Items', + _dialect.recentlyReleasedPath(_getUserId()), queryParameters: { 'ParentId': ?parentId, if (includeItemTypes != null) @@ -256,7 +265,7 @@ class JellyfinItemsApi implements ItemsApi { 'ImageTypeLimit': ?imageTypeLimit, 'SortBy': 'PremiereDate', 'SortOrder': 'Descending', - 'MaxPremiereDate': DateTime.now().toUtc().toIso8601String(), + 'MaxPremiereDate': DateTime.now().toUtc().toIso8601String(), }, ); final data = response.data; @@ -290,7 +299,10 @@ class JellyfinItemsApi implements ItemsApi { }) async { final response = await _dio.get( '/Items/$itemId/ThemeMedia', - queryParameters: {'InheritFromParent': inheritFromParent}, + queryParameters: { + if (_dialect.themeMediaNeedsUserId) 'UserId': _getUserId(), + 'InheritFromParent': inheritFromParent, + }, ); return response.data as Map; } @@ -298,7 +310,7 @@ class JellyfinItemsApi implements ItemsApi { @override Future> getPlaylists() async { final response = await _dio.get( - '/Items', + _dialect.playlistsPath(_getUserId()), queryParameters: { 'IncludeItemTypes': 'Playlist', 'Recursive': true, @@ -411,8 +423,8 @@ class JellyfinItemsApi implements ItemsApi { final queryParameters = { 'name': name, 'Name': name, - if (ids != null) 'ids': ids, - if (ids != null) 'Ids': ids, + 'ids': ?ids, + 'Ids': ?ids, }; Response response; @@ -560,6 +572,7 @@ class JellyfinItemsApi implements ItemsApi { @override Future> getLyrics(String itemId) async { + if (!_dialect.supportsLyrics) return const {'Lyrics': []}; final response = await _dio.get('/Audio/$itemId/Lyrics'); return response.data as Map; } @@ -595,16 +608,21 @@ class JellyfinItemsApi implements ItemsApi { @override Future>> getSpecialFeatures(String itemId) async { - final response = await _dio.get('/Items/$itemId/SpecialFeatures'); - final data = response.data; - if (data is List) { - return data.cast>(); + try { + final response = await _dio.get('/Items/$itemId/SpecialFeatures'); + final data = response.data; + if (data is List) { + return data.cast>(); + } + return const []; + } catch (_) { + return const []; } - return const []; } @override Future>> getMediaSegments(String itemId) async { + if (!_dialect.supportsMediaSegments) return const []; final response = await _dio.get('/MediaSegments/$itemId'); final data = response.data as Map; final items = data['Items'] as List? ?? []; @@ -617,6 +635,11 @@ class JellyfinItemsApi implements ItemsApi { required String language, bool? isPerfectMatch, }) async { + if (!_dialect.supportsRemoteSubtitleSearch) { + throw UnsupportedError( + 'Remote subtitle search is only supported for Jellyfin servers.', + ); + } final response = await _dio.get( '/Items/$itemId/RemoteSearch/Subtitles/$language', queryParameters: {'IsPerfectMatch': ?isPerfectMatch}, @@ -629,6 +652,11 @@ class JellyfinItemsApi implements ItemsApi { @override Future downloadRemoteSubtitle(String itemId, String subtitleId) async { + if (!_dialect.supportsRemoteSubtitleSearch) { + throw UnsupportedError( + 'Remote subtitle download is only supported for Jellyfin servers.', + ); + } await _dio.post('/Items/$itemId/RemoteSearch/Subtitles/$subtitleId'); } } diff --git a/packages/server_emby/lib/src/api/emby_live_tv_api.dart b/packages/server_core/lib/src/impl/server_live_tv_api.dart similarity index 95% rename from packages/server_emby/lib/src/api/emby_live_tv_api.dart rename to packages/server_core/lib/src/impl/server_live_tv_api.dart index e4259a9cd..ae7d58309 100644 --- a/packages/server_emby/lib/src/api/emby_live_tv_api.dart +++ b/packages/server_core/lib/src/impl/server_live_tv_api.dart @@ -1,11 +1,14 @@ import 'package:dio/dio.dart'; -import 'package:server_core/server_core.dart'; -class EmbyLiveTvApi implements LiveTvApi { +import '../api/live_tv_api.dart'; + +/// Shared Live TV implementation; the endpoints are identical on Jellyfin +/// and Emby. +class ServerLiveTvApi implements LiveTvApi { final Dio _dio; static const _postChannelIdsThreshold = 1800; - EmbyLiveTvApi(this._dio); + ServerLiveTvApi(this._dio); @override Future> getChannels({ diff --git a/packages/server_emby/lib/src/api/emby_playback_api.dart b/packages/server_core/lib/src/impl/server_playback_api.dart similarity index 96% rename from packages/server_emby/lib/src/api/emby_playback_api.dart rename to packages/server_core/lib/src/impl/server_playback_api.dart index 13aa874c6..678673b77 100644 --- a/packages/server_emby/lib/src/api/emby_playback_api.dart +++ b/packages/server_core/lib/src/impl/server_playback_api.dart @@ -1,11 +1,12 @@ import 'package:dio/dio.dart'; -import 'package:server_core/server_core.dart'; -class EmbyPlaybackApi implements PlaybackApi { +import '../api/playback_api.dart'; + +class ServerPlaybackApi implements PlaybackApi { final Dio _dio; final String Function() _getBaseUrl; - EmbyPlaybackApi(this._dio, this._getBaseUrl); + ServerPlaybackApi(this._dio, this._getBaseUrl); @override Future reportPlaybackStart(Map info) async { diff --git a/packages/server_jellyfin/lib/src/api/jellyfin_session_api.dart b/packages/server_core/lib/src/impl/server_session_api.dart similarity index 80% rename from packages/server_jellyfin/lib/src/api/jellyfin_session_api.dart rename to packages/server_core/lib/src/impl/server_session_api.dart index 1fb76cb76..a3e5105c5 100644 --- a/packages/server_jellyfin/lib/src/api/jellyfin_session_api.dart +++ b/packages/server_core/lib/src/impl/server_session_api.dart @@ -1,10 +1,14 @@ import 'package:dio/dio.dart'; -import 'package:server_core/server_core.dart'; -class JellyfinSessionApi implements SessionApi { +import '../api/session_api.dart'; +import '../server_dialect.dart'; + +/// Shared session implementation. +class ServerSessionApi implements SessionApi { final Dio _dio; + final ServerDialect _dialect; - JellyfinSessionApi(this._dio); + ServerSessionApi(this._dio, this._dialect); @override Future reportCapabilities(Map capabilities) async { @@ -12,9 +16,11 @@ class JellyfinSessionApi implements SessionApi { } @override - Future>> getSessions({String? controllableByUserId}) async { + Future>> getSessions({ + String? controllableByUserId, + }) async { final response = await _dio.get('/Sessions', queryParameters: { - 'controllableByUserId': ?controllableByUserId, + _dialect.controllableByUserIdParam: ?controllableByUserId, }); return (response.data as List).cast>(); } @@ -52,9 +58,7 @@ class JellyfinSessionApi implements SessionApi { }) async { await _dio.post( '/Sessions/$sessionId/Playing/$command', - queryParameters: { - 'seekPositionTicks': ?seekPositionTicks, - }, + queryParameters: {'seekPositionTicks': ?seekPositionTicks}, ); } diff --git a/packages/server_jellyfin/lib/src/api/jellyfin_system_api.dart b/packages/server_core/lib/src/impl/server_system_api.dart similarity index 75% rename from packages/server_jellyfin/lib/src/api/jellyfin_system_api.dart rename to packages/server_core/lib/src/impl/server_system_api.dart index e05ccd80c..bcbbc5f46 100644 --- a/packages/server_jellyfin/lib/src/api/jellyfin_system_api.dart +++ b/packages/server_core/lib/src/impl/server_system_api.dart @@ -1,10 +1,13 @@ import 'package:dio/dio.dart'; -import 'package:server_core/server_core.dart'; -class JellyfinSystemApi implements SystemApi { +import '../api/system_api.dart'; + +/// Shared system implementation; the endpoints are identical on Jellyfin +/// and Emby. +class ServerSystemApi implements SystemApi { final Dio _dio; - JellyfinSystemApi(this._dio); + ServerSystemApi(this._dio); @override Future> getPublicSystemInfo() async { diff --git a/packages/server_core/lib/src/impl/server_user_library_api.dart b/packages/server_core/lib/src/impl/server_user_library_api.dart new file mode 100644 index 000000000..d19cca983 --- /dev/null +++ b/packages/server_core/lib/src/impl/server_user_library_api.dart @@ -0,0 +1,54 @@ +import 'package:dio/dio.dart'; + +import '../api/user_library_api.dart'; +import '../server_dialect.dart'; + +class ServerUserLibraryApi implements UserLibraryApi { + final Dio _dio; + final ServerDialect _dialect; + final String Function() _getUserId; + + ServerUserLibraryApi(this._dio, this._dialect, this._getUserId); + + @override + Future markFavorite(String itemId) async { + await _dio.post(_dialect.favoriteItemPath(_getUserId(), itemId)); + } + + @override + Future unmarkFavorite(String itemId) async { + await _dio.delete(_dialect.favoriteItemPath(_getUserId(), itemId)); + } + + @override + Future markPlayed(String itemId) async { + await _dio.post(_dialect.playedItemPath(_getUserId(), itemId)); + } + + @override + Future unmarkPlayed(String itemId) async { + await _dio.delete(_dialect.playedItemPath(_getUserId(), itemId)); + } + + @override + Future updateUserRating(String itemId, {required bool likes}) async { + await _dio.post( + _dialect.ratingPath(_getUserId(), itemId), + queryParameters: {'Likes': likes}, + ); + } + + @override + Future deleteUserRating(String itemId) async { + await _dio.delete(_dialect.ratingPath(_getUserId(), itemId)); + } + + @override + Future> getItem(String itemId) async { + final response = await _dio.get( + _dialect.libraryItemPath(_getUserId(), itemId), + queryParameters: {'Fields': ?_dialect.defaultItemFields}, + ); + return response.data as Map; + } +} diff --git a/packages/server_emby/lib/src/api/emby_user_views_api.dart b/packages/server_core/lib/src/impl/server_user_views_api.dart similarity index 67% rename from packages/server_emby/lib/src/api/emby_user_views_api.dart rename to packages/server_core/lib/src/impl/server_user_views_api.dart index 82ff6dac8..bf89d66e8 100644 --- a/packages/server_emby/lib/src/api/emby_user_views_api.dart +++ b/packages/server_core/lib/src/impl/server_user_views_api.dart @@ -1,14 +1,17 @@ import 'package:dio/dio.dart'; -import 'package:server_core/server_core.dart'; -class EmbyUserViewsApi implements UserViewsApi { +import '../api/user_views_api.dart'; +import '../server_dialect.dart'; + +class ServerUserViewsApi implements UserViewsApi { final Dio _dio; + final ServerDialect _dialect; final String Function() _getUserId; Map? _cached; DateTime? _cachedAt; static const _cacheDuration = Duration(minutes: 5); - EmbyUserViewsApi(this._dio, this._getUserId); + ServerUserViewsApi(this._dio, this._dialect, this._getUserId); @override Future> getUserViews() async { @@ -17,8 +20,7 @@ class EmbyUserViewsApi implements UserViewsApi { DateTime.now().difference(_cachedAt!) < _cacheDuration) { return _cached!; } - final userId = _getUserId(); - final response = await _dio.get('/Users/$userId/Views'); + final response = await _dio.get(_dialect.userViewsPath(_getUserId())); _cached = response.data as Map; _cachedAt = DateTime.now(); return _cached!; diff --git a/packages/server_emby/lib/src/api/emby_users_api.dart b/packages/server_core/lib/src/impl/server_users_api.dart similarity index 51% rename from packages/server_emby/lib/src/api/emby_users_api.dart rename to packages/server_core/lib/src/impl/server_users_api.dart index d6e392a59..1b77d1d52 100644 --- a/packages/server_emby/lib/src/api/emby_users_api.dart +++ b/packages/server_core/lib/src/impl/server_users_api.dart @@ -1,23 +1,26 @@ import 'package:dio/dio.dart'; -import 'package:server_core/server_core.dart'; -class EmbyUsersApi implements UsersApi { +import '../api/users_api.dart'; +import '../models/server_models.dart'; +import '../models/system_models.dart'; +import '../server_dialect.dart'; + +class ServerUsersApi implements UsersApi { final Dio _dio; + final ServerDialect _dialect; final String Function() _getUserId; - EmbyUsersApi(this._dio, this._getUserId); + ServerUsersApi(this._dio, this._dialect, this._getUserId); @override Future getCurrentUser() async { - final userId = _getUserId(); - final response = await _dio.get('/Users/$userId'); + final response = await _dio.get(_dialect.currentUserPath(_getUserId())); return ServerUser.fromJson(response.data as Map); } @override Future getUserConfiguration() async { - final userId = _getUserId(); - final response = await _dio.get('/Users/$userId'); + final response = await _dio.get(_dialect.currentUserPath(_getUserId())); final data = response.data as Map; final config = data['Configuration'] as Map? ?? const {}; return UserConfiguration.fromJson(config); @@ -25,7 +28,9 @@ class EmbyUsersApi implements UsersApi { @override Future updateUserConfiguration(UserConfiguration config) async { - final userId = _getUserId(); - await _dio.post('/Users/$userId/Configuration', data: config.toJson()); + await _dio.post( + _dialect.userConfigPath(_getUserId()), + data: config.toJson(), + ); } } diff --git a/packages/server_core/lib/src/network/server_interceptors.dart b/packages/server_core/lib/src/network/server_interceptors.dart new file mode 100644 index 000000000..68f6232db --- /dev/null +++ b/packages/server_core/lib/src/network/server_interceptors.dart @@ -0,0 +1,44 @@ +import 'package:dio/dio.dart'; + +import '../diagnostics/server_log_sink.dart'; +import '../models/device_info.dart'; +import 'auth_header.dart'; +import 'redirect_interceptor.dart'; + +/// Wires up redirect handling, the Authorization header and network logging +/// shared by all media server clients. +void attachServerInterceptors( + Dio dio, { + required DeviceInfo deviceInfo, + required String authScheme, + required String? Function() getAccessToken, +}) { + dio.interceptors.add(redirectInterceptor(dio)); + dio.interceptors.add(InterceptorsWrapper( + onRequest: (options, handler) { + options.headers['Authorization'] = buildServerAuthorizationHeader( + scheme: authScheme, + deviceInfo: deviceInfo, + accessToken: getAccessToken(), + ); + ServerLog.network('→ ${options.method} ${options.uri}'); + handler.next(options); + }, + onResponse: (response, handler) { + ServerLog.network( + '← ${response.statusCode} ${response.requestOptions.method} ' + '${response.requestOptions.uri}', + ); + handler.next(response); + }, + onError: (error, handler) { + ServerLog.network( + '✗ ${error.requestOptions.method} ${error.requestOptions.uri} ' + '(${error.response?.statusCode ?? error.type.name})', + level: ServerLogLevel.error, + error: error.message ?? error.toString(), + ); + handler.next(error); + }, + )); +} diff --git a/packages/server_core/lib/src/server_dialect.dart b/packages/server_core/lib/src/server_dialect.dart new file mode 100644 index 000000000..0d893f26f --- /dev/null +++ b/packages/server_core/lib/src/server_dialect.dart @@ -0,0 +1,122 @@ +/// Captures the small behavioral differences between Jellyfin and Emby so a +/// single API implementation can serve both servers. +class ServerDialect { + /// Scheme used in the Authorization header (`MediaBrowser` or `Emby`). + final String authScheme; + + /// Emby addresses library data through `/Users/{userId}/...` routes; + /// Jellyfin 10.9+ prefers the user-less variants (`/UserItems/Resume`, + /// `/UserViews`, ...). + final bool userScopedLibraryEndpoints; + + final bool supportsQuickConnect; + final bool supportsLyrics; + final bool supportsMediaSegments; + final bool supportsRemoteSubtitleSearch; + + /// Emby authenticates plain image URLs with an `api_key` query parameter. + final bool includeApiKeyInImageUrls; + + /// Default `Fields` value for item lookups, null to omit the parameter. + final String? defaultItemFields; + + /// Display preferences are stored per client name; keep each server's + /// historical name so existing user settings survive. + final String displayPrefsClient; + + const ServerDialect._({ + required this.authScheme, + required this.userScopedLibraryEndpoints, + required this.supportsQuickConnect, + required this.supportsLyrics, + required this.supportsMediaSegments, + required this.supportsRemoteSubtitleSearch, + required this.includeApiKeyInImageUrls, + required this.defaultItemFields, + required this.displayPrefsClient, + }); + + static const jellyfin = ServerDialect._( + authScheme: 'MediaBrowser', + userScopedLibraryEndpoints: false, + supportsQuickConnect: true, + supportsLyrics: true, + supportsMediaSegments: true, + supportsRemoteSubtitleSearch: true, + includeApiKeyInImageUrls: false, + defaultItemFields: + 'Trickplay,Chapters,MediaSources,MediaStreams,People,Overview,Genres,' + 'RecursiveItemCount,ChildCount,ParentLogoItemId,ParentLogoImageTag', + displayPrefsClient: 'moonfin', + ); + + static const emby = ServerDialect._( + authScheme: 'Emby', + userScopedLibraryEndpoints: true, + supportsQuickConnect: false, + supportsLyrics: false, + supportsMediaSegments: false, + supportsRemoteSubtitleSearch: false, + includeApiKeyInImageUrls: true, + defaultItemFields: null, + displayPrefsClient: 'emby', + ); + + // -- Path helpers ------------------------------------------------------- + + String resumePath(String userId) => userScopedLibraryEndpoints + ? '/Users/$userId/Items/Resume' + : '/UserItems/Resume'; + + String latestPath(String userId) => userScopedLibraryEndpoints + ? '/Users/$userId/Items/Latest' + : '/Items/Latest'; + + String favoriteItemPath(String userId, String itemId) => + userScopedLibraryEndpoints + ? '/Users/$userId/FavoriteItems/$itemId' + : '/UserFavoriteItems/$itemId'; + + String playedItemPath(String userId, String itemId) => + userScopedLibraryEndpoints + ? '/Users/$userId/PlayedItems/$itemId' + : '/UserPlayedItems/$itemId'; + + String ratingPath(String userId, String itemId) => userScopedLibraryEndpoints + ? '/Users/$userId/Items/$itemId/Rating' + : '/UserItems/$itemId/Rating'; + + String userViewsPath(String userId) => + userScopedLibraryEndpoints ? '/Users/$userId/Views' : '/UserViews'; + + String currentUserPath(String userId) => + userScopedLibraryEndpoints ? '/Users/$userId' : '/Users/Me'; + + String userConfigPath(String userId) => userScopedLibraryEndpoints + ? '/Users/$userId/Configuration' + : '/Users/Configuration'; + + /// Single-item lookup used by the user-library API. + String libraryItemPath(String userId, String itemId) => + userScopedLibraryEndpoints + ? '/Users/$userId/Items/$itemId' + : '/Items/$itemId'; + + /// Playlist listing: Jellyfin queries server-wide, Emby per user. + String playlistsPath(String userId) => + userScopedLibraryEndpoints ? '/Users/$userId/Items' : '/Items'; + + /// Recently-released listing: Jellyfin queries per user, Emby server-wide. + String recentlyReleasedPath(String userId) => + userScopedLibraryEndpoints ? '/Items' : '/Users/$userId/Items'; + + /// Emby passes the user along when resolving theme media. + bool get themeMediaNeedsUserId => userScopedLibraryEndpoints; + + /// Query param name for filtering /Sessions by controllable user. + /// Each server documents a different casing; keep both wire formats + /// exactly as prescribed. + String get controllableByUserIdParam => userScopedLibraryEndpoints + ? 'ControllableByUserId' + : 'controllableByUserId'; +} diff --git a/packages/server_core/test/server_dialect_test.dart b/packages/server_core/test/server_dialect_test.dart new file mode 100644 index 000000000..1ef160c08 --- /dev/null +++ b/packages/server_core/test/server_dialect_test.dart @@ -0,0 +1,126 @@ +import 'package:dio/dio.dart'; +import 'package:server_core/server_core.dart'; +import 'package:test/test.dart'; + +class _Capture extends Interceptor { + RequestOptions? last; + + @override + void onRequest(RequestOptions options, RequestInterceptorHandler h) { + last = options; + h.resolve( + Response( + requestOptions: options, + statusCode: 200, + data: {'Items': [], 'TotalRecordCount': 0}, + ), + ); + } +} + +void main() { + group('dialect endpoint paths', () { + test('jellyfin uses user-less resume/latest/views paths', () async { + final cap = _Capture(); + final dio = Dio()..interceptors.add(cap); + const d = ServerDialect.jellyfin; + final items = ServerItemsApi(dio, d, () => 'u1'); + final views = ServerUserViewsApi(dio, d, () => 'u1'); + + await items.getResumeItems(); + expect(cap.last!.path, '/UserItems/Resume'); + await items.getLatestItems(); + expect(cap.last!.path, '/Items/Latest'); + await views.getUserViews(); + expect(cap.last!.path, '/UserViews'); + }); + + test('emby uses /Users/{id} scoped paths', () async { + final cap = _Capture(); + final dio = Dio()..interceptors.add(cap); + const d = ServerDialect.emby; + final items = ServerItemsApi(dio, d, () => 'u1'); + final views = ServerUserViewsApi(dio, d, () => 'u1'); + + await items.getResumeItems(); + expect(cap.last!.path, '/Users/u1/Items/Resume'); + await items.getLatestItems(); + expect(cap.last!.path, '/Users/u1/Items/Latest'); + await views.getUserViews(); + expect(cap.last!.path, '/Users/u1/Views'); + }); + }); + + group('dialect query param casing', () { + // Jellyfin documents camelCase, Emby documents PascalCase for the + // /Sessions controllable-user filter; each server gets its prescribed + // wire format. + Future capturedParamName(ServerDialect d) async { + RequestOptions? last; + final dio = Dio() + ..interceptors.add(InterceptorsWrapper(onRequest: (o, h) { + last = o; + h.resolve( + Response(requestOptions: o, statusCode: 200, data: [])); + })); + await ServerSessionApi(dio, d).getSessions(controllableByUserId: 'u1'); + return last!.queryParameters.keys.single; + } + + test('jellyfin sends controllableByUserId', () async { + expect(await capturedParamName(ServerDialect.jellyfin), + 'controllableByUserId'); + }); + + test('emby sends ControllableByUserId', () async { + expect(await capturedParamName(ServerDialect.emby), + 'ControllableByUserId'); + }); + }); + + group('dialect capabilities', () { + test('emby returns empty lyrics/segments without a request', () async { + var requests = 0; + final dio = Dio() + ..interceptors.add(InterceptorsWrapper(onRequest: (o, h) { + requests++; + h.resolve(Response(requestOptions: o, statusCode: 200, data: {})); + })); + final api = ServerItemsApi(dio, ServerDialect.emby, () => 'u1'); + + expect(await api.getLyrics('i1'), {'Lyrics': []}); + expect(await api.getMediaSegments('i1'), isEmpty); + expect(requests, 0); + }); + + test('emby throws on quick connect and remote subtitle search', () { + final auth = ServerAuthApi(Dio(), ServerDialect.emby); + final items = ServerItemsApi(Dio(), ServerDialect.emby, () => 'u1'); + + expect(auth.initiateQuickConnect, throwsUnsupportedError); + expect( + () => items.searchRemoteSubtitles('i1', language: 'en'), + throwsUnsupportedError, + ); + expect( + () => items.downloadRemoteSubtitle('i1', 's1'), + throwsUnsupportedError, + ); + }); + + test('jellyfin image urls omit api_key, emby includes it', () { + const base = 'http://server.local'; + final jf = ServerImageApi(() => base, () => 'tok', ServerDialect.jellyfin); + final em = ServerImageApi(() => base, () => 'tok', ServerDialect.emby); + + expect( + jf.getPrimaryImageUrl('i1'), + '$base/Items/i1/Images/Primary', + ); + expect( + em.getPrimaryImageUrl('i1'), + '$base/Items/i1/Images/Primary?api_key=tok', + ); + }); + }); +} diff --git a/packages/server_jellyfin/test/jellyfin_live_tv_api_test.dart b/packages/server_core/test/server_live_tv_api_test.dart similarity index 84% rename from packages/server_jellyfin/test/jellyfin_live_tv_api_test.dart rename to packages/server_core/test/server_live_tv_api_test.dart index 6f7e1c37a..549e33713 100644 --- a/packages/server_jellyfin/test/jellyfin_live_tv_api_test.dart +++ b/packages/server_core/test/server_live_tv_api_test.dart @@ -1,5 +1,5 @@ import 'package:dio/dio.dart'; -import 'package:server_jellyfin/src/api/jellyfin_live_tv_api.dart'; +import 'package:server_core/server_core.dart'; import 'package:test/test.dart'; class _Capture extends Interceptor { @@ -19,10 +19,10 @@ class _Capture extends Interceptor { } void main() { - group('JellyfinLiveTvApi.getGuide /LiveTv/Programs transport', () { + group('ServerLiveTvApi.getGuide /LiveTv/Programs transport', () { test('small channel list → GET with comma-joined ChannelIds string', () async { final cap = _Capture(); - final api = JellyfinLiveTvApi(Dio()..interceptors.add(cap)); + final api = ServerLiveTvApi(Dio()..interceptors.add(cap)); await api.getGuide(channelIds: ['a', 'b', 'c']); @@ -38,7 +38,7 @@ void main() { (i) => '0000000000000000000000000000${i.toString().padLeft(8, '0')}', ); final cap = _Capture(); - final api = JellyfinLiveTvApi(Dio()..interceptors.add(cap)); + final api = ServerLiveTvApi(Dio()..interceptors.add(cap)); await api.getGuide(channelIds: ids); @@ -52,7 +52,7 @@ void main() { test('forwards EnableImages/EnableUserData to keep the payload small', () async { final cap = _Capture(); - final api = JellyfinLiveTvApi(Dio()..interceptors.add(cap)); + final api = ServerLiveTvApi(Dio()..interceptors.add(cap)); await api.getGuide( channelIds: ['a', 'b'], diff --git a/packages/server_jellyfin/test/jellyfin_playback_api_test.dart b/packages/server_core/test/server_playback_api_test.dart similarity index 92% rename from packages/server_jellyfin/test/jellyfin_playback_api_test.dart rename to packages/server_core/test/server_playback_api_test.dart index 527b70086..aa557195a 100644 --- a/packages/server_jellyfin/test/jellyfin_playback_api_test.dart +++ b/packages/server_core/test/server_playback_api_test.dart @@ -1,10 +1,10 @@ import 'package:dio/dio.dart'; -import 'package:server_jellyfin/src/api/jellyfin_playback_api.dart'; +import 'package:server_core/server_core.dart'; import 'package:test/test.dart'; /// Intercepts requests before they hit the network so we can assert on the /// exact body sent and reply with a chosen status, mimicking how a strict -/// Jellyfin-compatible server (e.g. remux) responds. +/// Jellyfin/Emby-compatible server (e.g. remux) responds. class _FakeServer extends Interceptor { _FakeServer(this.handle); @@ -57,7 +57,7 @@ void main() { }), ); - final api = JellyfinPlaybackApi(dio, 'http://remux.local'); + final api = ServerPlaybackApi(dio, () => 'http://remux.local'); final result = await api.getPlaybackInfo( 'channel-1', userId: 'user-1', @@ -98,7 +98,7 @@ void main() { }), ); - final api = JellyfinPlaybackApi(dio, 'http://jellyfin.local'); + final api = ServerPlaybackApi(dio, () => 'http://jellyfin.local'); await expectLater( api.getPlaybackInfo( @@ -127,7 +127,7 @@ void main() { }), ); - final api = JellyfinPlaybackApi(dio, 'http://jellyfin.local'); + final api = ServerPlaybackApi(dio, () => 'http://jellyfin.local'); final result = await api.getPlaybackInfo( 'item-1', userId: 'user-1', @@ -154,7 +154,7 @@ void main() { }), ); - final api = JellyfinPlaybackApi(dio, 'http://jellyfin.local'); + final api = ServerPlaybackApi(dio, () => 'http://jellyfin.local'); await api.stopActiveEncodings( deviceId: 'device-1', playSessionId: 'session-1', @@ -178,7 +178,7 @@ void main() { }), ); - final api = JellyfinPlaybackApi(dio, 'http://jellyfin.local'); + final api = ServerPlaybackApi(dio, () => 'http://jellyfin.local'); await api.stopActiveEncodings(deviceId: 'device-1'); await api.stopActiveEncodings(deviceId: 'device-1', playSessionId: ''); diff --git a/packages/server_emby/lib/server_emby.dart b/packages/server_emby/lib/server_emby.dart index 142b492f6..961ec1e14 100644 --- a/packages/server_emby/lib/server_emby.dart +++ b/packages/server_emby/lib/server_emby.dart @@ -2,14 +2,3 @@ library; export 'src/emby_media_server_client.dart'; -export 'src/api/emby_auth_api.dart'; -export 'src/api/emby_items_api.dart'; -export 'src/api/emby_playback_api.dart'; -export 'src/api/emby_image_api.dart'; -export 'src/api/emby_session_api.dart'; -export 'src/api/emby_system_api.dart'; -export 'src/api/emby_user_library_api.dart'; -export 'src/api/emby_user_views_api.dart'; -export 'src/api/emby_live_tv_api.dart'; -export 'src/api/emby_instant_mix_api.dart'; -export 'src/api/emby_display_preferences_api.dart'; diff --git a/packages/server_emby/lib/src/api/emby_auth_api.dart b/packages/server_emby/lib/src/api/emby_auth_api.dart deleted file mode 100644 index f94c50365..000000000 --- a/packages/server_emby/lib/src/api/emby_auth_api.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:server_core/server_core.dart'; - -class EmbyAuthApi implements AuthApi { - final Dio _dio; - - EmbyAuthApi(this._dio); - - @override - Future> authenticateByName( - String username, - String password, - ) async { - final response = await _dio.post( - '/Users/AuthenticateByName', - data: {'Username': username, 'Pw': password}, - ); - return response.data as Map; - } - - @override - Future> initiateQuickConnect() { - throw UnsupportedError('QuickConnect is not supported on Emby'); - } - - @override - Future> checkQuickConnect(String secret) { - throw UnsupportedError('QuickConnect is not supported on Emby'); - } - - @override - Future authorizeQuickConnect(String code, {String? userId}) { - throw UnsupportedError('QuickConnect is not supported on Emby'); - } - - @override - Future> authenticateWithQuickConnect(String secret) { - throw UnsupportedError('QuickConnect is not supported on Emby'); - } - - @override - Future logout() async { - await _dio.post('/Sessions/Logout'); - } -} diff --git a/packages/server_emby/lib/src/api/emby_instant_mix_api.dart b/packages/server_emby/lib/src/api/emby_instant_mix_api.dart deleted file mode 100644 index 703ac3d53..000000000 --- a/packages/server_emby/lib/src/api/emby_instant_mix_api.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:server_core/server_core.dart'; - -class EmbyInstantMixApi implements InstantMixApi { - final Dio _dio; - - EmbyInstantMixApi(this._dio); - - @override - Future> getInstantMix( - String itemId, { - int? limit, - }) async { - final response = await _dio.get( - '/Items/$itemId/InstantMix', - queryParameters: { - 'Limit': ?limit, - }, - ); - return response.data as Map; - } -} diff --git a/packages/server_emby/lib/src/api/emby_items_api.dart b/packages/server_emby/lib/src/api/emby_items_api.dart deleted file mode 100644 index 619d3645c..000000000 --- a/packages/server_emby/lib/src/api/emby_items_api.dart +++ /dev/null @@ -1,650 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:server_core/server_core.dart'; - -class EmbyItemsApi implements ItemsApi { - final Dio _dio; - final String Function() _getUserId; - - EmbyItemsApi(this._dio, this._getUserId); - - bool _shouldRetryCollectionFallback(int statusCode) { - return statusCode == 400 || - statusCode == 404 || - statusCode == 405 || - statusCode == 415 || - statusCode == 422; - } - - @override - Future> getItems({ - bool? serverWide, - String? parentId, - List? ids, - List? includeItemTypes, - List? excludeItemTypes, - String? sortBy, - String? sortOrder, - int? startIndex, - int? limit, - bool? recursive, - String? searchTerm, - String? fields, - List? personIds, - List? artistIds, - List? filters, - List? seriesStatus, - String? nameStartsWith, - String? nameLessThan, - List? genreIds, - List? genres, - bool? isFavorite, - bool? collapseBoxSetItems, - bool? enableTotalRecordCount, - String? enableImageTypes, - int? imageTypeLimit, - List? tags, - List? studios, - DateTime? minPremiereDate, - String? maxOfficialRating, - bool? hasParentalRating, - String? anyProviderIdEquals, - }) async { - final queryParams = { - 'ParentId': ?parentId, - if (ids != null) 'Ids': ids.join(','), - if (includeItemTypes != null) - 'IncludeItemTypes': includeItemTypes.join(','), - if (excludeItemTypes != null) - 'ExcludeItemTypes': excludeItemTypes.join(','), - 'SortBy': ?sortBy, - 'SortOrder': ?sortOrder, - 'StartIndex': ?startIndex, - 'Limit': ?limit, - 'Recursive': ?recursive, - 'SearchTerm': ?searchTerm, - 'Fields': ?fields, - if (personIds != null) 'PersonIds': personIds.join(','), - if (artistIds != null) 'ArtistIds': artistIds.join(','), - if (filters != null) 'Filters': filters.join(','), - if (seriesStatus != null) 'SeriesStatus': seriesStatus.join(','), - 'NameStartsWith': ?nameStartsWith, - 'NameLessThan': ?nameLessThan, - if (genreIds != null) 'GenreIds': genreIds.join(','), - if (genres != null) 'Genres': genres.join(','), - 'IsFavorite': ?isFavorite, - 'CollapseBoxSetItems': ?collapseBoxSetItems, - 'EnableTotalRecordCount': ?enableTotalRecordCount, - 'EnableImageTypes': ?enableImageTypes, - 'ImageTypeLimit': ?imageTypeLimit, - if (tags != null && tags.isNotEmpty) 'Tags': tags.join('|'), - if (studios != null && studios.isNotEmpty) 'Studios': studios.join('|'), - if (minPremiereDate != null) - 'MinPremiereDate': minPremiereDate.toUtc().toIso8601String(), - 'MaxOfficialRating': ?maxOfficialRating, - 'HasParentalRating': ?hasParentalRating, - 'AnyProviderIdEquals': ?anyProviderIdEquals, - }; - final String path; - if (serverWide == true) { - path = '/Items'; - } else { - final userId = _getUserId(); - path = '/Users/$userId/Items'; - } - final response = await _dio.get( - path, - queryParameters: queryParams, - ); - return response.data as Map; - } - - @override - Future> getPersons({ - required String searchTerm, - int? limit, - String? fields, - String? enableImageTypes, - }) async { - final response = await _dio.get( - '/Persons', - queryParameters: { - 'UserId': _getUserId(), - 'SearchTerm': searchTerm, - 'Limit': ?limit, - 'Fields': ?fields, - 'EnableImageTypes': ?enableImageTypes, - }, - ); - return response.data as Map; - } - - @override - Future> getItem( - String itemId, { - String? mediaSourceId, - String? fields, - }) async { - final userId = _getUserId(); - final response = await _dio.get( - '/Users/$userId/Items/$itemId', - queryParameters: { - if (mediaSourceId != null) 'mediaSourceId': mediaSourceId, - if (fields != null && fields.isNotEmpty) 'Fields': fields, - }, - ); - return response.data as Map; - } - - @override - Future>> getAncestors(String itemId) async { - try { - final response = await _dio.get('/Items/$itemId/Ancestors'); - return ((response.data as List?) ?? const []) - .whereType() - .map((e) => e.cast()) - .toList(growable: false); - } catch (_) { - return const []; - } - } - - @override - Future> getSimilarItems( - String itemId, { - int? limit, - }) async { - final response = await _dio.get( - '/Items/$itemId/Similar', - queryParameters: {'Limit': ?limit}, - ); - return response.data as Map; - } - - @override - Future> getNextUp({ - String? seriesId, - String? parentId, - int? limit, - String? fields, - bool? enableResumable, - String? enableImageTypes, - int? imageTypeLimit, - }) async { - final userId = _getUserId(); - final response = await _dio.get( - '/Shows/NextUp', - queryParameters: { - 'UserId': userId, - 'SeriesId': ?seriesId, - 'ParentId': ?parentId, - 'Limit': ?limit, - 'Fields': ?fields, - 'EnableResumable': ?enableResumable, - 'EnableImageTypes': ?enableImageTypes, - 'ImageTypeLimit': ?imageTypeLimit, - }, - ); - return response.data as Map; - } - - @override - Future> getResumeItems({ - String? parentId, - List? includeItemTypes, - int? limit, - String? fields, - String? enableImageTypes, - int? imageTypeLimit, - }) async { - final userId = _getUserId(); - final response = await _dio.get( - '/Users/$userId/Items/Resume', - queryParameters: { - 'ParentId': ?parentId, - if (includeItemTypes != null) - 'IncludeItemTypes': includeItemTypes.join(','), - 'Limit': ?limit, - 'Fields': ?fields, - 'EnableImageTypes': ?enableImageTypes, - 'ImageTypeLimit': ?imageTypeLimit, - }, - ); - return response.data as Map; - } - - @override - Future> getLatestItems({ - String? parentId, - List? includeItemTypes, - int? limit, - String? fields, - String? enableImageTypes, - int? imageTypeLimit, - }) async { - final userId = _getUserId(); - final response = await _dio.get( - '/Users/$userId/Items/Latest', - queryParameters: { - if (parentId != null) 'ParentId': parentId, - if (includeItemTypes != null) - 'IncludeItemTypes': includeItemTypes.join(','), - if (limit != null) 'Limit': limit, - if (fields != null) 'Fields': fields, - if (enableImageTypes != null) 'EnableImageTypes': enableImageTypes, - if (imageTypeLimit != null) 'ImageTypeLimit': imageTypeLimit, - }, - ); - - // /Items/Latest returns a bare array, so normalize it into the - // same shape as /Items so all callers stay unchanged. - final list = response.data as List; - return { - 'Items': list, - 'TotalRecordCount': list.length, - }; - } - - @override - Future> getRecentlyReleasedItems({ - String? parentId, - List? includeItemTypes, - int? limit, - String? fields, - String? enableImageTypes, - int? imageTypeLimit, - }) async { - final response = await _dio.get( - '/Items', - queryParameters: { - if (parentId != null) 'ParentId': parentId, - if (includeItemTypes != null) - 'IncludeItemTypes': includeItemTypes.join(','), - if (limit != null) 'Limit': limit, - if (fields != null) 'Fields': fields, - if (enableImageTypes != null) 'EnableImageTypes': enableImageTypes, - if (imageTypeLimit != null) 'ImageTypeLimit': imageTypeLimit, - 'SortBy' : 'PremiereDate', - 'SortOrder' : 'Descending', - 'MaxPremiereDate': DateTime.now().toUtc().toIso8601String(), - }, - ); - - // /Items/Latest returns a bare array, so normalize it into the - // same shape as /Items so all callers stay unchanged. - final data = response.data; - if (data is List) { - return {'Items': data, 'TotalRecordCount': data.length}; - } - return data as Map; - } - - @override - Future> getSeasons(String seriesId) async { - final response = await _dio.get('/Shows/$seriesId/Seasons'); - return response.data as Map; - } - - @override - Future> getEpisodes( - String seriesId, { - String? seasonId, - String? fields, - }) async { - final response = await _dio.get( - '/Shows/$seriesId/Episodes', - queryParameters: {'SeasonId': ?seasonId, 'Fields': ?fields}, - ); - return response.data as Map; - } - - @override - Future> getThemeMedia( - String itemId, { - bool inheritFromParent = true, - }) async { - final userId = _getUserId(); - final response = await _dio.get( - '/Items/$itemId/ThemeMedia', - queryParameters: { - 'UserId': userId, - 'InheritFromParent': inheritFromParent, - }, - ); - return response.data as Map; - } - - @override - Future> getPlaylists() async { - final userId = _getUserId(); - final response = await _dio.get( - '/Users/$userId/Items', - queryParameters: { - 'IncludeItemTypes': 'Playlist', - 'Recursive': true, - 'SortBy': 'SortName', - 'SortOrder': 'Ascending', - }, - ); - return response.data as Map; - } - - @override - Future> getArtists({ - String? parentId, - String? userId, - String? sortBy, - String? sortOrder, - int? startIndex, - int? limit, - bool? recursive, - String? fields, - String? nameStartsWith, - String? nameLessThan, - bool? isFavorite, - }) async { - final response = await _dio.get( - '/Artists', - queryParameters: { - 'ParentId': ?parentId, - 'UserId': ?userId, - 'SortBy': ?sortBy, - 'SortOrder': ?sortOrder, - 'StartIndex': ?startIndex, - 'Limit': ?limit, - 'Recursive': ?recursive, - 'Fields': ?fields, - 'NameStartsWith': ?nameStartsWith, - 'NameLessThan': ?nameLessThan, - 'IsFavorite': ?isFavorite, - }, - ); - return response.data as Map; - } - - @override - Future> getAlbumArtists({ - String? parentId, - String? userId, - String? sortBy, - String? sortOrder, - int? startIndex, - int? limit, - bool? recursive, - String? fields, - String? nameStartsWith, - String? nameLessThan, - bool? isFavorite, - }) async { - final response = await _dio.get( - '/Artists/AlbumArtists', - queryParameters: { - 'ParentId': ?parentId, - 'UserId': ?userId, - 'SortBy': ?sortBy, - 'SortOrder': ?sortOrder, - 'StartIndex': ?startIndex, - 'Limit': ?limit, - 'Recursive': ?recursive, - 'Fields': ?fields, - 'NameStartsWith': ?nameStartsWith, - 'NameLessThan': ?nameLessThan, - 'IsFavorite': ?isFavorite, - }, - ); - return response.data as Map; - } - - @override - Future> getPlaylistItems(String playlistId) async { - final response = await _dio.get( - '/Playlists/$playlistId/Items', - queryParameters: { - 'Fields': - 'BasicSyncInfo,PrimaryImageAspectRatio,RunTimeTicks,Artists,AlbumArtist,IndexNumber,MediaType,PlaylistItemId,BackdropImageTags,ParentBackdropImageTags,ParentBackdropItemId,SeriesName,ParentIndexNumber,Genres', - 'EnableImageTypes': 'Primary,Backdrop,Logo,Thumb', - 'ImageTypeLimit': 1, - }, - ); - return response.data as Map; - } - - @override - Future> createPlaylist({ - required String name, - List? itemIds, - }) async { - final response = await _dio.post( - '/Playlists', - data: {'Name': name, 'Ids': ?itemIds}, - ); - return response.data as Map; - } - - @override - Future> createCollection({ - required String name, - List? itemIds, - }) async { - final hasItems = itemIds != null && itemIds.isNotEmpty; - final ids = hasItems ? itemIds.join(',') : null; - final queryParameters = { - 'name': name, - 'Name': name, - if (ids != null) 'ids': ids, - if (ids != null) 'Ids': ids, - }; - - Response response; - try { - response = await _dio.post( - '/Collections', - queryParameters: queryParameters, - ); - } on DioException catch (error) { - final statusCode = error.response?.statusCode ?? 0; - if (!_shouldRetryCollectionFallback(statusCode)) { - rethrow; - } - - response = await _dio.post( - '/Collections', - queryParameters: queryParameters, - data: { - 'Name': name, - if (hasItems) 'Ids': itemIds, - }, - ); - } - - final data = response.data; - if (data is Map) { - return data; - } - if (data is Map) { - return data.cast(); - } - return {}; - } - - @override - Future addToPlaylist(String playlistId, List itemIds) async { - await _dio.post( - '/Playlists/$playlistId/Items', - queryParameters: {'Ids': itemIds.join(',')}, - ); - } - - @override - Future addToCollection(String collectionId, List itemIds) async { - final ids = itemIds.join(','); - final path = '/Collections/$collectionId/Items'; - - Future send({ - String? queryKey, - bool includeBody = false, - }) async { - await _dio.post( - path, - queryParameters: queryKey == null ? null : {queryKey: ids}, - data: includeBody - ? { - 'Ids': itemIds, - 'ids': ids, - } - : null, - ); - } - - for (final queryKey in const ['Ids', 'ids']) { - try { - await send(queryKey: queryKey); - return; - } on DioException catch (error) { - final statusCode = error.response?.statusCode ?? 0; - if (!_shouldRetryCollectionFallback(statusCode)) { - rethrow; - } - } - } - - await send(includeBody: true); - } - - @override - Future removeFromPlaylist( - String playlistId, - List entryIds, - ) async { - await _dio.delete( - '/Playlists/$playlistId/Items', - queryParameters: {'EntryIds': entryIds.join(',')}, - ); - } - - @override - Future movePlaylistItem( - String playlistId, - String playlistItemId, - int newIndex, - ) async { - await _dio.post( - '/Playlists/$playlistId/Items/$playlistItemId/Move/$newIndex', - ); - } - - @override - Future renamePlaylist(String playlistId, String name) async { - await _dio.post('/Playlists/$playlistId', data: {'Name': name}); - } - - @override - Future deleteItem(String itemId) async { - await _dio.delete('/Items/$itemId'); - } - - @override - Future deletePlaylist(String playlistId) async { - await deleteItem(playlistId); - } - - @override - Future> getGenres({ - String? parentId, - String? userId, - String? sortBy, - String? sortOrder, - int? startIndex, - int? limit, - bool? recursive, - String? fields, - List? includeItemTypes, - }) async { - final response = await _dio.get( - '/Genres', - queryParameters: { - 'ParentId': ?parentId, - 'UserId': ?userId, - 'SortBy': ?sortBy, - 'SortOrder': ?sortOrder, - 'StartIndex': ?startIndex, - 'Limit': ?limit, - 'Recursive': ?recursive, - 'Fields': ?fields, - if (includeItemTypes != null && includeItemTypes.isNotEmpty) - 'IncludeItemTypes': includeItemTypes.join(','), - }, - ); - return response.data as Map; - } - - @override - Future> getLyrics(String itemId) async { - return const {'Lyrics': []}; - } - - List> _parseItemListResponse(dynamic data) { - if (data is List) return data.cast>(); - if (data is Map) { - final items = data['Items'] as List?; - if (items == null) return const []; - return items - .whereType() - .map((e) => e.cast()) - .toList(growable: false); - } - return const []; - } - - @override - Future>> getLocalTrailers(String itemId) async { - final userId = _getUserId(); - final response = await _dio.get( - '/Users/$userId/Items/$itemId/LocalTrailers', - ); - return _parseItemListResponse(response.data); - } - - @override - Future>> getIntros(String itemId) async { - final userId = _getUserId(); - final response = await _dio.get('/Users/$userId/Items/$itemId/Intros'); - return _parseItemListResponse(response.data); - } - - @override - Future>> getSpecialFeatures(String itemId) async { - try { - final response = await _dio.get('/Items/$itemId/SpecialFeatures'); - final data = response.data; - if (data is List) { - return data.cast>(); - } - return const []; - } catch (_) { - return const []; - } - } - - @override - Future>> getMediaSegments(String itemId) async { - return const []; - } - - @override - Future>> searchRemoteSubtitles( - String itemId, { - required String language, - bool? isPerfectMatch, - }) async { - throw UnsupportedError( - 'Remote subtitle search is only supported for Jellyfin servers.', - ); - } - - @override - Future downloadRemoteSubtitle(String itemId, String subtitleId) async { - throw UnsupportedError( - 'Remote subtitle download is only supported for Jellyfin servers.', - ); - } -} diff --git a/packages/server_emby/lib/src/api/emby_session_api.dart b/packages/server_emby/lib/src/api/emby_session_api.dart deleted file mode 100644 index 36d804db4..000000000 --- a/packages/server_emby/lib/src/api/emby_session_api.dart +++ /dev/null @@ -1,80 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:server_core/server_core.dart'; - -class EmbySessionApi implements SessionApi { - final Dio _dio; - - EmbySessionApi(this._dio); - - @override - Future reportCapabilities(Map capabilities) async { - await _dio.post('/Sessions/Capabilities/Full', data: capabilities); - } - - @override - Future>> getSessions({String? controllableByUserId}) async { - final response = await _dio.get('/Sessions', queryParameters: { - 'ControllableByUserId': ?controllableByUserId, - }); - return (response.data as List).cast>(); - } - - @override - Future sendPlayCommand( - String sessionId, { - required String playCommand, - required List itemIds, - int? startPositionTicks, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - int? startIndex, - }) async { - await _dio.post( - '/Sessions/$sessionId/Playing', - queryParameters: { - 'playCommand': playCommand, - 'itemIds': itemIds.join(','), - 'startPositionTicks': ?startPositionTicks, - 'mediaSourceId': ?mediaSourceId, - 'audioStreamIndex': ?audioStreamIndex, - 'subtitleStreamIndex': ?subtitleStreamIndex, - 'startIndex': ?startIndex, - }, - ); - } - - @override - Future sendPlayStateCommand(String sessionId, String command, {int? seekPositionTicks}) async { - await _dio.post( - '/Sessions/$sessionId/Playing/$command', - queryParameters: { - 'seekPositionTicks': ?seekPositionTicks, - }, - ); - } - - @override - Future sendMessage(String sessionId, String text, {String? header, int? timeoutMs}) async { - await _dio.post( - '/Sessions/$sessionId/Message', - data: { - 'Text': text, - 'Header': ?header, - 'TimeoutMs': ?timeoutMs, - }, - ); - } - - @override - Future sendGeneralCommand(String sessionId, String commandName, {Map? arguments}) async { - await _dio.post( - '/Sessions/$sessionId/Command', - data: { - 'Name': commandName, - 'Arguments': ?arguments, - }, - ); - } -} - diff --git a/packages/server_emby/lib/src/api/emby_system_api.dart b/packages/server_emby/lib/src/api/emby_system_api.dart deleted file mode 100644 index a488befb5..000000000 --- a/packages/server_emby/lib/src/api/emby_system_api.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:server_core/server_core.dart'; - -class EmbySystemApi implements SystemApi { - final Dio _dio; - - EmbySystemApi(this._dio); - - @override - Future> getPublicSystemInfo() async { - final response = await _dio.get('/System/Info/Public'); - return response.data as Map; - } - - @override - Future> getSystemInfo() async { - final response = await _dio.get('/System/Info'); - return response.data as Map; - } - - @override - Future ping() async { - try { - final response = await _dio.get('/System/Ping'); - return response.statusCode == 200; - } catch (_) { - return false; - } - } -} diff --git a/packages/server_emby/lib/src/api/emby_user_library_api.dart b/packages/server_emby/lib/src/api/emby_user_library_api.dart deleted file mode 100644 index 772c3546b..000000000 --- a/packages/server_emby/lib/src/api/emby_user_library_api.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:server_core/server_core.dart'; - -class EmbyUserLibraryApi implements UserLibraryApi { - final Dio _dio; - final String Function() _getUserId; - - EmbyUserLibraryApi(this._dio, this._getUserId); - - @override - Future markFavorite(String itemId) async { - final userId = _getUserId(); - await _dio.post('/Users/$userId/FavoriteItems/$itemId'); - } - - @override - Future unmarkFavorite(String itemId) async { - final userId = _getUserId(); - await _dio.delete('/Users/$userId/FavoriteItems/$itemId'); - } - - @override - Future markPlayed(String itemId) async { - final userId = _getUserId(); - await _dio.post('/Users/$userId/PlayedItems/$itemId'); - } - - @override - Future unmarkPlayed(String itemId) async { - final userId = _getUserId(); - await _dio.delete('/Users/$userId/PlayedItems/$itemId'); - } - - @override - Future updateUserRating(String itemId, {required bool likes}) async { - final userId = _getUserId(); - await _dio.post( - '/Users/$userId/Items/$itemId/Rating', - queryParameters: {'Likes': likes}, - ); - } - - @override - Future deleteUserRating(String itemId) async { - final userId = _getUserId(); - await _dio.delete('/Users/$userId/Items/$itemId/Rating'); - } - - @override - Future> getItem(String itemId) async { - final userId = _getUserId(); - final response = await _dio.get('/Users/$userId/Items/$itemId'); - return response.data as Map; - } -} diff --git a/packages/server_emby/lib/src/emby_media_server_client.dart b/packages/server_emby/lib/src/emby_media_server_client.dart index 88146f3ea..58e9b6eb9 100644 --- a/packages/server_emby/lib/src/emby_media_server_client.dart +++ b/packages/server_emby/lib/src/emby_media_server_client.dart @@ -1,22 +1,11 @@ import 'package:dio/dio.dart'; import 'package:server_core/server_core.dart'; -import 'api/emby_auth_api.dart'; -import 'api/emby_items_api.dart'; -import 'api/emby_playback_api.dart'; -import 'api/emby_image_api.dart'; -import 'api/emby_session_api.dart'; -import 'api/emby_system_api.dart'; -import 'api/emby_user_library_api.dart'; -import 'api/emby_user_views_api.dart'; -import 'api/emby_live_tv_api.dart'; -import 'api/emby_instant_mix_api.dart'; -import 'api/emby_display_preferences_api.dart'; -import 'api/emby_users_api.dart'; - class EmbyMediaServerClient extends MediaServerClient { final Dio _dio; + static const _dialect = ServerDialect.emby; + @override final DeviceInfo deviceInfo; @@ -31,27 +20,18 @@ class EmbyMediaServerClient extends MediaServerClient { )) { _baseUrl = baseUrl; configureServerDio(_dio); - _setupInterceptors(); + attachServerInterceptors( + _dio, + deviceInfo: deviceInfo, + authScheme: _dialect.authScheme, + getAccessToken: () => _accessToken, + ); } late String _baseUrl; String? _accessToken; String? _userId; - void _setupInterceptors() { - _dio.interceptors.add(redirectInterceptor(_dio)); - _dio.interceptors.add(InterceptorsWrapper( - onRequest: (options, handler) { - options.headers['Authorization'] = buildServerAuthorizationHeader( - scheme: 'Emby', - deviceInfo: deviceInfo, - accessToken: _accessToken, - ); - handler.next(options); - }, - )); - } - String _requireUserId() { final id = _userId; if (id == null) throw StateError('userId not configured'); @@ -83,45 +63,44 @@ class EmbyMediaServerClient extends MediaServerClient { set userId(String? id) => _userId = id; @override - late final AuthApi authApi = EmbyAuthApi(_dio); + late final AuthApi authApi = ServerAuthApi(_dio, _dialect); @override - late final ItemsApi itemsApi = EmbyItemsApi(_dio, _requireUserId); + late final ItemsApi itemsApi = ServerItemsApi(_dio, _dialect, _requireUserId); @override - late final PlaybackApi playbackApi = - EmbyPlaybackApi(_dio, () => _baseUrl); + late final PlaybackApi playbackApi = ServerPlaybackApi(_dio, () => _baseUrl); @override late final ImageApi imageApi = - EmbyImageApi(() => _baseUrl, () => _accessToken); + ServerImageApi(() => _baseUrl, () => _accessToken, _dialect); @override - late final SessionApi sessionApi = EmbySessionApi(_dio); + late final SessionApi sessionApi = ServerSessionApi(_dio, _dialect); @override - late final SystemApi systemApi = EmbySystemApi(_dio); + late final SystemApi systemApi = ServerSystemApi(_dio); @override late final UserLibraryApi userLibraryApi = - EmbyUserLibraryApi(_dio, _requireUserId); + ServerUserLibraryApi(_dio, _dialect, _requireUserId); @override - late final EmbyUserViewsApi userViewsApi = - EmbyUserViewsApi(_dio, _requireUserId); + late final UserViewsApi userViewsApi = + ServerUserViewsApi(_dio, _dialect, _requireUserId); @override - late final LiveTvApi liveTvApi = EmbyLiveTvApi(_dio); + late final LiveTvApi liveTvApi = ServerLiveTvApi(_dio); @override - late final InstantMixApi instantMixApi = EmbyInstantMixApi(_dio); + late final InstantMixApi instantMixApi = ServerInstantMixApi(_dio); @override - late final EmbyDisplayPreferencesApi displayPreferencesApi = - EmbyDisplayPreferencesApi(_dio); + late final DisplayPreferencesApi displayPreferencesApi = + ServerDisplayPreferencesApi(_dio, _dialect); @override - late final UsersApi usersApi = EmbyUsersApi(_dio, _requireUserId); + late final UsersApi usersApi = ServerUsersApi(_dio, _dialect, _requireUserId); @override AdminSystemApi get adminSystemApi => diff --git a/packages/server_emby/test/emby_live_tv_api_test.dart b/packages/server_emby/test/emby_live_tv_api_test.dart deleted file mode 100644 index 592cb2cc3..000000000 --- a/packages/server_emby/test/emby_live_tv_api_test.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:server_emby/src/api/emby_live_tv_api.dart'; -import 'package:test/test.dart'; - -class _Capture extends Interceptor { - RequestOptions? last; - - @override - void onRequest(RequestOptions options, RequestInterceptorHandler h) { - last = options; - h.resolve( - Response( - requestOptions: options, - statusCode: 200, - data: {'Items': [], 'TotalRecordCount': 0}, - ), - ); - } -} - -void main() { - group('EmbyLiveTvApi.getGuide /LiveTv/Programs transport', () { - test('small channel list → GET with comma-joined ChannelIds string', () async { - final cap = _Capture(); - final api = EmbyLiveTvApi(Dio()..interceptors.add(cap)); - - await api.getGuide(channelIds: ['a', 'b', 'c']); - - expect(cap.last!.method, 'GET'); - expect(cap.last!.path, '/LiveTv/Programs'); - expect(cap.last!.queryParameters['ChannelIds'], 'a,b,c'); - }); - - test('large channel list → POST with ChannelIds as a JSON array', () async { - final ids = List.generate( - 60, - (i) => '0000000000000000000000000000${i.toString().padLeft(8, '0')}', - ); - final cap = _Capture(); - final api = EmbyLiveTvApi(Dio()..interceptors.add(cap)); - - await api.getGuide(channelIds: ids); - - expect(cap.last!.method, 'POST'); - expect(cap.last!.path, '/LiveTv/Programs'); - final body = cap.last!.data as Map; - expect(body['ChannelIds'], isA>()); - expect((body['ChannelIds'] as List).length, 60); - }); - - test('forwards EnableImages/EnableUserData to keep the payload small', () async { - final cap = _Capture(); - final api = EmbyLiveTvApi(Dio()..interceptors.add(cap)); - - await api.getGuide( - channelIds: ['a', 'b'], - enableImages: false, - enableUserData: false, - ); - - expect(cap.last!.method, 'GET'); - expect(cap.last!.queryParameters['EnableImages'], false); - expect(cap.last!.queryParameters['EnableUserData'], false); - }); - }); -} diff --git a/packages/server_emby/test/emby_playback_api_test.dart b/packages/server_emby/test/emby_playback_api_test.dart deleted file mode 100644 index a798088d9..000000000 --- a/packages/server_emby/test/emby_playback_api_test.dart +++ /dev/null @@ -1,188 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:server_emby/src/api/emby_playback_api.dart'; -import 'package:test/test.dart'; - -/// Intercepts requests before they hit the network so we can assert on the -/// exact body sent and reply with a chosen status, mimicking how a strict -/// Jellyfin/Emby-compatible server (e.g. remux) responds. -class _FakeServer extends Interceptor { - _FakeServer(this.handle); - - final void Function(RequestOptions options, RequestInterceptorHandler h) - handle; - - @override - void onRequest(RequestOptions options, RequestInterceptorHandler h) => - handle(options, h); -} - -void main() { - group('EmbyPlaybackApi.getPlaybackInfo body-rejection fallback', () { - test( - '422 on full body retries without DeviceProfile and returns the 200 body', - () async { - final bodies = >[]; - final dio = Dio() - ..interceptors.add( - _FakeServer((options, h) { - final data = Map.from( - options.data as Map, - ); - bodies.add(data); - if (data.containsKey('DeviceProfile')) { - h.reject( - DioException( - requestOptions: options, - type: DioExceptionType.badResponse, - response: Response( - requestOptions: options, - statusCode: 422, - data: {'error': 'failed to deserialize device_profile'}, - ), - ), - ); - } else { - h.resolve( - Response( - requestOptions: options, - statusCode: 200, - data: { - 'MediaSources': [], - 'PlaySessionId': 'session-1', - }, - ), - ); - } - }), - ); - - final api = EmbyPlaybackApi(dio, () => 'http://remux.local'); - final result = await api.getPlaybackInfo( - 'channel-1', - userId: 'user-1', - requestBody: { - 'DeviceProfile': {'CodecProfiles': []}, - 'AutoOpenLiveStream': true, - 'EnableDirectPlay': true, - }, - ); - - expect(result['PlaySessionId'], 'session-1'); - expect(bodies.length, 2); - expect(bodies[0].containsKey('DeviceProfile'), isTrue); - expect(bodies[1].containsKey('DeviceProfile'), isFalse); - expect(bodies[1]['AutoOpenLiveStream'], isTrue); - expect(bodies[1]['UserId'], 'user-1'); - }, - ); - - test('non-body-rejection status (401) is rethrown, no retry', () async { - var attempts = 0; - final dio = Dio() - ..interceptors.add( - _FakeServer((options, h) { - attempts++; - h.reject( - DioException( - requestOptions: options, - type: DioExceptionType.badResponse, - response: Response( - requestOptions: options, - statusCode: 401, - ), - ), - ); - }), - ); - - final api = EmbyPlaybackApi(dio, () => 'http://emby.local'); - - await expectLater( - api.getPlaybackInfo( - 'item-1', - userId: 'user-1', - requestBody: {'DeviceProfile': {}}, - ), - throwsA(isA()), - ); - expect(attempts, 1); - }); - - test('200 on the first try returns immediately with no retry', () async { - var requests = 0; - final dio = Dio() - ..interceptors.add( - _FakeServer((options, h) { - requests++; - h.resolve( - Response( - requestOptions: options, - statusCode: 200, - data: {'PlaySessionId': 'ok'}, - ), - ); - }), - ); - - final api = EmbyPlaybackApi(dio, () => 'http://emby.local'); - final result = await api.getPlaybackInfo( - 'item-1', - userId: 'user-1', - requestBody: { - 'DeviceProfile': {}, - 'AutoOpenLiveStream': true, - }, - ); - - // Happy path is untouched: one request, DeviceProfile intact, no fallback. - expect(result['PlaySessionId'], 'ok'); - expect(requests, 1); - }); - }); - - group('stopActiveEncodings', () { - test('issues DELETE /Videos/ActiveEncodings with both params', () async { - RequestOptions? captured; - final dio = Dio() - ..interceptors.add( - _FakeServer((options, h) { - captured = options; - h.resolve(Response(requestOptions: options, statusCode: 204)); - }), - ); - - final api = EmbyPlaybackApi(dio, () => 'http://emby.local'); - await api.stopActiveEncodings( - deviceId: 'device-1', - playSessionId: 'session-1', - ); - - expect(captured!.method, 'DELETE'); - expect(captured!.path, '/Videos/ActiveEncodings'); - expect(captured!.queryParameters, { - 'deviceId': 'device-1', - 'playSessionId': 'session-1', - }); - }); - - test('omits playSessionId when null or empty', () async { - final queries = >[]; - final dio = Dio() - ..interceptors.add( - _FakeServer((options, h) { - queries.add(options.queryParameters); - h.resolve(Response(requestOptions: options, statusCode: 204)); - }), - ); - - final api = EmbyPlaybackApi(dio, () => 'http://emby.local'); - await api.stopActiveEncodings(deviceId: 'device-1'); - await api.stopActiveEncodings(deviceId: 'device-1', playSessionId: ''); - - expect(queries, [ - {'deviceId': 'device-1'}, - {'deviceId': 'device-1'}, - ]); - }); - }); -} diff --git a/packages/server_jellyfin/lib/src/api/jellyfin_display_preferences_api.dart b/packages/server_jellyfin/lib/src/api/jellyfin_display_preferences_api.dart deleted file mode 100644 index ad4ab6138..000000000 --- a/packages/server_jellyfin/lib/src/api/jellyfin_display_preferences_api.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:server_core/server_core.dart'; - -class JellyfinDisplayPreferencesApi implements DisplayPreferencesApi { - final Dio _dio; - - JellyfinDisplayPreferencesApi(this._dio); - - @override - Future getDisplayPreferences( - String id, { - String? client, - }) async { - final response = - await _dio.get('/DisplayPreferences/$id', queryParameters: { - 'client': client ?? 'moonfin', - }); - return DisplayPreferences.fromJson(response.data as Map); - } - - @override - Future saveDisplayPreferences( - String id, - DisplayPreferences prefs, { - String? client, - }) async { - await _dio.post( - '/DisplayPreferences/$id', - data: prefs.toJson(), - queryParameters: { - 'client': client ?? 'moonfin', - }, - ); - } -} diff --git a/packages/server_jellyfin/lib/src/api/jellyfin_image_api.dart b/packages/server_jellyfin/lib/src/api/jellyfin_image_api.dart deleted file mode 100644 index 021b0dc2f..000000000 --- a/packages/server_jellyfin/lib/src/api/jellyfin_image_api.dart +++ /dev/null @@ -1,113 +0,0 @@ -import 'package:server_core/server_core.dart'; - -class JellyfinImageApi implements ImageApi { - final String _baseUrl; - - JellyfinImageApi(this._baseUrl); - - String _buildQuery(Map params) { - if (params.isEmpty) return ''; - return '?${params.entries.map((e) => '${e.key}=${e.value}').join('&')}'; - } - - @override - String getPrimaryImageUrl( - String itemId, { - int? maxWidth, - int? maxHeight, - String? tag, - }) { - final query = _buildQuery({ - if (maxWidth != null) 'maxWidth': maxWidth.toString(), - if (maxHeight != null) 'maxHeight': maxHeight.toString(), - 'tag': ?tag, - }); - return '$_baseUrl/Items/$itemId/Images/Primary$query'; - } - - @override - String getBackdropImageUrl( - String itemId, { - int? maxWidth, - int? index, - String? tag, - }) { - final idx = index ?? 0; - final query = _buildQuery({ - if (maxWidth != null) 'maxWidth': maxWidth.toString(), - 'tag': ?tag, - }); - return '$_baseUrl/Items/$itemId/Images/Backdrop/$idx$query'; - } - - @override - String getLogoImageUrl( - String itemId, { - int? maxWidth, - String? tag, - }) { - final query = _buildQuery({ - if (maxWidth != null) 'maxWidth': maxWidth.toString(), - 'tag': ?tag, - }); - return '$_baseUrl/Items/$itemId/Images/Logo$query'; - } - - @override - String getBannerImageUrl( - String itemId, { - int? maxWidth, - String? tag, - }) { - final query = _buildQuery({ - if (maxWidth != null) 'maxWidth': maxWidth.toString(), - 'tag': ?tag, - }); - return '$_baseUrl/Items/$itemId/Images/Banner$query'; - } - - @override - String getThumbImageUrl( - String itemId, { - int? maxWidth, - String? tag, - }) { - final query = _buildQuery({ - if (maxWidth != null) 'maxWidth': maxWidth.toString(), - 'tag': ?tag, - }); - return '$_baseUrl/Items/$itemId/Images/Thumb$query'; - } - - @override - String getChapterImageUrl( - String itemId, { - required int index, - int? maxWidth, - String? tag, - }) { - final query = _buildQuery({ - if (maxWidth != null) 'maxWidth': maxWidth.toString(), - 'tag': ?tag, - }); - return '$_baseUrl/Items/$itemId/Images/Chapter/$index$query'; - } - - @override - String getUserImageUrl(String userId) { - return '$_baseUrl/Users/$userId/Images/Primary'; - } - - @override - String getTrickplayTileImageUrl( - String itemId, { - required int width, - required int index, - String? mediaSourceId, - }) { - final query = _buildQuery({ - 'mediaSourceId': ?mediaSourceId, - }); - return '$_baseUrl/Videos/$itemId/Trickplay/$width/$index.jpg$query'; - } -} diff --git a/packages/server_jellyfin/lib/src/api/jellyfin_item_fields.dart b/packages/server_jellyfin/lib/src/api/jellyfin_item_fields.dart deleted file mode 100644 index b855ded5e..000000000 --- a/packages/server_jellyfin/lib/src/api/jellyfin_item_fields.dart +++ /dev/null @@ -1,2 +0,0 @@ -const kItemFields = - 'Trickplay,Chapters,MediaSources,MediaStreams,People,Overview,Genres,RecursiveItemCount,ChildCount,ParentLogoItemId,ParentLogoImageTag'; diff --git a/packages/server_jellyfin/lib/src/api/jellyfin_live_tv_api.dart b/packages/server_jellyfin/lib/src/api/jellyfin_live_tv_api.dart deleted file mode 100644 index 53a447fef..000000000 --- a/packages/server_jellyfin/lib/src/api/jellyfin_live_tv_api.dart +++ /dev/null @@ -1,150 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:server_core/server_core.dart'; - -class JellyfinLiveTvApi implements LiveTvApi { - final Dio _dio; - static const _postChannelIdsThreshold = 1800; - - JellyfinLiveTvApi(this._dio); - - @override - Future> getChannels({ - int? startIndex, - int? limit, - String? sortBy, - String? sortOrder, - String? fields, - bool? enableTotalRecordCount, - String? userId, - }) async { - final response = await _dio.get('/LiveTv/Channels', queryParameters: { - 'StartIndex': ?startIndex, - 'Limit': ?limit, - 'SortBy': ?sortBy, - 'SortOrder': ?sortOrder, - 'Fields': ?fields, - 'EnableTotalRecordCount': ?enableTotalRecordCount, - 'UserId': ?userId, - }); - return response.data as Map; - } - - @override - Future> getGuide({ - DateTime? startDate, - DateTime? endDate, - List? channelIds, - String? fields, - bool? enableTotalRecordCount, - bool? enableImages, - bool? enableUserData, - String? userId, - }) async { - final ids = - (channelIds != null && channelIds.isNotEmpty) ? channelIds : null; - final joined = ids?.join(','); - final usePost = joined != null && joined.length > _postChannelIdsThreshold; - - // EnableImages/EnableUserData are off for the guide to keep the per-program - // payload small (issue #666). Channel logos come from /LiveTv/Channels, not - // this response, so they are unaffected. - final common = { - if (startDate != null) 'MinEndDate': startDate.toUtc().toIso8601String(), - if (endDate != null) 'MaxStartDate': endDate.toUtc().toIso8601String(), - 'Fields': ?fields, - 'EnableTotalRecordCount': ?enableTotalRecordCount, - 'EnableImages': ?enableImages, - 'EnableUserData': ?enableUserData, - 'UserId': ?userId, - }; - - // POST needs ChannelIds as a JSON array (Jellyfin GetProgramsDto and remux - // GetProgramsBody both type it as a GUID list); a comma string is rejected - // by strict servers. GET uses the conventional comma form. - final Response response; - if (usePost) { - response = await _dio.post('/LiveTv/Programs', data: { - ...common, - 'ChannelIds': ids, - }); - } else { - response = await _dio.get('/LiveTv/Programs', queryParameters: { - ...common, - 'ChannelIds': ?joined, - }); - } - return response.data as Map; - } - - @override - Future> getRecommendedPrograms({int? limit}) async { - final response = await _dio.get( - '/LiveTv/Programs/Recommended', - queryParameters: {'Limit': ?limit}, - ); - return response.data as Map; - } - - @override - Future> getRecordings({ - int? limit, - String? fields, - bool? enableImages, - bool? isSeries, - bool? isMovie, - bool? isSports, - bool? isKids, - }) async { - final response = await _dio.get('/LiveTv/Recordings', queryParameters: { - 'Limit': ?limit, - 'Fields': ?fields, - 'EnableImages': ?enableImages, - 'IsSeries': ?isSeries, - 'IsMovie': ?isMovie, - 'IsSports': ?isSports, - 'IsKids': ?isKids, - }); - return response.data as Map; - } - - @override - Future> getTimers() async { - final response = await _dio.get('/LiveTv/Timers'); - return response.data as Map; - } - - @override - Future> getSeriesTimers() async { - final response = await _dio.get('/LiveTv/SeriesTimers'); - return response.data as Map; - } - - @override - Future createTimer(String programId) async { - Map payload; - try { - final defaults = await _dio.get( - '/LiveTv/Timers/Defaults', - queryParameters: {'ProgramId': programId}, - ); - payload = Map.from( - (defaults.data as Map?)?.cast() ?? - {}, - ); - payload.putIfAbsent('ProgramId', () => programId); - } catch (_) { - payload = {'ProgramId': programId}; - } - await _dio.post('/LiveTv/Timers', data: payload); - } - - @override - Future cancelTimer(String timerId) async { - await _dio.delete('/LiveTv/Timers/$timerId'); - } - - @override - Future cancelSeriesTimer(String seriesTimerId) async { - await _dio.delete('/LiveTv/SeriesTimers/$seriesTimerId'); - } -} diff --git a/packages/server_jellyfin/lib/src/api/jellyfin_playback_api.dart b/packages/server_jellyfin/lib/src/api/jellyfin_playback_api.dart deleted file mode 100644 index b8e7a2448..000000000 --- a/packages/server_jellyfin/lib/src/api/jellyfin_playback_api.dart +++ /dev/null @@ -1,137 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:server_core/server_core.dart'; - -class JellyfinPlaybackApi implements PlaybackApi { - final Dio _dio; - final String _baseUrl; - - JellyfinPlaybackApi(this._dio, this._baseUrl); - - @override - Future reportPlaybackStart(Map info) async { - await _dio.post('/Sessions/Playing', data: info); - } - - @override - Future reportPlaybackProgress(Map info) async { - await _dio.post('/Sessions/Playing/Progress', data: info); - } - - @override - Future reportPlaybackStopped(Map info) async { - await _dio.post('/Sessions/Playing/Stopped', data: info); - } - - @override - Future closeLiveStream(String liveStreamId) async { - await _dio.post( - '/LiveStreams/Close', - queryParameters: {'LiveStreamId': liveStreamId}, - ); - } - - @override - Future stopActiveEncodings({ - required String deviceId, - String? playSessionId, - }) async { - await _dio.delete( - '/Videos/ActiveEncodings', - queryParameters: { - 'deviceId': deviceId, - if (playSessionId != null && playSessionId.isNotEmpty) - 'playSessionId': playSessionId, - }, - ); - } - - @override - Future> getPlaybackInfo( - String itemId, { - Map? requestBody, - String? userId, - int? startTimeTicks, - }) async { - final body = { - 'UserId': ?userId, - ...?requestBody, - }; - - final audioStreamIndex = requestBody?['AudioStreamIndex'] ?? requestBody?['audioStreamIndex']; - final subtitleStreamIndex = requestBody?['SubtitleStreamIndex'] ?? requestBody?['subtitleStreamIndex']; - final mediaSourceId = requestBody?['MediaSourceId'] ?? requestBody?['mediaSourceId']; - final maxStreamingBitrate = requestBody?['MaxStreamingBitrate'] ?? requestBody?['maxStreamingBitrate']; - - final query = { - 'userId': ?userId, - 'startTimeTicks': ?startTimeTicks, - 'audioStreamIndex': ?audioStreamIndex?.toString(), - 'subtitleStreamIndex': ?subtitleStreamIndex?.toString(), - 'mediaSourceId': ?mediaSourceId, - 'maxStreamingBitrate': ?maxStreamingBitrate?.toString(), - }; - - try { - final response = await _dio.post( - '/Items/$itemId/PlaybackInfo', - data: body, - queryParameters: query, - ); - return response.data as Map; - } on DioException catch (e) { - // Strict servers (e.g. remux) reject our full DeviceProfile with 400/422; - // real Jellyfin/Emby return 200 and never reach here. Retry without the - // profile, then with a bare body, before giving up. - final status = e.response?.statusCode; - final isBodyRejection = status == 400 || status == 422 || status == 500; - if (!isBodyRejection) rethrow; - - final withoutProfile = Map.from(body) - ..remove('DeviceProfile'); - try { - final retry = await _dio.post( - '/Items/$itemId/PlaybackInfo', - data: withoutProfile, - queryParameters: query, - ); - return retry.data as Map; - } on DioException catch (_) { - try { - final minimal = await _dio.post( - '/Items/$itemId/PlaybackInfo', - data: { - 'UserId': ?userId, - 'AutoOpenLiveStream': true, - }, - queryParameters: query, - ); - return minimal.data as Map; - } on DioException catch (e2) { - throw Exception( - 'PlaybackInfo failed for item $itemId: ' - 'HTTP ${e2.response?.statusCode} ${e2.response?.data}', - ); - } - } - } - } - - @override - String getStreamUrl( - String itemId, { - String? mediaSourceId, - String? audioStreamIndex, - String? subtitleStreamIndex, - String? liveStreamId, - }) { - final params = { - 'MediaSourceId': ?mediaSourceId, - 'AudioStreamIndex': ?audioStreamIndex, - 'SubtitleStreamIndex': ?subtitleStreamIndex, - 'LiveStreamId': ?liveStreamId, - 'Static': 'true', - }; - final query = params.entries.map((e) => '${e.key}=${e.value}').join('&'); - return '$_baseUrl/Videos/$itemId/stream?$query'; - } -} diff --git a/packages/server_jellyfin/lib/src/api/jellyfin_user_library_api.dart b/packages/server_jellyfin/lib/src/api/jellyfin_user_library_api.dart deleted file mode 100644 index 90017a780..000000000 --- a/packages/server_jellyfin/lib/src/api/jellyfin_user_library_api.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:server_core/server_core.dart'; -import 'jellyfin_item_fields.dart'; - -class JellyfinUserLibraryApi implements UserLibraryApi { - final Dio _dio; - - JellyfinUserLibraryApi(this._dio); - - @override - Future markFavorite(String itemId) async { - await _dio.post('/UserFavoriteItems/$itemId'); - } - - @override - Future unmarkFavorite(String itemId) async { - await _dio.delete('/UserFavoriteItems/$itemId'); - } - - @override - Future markPlayed(String itemId) async { - await _dio.post('/UserPlayedItems/$itemId'); - } - - @override - Future unmarkPlayed(String itemId) async { - await _dio.delete('/UserPlayedItems/$itemId'); - } - - @override - Future updateUserRating(String itemId, {required bool likes}) async { - await _dio.post('/UserItems/$itemId/Rating', queryParameters: { - 'Likes': likes, - }); - } - - @override - Future deleteUserRating(String itemId) async { - await _dio.delete('/UserItems/$itemId/Rating'); - } - - @override - Future> getItem(String itemId) async { - final response = await _dio.get('/Items/$itemId', queryParameters: { - 'Fields': kItemFields, - }); - return response.data as Map; - } -} diff --git a/packages/server_jellyfin/lib/src/api/jellyfin_user_views_api.dart b/packages/server_jellyfin/lib/src/api/jellyfin_user_views_api.dart deleted file mode 100644 index 1dd068675..000000000 --- a/packages/server_jellyfin/lib/src/api/jellyfin_user_views_api.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:server_core/server_core.dart'; - -class JellyfinUserViewsApi implements UserViewsApi { - final Dio _dio; - - JellyfinUserViewsApi(this._dio); - - @override - Future> getUserViews() async { - final response = await _dio.get('/UserViews'); - return response.data as Map; - } -} diff --git a/packages/server_jellyfin/lib/src/api/jellyfin_users_api.dart b/packages/server_jellyfin/lib/src/api/jellyfin_users_api.dart deleted file mode 100644 index 433cfac52..000000000 --- a/packages/server_jellyfin/lib/src/api/jellyfin_users_api.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:server_core/server_core.dart'; - -class JellyfinUsersApi implements UsersApi { - final Dio _dio; - - JellyfinUsersApi(this._dio); - - @override - Future getCurrentUser() async { - final response = await _dio.get('/Users/Me'); - return ServerUser.fromJson(response.data as Map); - } - - @override - Future getUserConfiguration() async { - final response = await _dio.get('/Users/Me'); - final data = response.data as Map; - final config = data['Configuration'] as Map? ?? const {}; - return UserConfiguration.fromJson(config); - } - - @override - Future updateUserConfiguration(UserConfiguration config) async { - await _dio.post('/Users/Configuration', data: config.toJson()); - } -} diff --git a/packages/server_jellyfin/lib/src/jellyfin_media_server_client.dart b/packages/server_jellyfin/lib/src/jellyfin_media_server_client.dart index a0db568bd..8ca018608 100644 --- a/packages/server_jellyfin/lib/src/jellyfin_media_server_client.dart +++ b/packages/server_jellyfin/lib/src/jellyfin_media_server_client.dart @@ -1,18 +1,6 @@ import 'package:dio/dio.dart'; import 'package:server_core/server_core.dart'; -import 'api/jellyfin_auth_api.dart'; -import 'api/jellyfin_items_api.dart'; -import 'api/jellyfin_playback_api.dart'; -import 'api/jellyfin_image_api.dart'; -import 'api/jellyfin_session_api.dart'; -import 'api/jellyfin_system_api.dart'; -import 'api/jellyfin_user_library_api.dart'; -import 'api/jellyfin_user_views_api.dart'; -import 'api/jellyfin_live_tv_api.dart'; -import 'api/jellyfin_instant_mix_api.dart'; -import 'api/jellyfin_display_preferences_api.dart'; -import 'api/jellyfin_users_api.dart'; import 'api/jellyfin_admin_system_api.dart'; import 'api/jellyfin_admin_users_api.dart'; import 'api/jellyfin_admin_library_api.dart'; @@ -30,6 +18,8 @@ import 'api/jellyfin_syncplay_api.dart'; class JellyfinMediaServerClient extends MediaServerClient { final Dio _dio; + static const _dialect = ServerDialect.jellyfin; + @override final DeviceInfo deviceInfo; @@ -44,44 +34,18 @@ class JellyfinMediaServerClient extends MediaServerClient { )) { _baseUrl = baseUrl; configureServerDio(_dio); - _setupInterceptors(); + attachServerInterceptors( + _dio, + deviceInfo: deviceInfo, + authScheme: _dialect.authScheme, + getAccessToken: () => _accessToken, + ); } late String _baseUrl; String? _accessToken; String? _userId; - void _setupInterceptors() { - _dio.interceptors.add(redirectInterceptor(_dio)); - _dio.interceptors.add(InterceptorsWrapper( - onRequest: (options, handler) { - options.headers['Authorization'] = buildServerAuthorizationHeader( - scheme: 'MediaBrowser', - deviceInfo: deviceInfo, - accessToken: _accessToken, - ); - ServerLog.network('→ ${options.method} ${options.uri}'); - handler.next(options); - }, - onResponse: (response, handler) { - ServerLog.network( - '← ${response.statusCode} ${response.requestOptions.method} ' - '${response.requestOptions.uri}', - ); - handler.next(response); - }, - onError: (error, handler) { - ServerLog.network( - '✗ ${error.requestOptions.method} ${error.requestOptions.uri} ' - '(${error.response?.statusCode ?? error.type.name})', - level: ServerLogLevel.error, - error: error.message ?? error.toString(), - ); - handler.next(error); - }, - )); - } - @override ServerType get serverType => ServerType.jellyfin; @@ -113,41 +77,44 @@ class JellyfinMediaServerClient extends MediaServerClient { } @override - late final AuthApi authApi = JellyfinAuthApi(_dio); + late final AuthApi authApi = ServerAuthApi(_dio, _dialect); @override - late final ItemsApi itemsApi = JellyfinItemsApi(_dio, _requireUserId); + late final ItemsApi itemsApi = ServerItemsApi(_dio, _dialect, _requireUserId); @override - late final PlaybackApi playbackApi = JellyfinPlaybackApi(_dio, _baseUrl); + late final PlaybackApi playbackApi = ServerPlaybackApi(_dio, () => _baseUrl); @override - late final ImageApi imageApi = JellyfinImageApi(_baseUrl); + late final ImageApi imageApi = + ServerImageApi(() => _baseUrl, () => _accessToken, _dialect); @override - late final SessionApi sessionApi = JellyfinSessionApi(_dio); + late final SessionApi sessionApi = ServerSessionApi(_dio, _dialect); @override - late final SystemApi systemApi = JellyfinSystemApi(_dio); + late final SystemApi systemApi = ServerSystemApi(_dio); @override - late final UserLibraryApi userLibraryApi = JellyfinUserLibraryApi(_dio); + late final UserLibraryApi userLibraryApi = + ServerUserLibraryApi(_dio, _dialect, _requireUserId); @override - late final UserViewsApi userViewsApi = JellyfinUserViewsApi(_dio); + late final UserViewsApi userViewsApi = + ServerUserViewsApi(_dio, _dialect, _requireUserId); @override - late final LiveTvApi liveTvApi = JellyfinLiveTvApi(_dio); + late final LiveTvApi liveTvApi = ServerLiveTvApi(_dio); @override - late final InstantMixApi instantMixApi = JellyfinInstantMixApi(_dio); + late final InstantMixApi instantMixApi = ServerInstantMixApi(_dio); @override late final DisplayPreferencesApi displayPreferencesApi = - JellyfinDisplayPreferencesApi(_dio); + ServerDisplayPreferencesApi(_dio, _dialect); @override - late final UsersApi usersApi = JellyfinUsersApi(_dio); + late final UsersApi usersApi = ServerUsersApi(_dio, _dialect, _requireUserId); @override late final AdminSystemApi adminSystemApi = JellyfinAdminSystemApi(_dio);