Initial commit
This commit is contained in:
@@ -0,0 +1,616 @@
|
||||
import 'dart:async';
|
||||
|
||||
import '../../../core/contracts/auth.dart';
|
||||
|
||||
import '../../../core/contracts/cancellation.dart';
|
||||
import '../../../core/contracts/danmaku.dart';
|
||||
import '../../../core/contracts/library.dart';
|
||||
import '../../../core/contracts/playback.dart';
|
||||
import '../../../core/contracts/trakt/trakt_scrobble_target.dart';
|
||||
import '../../../core/domain/ports/authed_trakt_gateway.dart';
|
||||
import '../../../core/domain/ports/emby_gateway.dart';
|
||||
import '../../../core/infra/emby_api/emby_headers.dart';
|
||||
import '../../../core/infra/trakt_api/trakt_media_matcher.dart';
|
||||
import '../../../core/infra/trakt_api/trakt_pending_sync_queue.dart';
|
||||
import '../../../core/infra/emby_api/playback_resolver.dart';
|
||||
import '../../../core/infra/player_engine/fvp_player_engine.dart';
|
||||
import '../../../core/infra/player_engine/media3_player_engine.dart';
|
||||
import '../../../core/infra/player_engine/media_kit_player_engine.dart';
|
||||
import '../../../core/infra/player_engine/player_engine.dart';
|
||||
import '../../../core/media/external_subtitle_loader.dart';
|
||||
import '../../../core/media/media_capability_analyzer.dart';
|
||||
import '../../../core/media/media_source_metadata_resolver.dart';
|
||||
import '../../../providers/audio_settings_provider.dart';
|
||||
import '../../../providers/player_engine_override_provider.dart';
|
||||
import '../../../providers/player_settings_provider.dart';
|
||||
import '../../../providers/subtitle_settings_provider.dart';
|
||||
import '../../../shared/mappers/media_image_url.dart';
|
||||
import '../../../shared/utils/app_logger.dart';
|
||||
import '../../../shared/utils/emby_ticks.dart';
|
||||
import '../services/danmaku/danmaku_feeder.dart';
|
||||
import '../services/emby_playback_reporter.dart';
|
||||
import '../services/network_speed_monitor.dart';
|
||||
import '../services/trakt_scrobble_reporter.dart';
|
||||
import 'initial_subtitle_resolver.dart';
|
||||
import 'playback_request.dart';
|
||||
import 'playback_session.dart';
|
||||
import 'player_engine_holder.dart';
|
||||
import 'player_engine_resolution.dart';
|
||||
|
||||
const _tag = 'PlaybackSessionBuilder';
|
||||
const _seriesIdentitySoftTimeout = Duration(milliseconds: 800);
|
||||
const _engineOpenTimeout = Duration(seconds: 60);
|
||||
|
||||
|
||||
class PlaybackSessionBuilder {
|
||||
PlaybackSessionBuilder({
|
||||
required this.resolver,
|
||||
required this.gateway,
|
||||
required this.metadataResolver,
|
||||
required this.engineHolder,
|
||||
required this.readActiveSession,
|
||||
required this.deviceId,
|
||||
required this.deviceName,
|
||||
required this.playerSettingsNotifier,
|
||||
required this.audioSettingsNotifier,
|
||||
required this.subtitleSettingsNotifier,
|
||||
required this.readEngineOverride,
|
||||
this.loadItemDetail,
|
||||
this.traktGateway,
|
||||
this.traktQueue,
|
||||
this.traktAppVersion,
|
||||
this.traktOnSuccess,
|
||||
this.traktOnError,
|
||||
});
|
||||
|
||||
final PlaybackResolver resolver;
|
||||
final EmbyGateway gateway;
|
||||
final MediaSourceMetadataResolver metadataResolver;
|
||||
|
||||
|
||||
final PlayerEngineHolder engineHolder;
|
||||
|
||||
|
||||
final AuthedTraktGateway? traktGateway;
|
||||
final TraktPendingSyncQueue? traktQueue;
|
||||
final String? traktAppVersion;
|
||||
final void Function()? traktOnSuccess;
|
||||
final void Function(String error)? traktOnError;
|
||||
Future<bool>? _traktConnectedCache;
|
||||
|
||||
|
||||
final AuthedSession Function() readActiveSession;
|
||||
|
||||
|
||||
final PlayerEngineOverride Function() readEngineOverride;
|
||||
final String deviceId;
|
||||
final String deviceName;
|
||||
final PlayerSettingsNotifier playerSettingsNotifier;
|
||||
final AudioSettingsNotifier audioSettingsNotifier;
|
||||
final SubtitleSettingsNotifier subtitleSettingsNotifier;
|
||||
final Future<EmbyRawItem> Function(String serverId, String itemId)?
|
||||
loadItemDetail;
|
||||
|
||||
|
||||
Future<PlaybackSession> build({
|
||||
required PlaybackRequest request,
|
||||
required DanmakuFeeder feeder,
|
||||
CancellationToken? externalCancel,
|
||||
Future<void>? priorDisposeFuture,
|
||||
Map<String, dynamic>? preloadedPlaybackInfo,
|
||||
PlayerEngineKind? forcedEngineKind,
|
||||
void Function(PlayerEngineKind kind, bool isDolbyVision)? onEngineResolved,
|
||||
}) async {
|
||||
externalCancel?.throwIfCancelled();
|
||||
|
||||
final directStreamUrl = request.directStreamUrl?.trim();
|
||||
final isDirectStream = directStreamUrl?.isNotEmpty ?? false;
|
||||
|
||||
final identity = request.targetServer;
|
||||
final String effectiveServerId;
|
||||
final String effectiveServerUrl;
|
||||
final String effectiveToken;
|
||||
final String effectiveUserId;
|
||||
if (identity != null) {
|
||||
effectiveServerId = identity.serverId;
|
||||
effectiveServerUrl = identity.baseUrl;
|
||||
effectiveToken = identity.token;
|
||||
effectiveUserId = identity.userId;
|
||||
} else if (isDirectStream) {
|
||||
effectiveServerId = '';
|
||||
effectiveServerUrl = '';
|
||||
effectiveToken = '';
|
||||
effectiveUserId = '';
|
||||
} else {
|
||||
final session = readActiveSession();
|
||||
effectiveServerId = session.serverId;
|
||||
effectiveServerUrl = session.serverUrl;
|
||||
effectiveToken = session.token;
|
||||
effectiveUserId = session.userId;
|
||||
}
|
||||
final ctx = AuthedRequestContext(
|
||||
baseUrl: effectiveServerUrl,
|
||||
token: effectiveToken,
|
||||
userId: effectiveUserId,
|
||||
);
|
||||
final traktConnectedFuture = isDirectStream
|
||||
? Future<bool>.value(false)
|
||||
: _readTraktConnected();
|
||||
|
||||
final PlaybackRequestResult result;
|
||||
if (isDirectStream) {
|
||||
final item =
|
||||
request.preloadedItem ??
|
||||
EmbyRawItem(Id: request.itemId, Name: request.itemId, Type: 'Video');
|
||||
result = PlaybackRequestResult(
|
||||
streamUrl: directStreamUrl!,
|
||||
directStream: true,
|
||||
item: item,
|
||||
startPositionSeconds: request.playFromStart
|
||||
? 0
|
||||
: (request.startPositionTicks ?? 0) / kTicksPerSecond,
|
||||
durationSeconds: item.RunTimeTicks == null
|
||||
? null
|
||||
: item.RunTimeTicks! / kTicksPerSecond,
|
||||
playMethod: 'DirectStream',
|
||||
);
|
||||
} else {
|
||||
result = await resolver.prepare(
|
||||
ctx: ctx,
|
||||
payload: PlaybackRequestPayload(
|
||||
itemId: request.itemId,
|
||||
mediaSourceId: request.mediaSourceId,
|
||||
startPositionTicks: request.startPositionTicks,
|
||||
playFromStart: request.playFromStart,
|
||||
),
|
||||
preloadedItem: request.preloadedItem,
|
||||
preloadedPlaybackInfo: preloadedPlaybackInfo,
|
||||
);
|
||||
}
|
||||
externalCancel?.throwIfCancelled();
|
||||
|
||||
final providerIds = providerIdsOfItem(result.item);
|
||||
final parentIndexNumber = (result.item.extra['ParentIndexNumber'] as num?)
|
||||
?.toInt();
|
||||
final memoryKey = playbackMemoryKey(
|
||||
serverId: effectiveServerId,
|
||||
userId: effectiveUserId,
|
||||
itemId: result.item.Id,
|
||||
seriesId: result.item.SeriesId,
|
||||
);
|
||||
final rememberedAudioFuture =
|
||||
!isDirectStream && request.audioSettings.rememberAudioTrack
|
||||
? _loadRememberedAudio(memoryKey)
|
||||
: Future<RememberedAudioTrack?>.value();
|
||||
final rememberedSubtitleFuture = isDirectStream
|
||||
? Future<RememberedSubtitleTrack?>.value()
|
||||
: _loadRememberedSubtitle(memoryKey);
|
||||
final directStreamHeaders = Map<String, String>.unmodifiable(
|
||||
request.directStreamHeaders,
|
||||
);
|
||||
final streamHeaders = isDirectStream
|
||||
? directStreamHeaders
|
||||
: EmbyRequestHeaders.media(
|
||||
deviceId: deviceId,
|
||||
deviceName: deviceName,
|
||||
token: effectiveToken,
|
||||
userId: effectiveUserId,
|
||||
);
|
||||
final engineHeaders = isDirectStream
|
||||
? directStreamHeaders
|
||||
: EmbyRequestHeaders.directMedia();
|
||||
final externalSubtitleLoader = ExternalSubtitleLoader();
|
||||
if (!isDirectStream) {
|
||||
unawaited(
|
||||
_prefetchInitialSubtitle(
|
||||
request: request,
|
||||
prepareResult: result,
|
||||
rememberedSubtitleFuture: rememberedSubtitleFuture,
|
||||
loader: externalSubtitleLoader,
|
||||
streamHeaders: streamHeaders,
|
||||
),
|
||||
);
|
||||
}
|
||||
final seriesIdentityFuture = isDirectStream
|
||||
? Future<
|
||||
({Map<String, String> providerIds, int? productionYear})?
|
||||
>.value()
|
||||
: _loadSeriesIdentity(
|
||||
ctx: ctx,
|
||||
serverId: effectiveServerId,
|
||||
item: result.item,
|
||||
providerIds: providerIds,
|
||||
traktConnectedFuture: traktConnectedFuture,
|
||||
externalCancel: externalCancel,
|
||||
);
|
||||
|
||||
final probeResult = isDirectStream
|
||||
? null
|
||||
: await metadataResolver.resolve(
|
||||
source: result.mediaSource,
|
||||
streamUrl: result.streamUrl,
|
||||
item: result.item,
|
||||
timeout: const Duration(seconds: 5),
|
||||
);
|
||||
externalCancel?.throwIfCancelled();
|
||||
final capability = MediaCapabilityAnalyzer.analyze(
|
||||
result.mediaSource,
|
||||
probeResult: probeResult,
|
||||
);
|
||||
final isDolbyVision =
|
||||
capability.rangeCategory == VideoRangeCategory.dolbyVision;
|
||||
final isHdr = switch (capability.rangeCategory) {
|
||||
VideoRangeCategory.hdr10 || VideoRangeCategory.hlg => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
final engineOverride = readEngineOverride();
|
||||
final engineKind =
|
||||
forcedEngineKind ??
|
||||
resolvePlayerEngineKind(
|
||||
engineOverride,
|
||||
isDolbyVision: isDolbyVision,
|
||||
videoCodec: capability.codec,
|
||||
);
|
||||
AppLogger.debug(
|
||||
_tag,
|
||||
'route override=${engineOverride.name} '
|
||||
'range=${capability.rangeCategory.name} isDV=$isDolbyVision isHDR=$isHdr '
|
||||
'forced=${forcedEngineKind?.name ?? '<none>'} -> ${engineKind.name}',
|
||||
);
|
||||
onEngineResolved?.call(engineKind, isDolbyVision);
|
||||
if (priorDisposeFuture != null) {
|
||||
await priorDisposeFuture;
|
||||
externalCancel?.throwIfCancelled();
|
||||
}
|
||||
|
||||
final engine = await engineHolder.acquire(
|
||||
engineKind,
|
||||
() => createEngine(engineKind),
|
||||
);
|
||||
externalCancel?.throwIfCancelled();
|
||||
if (engine is FvpPlayerEngine) {
|
||||
engine
|
||||
..setDolbyVision(isDolbyVision)
|
||||
..configureHardwareDecoding(request.playerSettings.hwdecMode);
|
||||
}
|
||||
final initialVolume = request.audioSettings.volumeBoost
|
||||
? request.playerSettings.volume
|
||||
: request.playerSettings.volume.clamp(0.0, 100.0);
|
||||
await engine.setVolume(initialVolume);
|
||||
externalCancel?.throwIfCancelled();
|
||||
|
||||
final isTranscode = result.playMethod == 'Transcode';
|
||||
final startSeconds = result.startPositionSeconds;
|
||||
final startDuration = (!isTranscode && startSeconds > 0)
|
||||
? Duration(milliseconds: (startSeconds * 1000).round())
|
||||
: Duration.zero;
|
||||
externalCancel?.throwIfCancelled();
|
||||
AppLogger.debug(
|
||||
_tag,
|
||||
'open engine=${engineKind.name} method=${result.playMethod} '
|
||||
'start=${startDuration.inSeconds}s itemId=${request.itemId}',
|
||||
);
|
||||
final engineOpenFuture = engine.open(
|
||||
result.streamUrl,
|
||||
start: startDuration,
|
||||
play: false,
|
||||
headers: engineHeaders,
|
||||
cancel: externalCancel,
|
||||
);
|
||||
final awaitEngineOpenInBuild = !engine.usesPlatformSurface;
|
||||
|
||||
final reporter = EmbyPlaybackReporter(
|
||||
gateway: gateway,
|
||||
context: ctx,
|
||||
enabled: !isDirectStream,
|
||||
);
|
||||
final speedMonitor = NetworkSpeedMonitor(
|
||||
totalRxBytes: engine.totalRxBytes,
|
||||
);
|
||||
|
||||
final seriesIdentity = await seriesIdentityFuture;
|
||||
externalCancel?.throwIfCancelled();
|
||||
final seriesProviderIds =
|
||||
seriesIdentity?.providerIds ?? const <String, String>{};
|
||||
final seriesProductionYear = seriesIdentity?.productionYear;
|
||||
|
||||
final context = PlaybackContext(
|
||||
itemId: result.item.Id,
|
||||
itemName: result.item.Name,
|
||||
itemType: result.item.Type,
|
||||
itemIndexNumber: result.item.IndexNumber,
|
||||
seriesName: result.item.SeriesName,
|
||||
seriesId: result.item.SeriesId,
|
||||
seasonId: result.item.SeasonId,
|
||||
mediaSourceId: result.mediaSource?.Id,
|
||||
playSessionId: result.playSessionId,
|
||||
runTimeTicks: result.item.RunTimeTicks,
|
||||
playMethod: result.playMethod,
|
||||
isDolbyVision: isDolbyVision,
|
||||
embySubtitles: result.subtitles,
|
||||
embyAudioTracks: result.audioTracks,
|
||||
allMediaSources: mediaSourcesOfItem(result.item),
|
||||
providerIds: providerIds,
|
||||
seriesProviderIds: seriesProviderIds,
|
||||
parentIndexNumber: parentIndexNumber,
|
||||
danmakuMatchContext: _buildDanmakuMatchContext(result.item),
|
||||
serverId: effectiveServerId,
|
||||
userId: effectiveUserId,
|
||||
baseUrl: effectiveServerUrl,
|
||||
token: effectiveToken,
|
||||
logoUrl: _buildLogoUrl(
|
||||
result.item,
|
||||
baseUrl: effectiveServerUrl,
|
||||
token: effectiveToken,
|
||||
),
|
||||
);
|
||||
|
||||
final rememberedAudioTrack = await rememberedAudioFuture;
|
||||
final rememberedPrimarySubtitle = await rememberedSubtitleFuture;
|
||||
final traktConnected = await traktConnectedFuture;
|
||||
|
||||
final traktReporterFactory = isDirectStream
|
||||
? null
|
||||
: await _buildTraktReporterFactory(
|
||||
item: result.item,
|
||||
providerIds: providerIds,
|
||||
seriesProviderIds: seriesProviderIds,
|
||||
seriesProductionYear: seriesProductionYear,
|
||||
parentIndexNumber: parentIndexNumber,
|
||||
traktConnected: traktConnected,
|
||||
);
|
||||
externalCancel?.throwIfCancelled();
|
||||
|
||||
if (awaitEngineOpenInBuild) {
|
||||
try {
|
||||
await _runWithTimeout(engineOpenFuture, _engineOpenTimeout);
|
||||
externalCancel?.throwIfCancelled();
|
||||
} catch (_) {
|
||||
try {
|
||||
await engine.stop();
|
||||
} catch (e) {
|
||||
AppLogger.warn(_tag, 'engine.stop after open failure failed', e);
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
return PlaybackSession(
|
||||
engine: engine,
|
||||
reporter: reporter,
|
||||
speedMonitor: speedMonitor,
|
||||
feeder: feeder,
|
||||
context: context,
|
||||
request: request,
|
||||
prepareResult: result,
|
||||
streamHeaders: streamHeaders,
|
||||
pendingEngineOpenFuture: awaitEngineOpenInBuild ? null : engineOpenFuture,
|
||||
playerSettingsNotifier: playerSettingsNotifier,
|
||||
traktReporterFactory: traktReporterFactory,
|
||||
rememberedAudioTrack: rememberedAudioTrack,
|
||||
rememberedPrimarySubtitle: rememberedPrimarySubtitle,
|
||||
externalSubtitleLoader: externalSubtitleLoader,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _prefetchInitialSubtitle({
|
||||
required PlaybackRequest request,
|
||||
required PlaybackRequestResult prepareResult,
|
||||
required Future<RememberedSubtitleTrack?> rememberedSubtitleFuture,
|
||||
required ExternalSubtitleLoader loader,
|
||||
required Map<String, String> streamHeaders,
|
||||
}) async {
|
||||
try {
|
||||
final rememberedSubtitle = await rememberedSubtitleFuture;
|
||||
final selection = resolveInitialSubtitleSelection(
|
||||
subtitles: prepareResult.subtitles,
|
||||
preferredSubtitleStreamIndex: request.preferredSubtitleStreamIndex,
|
||||
defaultSubtitleStreamIndex: prepareResult.defaultSubtitleIndex,
|
||||
rememberedSubtitle: rememberedSubtitle,
|
||||
);
|
||||
final selectedTrack = selection.track;
|
||||
final url = selectedTrack?.deliveryUrl;
|
||||
final canPrefetch =
|
||||
selection.type == InitialSubtitleSelectionType.track &&
|
||||
selectedTrack != null &&
|
||||
url != null &&
|
||||
url.isNotEmpty &&
|
||||
PlaybackResolver.isTextSubtitleCodec(selectedTrack.codec);
|
||||
if (!canPrefetch) return;
|
||||
|
||||
AppLogger.debug(
|
||||
_tag,
|
||||
'prefetch initial subtitle index=${selectedTrack.index} '
|
||||
'external=${selectedTrack.isExternal}',
|
||||
);
|
||||
await loader.load(
|
||||
url,
|
||||
headers: streamHeaders,
|
||||
receiveTimeout: selectedTrack.isExternal
|
||||
? null
|
||||
: embeddedSubtitleFetchTimeout,
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
AppLogger.debug(_tag, 'initial subtitle prefetch failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _runWithTimeout(Future<void> future, Duration timeout) {
|
||||
return future.timeout(
|
||||
timeout,
|
||||
onTimeout: () {
|
||||
throw TimeoutException('engine.open exceeded $timeout', timeout);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
static PlayerEngine createEngine(PlayerEngineKind kind) {
|
||||
switch (kind) {
|
||||
case PlayerEngineKind.fvp:
|
||||
return FvpPlayerEngine();
|
||||
case PlayerEngineKind.mpv:
|
||||
return MediaKitPlayerEngine();
|
||||
case PlayerEngineKind.media3:
|
||||
return Media3PlayerEngine();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Future<TraktScrobbleReporter Function(double Function())?>
|
||||
_buildTraktReporterFactory({
|
||||
required EmbyRawItem item,
|
||||
required Map<String, String> providerIds,
|
||||
required Map<String, String> seriesProviderIds,
|
||||
required int? seriesProductionYear,
|
||||
required int? parentIndexNumber,
|
||||
required bool traktConnected,
|
||||
}) async {
|
||||
final tGateway = traktGateway;
|
||||
final tQueue = traktQueue;
|
||||
if (tGateway == null || tQueue == null) return null;
|
||||
|
||||
final target = TraktMediaMatcher.match(
|
||||
itemType: item.Type,
|
||||
itemName: item.Name,
|
||||
productionYear: item.ProductionYear,
|
||||
providerIds: providerIds,
|
||||
seriesProviderIds: seriesProviderIds,
|
||||
seriesName: item.SeriesName,
|
||||
seriesProductionYear: seriesProductionYear,
|
||||
parentIndexNumber: parentIndexNumber,
|
||||
itemIndexNumber: item.IndexNumber,
|
||||
);
|
||||
if (target == null) {
|
||||
AppLogger.debug(_tag, 'trakt: no match for ${item.Type} "${item.Name}"');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!traktConnected) {
|
||||
AppLogger.debug(_tag, 'trakt: not connected, scrobble disabled');
|
||||
return null;
|
||||
}
|
||||
|
||||
final TraktScrobbleTarget matched = target;
|
||||
return (readProgress) => TraktScrobbleReporter(
|
||||
gateway: tGateway,
|
||||
queue: tQueue,
|
||||
target: matched,
|
||||
readProgress: readProgress,
|
||||
appVersion: traktAppVersion,
|
||||
onSuccess: traktOnSuccess,
|
||||
onError: traktOnError,
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _readTraktConnected() {
|
||||
final tGateway = traktGateway;
|
||||
if (tGateway == null || traktQueue == null) {
|
||||
return Future<bool>.value(false);
|
||||
}
|
||||
return _traktConnectedCache ??= tGateway.isConnected().catchError((
|
||||
Object e,
|
||||
) {
|
||||
AppLogger.debug(_tag, 'trakt: isConnected failed', e);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<RememberedAudioTrack?> _loadRememberedAudio(String key) async {
|
||||
try {
|
||||
return await audioSettingsNotifier.getLastAudioTrack(key);
|
||||
} catch (e) {
|
||||
AppLogger.warn(_tag, 'load remembered audio failed', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<RememberedSubtitleTrack?> _loadRememberedSubtitle(String key) async {
|
||||
try {
|
||||
return await subtitleSettingsNotifier.getLastSubtitleTrack(key);
|
||||
} catch (e) {
|
||||
AppLogger.warn(_tag, 'load remembered subtitle failed', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<({Map<String, String> providerIds, int? productionYear})?>
|
||||
_loadSeriesIdentity({
|
||||
required AuthedRequestContext ctx,
|
||||
required String serverId,
|
||||
required EmbyRawItem item,
|
||||
required Map<String, String> providerIds,
|
||||
required Future<bool> traktConnectedFuture,
|
||||
CancellationToken? externalCancel,
|
||||
}) async {
|
||||
if (item.Type != 'Episode' || !(item.SeriesId?.isNotEmpty ?? false)) {
|
||||
return null;
|
||||
}
|
||||
final traktConnected = await traktConnectedFuture;
|
||||
externalCancel?.throwIfCancelled();
|
||||
if (!traktConnected) return null;
|
||||
|
||||
try {
|
||||
final loadItem = loadItemDetail;
|
||||
final Future<EmbyRawItem?> future = loadItem != null
|
||||
? loadItem(serverId, item.SeriesId!).then<EmbyRawItem?>((v) => v)
|
||||
: gateway
|
||||
.getItemDetail(ctx, item.SeriesId!)
|
||||
.then<EmbyRawItem?>((v) => v);
|
||||
final seriesItem = await future.timeout(
|
||||
_seriesIdentitySoftTimeout,
|
||||
onTimeout: () {
|
||||
AppLogger.debug(
|
||||
_tag,
|
||||
'fetch series identity timed out after '
|
||||
'${_seriesIdentitySoftTimeout.inMilliseconds}ms',
|
||||
);
|
||||
return null;
|
||||
},
|
||||
);
|
||||
externalCancel?.throwIfCancelled();
|
||||
if (seriesItem == null) return null;
|
||||
return (
|
||||
providerIds: providerIdsOfItem(seriesItem),
|
||||
productionYear: seriesItem.ProductionYear,
|
||||
);
|
||||
} on CancelledException {
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
AppLogger.warn(_tag, 'fetch series identity failed', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static DanmakuMatchContext _buildDanmakuMatchContext(EmbyRawItem item) {
|
||||
return DanmakuMatchContext(
|
||||
itemId: item.Id,
|
||||
name: item.Name,
|
||||
type: item.Type,
|
||||
seriesName: item.SeriesName,
|
||||
season: (item.extra['ParentIndexNumber'] as num?)?.toInt(),
|
||||
episode: item.IndexNumber,
|
||||
durationSec: ((item.RunTimeTicks ?? 0) / kTicksPerSecond).round(),
|
||||
);
|
||||
}
|
||||
|
||||
static String? _buildLogoUrl(
|
||||
EmbyRawItem item, {
|
||||
required String baseUrl,
|
||||
required String token,
|
||||
}) {
|
||||
final isEpisode = item.Type == 'Episode';
|
||||
final logoItemId = isEpisode && (item.SeriesId?.isNotEmpty ?? false)
|
||||
? item.SeriesId!
|
||||
: item.Id;
|
||||
final logoTag = isEpisode ? null : item.ImageTags?['Logo'];
|
||||
return EmbyImageUrl.logo(
|
||||
baseUrl: baseUrl,
|
||||
token: token,
|
||||
itemId: logoItemId,
|
||||
tag: logoTag,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user