617 lines
20 KiB
Dart
617 lines
20 KiB
Dart
|
|
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,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|