Initial commit
This commit is contained in:
@@ -0,0 +1,706 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../shared/utils/app_logger.dart';
|
||||
import '../../../shared/utils/url_utils.dart';
|
||||
import '../../contracts/contracts.dart';
|
||||
import '../../domain/errors.dart';
|
||||
import '../../domain/ports/emby_gateway.dart';
|
||||
import 'emby_headers.dart';
|
||||
|
||||
class EmbyApiClient implements EmbyGateway {
|
||||
static const _detailFields =
|
||||
'BasicSyncInfo,RunTimeTicks,CommunityRating,ProductionYear,ChildCount,'
|
||||
'Container,Overview,OfficialRating,Genres,Status,People,Studios,'
|
||||
'ProviderIds,AlternateMediaSources,UserData,UserDataLastPlayedDate';
|
||||
static const _seasonFields =
|
||||
'BasicSyncInfo,CanDelete,Container,PrimaryImageAspectRatio,ChildCount,'
|
||||
'ProductionYear,IndexNumber,Status,EndDate,Overview,'
|
||||
'OfficialRating,Genres,CommunityRating,ProviderIds';
|
||||
static const _episodeFields =
|
||||
'BasicSyncInfo,RunTimeTicks,CommunityRating,Container,'
|
||||
'People,PrimaryImageAspectRatio,DateCreated,Genres,'
|
||||
'MediaStreams,MediaSources,UserData,UserDataLastPlayedDate,Path,ParentId,'
|
||||
'Overview,Studios,ProviderIds,AlternateMediaSources,Chapters';
|
||||
static const _similarFields =
|
||||
'BasicSyncInfo,CommunityRating,ProductionYear,ChildCount,Container,'
|
||||
'Overview,OfficialRating,Genres,People,Studios,ProviderIds';
|
||||
|
||||
final Dio _dio;
|
||||
final String _deviceId;
|
||||
final String _deviceName;
|
||||
|
||||
EmbyApiClient({
|
||||
Dio? dio,
|
||||
required this._deviceId,
|
||||
this._deviceName = 'iPad',
|
||||
}) : _dio = dio ?? _createDio();
|
||||
|
||||
static Dio _createDio() => Dio(
|
||||
BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(seconds: 30),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
Future<dynamic> _get(
|
||||
String url, {
|
||||
String? token,
|
||||
String? userId,
|
||||
Map<String, dynamic>? query,
|
||||
CancellationToken? cancelToken,
|
||||
}) async {
|
||||
return _request(
|
||||
method: 'GET',
|
||||
url: url,
|
||||
token: token,
|
||||
userId: userId,
|
||||
query: query,
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
}
|
||||
|
||||
Future<dynamic> _post(
|
||||
String url, {
|
||||
String? token,
|
||||
String? userId,
|
||||
Map<String, dynamic>? query,
|
||||
Object? body,
|
||||
CancellationToken? cancelToken,
|
||||
bool quiet = false,
|
||||
}) async {
|
||||
return _request(
|
||||
method: 'POST',
|
||||
url: url,
|
||||
token: token,
|
||||
userId: userId,
|
||||
query: query,
|
||||
body: body,
|
||||
cancelToken: cancelToken,
|
||||
quiet: quiet,
|
||||
);
|
||||
}
|
||||
|
||||
Future<dynamic> _delete(
|
||||
String url, {
|
||||
String? token,
|
||||
String? userId,
|
||||
Map<String, dynamic>? query,
|
||||
CancellationToken? cancelToken,
|
||||
}) async {
|
||||
return _request(
|
||||
method: 'DELETE',
|
||||
url: url,
|
||||
token: token,
|
||||
userId: userId,
|
||||
query: query,
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
}
|
||||
|
||||
static const _tag = 'EmbyApi';
|
||||
|
||||
static String _extractPath(String url) {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null) return url;
|
||||
return uri.path;
|
||||
}
|
||||
|
||||
Future<dynamic> _request({
|
||||
required String method,
|
||||
required String url,
|
||||
String? token,
|
||||
String? userId,
|
||||
Map<String, dynamic>? query,
|
||||
Object? body,
|
||||
CancellationToken? cancelToken,
|
||||
bool quiet = false,
|
||||
}) async {
|
||||
final path = _extractPath(url);
|
||||
final queryLog = query != null && query.isNotEmpty
|
||||
? ' query=${query.keys.join(',')}'
|
||||
: '';
|
||||
if (!quiet) {
|
||||
AppLogger.debug(_tag, '--> $method $path$queryLog');
|
||||
}
|
||||
final stopwatch = Stopwatch()..start();
|
||||
|
||||
final headers = EmbyRequestHeaders.json(
|
||||
deviceId: _deviceId,
|
||||
deviceName: _deviceName,
|
||||
token: token,
|
||||
userId: userId,
|
||||
hasBody: body != null,
|
||||
);
|
||||
|
||||
CancelToken? dioCancelToken;
|
||||
if (cancelToken != null) {
|
||||
dioCancelToken = CancelToken();
|
||||
cancelToken.whenCancelled.then((_) {
|
||||
if (!dioCancelToken!.isCancelled) {
|
||||
dioCancelToken.cancel(cancelToken.reason ?? 'cancelled');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await _dio.request<dynamic>(
|
||||
url,
|
||||
queryParameters: query,
|
||||
data: body,
|
||||
cancelToken: dioCancelToken,
|
||||
options: Options(
|
||||
method: method,
|
||||
headers: headers,
|
||||
responseType: ResponseType.json,
|
||||
validateStatus: (s) => true,
|
||||
),
|
||||
);
|
||||
|
||||
stopwatch.stop();
|
||||
final status = response.statusCode ?? 0;
|
||||
final ms = stopwatch.elapsedMilliseconds;
|
||||
if (!quiet) {
|
||||
AppLogger.debug(_tag, '<-- $status $method $path (${ms}ms)');
|
||||
}
|
||||
|
||||
if (status == 401) {
|
||||
throw DomainError(
|
||||
ErrorCode.authInvalidCredentials,
|
||||
'用户名或密码错误',
|
||||
details: {'status': status},
|
||||
);
|
||||
}
|
||||
if (status == 403) {
|
||||
throw DomainError(
|
||||
ErrorCode.authForbidden,
|
||||
'权限不足',
|
||||
details: {'status': status},
|
||||
);
|
||||
}
|
||||
if (status >= 400) {
|
||||
AppLogger.warn(_tag, '<-- $status $method $path (${ms}ms)');
|
||||
throw DomainError(
|
||||
ErrorCode.serverHttpError,
|
||||
'HTTP 请求失败($status)',
|
||||
retryable: status >= 500,
|
||||
details: {'status': status},
|
||||
);
|
||||
}
|
||||
return response.data;
|
||||
} on DomainError {
|
||||
rethrow;
|
||||
} on DioException catch (e) {
|
||||
stopwatch.stop();
|
||||
final ms = stopwatch.elapsedMilliseconds;
|
||||
if (CancelToken.isCancel(e)) {
|
||||
AppLogger.debug(_tag, '<-- CANCELLED $method $path (${ms}ms)');
|
||||
throw CancelledException(e.message);
|
||||
}
|
||||
if (e.type == DioExceptionType.connectionTimeout ||
|
||||
e.type == DioExceptionType.receiveTimeout ||
|
||||
e.type == DioExceptionType.sendTimeout) {
|
||||
AppLogger.warn(_tag, '<-- TIMEOUT $method $path (${ms}ms)');
|
||||
throw DomainError(
|
||||
ErrorCode.serverTimeout,
|
||||
'请求超时',
|
||||
retryable: true,
|
||||
details: {'message': e.message},
|
||||
);
|
||||
}
|
||||
AppLogger.warn(_tag, '<-- NETWORK_ERROR $method $path (${ms}ms)', e);
|
||||
throw DomainError(
|
||||
ErrorCode.serverUnreachable,
|
||||
'无法连接服务器',
|
||||
retryable: true,
|
||||
details: {'message': e.message},
|
||||
);
|
||||
} catch (e) {
|
||||
stopwatch.stop();
|
||||
AppLogger.warn(
|
||||
_tag,
|
||||
'<-- ERROR $method $path (${stopwatch.elapsedMilliseconds}ms)',
|
||||
e,
|
||||
);
|
||||
throw DomainError(
|
||||
ErrorCode.internalError,
|
||||
'内部错误:$e',
|
||||
details: {'message': e.toString()},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProbePublicInfoRes> probePublicInfo(String baseUrl) async {
|
||||
final url = '${stripTrailingSlash(baseUrl)}/System/Info/Public';
|
||||
final json = await _get(url);
|
||||
return ProbePublicInfoRes(
|
||||
serverName: (json['ServerName'] ?? '') as String,
|
||||
version: (json['Version'] ?? '') as String,
|
||||
productName: (json['ProductName'] ?? 'Emby Server') as String,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<EmbyAuthResult> authenticateByName({
|
||||
required String baseUrl,
|
||||
required String username,
|
||||
required String password,
|
||||
}) async {
|
||||
final url = '${stripTrailingSlash(baseUrl)}/Users/AuthenticateByName';
|
||||
final body = {'Username': username, 'Pw': password, 'Password': password};
|
||||
final json = await _post(url, body: body);
|
||||
final data = json as Map<String, dynamic>;
|
||||
final user = data['User'] as Map<String, dynamic>?;
|
||||
if (user == null) {
|
||||
throw DomainError(ErrorCode.authInvalidCredentials, '认证响应格式错误');
|
||||
}
|
||||
return EmbyAuthResult(
|
||||
accessToken: data['AccessToken'] as String,
|
||||
userId: user['Id'] as String,
|
||||
userName: user['Name'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> changePassword({
|
||||
required AuthedRequestContext ctx,
|
||||
required String currentPassword,
|
||||
required String newPassword,
|
||||
}) async {
|
||||
final url =
|
||||
'${stripTrailingSlash(ctx.baseUrl)}/Users/${ctx.userId}/Password';
|
||||
await _post(
|
||||
url,
|
||||
token: ctx.token,
|
||||
userId: ctx.userId,
|
||||
body: {'CurrentPw': currentPassword, 'NewPw': newPassword},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<EmbyRawLibraries> getUserViews(AuthedRequestContext ctx) async {
|
||||
final url = '${stripTrailingSlash(ctx.baseUrl)}/Users/${ctx.userId}/Views';
|
||||
final json = await _get(url, token: ctx.token, userId: ctx.userId);
|
||||
return EmbyRawLibraries.fromJson(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<LibraryLatestRes> getLatestItems(
|
||||
AuthedRequestContext ctx,
|
||||
String parentId, {
|
||||
int? limit,
|
||||
CancellationToken? cancelToken,
|
||||
}) async {
|
||||
final url = '${stripTrailingSlash(ctx.baseUrl)}/Users/${ctx.userId}/Items';
|
||||
final json = await _get(
|
||||
url,
|
||||
token: ctx.token,
|
||||
userId: ctx.userId,
|
||||
query: {
|
||||
'ParentId': parentId,
|
||||
'Fields':
|
||||
'BasicSyncInfo,CollectionType,PrimaryImageAspectRatio,UserData,CommunityRating,ProviderIds,ProductionYear,ChildCount,Container,CanDelete',
|
||||
'Recursive': 'true',
|
||||
'IncludeItemTypes': 'Series,Movie,Video,MusicVideo,MusicAlbum',
|
||||
'Limit': '${limit ?? 30}',
|
||||
'EnableImageTypes': 'Primary,Backdrop,Thumb',
|
||||
'SortBy': 'DateLastContentAdded,DateCreated,SortName',
|
||||
'SortOrder': 'Descending',
|
||||
'StartIndex': '0',
|
||||
},
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
final totalCount = (json['TotalRecordCount'] as num?)?.toInt() ?? 0;
|
||||
return LibraryLatestRes(
|
||||
parentId: parentId,
|
||||
items: _parseItems(json),
|
||||
totalCount: totalCount,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<LibraryResumeRes> getResumeItems(
|
||||
AuthedRequestContext ctx, {
|
||||
int? limit,
|
||||
}) async {
|
||||
final url =
|
||||
'${stripTrailingSlash(ctx.baseUrl)}/Users/${ctx.userId}/Items/Resume';
|
||||
final json = await _get(
|
||||
url,
|
||||
token: ctx.token,
|
||||
userId: ctx.userId,
|
||||
query: {
|
||||
'Fields':
|
||||
'BasicSyncInfo,CanDelete,Container,PrimaryImageAspectRatio,ProductionYear,ProviderIds,UserDataLastPlayedDate',
|
||||
'Recursive': 'true',
|
||||
'MediaTypes': 'Video',
|
||||
'Limit': '${limit ?? 20}',
|
||||
'ImageTypeLimit': '1',
|
||||
'EnableImageTypes': 'Primary,Backdrop,Thumb',
|
||||
},
|
||||
);
|
||||
return LibraryResumeRes(Items: _parseItems(json));
|
||||
}
|
||||
|
||||
|
||||
static List<EmbyRawItem> _parseItems(dynamic json) {
|
||||
final items = json is List<dynamic>
|
||||
? json
|
||||
: (json['Items'] as List<dynamic>?) ?? <dynamic>[];
|
||||
return items
|
||||
.map((e) => EmbyRawItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<EmbyRawItem> getItemDetail(
|
||||
AuthedRequestContext ctx,
|
||||
String itemId,
|
||||
) async {
|
||||
final url =
|
||||
'${stripTrailingSlash(ctx.baseUrl)}/Users/${ctx.userId}/Items/$itemId';
|
||||
final json = await _get(
|
||||
url,
|
||||
token: ctx.token,
|
||||
userId: ctx.userId,
|
||||
query: {
|
||||
'Fields': _detailFields,
|
||||
'EnableImageTypes': 'Primary,Backdrop,Thumb,Logo',
|
||||
},
|
||||
);
|
||||
return EmbyRawItem.fromJson(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> reportPlaybackStarted({
|
||||
required AuthedRequestContext ctx,
|
||||
required PlaybackReportPayload payload,
|
||||
}) async {
|
||||
final url = '${stripTrailingSlash(ctx.baseUrl)}/Sessions/Playing';
|
||||
await _post(
|
||||
url,
|
||||
token: ctx.token,
|
||||
userId: ctx.userId,
|
||||
body: payload.toJson(includeNowPlayingQueue: true),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> reportPlaybackProgress({
|
||||
required AuthedRequestContext ctx,
|
||||
required PlaybackReportPayload payload,
|
||||
}) async {
|
||||
final url = '${stripTrailingSlash(ctx.baseUrl)}/Sessions/Playing/Progress';
|
||||
await _post(
|
||||
url,
|
||||
token: ctx.token,
|
||||
userId: ctx.userId,
|
||||
body: payload.toJson(),
|
||||
quiet: true,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> reportPlaybackStopped({
|
||||
required AuthedRequestContext ctx,
|
||||
required PlaybackReportPayload payload,
|
||||
}) async {
|
||||
final url = '${stripTrailingSlash(ctx.baseUrl)}/Sessions/Playing/Stopped';
|
||||
await _post(
|
||||
url,
|
||||
token: ctx.token,
|
||||
userId: ctx.userId,
|
||||
body: payload.toJson(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<EmbyRawItem>> getSeriesNextUp(
|
||||
AuthedRequestContext ctx,
|
||||
String seriesId, {
|
||||
int? limit,
|
||||
}) async {
|
||||
final url = '${stripTrailingSlash(ctx.baseUrl)}/Shows/NextUp';
|
||||
final json = await _get(
|
||||
url,
|
||||
token: ctx.token,
|
||||
userId: ctx.userId,
|
||||
query: {
|
||||
'UserId': ctx.userId,
|
||||
'SeriesId': seriesId,
|
||||
'Limit': '${limit ?? 1}',
|
||||
'Fields':
|
||||
'BasicSyncInfo,CanDelete,Container,PrimaryImageAspectRatio,ProductionYear,DateCreated,Genres,Path,ParentId,Overview',
|
||||
},
|
||||
);
|
||||
return _parseItems(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<EmbyRawSeason>> getSeriesSeasons(
|
||||
AuthedRequestContext ctx,
|
||||
String seriesId, {
|
||||
CancellationToken? cancelToken,
|
||||
}) async {
|
||||
final url = '${stripTrailingSlash(ctx.baseUrl)}/Shows/$seriesId/Seasons';
|
||||
final json = await _get(
|
||||
url,
|
||||
token: ctx.token,
|
||||
userId: ctx.userId,
|
||||
query: {
|
||||
'UserId': ctx.userId,
|
||||
'EnableTotalRecordCount': 'false',
|
||||
'Fields': _seasonFields,
|
||||
},
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
final items = (json['Items'] as List<dynamic>?) ?? <dynamic>[];
|
||||
return items
|
||||
.map((e) => EmbyRawSeason.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<EmbyRawItem>> getSeriesEpisodes(
|
||||
AuthedRequestContext ctx,
|
||||
String seriesId,
|
||||
String seasonId, {
|
||||
CancellationToken? cancelToken,
|
||||
}) async {
|
||||
final url = '${stripTrailingSlash(ctx.baseUrl)}/Shows/$seriesId/Episodes';
|
||||
final json = await _get(
|
||||
url,
|
||||
token: ctx.token,
|
||||
userId: ctx.userId,
|
||||
query: {
|
||||
'UserId': ctx.userId,
|
||||
'EnableTotalRecordCount': 'false',
|
||||
'Fields': _episodeFields,
|
||||
'EnableImageTypes': 'Primary,Backdrop,Thumb',
|
||||
'SeasonId': seasonId,
|
||||
},
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
return _parseItems(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> setFavoriteStatus({
|
||||
required AuthedRequestContext ctx,
|
||||
required String itemId,
|
||||
required bool isFavorite,
|
||||
}) async {
|
||||
final url =
|
||||
'${stripTrailingSlash(ctx.baseUrl)}/Users/${ctx.userId}/FavoriteItems/$itemId';
|
||||
if (isFavorite) {
|
||||
await _post(url, token: ctx.token, userId: ctx.userId);
|
||||
} else {
|
||||
await _delete(url, token: ctx.token, userId: ctx.userId);
|
||||
}
|
||||
return isFavorite;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> setPlayedStatus({
|
||||
required AuthedRequestContext ctx,
|
||||
required String itemId,
|
||||
required bool played,
|
||||
}) async {
|
||||
final url =
|
||||
'${stripTrailingSlash(ctx.baseUrl)}/Users/${ctx.userId}/PlayedItems/$itemId';
|
||||
if (played) {
|
||||
await _post(url, token: ctx.token, userId: ctx.userId);
|
||||
} else {
|
||||
await _delete(url, token: ctx.token, userId: ctx.userId);
|
||||
}
|
||||
return played;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> hideFromResume({
|
||||
required AuthedRequestContext ctx,
|
||||
required String itemId,
|
||||
required bool hide,
|
||||
}) async {
|
||||
final url =
|
||||
'${stripTrailingSlash(ctx.baseUrl)}/Users/${ctx.userId}/Items/$itemId/HideFromResume';
|
||||
await _post(
|
||||
url,
|
||||
token: ctx.token,
|
||||
userId: ctx.userId,
|
||||
query: {'Hide': hide ? 'true' : 'false'},
|
||||
);
|
||||
return hide;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<EmbyRawItem>> getSimilarItems(
|
||||
AuthedRequestContext ctx,
|
||||
String itemId, {
|
||||
int? limit,
|
||||
}) async {
|
||||
final url = '${stripTrailingSlash(ctx.baseUrl)}/Items/$itemId/Similar';
|
||||
final json = await _get(
|
||||
url,
|
||||
token: ctx.token,
|
||||
userId: ctx.userId,
|
||||
query: {
|
||||
'Fields': _similarFields,
|
||||
'Limit': '${limit ?? 10}',
|
||||
'ImageTypes': 'Logo',
|
||||
'SortBy': 'ProductionYear,CommunityRating,PlayCount',
|
||||
'Recursive': 'true',
|
||||
'EnableImageTypes': 'Primary,Backdrop,Thumb,Logo',
|
||||
'SortOrder': 'Descending',
|
||||
'IncludeItemTypes': 'Series,Movie,Video',
|
||||
'ImageTypeLimit': '1',
|
||||
},
|
||||
);
|
||||
return _parseItems(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<EmbyRawItem>> getAdditionalParts(
|
||||
AuthedRequestContext ctx,
|
||||
String itemId,
|
||||
) async {
|
||||
final url =
|
||||
'${stripTrailingSlash(ctx.baseUrl)}/Videos/$itemId/AdditionalParts';
|
||||
final json = await _get(url, token: ctx.token, userId: ctx.userId);
|
||||
return _parseItems(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<EmbyRawItem>> getSpecialFeatures(
|
||||
AuthedRequestContext ctx,
|
||||
String itemId,
|
||||
) async {
|
||||
final url =
|
||||
'${stripTrailingSlash(ctx.baseUrl)}/Users/${ctx.userId}/Items/$itemId/SpecialFeatures';
|
||||
final json = await _get(url, token: ctx.token, userId: ctx.userId);
|
||||
return _parseItems(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<EmbyRawItem>> getLibraryItems({
|
||||
required AuthedRequestContext ctx,
|
||||
String? parentId,
|
||||
String? includeItemTypes,
|
||||
List<String>? genreIds,
|
||||
String? searchTerm,
|
||||
String? anyProviderIdEquals,
|
||||
List<String>? personIds,
|
||||
String? fields,
|
||||
String? enableImageTypes,
|
||||
bool? groupProgramsBySeries,
|
||||
int? imageTypeLimit,
|
||||
int? startIndex,
|
||||
int? limit,
|
||||
bool? recursive,
|
||||
String? sortBy,
|
||||
SortOrder? sortOrder,
|
||||
String? filters,
|
||||
CancellationToken? cancelToken,
|
||||
}) async {
|
||||
final url = '${stripTrailingSlash(ctx.baseUrl)}/Users/${ctx.userId}/Items';
|
||||
final query = <String, dynamic>{
|
||||
if (parentId != null) 'ParentId': parentId,
|
||||
if (includeItemTypes != null) 'IncludeItemTypes': includeItemTypes,
|
||||
if (genreIds != null && genreIds.isNotEmpty)
|
||||
'GenreIds': genreIds.join(','),
|
||||
if (searchTerm != null) 'SearchTerm': searchTerm,
|
||||
if (anyProviderIdEquals != null)
|
||||
'AnyProviderIdEquals': anyProviderIdEquals,
|
||||
if (personIds != null && personIds.isNotEmpty)
|
||||
'PersonIds': personIds.join(','),
|
||||
if (filters != null) 'Filters': filters,
|
||||
'Fields':
|
||||
fields ??
|
||||
'BasicSyncInfo,CollectionType,PrimaryImageAspectRatio,UserData,CommunityRating,ProviderIds,ProductionYear,ChildCount,Container,CanDelete',
|
||||
'EnableImageTypes': enableImageTypes ?? 'Primary,Backdrop,Thumb',
|
||||
if (groupProgramsBySeries != null)
|
||||
'GroupProgramsBySeries': groupProgramsBySeries ? 'true' : 'false',
|
||||
if (imageTypeLimit != null) 'ImageTypeLimit': '$imageTypeLimit',
|
||||
'StartIndex': '${startIndex ?? 0}',
|
||||
'Limit': '${limit ?? 50}',
|
||||
'Recursive': (recursive ?? true) ? 'true' : 'false',
|
||||
'SortBy': sortBy ?? 'SortName',
|
||||
'SortOrder': sortOrder?.value ?? 'Ascending',
|
||||
};
|
||||
final json = await _get(
|
||||
url,
|
||||
token: ctx.token,
|
||||
userId: ctx.userId,
|
||||
query: query,
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
return _parseItems(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<EmbyRawItem>> getFavoriteItems({
|
||||
required AuthedRequestContext ctx,
|
||||
required String includeItemTypes,
|
||||
required String sortBy,
|
||||
SortOrder? sortOrder,
|
||||
}) async {
|
||||
final url = '${stripTrailingSlash(ctx.baseUrl)}/Users/${ctx.userId}/Items';
|
||||
final json = await _get(
|
||||
url,
|
||||
token: ctx.token,
|
||||
userId: ctx.userId,
|
||||
query: {
|
||||
'SortOrder': sortOrder?.value ?? 'Ascending',
|
||||
'Filters': 'IsFavorite',
|
||||
'Recursive': 'true',
|
||||
'Fields':
|
||||
'BasicSyncInfo,CommunityRating,ProductionYear,ChildCount,Container,'
|
||||
'Overview,UserDataLastPlayedDate,OfficialRating,Genres,Status,'
|
||||
'People,Studios,ProviderIds',
|
||||
'EnableImageTypes': 'Primary,Backdrop,Thumb,Logo',
|
||||
'CollapseBoxSetItems': 'false',
|
||||
'IncludeItemTypes': includeItemTypes,
|
||||
'SortBy': sortBy,
|
||||
},
|
||||
);
|
||||
return _parseItems(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ItemCountsRes> getItemCounts(AuthedRequestContext ctx) async {
|
||||
final url = '${stripTrailingSlash(ctx.baseUrl)}/Items/Counts';
|
||||
final json = await _get(url, token: ctx.token, userId: ctx.userId);
|
||||
return ItemCountsRes.fromJson(json as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> fetchPlaybackInfo({
|
||||
required AuthedRequestContext ctx,
|
||||
required String itemId,
|
||||
String? mediaSourceId,
|
||||
int startTimeTicks = 0,
|
||||
bool isPlayback = false,
|
||||
required Map<String, dynamic> deviceProfile,
|
||||
}) async {
|
||||
final url = '${stripTrailingSlash(ctx.baseUrl)}/Items/$itemId/PlaybackInfo';
|
||||
final json = await _post(
|
||||
url,
|
||||
token: ctx.token,
|
||||
userId: ctx.userId,
|
||||
query: {
|
||||
'AutoOpenLiveStream': 'false',
|
||||
'reqformat': 'json',
|
||||
'StartTimeTicks': '$startTimeTicks',
|
||||
'UserId': ctx.userId,
|
||||
'MaxStreamingBitrate': '200000000',
|
||||
'IsPlayback': isPlayback ? 'true' : 'false',
|
||||
if (mediaSourceId != null) 'MediaSourceId': mediaSourceId,
|
||||
},
|
||||
body: {'DeviceProfile': deviceProfile},
|
||||
);
|
||||
return (json as Map<String, dynamic>);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
class EmbyAuthHeader {
|
||||
|
||||
|
||||
static String build({
|
||||
required String deviceId,
|
||||
required String deviceName,
|
||||
String? token,
|
||||
String? userId,
|
||||
}) {
|
||||
final parts = <String>[];
|
||||
if (token != null) {
|
||||
parts.add('Token="$token"');
|
||||
}
|
||||
if (userId != null && userId.isNotEmpty) {
|
||||
parts.add('Emby UserId="$userId"');
|
||||
}
|
||||
parts.addAll([
|
||||
'Client="${EmbyRequestHeaders.clientName}"',
|
||||
'Device="$deviceName"',
|
||||
'DeviceId="$deviceId"',
|
||||
'Version="${EmbyRequestHeaders.version}"',
|
||||
]);
|
||||
return 'MediaBrowser ${parts.join(', ')}';
|
||||
}
|
||||
}
|
||||
|
||||
class EmbyRequestHeaders {
|
||||
static const version = '6.1.0';
|
||||
static const clientName = 'SenPlayer';
|
||||
|
||||
|
||||
static const userAgent = 'SenPlayer/6.2.0';
|
||||
static const acceptLanguage = 'zh-CN,zh-Hans;q=0.9';
|
||||
static const embyLanguage = 'zh-cn';
|
||||
|
||||
static Map<String, String> json({
|
||||
required String deviceId,
|
||||
required String deviceName,
|
||||
String? token,
|
||||
String? userId,
|
||||
bool hasBody = false,
|
||||
}) {
|
||||
return {
|
||||
'Accept': 'application/json',
|
||||
'Accept-Language': acceptLanguage,
|
||||
'User-Agent': userAgent,
|
||||
..._clientIdentity(deviceId: deviceId, deviceName: deviceName),
|
||||
..._auth(
|
||||
deviceId: deviceId,
|
||||
deviceName: deviceName,
|
||||
token: token,
|
||||
userId: userId,
|
||||
),
|
||||
if (hasBody) 'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
static Map<String, String> image({
|
||||
required String deviceId,
|
||||
required String deviceName,
|
||||
required String token,
|
||||
required String userId,
|
||||
}) {
|
||||
return {
|
||||
'User-Agent': userAgent,
|
||||
'Accept': 'image/*,*/*;q=0.8',
|
||||
'Accept-Language': acceptLanguage,
|
||||
'Priority': 'u=3, i',
|
||||
..._clientIdentity(deviceId: deviceId, deviceName: deviceName),
|
||||
..._auth(
|
||||
deviceId: deviceId,
|
||||
deviceName: deviceName,
|
||||
token: token,
|
||||
userId: userId,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
static Map<String, String> media({
|
||||
required String deviceId,
|
||||
required String deviceName,
|
||||
required String token,
|
||||
required String userId,
|
||||
}) {
|
||||
return {
|
||||
'User-Agent': userAgent,
|
||||
'Accept': '*/*',
|
||||
'Accept-Language': acceptLanguage,
|
||||
..._clientIdentity(deviceId: deviceId, deviceName: deviceName),
|
||||
..._auth(
|
||||
deviceId: deviceId,
|
||||
deviceName: deviceName,
|
||||
token: token,
|
||||
userId: userId,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
static Map<String, String> directMedia() {
|
||||
return {
|
||||
'User-Agent': userAgent,
|
||||
'Accept': '*/*',
|
||||
'Accept-Language': acceptLanguage,
|
||||
};
|
||||
}
|
||||
|
||||
static Map<String, String> _clientIdentity({
|
||||
required String deviceId,
|
||||
required String deviceName,
|
||||
}) {
|
||||
return {
|
||||
'X-Emby-Client': clientName,
|
||||
'X-Emby-Client-Version': version,
|
||||
'X-Emby-Device-Id': deviceId,
|
||||
'X-Emby-Device-Name': deviceName,
|
||||
'X-Emby-Language': embyLanguage,
|
||||
};
|
||||
}
|
||||
|
||||
static Map<String, String> _auth({
|
||||
required String deviceId,
|
||||
required String deviceName,
|
||||
String? token,
|
||||
String? userId,
|
||||
}) {
|
||||
final authorizationHeader = EmbyAuthHeader.build(
|
||||
deviceId: deviceId,
|
||||
deviceName: deviceName,
|
||||
token: token,
|
||||
userId: userId,
|
||||
);
|
||||
return {
|
||||
'X-Emby-Authorization': authorizationHeader,
|
||||
'Authorization': authorizationHeader,
|
||||
if (token != null && token.isNotEmpty) ...{
|
||||
'X-Emby-Token': token,
|
||||
'X-MediaBrowser-Token': token,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../../shared/utils/app_logger.dart';
|
||||
import '../../../shared/utils/emby_ticks.dart';
|
||||
import '../../../shared/utils/url_utils.dart';
|
||||
import '../../contracts/contracts.dart';
|
||||
import '../../domain/errors.dart';
|
||||
import '../../domain/ports/emby_gateway.dart';
|
||||
import 'emby_headers.dart';
|
||||
|
||||
const _tag = 'PlaybackResolver';
|
||||
|
||||
class PlaybackResolver {
|
||||
final EmbyGateway gateway;
|
||||
final String _deviceId;
|
||||
final String _deviceName;
|
||||
|
||||
PlaybackResolver({
|
||||
required this.gateway,
|
||||
required this._deviceId,
|
||||
this._deviceName = 'iPad',
|
||||
});
|
||||
|
||||
|
||||
Future<PlaybackRequestResult> prepare({
|
||||
required AuthedRequestContext ctx,
|
||||
required PlaybackRequestPayload payload,
|
||||
EmbyRawItem? preloadedItem,
|
||||
Map<String, dynamic>? preloadedPlaybackInfo,
|
||||
}) async {
|
||||
var item = preloadedItem?.Id == payload.itemId
|
||||
? preloadedItem!
|
||||
: await gateway.getItemDetail(ctx, payload.itemId);
|
||||
|
||||
if (item.Type == 'Series') {
|
||||
preloadedPlaybackInfo = null;
|
||||
final seasons = await gateway.getSeriesSeasons(ctx, item.Id);
|
||||
if (seasons.isEmpty) {
|
||||
throw DomainError(
|
||||
ErrorCode.storeNotFound,
|
||||
'剧集没有可用季',
|
||||
details: {'seriesId': item.Id},
|
||||
);
|
||||
}
|
||||
final episodes = await gateway.getSeriesEpisodes(
|
||||
ctx,
|
||||
item.Id,
|
||||
seasons.first.Id,
|
||||
);
|
||||
if (episodes.isEmpty) {
|
||||
throw DomainError(
|
||||
ErrorCode.storeNotFound,
|
||||
'剧集没有可用分集',
|
||||
details: {'seriesId': item.Id},
|
||||
);
|
||||
}
|
||||
item = episodes.first;
|
||||
}
|
||||
|
||||
final resumePositionTicks = _startPositionTicksFor(payload, item);
|
||||
|
||||
final playbackInfo =
|
||||
(preloadedPlaybackInfo != null && preloadedPlaybackInfo.isNotEmpty)
|
||||
? preloadedPlaybackInfo
|
||||
: await gateway.fetchPlaybackInfo(
|
||||
ctx: ctx,
|
||||
itemId: item.Id,
|
||||
mediaSourceId: payload.mediaSourceId,
|
||||
startTimeTicks: resumePositionTicks,
|
||||
isPlayback: true,
|
||||
deviceProfile: deviceProfile(),
|
||||
);
|
||||
|
||||
final mediaSources =
|
||||
(playbackInfo['MediaSources'] as List<dynamic>?) ?? <dynamic>[];
|
||||
if (mediaSources.isEmpty) {
|
||||
final serverErrorCode = playbackInfo['ErrorCode'] as String?;
|
||||
final responseKeys = playbackInfo.keys.take(10).toList();
|
||||
AppLogger.warn(
|
||||
_tag,
|
||||
'PlaybackInfo returned empty MediaSources for itemId=${item.Id} '
|
||||
'name=${item.Name} serverErrorCode=$serverErrorCode '
|
||||
'responseKeys=$responseKeys',
|
||||
);
|
||||
throw DomainError(
|
||||
ErrorCode.storeNotFound,
|
||||
serverErrorCode != null
|
||||
? '没有可播放的媒体源(服务端:$serverErrorCode)'
|
||||
: '没有可播放的媒体源',
|
||||
details: {
|
||||
'itemId': item.Id,
|
||||
'itemName': item.Name,
|
||||
if (serverErrorCode != null) 'serverErrorCode': serverErrorCode,
|
||||
if (payload.mediaSourceId != null)
|
||||
'requestedMediaSourceId': payload.mediaSourceId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> sourceJson;
|
||||
if (payload.mediaSourceId != null) {
|
||||
sourceJson = mediaSources.cast<Map<String, dynamic>>().firstWhere(
|
||||
(s) => s['Id'] == payload.mediaSourceId,
|
||||
orElse: () => mediaSources.first as Map<String, dynamic>,
|
||||
);
|
||||
} else {
|
||||
sourceJson = mediaSources.first as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
final source = EmbyRawMediaSource.fromJson(sourceJson);
|
||||
final playSessionId =
|
||||
(playbackInfo['PlaySessionId'] as String?) ??
|
||||
(sourceJson['PlaySessionId'] as String?);
|
||||
final streamUrl = _resolveStreamUrl(
|
||||
ctx: ctx,
|
||||
itemId: item.Id,
|
||||
sourceJson: sourceJson,
|
||||
playSessionId: playSessionId,
|
||||
);
|
||||
|
||||
final subtitles = resolveSubtitles(
|
||||
source.MediaStreams ?? [],
|
||||
baseUrl: ctx.baseUrl,
|
||||
token: ctx.token,
|
||||
itemId: item.Id,
|
||||
mediaSourceId: source.Id,
|
||||
);
|
||||
final audioTracks = _resolveAudioTracks(streams: source.MediaStreams ?? []);
|
||||
|
||||
final positionTicks = resumePositionTicks;
|
||||
final startSeconds = positionTicks / kTicksPerSecond;
|
||||
final durationTicks = item.RunTimeTicks;
|
||||
final durationSeconds = durationTicks != null
|
||||
? durationTicks / kTicksPerSecond
|
||||
: null;
|
||||
|
||||
final playMethod = _resolvePlayMethod(sourceJson);
|
||||
|
||||
AppLogger.debug(
|
||||
_tag,
|
||||
'prepared item=${item.Name} (${item.Id}) source=${source.Id} '
|
||||
'container=${source.Container} method=$playMethod '
|
||||
'startSeconds=${startSeconds.toStringAsFixed(1)} '
|
||||
'subs=${subtitles.length} audio=${audioTracks.length}',
|
||||
);
|
||||
|
||||
return PlaybackRequestResult(
|
||||
streamUrl: streamUrl,
|
||||
container: _normalizeContainer(source.Container),
|
||||
mimeType: _guessMimeType(source.Container),
|
||||
directStream: sourceJson['SupportsDirectStream'] as bool? ?? false,
|
||||
item: item,
|
||||
mediaSource: source,
|
||||
startPositionSeconds: startSeconds,
|
||||
durationSeconds: durationSeconds,
|
||||
subtitles: subtitles,
|
||||
defaultSubtitleIndex: source.DefaultSubtitleStreamIndex,
|
||||
audioTracks: audioTracks,
|
||||
defaultAudioStreamIndex: source.DefaultAudioStreamIndex,
|
||||
playSessionId: playSessionId,
|
||||
playMethod: playMethod,
|
||||
);
|
||||
}
|
||||
|
||||
int _startPositionTicksFor(PlaybackRequestPayload payload, EmbyRawItem item) {
|
||||
if (payload.playFromStart) return 0;
|
||||
final explicitTicks = payload.startPositionTicks;
|
||||
if (explicitTicks != null && explicitTicks > 0) return explicitTicks;
|
||||
return item.UserData?.PlaybackPositionTicks ?? 0;
|
||||
}
|
||||
|
||||
|
||||
String _resolveStreamUrl({
|
||||
required AuthedRequestContext ctx,
|
||||
required String itemId,
|
||||
required Map<String, dynamic> sourceJson,
|
||||
String? playSessionId,
|
||||
}) {
|
||||
final base = stripTrailingSlash(ctx.baseUrl);
|
||||
final mediaSourceId = sourceJson['Id'] as String?;
|
||||
final container = _normalizeContainer(sourceJson['Container'] as String?);
|
||||
|
||||
final directStreamUrl = sourceJson['DirectStreamUrl'] as String?;
|
||||
if (directStreamUrl != null && directStreamUrl.isNotEmpty) {
|
||||
return _buildPlayableUrl(
|
||||
base,
|
||||
directStreamUrl,
|
||||
ctx.token,
|
||||
appendTokenToCrossOrigin: false,
|
||||
);
|
||||
}
|
||||
|
||||
final transcodingUrl = sourceJson['TranscodingUrl'] as String?;
|
||||
if (transcodingUrl != null && transcodingUrl.isNotEmpty) {
|
||||
return _buildPlayableUrl(
|
||||
base,
|
||||
transcodingUrl,
|
||||
ctx.token,
|
||||
appendTokenToCrossOrigin: false,
|
||||
);
|
||||
}
|
||||
|
||||
final ext = container ?? 'mkv';
|
||||
final params = _staticStreamQueryParameters(
|
||||
ctx: ctx,
|
||||
mediaSourceId: mediaSourceId,
|
||||
playSessionId: playSessionId,
|
||||
);
|
||||
final query = params.entries
|
||||
.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}')
|
||||
.join('&');
|
||||
return '$base/Videos/$itemId/stream.$ext?$query';
|
||||
}
|
||||
|
||||
Map<String, String> _staticStreamQueryParameters({
|
||||
required AuthedRequestContext ctx,
|
||||
String? mediaSourceId,
|
||||
String? playSessionId,
|
||||
}) {
|
||||
return {
|
||||
'UserId': ctx.userId,
|
||||
'IsPlayback': 'true',
|
||||
'MaxStreamingBitrate': '500000000',
|
||||
'Static': 'true',
|
||||
'api_key': ctx.token,
|
||||
'X-Emby-Authorization': EmbyAuthHeader.build(
|
||||
deviceId: _deviceId,
|
||||
deviceName: _deviceName,
|
||||
),
|
||||
'X-Emby-Client': EmbyRequestHeaders.clientName,
|
||||
'X-Emby-Client-Version': EmbyRequestHeaders.version,
|
||||
'X-Emby-Device-Id': _deviceId,
|
||||
'X-Emby-Device-Name': _deviceName,
|
||||
'X-Emby-Language': 'zh-cn',
|
||||
'X-Emby-Token': ctx.token,
|
||||
if (mediaSourceId != null) 'MediaSourceId': mediaSourceId,
|
||||
if (playSessionId != null) 'PlaySessionId': playSessionId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
String _resolvePlayMethod(Map<String, dynamic> sourceJson) {
|
||||
final directStreamUrl = sourceJson['DirectStreamUrl'] as String?;
|
||||
if (directStreamUrl != null && directStreamUrl.isNotEmpty) {
|
||||
return 'DirectStream';
|
||||
}
|
||||
final transcodingUrl = sourceJson['TranscodingUrl'] as String?;
|
||||
if (transcodingUrl != null && transcodingUrl.isNotEmpty) {
|
||||
return 'Transcode';
|
||||
}
|
||||
return 'DirectStream';
|
||||
}
|
||||
|
||||
|
||||
static String _buildPlayableUrl(
|
||||
String base,
|
||||
String rawUrl,
|
||||
String token, {
|
||||
bool appendTokenToCrossOrigin = true,
|
||||
}) {
|
||||
final baseUri = Uri.parse(base);
|
||||
final rawUri = Uri.parse(rawUrl);
|
||||
final playableUri = rawUri.hasScheme
|
||||
? rawUri
|
||||
: Uri.parse('$base${rawUrl.startsWith('/') ? '' : '/'}$rawUrl');
|
||||
if (!appendTokenToCrossOrigin && playableUri.origin != baseUri.origin) {
|
||||
return playableUri.toString();
|
||||
}
|
||||
if (playableUri.queryParameters.containsKey('api_key')) {
|
||||
return playableUri.toString();
|
||||
}
|
||||
|
||||
return playableUri
|
||||
.replace(
|
||||
queryParameters: {
|
||||
...playableUri.queryParametersAll,
|
||||
'api_key': token,
|
||||
},
|
||||
)
|
||||
.toString();
|
||||
}
|
||||
|
||||
|
||||
@visibleForTesting
|
||||
static List<EmbySubtitleTrack> resolveSubtitles(
|
||||
List<EmbyRawMediaStream> streams, {
|
||||
String? baseUrl,
|
||||
String? token,
|
||||
String? itemId,
|
||||
String? mediaSourceId,
|
||||
}) {
|
||||
final subtitles = <EmbySubtitleTrack>[];
|
||||
|
||||
for (final stream in streams) {
|
||||
if (stream.Type != 'Subtitle') continue;
|
||||
final isExternal =
|
||||
(stream.IsExternal ?? false) ||
|
||||
stream.DeliveryMethod == 'External' ||
|
||||
(stream.DeliveryUrl != null && stream.DeliveryUrl!.isNotEmpty);
|
||||
if (isExternal) {
|
||||
if (!isTextSubtitleCodec(stream.Codec)) continue;
|
||||
final rawDeliveryUrl = stream.DeliveryUrl;
|
||||
if (rawDeliveryUrl == null ||
|
||||
rawDeliveryUrl.isEmpty ||
|
||||
baseUrl == null ||
|
||||
token == null) {
|
||||
continue;
|
||||
}
|
||||
subtitles.add(
|
||||
EmbySubtitleTrack(
|
||||
index: stream.Index ?? 0,
|
||||
title: stream.Title ?? stream.DisplayTitle,
|
||||
language: stream.Language,
|
||||
isDefault: stream.IsDefault ?? false,
|
||||
isForced: stream.IsForced ?? false,
|
||||
isExternal: true,
|
||||
deliveryUrl: _buildPlayableUrl(
|
||||
stripTrailingSlash(baseUrl),
|
||||
rawDeliveryUrl,
|
||||
token,
|
||||
),
|
||||
codec: stream.Codec,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
subtitles.add(
|
||||
EmbySubtitleTrack(
|
||||
index: stream.Index ?? 0,
|
||||
title: stream.Title ?? stream.DisplayTitle,
|
||||
language: stream.Language,
|
||||
isDefault: stream.IsDefault ?? false,
|
||||
isForced: stream.IsForced ?? false,
|
||||
codec: stream.Codec,
|
||||
deliveryUrl: _embeddedTextSubtitleUrl(
|
||||
baseUrl: baseUrl,
|
||||
token: token,
|
||||
itemId: itemId,
|
||||
mediaSourceId: mediaSourceId,
|
||||
index: stream.Index,
|
||||
codec: stream.Codec,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return subtitles;
|
||||
}
|
||||
|
||||
|
||||
static String? _embeddedTextSubtitleUrl({
|
||||
required String? baseUrl,
|
||||
required String? token,
|
||||
required String? itemId,
|
||||
required String? mediaSourceId,
|
||||
required int? index,
|
||||
required String? codec,
|
||||
}) {
|
||||
if (!isTextSubtitleCodec(codec)) return null;
|
||||
if (baseUrl == null ||
|
||||
token == null ||
|
||||
itemId == null ||
|
||||
mediaSourceId == null ||
|
||||
index == null) {
|
||||
return null;
|
||||
}
|
||||
final extension = _subtitleDeliveryExtension(codec);
|
||||
final path =
|
||||
'/Videos/$itemId/$mediaSourceId/Subtitles/$index/0/Stream.$extension';
|
||||
return _buildPlayableUrl(stripTrailingSlash(baseUrl), path, token);
|
||||
}
|
||||
|
||||
|
||||
static String _subtitleDeliveryExtension(String? codec) {
|
||||
final normalized = (codec ?? 'srt').toLowerCase();
|
||||
return normalized == 'webvtt' ? 'vtt' : normalized;
|
||||
}
|
||||
|
||||
|
||||
static bool isTextSubtitleCodec(String? codec) {
|
||||
if (codec == null) return false;
|
||||
const textCodecs = {'srt', 'subrip', 'vtt', 'webvtt', 'ass', 'ssa'};
|
||||
return textCodecs.contains(codec.toLowerCase());
|
||||
}
|
||||
|
||||
List<EmbyAudioTrack> _resolveAudioTracks({
|
||||
required List<EmbyRawMediaStream> streams,
|
||||
}) {
|
||||
final tracks = <EmbyAudioTrack>[];
|
||||
for (final stream in streams) {
|
||||
if (stream.Type != 'Audio') continue;
|
||||
tracks.add(
|
||||
EmbyAudioTrack(
|
||||
index: stream.Index ?? 0,
|
||||
title: stream.Title,
|
||||
language: stream.Language,
|
||||
codec: stream.Codec,
|
||||
channels: stream.Channels,
|
||||
displayTitle: stream.DisplayTitle,
|
||||
isDefault: stream.IsDefault ?? false,
|
||||
),
|
||||
);
|
||||
}
|
||||
return tracks;
|
||||
}
|
||||
|
||||
|
||||
static Map<String, dynamic> deviceProfile() {
|
||||
return {
|
||||
'MaxStaticBitrate': 200000000,
|
||||
'MaxStreamingBitrate': 200000000,
|
||||
'MusicStreamingTranscodingBitrate': 200000000,
|
||||
'DirectPlayProfiles': [
|
||||
{'Type': 'Video'},
|
||||
{'Type': 'Audio'},
|
||||
],
|
||||
'TranscodingProfiles': [
|
||||
{
|
||||
'Container': 'ts',
|
||||
'Type': 'Video',
|
||||
'AudioCodec': 'aac,mp3,wav,ac3,eac3,flac,opus',
|
||||
'VideoCodec': 'hevc,h264,h265,mpeg4',
|
||||
'Context': 'Streaming',
|
||||
'Protocol': 'hls',
|
||||
'MaxAudioChannels': '6',
|
||||
'MinSegments': '1',
|
||||
'BreakOnNonKeyFrames': true,
|
||||
'ManifestSubtitles': 'vtt',
|
||||
},
|
||||
],
|
||||
'ContainerProfiles': <Map<String, dynamic>>[],
|
||||
'SubtitleProfiles': [
|
||||
{'Format': 'vtt', 'Method': 'External'},
|
||||
{'Format': 'ass', 'Method': 'External'},
|
||||
{'Format': 'ssa', 'Method': 'External'},
|
||||
{'Format': 'srt', 'Method': 'External'},
|
||||
{'Format': 'sub', 'Method': 'External'},
|
||||
{'Format': 'subrip', 'Method': 'External'},
|
||||
{'Format': 'smi', 'Method': 'External'},
|
||||
{'Format': 'ttml', 'Method': 'External'},
|
||||
{'Format': 'webvtt', 'Method': 'External'},
|
||||
{'Format': 'dvdsub', 'Method': 'External'},
|
||||
{'Format': 'sup', 'Method': 'External'},
|
||||
{'Format': 'dvdsub', 'Method': 'Embed'},
|
||||
{'Format': 'vobsub', 'Method': 'Embed'},
|
||||
{'Format': 'vtt', 'Method': 'Embed'},
|
||||
{'Format': 'ass', 'Method': 'Embed'},
|
||||
{'Format': 'ssa', 'Method': 'Embed'},
|
||||
{'Format': 'srt', 'Method': 'Embed'},
|
||||
{'Format': 'sub', 'Method': 'Embed'},
|
||||
{'Format': 'pgssub', 'Method': 'Embed'},
|
||||
{'Format': 'pgs', 'Method': 'Embed'},
|
||||
{'Format': 'subrip', 'Method': 'Embed'},
|
||||
{'Format': 'smi', 'Method': 'Embed'},
|
||||
{'Format': 'ttml', 'Method': 'Embed'},
|
||||
{'Format': 'webvtt', 'Method': 'Embed'},
|
||||
{'Format': 'mov_text', 'Method': 'Embed'},
|
||||
{'Format': 'dvb_teletext', 'Method': 'Embed'},
|
||||
{'Format': 'dvb_subtitle', 'Method': 'Embed'},
|
||||
{'Format': 'dvbsub', 'Method': 'Embed'},
|
||||
{'Format': 'idx', 'Method': 'Embed'},
|
||||
{'Format': 'sup', 'Method': 'Embed'},
|
||||
{'Format': 'vtt', 'Method': 'Hls'},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
String? _normalizeContainer(String? raw) {
|
||||
if (raw == null) return null;
|
||||
final container = raw.split(',').first.trim().toLowerCase();
|
||||
if (container.isEmpty) return null;
|
||||
if (container == 'matroska') return 'mkv';
|
||||
return container;
|
||||
}
|
||||
|
||||
String? _guessMimeType(String? container) {
|
||||
if (container == null) return null;
|
||||
return switch (_normalizeContainer(container)) {
|
||||
'mp4' || 'm4v' => 'video/mp4',
|
||||
'mkv' => 'video/x-matroska',
|
||||
'webm' => 'video/webm',
|
||||
'avi' => 'video/x-msvideo',
|
||||
'mov' => 'video/quicktime',
|
||||
'ts' || 'm2ts' || 'mpegts' => 'video/mp2t',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user