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
@@ -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>);
}
}
+142
View File
@@ -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,
};
}
}