Initial commit
This commit is contained in:
@@ -0,0 +1,420 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../shared/utils/app_logger.dart';
|
||||
import '../../contracts/tmdb.dart';
|
||||
import '../../domain/ports/tmdb_gateway.dart';
|
||||
import 'tmdb_rest_api.dart';
|
||||
|
||||
class TmdbApiClient implements TmdbGateway {
|
||||
static const _tag = 'Tmdb';
|
||||
|
||||
TmdbApiClient({Dio? dio}) : _api = _buildApi(dio);
|
||||
|
||||
static TmdbRestApi _buildApi(Dio? injected) {
|
||||
final dio =
|
||||
injected ??
|
||||
Dio(
|
||||
BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 8),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
headers: {'Accept': 'application/json'},
|
||||
),
|
||||
);
|
||||
dio.interceptors.add(TmdbConfigInterceptor());
|
||||
dio.interceptors.add(TmdbAuthRetryInterceptor(dio));
|
||||
return TmdbRestApi(dio);
|
||||
}
|
||||
|
||||
final TmdbRestApi _api;
|
||||
|
||||
Map<String, dynamic> _extras(TmdbRequestConfig config) => <String, dynamic>{
|
||||
kTmdbConfigExtraKey: config,
|
||||
};
|
||||
|
||||
|
||||
Map<String, dynamic> _extrasWithLang(
|
||||
TmdbRequestConfig config,
|
||||
String language,
|
||||
) => <String, dynamic>{
|
||||
kTmdbConfigExtraKey: config,
|
||||
kTmdbAcceptLanguageExtraKey: language,
|
||||
};
|
||||
|
||||
@override
|
||||
Future<TmdbCreditsRes?> getCredits(
|
||||
TmdbRequestConfig config,
|
||||
String tmdbId,
|
||||
String mediaType, {
|
||||
required String language,
|
||||
}) async {
|
||||
final type = _normalizeMediaType(mediaType);
|
||||
if (tmdbId.isEmpty || type == null) return null;
|
||||
try {
|
||||
return await _api.getCredits(type, tmdbId, language, _extras(config));
|
||||
} catch (e) {
|
||||
AppLogger.warn(_tag, 'GET /$type/$tmdbId/credits failed', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TmdbRecommendationsRes?> getRecommendations(
|
||||
TmdbRequestConfig config,
|
||||
String tmdbId,
|
||||
String mediaType, {
|
||||
required String language,
|
||||
}) async {
|
||||
final type = _normalizeMediaType(mediaType);
|
||||
if (tmdbId.isEmpty || type == null) return null;
|
||||
try {
|
||||
return await _api.getRecommendations(
|
||||
type,
|
||||
tmdbId,
|
||||
language,
|
||||
'1',
|
||||
_extras(config),
|
||||
);
|
||||
} catch (e) {
|
||||
AppLogger.warn(_tag, 'GET /$type/$tmdbId/recommendations failed', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TmdbSeasonDetail?> getSeasonDetail(
|
||||
TmdbRequestConfig config,
|
||||
String tmdbId,
|
||||
int seasonNumber, {
|
||||
required String language,
|
||||
}) async {
|
||||
if (tmdbId.isEmpty || seasonNumber < 0) return null;
|
||||
try {
|
||||
return await _api.getSeasonDetail(
|
||||
tmdbId,
|
||||
seasonNumber,
|
||||
language,
|
||||
_extras(config),
|
||||
);
|
||||
} catch (e) {
|
||||
AppLogger.warn(_tag, 'GET /tv/$tmdbId/season/$seasonNumber failed', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TmdbImageSet?> getImages(
|
||||
TmdbRequestConfig config,
|
||||
String tmdbId,
|
||||
String mediaType, {
|
||||
required String language,
|
||||
}) async {
|
||||
final type = _normalizeMediaType(mediaType);
|
||||
if (tmdbId.isEmpty || type == null) return null;
|
||||
try {
|
||||
return await _api.getImages(
|
||||
type,
|
||||
tmdbId,
|
||||
_includeImageLanguage(language),
|
||||
_extrasWithLang(config, language),
|
||||
);
|
||||
} catch (e) {
|
||||
AppLogger.warn(_tag, 'GET /$type/$tmdbId/images failed', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TmdbEnrichedDetail?> getEnrichedDetail(
|
||||
TmdbRequestConfig config,
|
||||
String tmdbId,
|
||||
String mediaType, {
|
||||
required String language,
|
||||
List<String> appendToResponse = const [
|
||||
'videos',
|
||||
'credits',
|
||||
'recommendations',
|
||||
'images',
|
||||
'external_ids',
|
||||
],
|
||||
}) async {
|
||||
final type = _normalizeMediaType(mediaType);
|
||||
if (tmdbId.isEmpty || type == null) return null;
|
||||
|
||||
final TmdbEnrichedDetail detail;
|
||||
try {
|
||||
detail = await _api.getEnrichedDetail(
|
||||
type,
|
||||
tmdbId,
|
||||
language,
|
||||
appendToResponse.join(','),
|
||||
appendToResponse.contains('images')
|
||||
? _includeImageLanguage(language)
|
||||
: null,
|
||||
_extras(config),
|
||||
);
|
||||
} catch (e) {
|
||||
AppLogger.warn(_tag, 'GET /$type/$tmdbId failed', e);
|
||||
return null;
|
||||
}
|
||||
|
||||
final shouldFallbackVideos =
|
||||
appendToResponse.contains('videos') &&
|
||||
!_isEnglishLanguage(language) &&
|
||||
(detail.videos?.results.isEmpty ?? true);
|
||||
if (!shouldFallbackVideos) return detail;
|
||||
|
||||
final fallbackVideos = await getVideos(
|
||||
config,
|
||||
tmdbId,
|
||||
type,
|
||||
language: 'en-US',
|
||||
);
|
||||
if (fallbackVideos == null || fallbackVideos.results.isEmpty) {
|
||||
return detail;
|
||||
}
|
||||
return detail.copyWith(videos: fallbackVideos);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TmdbVideosRes?> getVideos(
|
||||
TmdbRequestConfig config,
|
||||
String tmdbId,
|
||||
String mediaType, {
|
||||
required String language,
|
||||
}) async {
|
||||
final type = _normalizeMediaType(mediaType);
|
||||
if (tmdbId.isEmpty || type == null) return null;
|
||||
try {
|
||||
return await _api.getVideos(type, tmdbId, language, _extras(config));
|
||||
} catch (e) {
|
||||
AppLogger.warn(_tag, 'GET /$type/$tmdbId/videos failed', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TmdbPersonDetail?> getPersonDetail(
|
||||
TmdbRequestConfig config,
|
||||
int personId, {
|
||||
required String language,
|
||||
}) async {
|
||||
if (personId <= 0) return null;
|
||||
try {
|
||||
return await _api.getPersonDetail(personId, language, _extras(config));
|
||||
} catch (e) {
|
||||
AppLogger.warn(_tag, 'GET /person/$personId failed', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TmdbPersonCreditsRes?> getPersonCredits(
|
||||
TmdbRequestConfig config,
|
||||
int personId, {
|
||||
required String language,
|
||||
}) async {
|
||||
if (personId <= 0) return null;
|
||||
try {
|
||||
return await _api.getPersonCredits(personId, language, _extras(config));
|
||||
} catch (e) {
|
||||
AppLogger.warn(_tag, 'GET /person/$personId/combined_credits failed', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TmdbMediaDetail?> getMediaDetail(
|
||||
TmdbRequestConfig config,
|
||||
String tmdbId,
|
||||
String mediaType, {
|
||||
required String language,
|
||||
}) async {
|
||||
final type = _normalizeMediaType(mediaType);
|
||||
if (tmdbId.isEmpty || type == null) return null;
|
||||
try {
|
||||
return await _api.getMediaDetail(type, tmdbId, language, _extras(config));
|
||||
} catch (e) {
|
||||
AppLogger.warn(_tag, 'GET /$type/$tmdbId failed', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TmdbRecommendationsRes?> getTrending(
|
||||
TmdbRequestConfig config, {
|
||||
required String language,
|
||||
String mediaType = 'all',
|
||||
String timeWindow = 'day',
|
||||
}) async {
|
||||
try {
|
||||
return await _api.getTrending(
|
||||
mediaType,
|
||||
timeWindow,
|
||||
language,
|
||||
'1',
|
||||
_extras(config),
|
||||
);
|
||||
} catch (e) {
|
||||
AppLogger.warn(_tag, 'GET /trending/$mediaType/$timeWindow failed', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TmdbRecommendationsRes?> getPopularMovies(
|
||||
TmdbRequestConfig config, {
|
||||
required String language,
|
||||
}) async {
|
||||
try {
|
||||
return await _api.getPopularMovies(language, '1', _extras(config));
|
||||
} catch (e) {
|
||||
AppLogger.warn(_tag, 'GET /movie/popular failed', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TmdbRecommendationsRes?> getPopularTv(
|
||||
TmdbRequestConfig config, {
|
||||
required String language,
|
||||
}) async {
|
||||
try {
|
||||
return await _api.getPopularTv(language, '1', _extras(config));
|
||||
} catch (e) {
|
||||
AppLogger.warn(_tag, 'GET /tv/popular failed', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TmdbRecommendationsRes?> getAiringTodayTv(
|
||||
TmdbRequestConfig config, {
|
||||
required String language,
|
||||
}) async {
|
||||
try {
|
||||
return await _api.getAiringTodayTv(language, '1', _extras(config));
|
||||
} catch (e) {
|
||||
AppLogger.warn(_tag, 'GET /tv/airing_today failed', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TmdbRecommendationsRes?> getTopRatedMovies(
|
||||
TmdbRequestConfig config, {
|
||||
required String language,
|
||||
}) async {
|
||||
try {
|
||||
return await _api.getTopRatedMovies(language, '1', _extras(config));
|
||||
} catch (e) {
|
||||
AppLogger.warn(_tag, 'GET /movie/top_rated failed', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TmdbRecommendationsRes?> getDiscover(
|
||||
TmdbRequestConfig config,
|
||||
String mediaType, {
|
||||
required String language,
|
||||
String sortBy = 'popularity.desc',
|
||||
String? withGenres,
|
||||
String? voteCountGte,
|
||||
}) async {
|
||||
final type = _normalizeMediaType(mediaType);
|
||||
if (type == null) return null;
|
||||
try {
|
||||
return await _api.getDiscover(
|
||||
type,
|
||||
language,
|
||||
'1',
|
||||
sortBy,
|
||||
withGenres,
|
||||
voteCountGte,
|
||||
_extras(config),
|
||||
);
|
||||
} catch (e) {
|
||||
AppLogger.warn(_tag, 'GET /discover/$type failed', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> testConnection(
|
||||
TmdbRequestConfig config, {
|
||||
required String language,
|
||||
}) async {
|
||||
final accessToken = config.accessToken.trim();
|
||||
final apiKey = config.apiKey.trim();
|
||||
final authMode = accessToken.isNotEmpty
|
||||
? 'bearer(${_maskSecret(accessToken)})'
|
||||
: apiKey.isNotEmpty
|
||||
? 'apiKey(${_maskSecret(apiKey)})'
|
||||
: 'none';
|
||||
AppLogger.info(
|
||||
_tag,
|
||||
'testConnection → GET ${config.apiBaseUrl}/configuration '
|
||||
'auth=$authMode lang=$language',
|
||||
);
|
||||
try {
|
||||
final res = await _api.getConfiguration(
|
||||
_extrasWithLang(config, language),
|
||||
);
|
||||
final data = res.data;
|
||||
final ok =
|
||||
data is Map && (data['images'] is Map || data['change_keys'] is List);
|
||||
AppLogger.info(
|
||||
_tag,
|
||||
'testConnection ← ${res.response.statusCode} '
|
||||
'ok=$ok (body=${data.runtimeType})',
|
||||
);
|
||||
return ok;
|
||||
} on DioException catch (e) {
|
||||
final status = e.response?.statusCode;
|
||||
final hint = switch (status) {
|
||||
401 => '认证失败:token/api_key 被拒绝,检查认证方式(Bearer/ApiKey)是否与凭据类型匹配',
|
||||
403 => '权限不足:凭据有效但无访问权限',
|
||||
404 => 'baseUrl 路径错误:检查 ${config.apiBaseUrl}',
|
||||
null => '网络层失败(${e.type.name}):检查 baseUrl 可达性/代理',
|
||||
_ => '',
|
||||
};
|
||||
AppLogger.warn(
|
||||
_tag,
|
||||
'testConnection ← HTTP $status auth=$authMode '
|
||||
'url=${config.apiBaseUrl}/configuration'
|
||||
'${hint.isEmpty ? '' : ' — $hint'}',
|
||||
e,
|
||||
);
|
||||
return false;
|
||||
} catch (e) {
|
||||
AppLogger.warn(_tag, 'testConnection ← 非 Dio 异常 auth=$authMode', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static String _maskSecret(String secret) {
|
||||
if (secret.length <= 4) return '****';
|
||||
return 'len=${secret.length},…${secret.substring(secret.length - 4)}';
|
||||
}
|
||||
|
||||
static String? _normalizeMediaType(String mediaType) {
|
||||
return switch (mediaType) {
|
||||
'movie' || 'tv' => mediaType,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
static String _includeImageLanguage(String language) {
|
||||
final imageLanguages = <String>[_languageCode(language), 'en', 'null'];
|
||||
return imageLanguages.where((v) => v.isNotEmpty).toSet().join(',');
|
||||
}
|
||||
|
||||
static bool _isEnglishLanguage(String language) {
|
||||
return _languageCode(language) == 'en';
|
||||
}
|
||||
|
||||
static String _languageCode(String language) {
|
||||
return language.trim().split(RegExp('[-_]')).first.toLowerCase();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../contracts/tmdb.dart';
|
||||
import '../../domain/ports/tmdb_gateway.dart';
|
||||
|
||||
|
||||
const String kTmdbConfigExtraKey = 'tmdbConfig';
|
||||
|
||||
|
||||
const String kTmdbAcceptLanguageExtraKey = 'tmdbAcceptLanguage';
|
||||
|
||||
|
||||
const String kTmdbOverrideAccessTokenKey = 'tmdbOverrideAccessToken';
|
||||
|
||||
|
||||
const String kTmdbAuthRetriedKey = 'tmdbAuthRetried';
|
||||
|
||||
|
||||
class TmdbRestApi {
|
||||
TmdbRestApi(this._dio);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
Future<TmdbCreditsRes> getCredits(
|
||||
String mediaType,
|
||||
String tmdbId,
|
||||
String language,
|
||||
Map<String, dynamic> extras,
|
||||
) {
|
||||
return _getJson(
|
||||
'/$mediaType/$tmdbId/credits',
|
||||
queryParameters: {'language': language},
|
||||
extras: extras,
|
||||
fromJson: TmdbCreditsRes.fromJson,
|
||||
);
|
||||
}
|
||||
|
||||
Future<TmdbRecommendationsRes> getRecommendations(
|
||||
String mediaType,
|
||||
String tmdbId,
|
||||
String language,
|
||||
String page,
|
||||
Map<String, dynamic> extras,
|
||||
) {
|
||||
return _getJson(
|
||||
'/$mediaType/$tmdbId/recommendations',
|
||||
queryParameters: {'language': language, 'page': page},
|
||||
extras: extras,
|
||||
fromJson: TmdbRecommendationsRes.fromJson,
|
||||
);
|
||||
}
|
||||
|
||||
Future<TmdbSeasonDetail> getSeasonDetail(
|
||||
String tmdbId,
|
||||
int seasonNumber,
|
||||
String language,
|
||||
Map<String, dynamic> extras,
|
||||
) {
|
||||
return _getJson(
|
||||
'/tv/$tmdbId/season/$seasonNumber',
|
||||
queryParameters: {'language': language},
|
||||
extras: extras,
|
||||
fromJson: TmdbSeasonDetail.fromJson,
|
||||
);
|
||||
}
|
||||
|
||||
Future<TmdbImageSet> getImages(
|
||||
String mediaType,
|
||||
String tmdbId,
|
||||
String includeImageLanguage,
|
||||
Map<String, dynamic> extras,
|
||||
) {
|
||||
return _getJson(
|
||||
'/$mediaType/$tmdbId/images',
|
||||
queryParameters: {'include_image_language': includeImageLanguage},
|
||||
extras: extras,
|
||||
fromJson: TmdbImageSet.fromJson,
|
||||
);
|
||||
}
|
||||
|
||||
Future<TmdbEnrichedDetail> getEnrichedDetail(
|
||||
String mediaType,
|
||||
String tmdbId,
|
||||
String language,
|
||||
String appendToResponse,
|
||||
String? includeImageLanguage,
|
||||
Map<String, dynamic> extras,
|
||||
) {
|
||||
return _getJson(
|
||||
'/$mediaType/$tmdbId',
|
||||
queryParameters: {
|
||||
'language': language,
|
||||
'append_to_response': appendToResponse,
|
||||
'include_image_language': includeImageLanguage,
|
||||
},
|
||||
extras: extras,
|
||||
fromJson: TmdbEnrichedDetail.fromJson,
|
||||
);
|
||||
}
|
||||
|
||||
Future<TmdbMediaDetail> getMediaDetail(
|
||||
String mediaType,
|
||||
String tmdbId,
|
||||
String language,
|
||||
Map<String, dynamic> extras,
|
||||
) {
|
||||
return _getJson(
|
||||
'/$mediaType/$tmdbId',
|
||||
queryParameters: {'language': language},
|
||||
extras: extras,
|
||||
fromJson: TmdbMediaDetail.fromJson,
|
||||
);
|
||||
}
|
||||
|
||||
Future<TmdbVideosRes> getVideos(
|
||||
String mediaType,
|
||||
String tmdbId,
|
||||
String language,
|
||||
Map<String, dynamic> extras,
|
||||
) {
|
||||
return _getJson(
|
||||
'/$mediaType/$tmdbId/videos',
|
||||
queryParameters: {'language': language},
|
||||
extras: extras,
|
||||
fromJson: TmdbVideosRes.fromJson,
|
||||
);
|
||||
}
|
||||
|
||||
Future<TmdbPersonDetail> getPersonDetail(
|
||||
int personId,
|
||||
String language,
|
||||
Map<String, dynamic> extras,
|
||||
) {
|
||||
return _getJson(
|
||||
'/person/$personId',
|
||||
queryParameters: {'language': language},
|
||||
extras: extras,
|
||||
fromJson: TmdbPersonDetail.fromJson,
|
||||
);
|
||||
}
|
||||
|
||||
Future<TmdbPersonCreditsRes> getPersonCredits(
|
||||
int personId,
|
||||
String language,
|
||||
Map<String, dynamic> extras,
|
||||
) {
|
||||
return _getJson(
|
||||
'/person/$personId/combined_credits',
|
||||
queryParameters: {'language': language},
|
||||
extras: extras,
|
||||
fromJson: TmdbPersonCreditsRes.fromJson,
|
||||
);
|
||||
}
|
||||
|
||||
Future<TmdbRecommendationsRes> getTrending(
|
||||
String mediaType,
|
||||
String timeWindow,
|
||||
String language,
|
||||
String page,
|
||||
Map<String, dynamic> extras,
|
||||
) {
|
||||
return _getJson(
|
||||
'/trending/$mediaType/$timeWindow',
|
||||
queryParameters: {'language': language, 'page': page},
|
||||
extras: extras,
|
||||
fromJson: TmdbRecommendationsRes.fromJson,
|
||||
);
|
||||
}
|
||||
|
||||
Future<TmdbRecommendationsRes> getPopularMovies(
|
||||
String language,
|
||||
String page,
|
||||
Map<String, dynamic> extras,
|
||||
) {
|
||||
return _getJson(
|
||||
'/movie/popular',
|
||||
queryParameters: {'language': language, 'page': page},
|
||||
extras: extras,
|
||||
fromJson: TmdbRecommendationsRes.fromJson,
|
||||
);
|
||||
}
|
||||
|
||||
Future<TmdbRecommendationsRes> getPopularTv(
|
||||
String language,
|
||||
String page,
|
||||
Map<String, dynamic> extras,
|
||||
) {
|
||||
return _getJson(
|
||||
'/tv/popular',
|
||||
queryParameters: {'language': language, 'page': page},
|
||||
extras: extras,
|
||||
fromJson: TmdbRecommendationsRes.fromJson,
|
||||
);
|
||||
}
|
||||
|
||||
Future<TmdbRecommendationsRes> getAiringTodayTv(
|
||||
String language,
|
||||
String page,
|
||||
Map<String, dynamic> extras,
|
||||
) {
|
||||
return _getJson(
|
||||
'/tv/airing_today',
|
||||
queryParameters: {'language': language, 'page': page},
|
||||
extras: extras,
|
||||
fromJson: TmdbRecommendationsRes.fromJson,
|
||||
);
|
||||
}
|
||||
|
||||
Future<TmdbRecommendationsRes> getTopRatedMovies(
|
||||
String language,
|
||||
String page,
|
||||
Map<String, dynamic> extras,
|
||||
) {
|
||||
return _getJson(
|
||||
'/movie/top_rated',
|
||||
queryParameters: {'language': language, 'page': page},
|
||||
extras: extras,
|
||||
fromJson: TmdbRecommendationsRes.fromJson,
|
||||
);
|
||||
}
|
||||
|
||||
Future<TmdbRecommendationsRes> getDiscover(
|
||||
String mediaType,
|
||||
String language,
|
||||
String page,
|
||||
String sortBy,
|
||||
String? withGenres,
|
||||
String? voteCountGte,
|
||||
Map<String, dynamic> extras,
|
||||
) {
|
||||
return _getJson(
|
||||
'/discover/$mediaType',
|
||||
queryParameters: {
|
||||
'language': language,
|
||||
'page': page,
|
||||
'sort_by': sortBy,
|
||||
'with_genres': withGenres,
|
||||
'vote_count.gte': voteCountGte,
|
||||
},
|
||||
extras: extras,
|
||||
fromJson: TmdbRecommendationsRes.fromJson,
|
||||
);
|
||||
}
|
||||
|
||||
Future<TmdbRawResponse<dynamic>> getConfiguration(
|
||||
Map<String, dynamic> extras,
|
||||
) async {
|
||||
final response = await _dio.get<dynamic>(
|
||||
'/configuration',
|
||||
options: Options(extra: extras),
|
||||
);
|
||||
return TmdbRawResponse(data: response.data, response: response);
|
||||
}
|
||||
|
||||
Future<T> _getJson<T>(
|
||||
String path, {
|
||||
required Map<String, Object?> queryParameters,
|
||||
required Map<String, dynamic> extras,
|
||||
required T Function(Map<String, dynamic> json) fromJson,
|
||||
}) async {
|
||||
final response = await _dio.get<dynamic>(
|
||||
path,
|
||||
queryParameters: _withoutNullValues(queryParameters),
|
||||
options: Options(extra: extras),
|
||||
);
|
||||
final data = response.data;
|
||||
if (data is Map<String, dynamic>) return fromJson(data);
|
||||
if (data is Map) return fromJson(Map<String, dynamic>.from(data));
|
||||
throw FormatException(
|
||||
'TMDB response is not a JSON object: ${data.runtimeType}',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _withoutNullValues(Map<String, Object?> values) {
|
||||
return <String, dynamic>{
|
||||
for (final entry in values.entries)
|
||||
if (entry.value != null) entry.key: entry.value,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class TmdbRawResponse<T> {
|
||||
const TmdbRawResponse({required this.data, required this.response});
|
||||
|
||||
final T data;
|
||||
final Response<T> response;
|
||||
}
|
||||
|
||||
|
||||
class TmdbConfigInterceptor extends Interceptor {
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
final config = options.extra[kTmdbConfigExtraKey];
|
||||
if (config is! TmdbRequestConfig) {
|
||||
handler.next(options);
|
||||
return;
|
||||
}
|
||||
|
||||
options.baseUrl = config.apiBaseUrl;
|
||||
|
||||
final override = options.extra[kTmdbOverrideAccessTokenKey];
|
||||
final accessToken = (override is String && override.trim().isNotEmpty)
|
||||
? override.trim()
|
||||
: config.accessToken.trim();
|
||||
final apiKey = config.apiKey.trim();
|
||||
if (accessToken.isNotEmpty) {
|
||||
options.headers['Authorization'] = 'Bearer $accessToken';
|
||||
} else if (apiKey.isNotEmpty) {
|
||||
options.queryParameters['api_key'] = apiKey;
|
||||
}
|
||||
|
||||
final extraLang = options.extra[kTmdbAcceptLanguageExtraKey];
|
||||
final queryLang = options.queryParameters['language'];
|
||||
final lang = extraLang is String && extraLang.trim().isNotEmpty
|
||||
? extraLang.trim()
|
||||
: (queryLang is String && queryLang.trim().isNotEmpty
|
||||
? queryLang.trim()
|
||||
: null);
|
||||
if (lang != null) {
|
||||
options.headers['Accept-Language'] = '$lang,en,*';
|
||||
}
|
||||
|
||||
handler.next(options);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TmdbAuthRetryInterceptor extends Interceptor {
|
||||
TmdbAuthRetryInterceptor(this._dio);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
@override
|
||||
Future<void> onError(
|
||||
DioException err,
|
||||
ErrorInterceptorHandler handler,
|
||||
) async {
|
||||
final options = err.requestOptions;
|
||||
final config = options.extra[kTmdbConfigExtraKey];
|
||||
final refresh = config is TmdbRequestConfig ? config.onUnauthorized : null;
|
||||
final alreadyRetried = options.extra[kTmdbAuthRetriedKey] == true;
|
||||
|
||||
if (err.response?.statusCode != 401 || refresh == null || alreadyRetried) {
|
||||
handler.next(err);
|
||||
return;
|
||||
}
|
||||
|
||||
final newToken = await refresh();
|
||||
if (newToken == null || newToken.trim().isEmpty) {
|
||||
handler.next(err);
|
||||
return;
|
||||
}
|
||||
|
||||
options.extra[kTmdbOverrideAccessTokenKey] = newToken.trim();
|
||||
options.extra[kTmdbAuthRetriedKey] = true;
|
||||
try {
|
||||
final response = await _dio.fetch<dynamic>(options);
|
||||
handler.resolve(response);
|
||||
} on DioException catch (e) {
|
||||
handler.next(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user