Initial commit

This commit is contained in:
admin1
2026-07-14 11:11:36 +08:00
commit 656499cf94
604 changed files with 119518 additions and 0 deletions
+420
View File
@@ -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();
}
}
+363
View File
@@ -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);
}
}
}