import 'dart:async'; import '../../../shared/utils/app_logger.dart'; import '../../contracts/contracts.dart'; import '../ports/emby_gateway.dart'; import 'usecase_helpers.dart'; const _ucTag = 'UseCase'; class GetMediaDetailUseCase extends AuthedUseCase { GetMediaDetailUseCase({ required super.sessionRepository, required super.serverRepository, required super.embyGateway, }); Future execute(MediaDetailReq input) async { final base = await embyGateway.getItemDetail(await ctx(), input.itemId); return MediaDetailRes(base: base); } Future executeForServer({ required String serverId, required String itemId, }) async { final ctx = await requireCtxForServer(serverId); final base = await embyGateway.getItemDetail(ctx, itemId); return MediaDetailRes(base: base); } } class SetFavoriteUseCase extends AuthedUseCase { SetFavoriteUseCase({ required super.sessionRepository, required super.serverRepository, required super.embyGateway, }); Future execute(FavoriteSetReq input) async { final result = await embyGateway.setFavoriteStatus( ctx: await ctx(), itemId: input.itemId, isFavorite: input.isFavorite, ); return FavoriteSetRes(itemId: input.itemId, isFavorite: result); } Future executeForServer({ required String serverId, required FavoriteSetReq input, }) async { final ctx = await requireCtxForServer(serverId); final result = await embyGateway.setFavoriteStatus( ctx: ctx, itemId: input.itemId, isFavorite: input.isFavorite, ); return FavoriteSetRes(itemId: input.itemId, isFavorite: result); } } class SetPlayedUseCase extends AuthedUseCase { SetPlayedUseCase({ required super.sessionRepository, required super.serverRepository, required super.embyGateway, }); Future execute(PlayedSetReq input) async { final result = await embyGateway.setPlayedStatus( ctx: await ctx(), itemId: input.itemId, played: input.played, ); return PlayedSetRes(itemId: input.itemId, played: result); } Future executeForServer({ required String serverId, required PlayedSetReq input, }) async { final ctx = await requireCtxForServer(serverId); final result = await embyGateway.setPlayedStatus( ctx: ctx, itemId: input.itemId, played: input.played, ); return PlayedSetRes(itemId: input.itemId, played: result); } } class HideFromResumeUseCase extends AuthedUseCase { HideFromResumeUseCase({ required super.sessionRepository, required super.serverRepository, required super.embyGateway, }); Future execute(HideFromResumeReq input) async { final result = await embyGateway.hideFromResume( ctx: await ctx(), itemId: input.itemId, hide: input.hide, ); return HideFromResumeRes(itemId: input.itemId, hide: result); } } class GetSeriesEpisodesUseCase extends AuthedUseCase { GetSeriesEpisodesUseCase({ required super.sessionRepository, required super.serverRepository, required super.embyGateway, }); Future execute(MediaEpisodesReq input) async { final items = await embyGateway.getSeriesEpisodes( await ctx(), input.seriesId, input.seasonId, ); return MediaEpisodesRes( seriesId: input.seriesId, seasonId: input.seasonId, items: items, ); } Future executeForServer({ required String serverId, required MediaEpisodesReq input, }) async { final ctx = await requireCtxForServer(serverId); final items = await embyGateway.getSeriesEpisodes( ctx, input.seriesId, input.seasonId, ); return MediaEpisodesRes( seriesId: input.seriesId, seasonId: input.seasonId, items: items, ); } } class GetSeriesSeasonsUseCase extends AuthedUseCase { GetSeriesSeasonsUseCase({ required super.sessionRepository, required super.serverRepository, required super.embyGateway, }); Future execute(SeriesSeasonsReq input) async { final seasons = await embyGateway.getSeriesSeasons( await ctx(), input.seriesId, ); return SeriesSeasonsRes(seriesId: input.seriesId, items: seasons); } Future executeForServer({ required String serverId, required SeriesSeasonsReq input, }) async { final ctx = await requireCtxForServer(serverId); final seasons = await embyGateway.getSeriesSeasons(ctx, input.seriesId); return SeriesSeasonsRes(seriesId: input.seriesId, items: seasons); } } class GetSimilarItemsUseCase extends AuthedUseCase { GetSimilarItemsUseCase({ required super.sessionRepository, required super.serverRepository, required super.embyGateway, }); Future execute(SimilarItemsReq input) async { final items = await embyGateway.getSimilarItems( await ctx(), input.itemId, limit: input.limit, ); return SimilarItemsRes(itemId: input.itemId, items: items); } } class GetAdditionalPartsUseCase extends AuthedUseCase { GetAdditionalPartsUseCase({ required super.sessionRepository, required super.serverRepository, required super.embyGateway, }); Future execute(AdditionalPartsReq input) async { final items = await embyGateway.getAdditionalParts( await ctx(), input.itemId, ); return AdditionalPartsRes(itemId: input.itemId, items: items); } Future executeForServer({ required String serverId, required String itemId, }) async { final ctx = await resolveAuthedContextForServer( sessionRepository: sessionRepository, serverRepository: serverRepository, serverId: serverId, ); if (ctx == null) { return AdditionalPartsRes(itemId: itemId, items: const []); } final items = await embyGateway.getAdditionalParts(ctx, itemId); return AdditionalPartsRes(itemId: itemId, items: items); } } class GetSpecialFeaturesUseCase extends AuthedUseCase { GetSpecialFeaturesUseCase({ required super.sessionRepository, required super.serverRepository, required super.embyGateway, }); Future> execute(String itemId) async { return embyGateway.getSpecialFeatures(await ctx(), itemId); } } class CrossServerSearchUseCase extends AuthedUseCase { CrossServerSearchUseCase({ required super.sessionRepository, required super.serverRepository, required super.embyGateway, }); final Map _seriesIdentityCache = {}; final Map _emptyServerSearchStreaks = {}; static const _emptyServerSkipThreshold = 3; static const _emptyServerSkipTtl = Duration(hours: 1); String _identityCacheKey(String serverId, String seriesPidQuery) => '$serverId|$seriesPidQuery'; Stream> executeStream( CrossServerSearchReq input, { CancellationToken? cancelToken, }) { final controller = StreamController>(); final sw = Stopwatch()..start(); () async { try { final activeServerId = await sessionRepository.getActiveServerId(); final excludedServerId = input.excludedServerId ?? activeServerId; final allSessions = await sessionRepository.listAll(); final allServers = await serverRepository.list(); final serverMap = {for (final s in allServers) s.id: s}; final acc = []; var probed = 0; var skipped = 0; final futures = >[]; for (final session in allSessions) { if (session.serverId == excludedServerId) continue; final server = serverMap[session.serverId]; if (server == null) continue; if (_shouldSkipEmptyServer(server)) { skipped++; final streak = _emptyServerSearchStreaks[server.id]; AppLogger.debug( 'DetailTiming', 'xserver.server name=${server.name} skipped emptyStreak=${streak?.count ?? 0}', ); continue; } probed++; futures.add(() async { final serverSw = Stopwatch()..start(); final cards = await _buildCardsForServer( server, session, input, cancelToken: cancelToken, ); AppLogger.debug( 'DetailTiming', 'xserver.server name=${server.name} cards=${cards.length} ms=${serverSw.elapsedMilliseconds}', ); if (cancelToken?.isCancelled == true) return; _recordServerSearchResult(server, cards); if (cards.isEmpty) return; acc.addAll(cards); if (!controller.isClosed) { controller.add(List.unmodifiable(acc)); } }()); } await Future.wait(futures); AppLogger.debug( 'DetailTiming', 'xserver.total probed=$probed skipped=$skipped cards=${acc.length} ms=${sw.elapsedMilliseconds}', ); } catch (e) { if (!controller.isClosed) controller.addError(e); } finally { if (!controller.isClosed) await controller.close(); } }(); return controller.stream; } bool _shouldSkipEmptyServer(EmbyServer server) { final streak = _emptyServerSearchStreaks[server.id]; final skipUntil = streak?.skipUntil; if (skipUntil == null) return false; if (DateTime.now().isBefore(skipUntil)) return true; _emptyServerSearchStreaks.remove(server.id); return false; } void _recordServerSearchResult( EmbyServer server, List cards, ) { if (cards.isNotEmpty) { _emptyServerSearchStreaks.remove(server.id); return; } final streak = _emptyServerSearchStreaks.putIfAbsent( server.id, _EmptyServerSearchStreak.new, ); streak.count++; if (streak.count < _emptyServerSkipThreshold) return; streak.skipUntil = DateTime.now().add(_emptyServerSkipTtl); AppLogger.debug( 'DetailTiming', 'xserver.server name=${server.name} emptyStreak=${streak.count} ' 'skipUntil=${streak.skipUntil!.toIso8601String()}', ); } Future> _buildCardsForServer( EmbyServer server, SessionData session, CrossServerSearchReq req, { CancellationToken? cancelToken, }) async { final ctx = AuthedRequestContext( baseUrl: server.baseUrl, token: session.token, userId: session.userId, ); try { if (req.anchorType == 'Movie') { return await _buildMovieCards( ctx, server, session, req, cancelToken: cancelToken, ); } if (req.anchorType == 'Episode') { return await _buildEpisodeCards( ctx, server, session, req, cancelToken: cancelToken, ); } return const []; } on CancelledException { return const []; } catch (e) { AppLogger.warn(_ucTag, 'CrossServerSearch on ${server.name} failed', e); return const []; } } Future> _buildMovieCards( AuthedRequestContext ctx, EmbyServer server, SessionData session, CrossServerSearchReq req, { CancellationToken? cancelToken, }) async { const movieFields = 'ProviderIds,UserData,MediaSources,MediaStreams'; final providerQuery = _buildProviderIdQuery(req.providerIds); var items = []; if (providerQuery != null && providerQuery.isNotEmpty) { final providerItems = await embyGateway.getLibraryItems( ctx: ctx, includeItemTypes: 'Movie', anyProviderIdEquals: providerQuery, fields: movieFields, limit: 10, recursive: true, cancelToken: cancelToken, ); items = providerItems .where((item) => _itemMatchesAnyProviderId(item, req.providerIds)) .toList(growable: false); final droppedItemCount = providerItems.length - items.length; if (droppedItemCount > 0) { AppLogger.debug( 'DetailTiming', 'xserver.provider-filter server=${server.name} ' 'dropped=$droppedItemCount returned=${providerItems.length}', ); } } if (items.isEmpty && req.itemName.isNotEmpty) { final nameSearchItems = await embyGateway.getLibraryItems( ctx: ctx, includeItemTypes: 'Movie', searchTerm: req.itemName, fields: movieFields, limit: 20, recursive: true, cancelToken: cancelToken, ); items = nameSearchItems .where( (item) => !_itemHasConflictingProviderId(item, req.providerIds), ) .toList(growable: false); } final cards = []; if (cancelToken?.isCancelled == true) return List.unmodifiable(cards); final detailedList = await Future.wait( items.map( (it) => _fetchDetailedItem(ctx, server, it, cancelToken: cancelToken), ), ); if (cancelToken?.isCancelled == true) return List.unmodifiable(cards); for (final detailed in detailedList) { cards.addAll(_expandToCards(server, session, detailed)); } return List.unmodifiable(cards); } Future _fetchDetailedItem( AuthedRequestContext ctx, EmbyServer server, EmbyRawItem item, { CancellationToken? cancelToken, }) async { if (cancelToken?.isCancelled == true) return item; try { final full = await embyGateway.getItemDetail(ctx, item.Id); final fullSources = full.extra['MediaSources']; if (fullSources is List && fullSources.isNotEmpty) return full; return item; } catch (e) { AppLogger.warn( _ucTag, 'CrossServer detail fetch failed on ${server.name} item=${item.Id}', e, ); return item; } } Future> _buildEpisodeCards( AuthedRequestContext ctx, EmbyServer server, SessionData session, CrossServerSearchReq req, { CancellationToken? cancelToken, }) async { const episodeFields = 'ProviderIds,UserData,MediaSources,MediaStreams'; final providerQuery = _buildProviderIdQuery(req.providerIds); if (providerQuery != null && providerQuery.isNotEmpty) { final episodes = await embyGateway.getLibraryItems( ctx: ctx, includeItemTypes: 'Episode', anyProviderIdEquals: providerQuery, fields: episodeFields, limit: 5, recursive: true, cancelToken: cancelToken, ); final exact = _matchEpisodeBySE( episodes, req.parentIndexNumber, req.indexNumber, ); if (exact != null) { final detailed = await _fetchDetailedItem( ctx, server, exact, cancelToken: cancelToken, ); return _expandToCards(server, session, detailed); } } final seriesProviderIds = req.seriesProviderIds; if (seriesProviderIds == null || seriesProviderIds.isEmpty) { return const []; } final identity = await _resolveSeriesIdentity( ctx, server.id, seriesProviderIds, cancelToken: cancelToken, ); if (identity == null) return const []; EmbyRawSeason? matchedSeason; for (final s in identity.seasons) { if (s.IndexNumber == req.parentIndexNumber) { matchedSeason = s; break; } } if (matchedSeason == null) return const []; final episodes = await embyGateway.getSeriesEpisodes( ctx, identity.remoteSeriesId, matchedSeason.Id, cancelToken: cancelToken, ); final target = _matchEpisodeBySE( episodes, req.parentIndexNumber, req.indexNumber, ); if (target == null) return const []; final detailed = await _fetchDetailedItem( ctx, server, target, cancelToken: cancelToken, ); return _expandToCards(server, session, detailed); } Future<_SeriesIdentity?> _resolveSeriesIdentity( AuthedRequestContext ctx, String serverId, Map seriesProviderIds, { CancellationToken? cancelToken, }) async { final seriesPidQuery = _buildProviderIdQuery(seriesProviderIds); if (seriesPidQuery == null || seriesPidQuery.isEmpty) return null; final key = _identityCacheKey(serverId, seriesPidQuery); if (_seriesIdentityCache.containsKey(key)) { return _seriesIdentityCache[key]; } final seriesList = await embyGateway.getLibraryItems( ctx: ctx, includeItemTypes: 'Series', anyProviderIdEquals: seriesPidQuery, fields: 'ProviderIds', limit: 1, recursive: true, cancelToken: cancelToken, ); if (seriesList.isEmpty || !_itemMatchesAnyProviderId(seriesList.first, seriesProviderIds)) { _seriesIdentityCache[key] = null; return null; } final remoteSeriesId = seriesList.first.Id; final seasons = await embyGateway.getSeriesSeasons( ctx, remoteSeriesId, cancelToken: cancelToken, ); final identity = _SeriesIdentity( remoteSeriesId: remoteSeriesId, seasons: seasons, ); _seriesIdentityCache[key] = identity; return identity; } Future prewarmSeriesIdentity( Map seriesProviderIds, { CancellationToken? cancelToken, }) async { if (seriesProviderIds.isEmpty) return; try { final activeServerId = await sessionRepository.getActiveServerId(); final allSessions = await sessionRepository.listAll(); final allServers = await serverRepository.list(); final serverMap = {for (final s in allServers) s.id: s}; final futures = >[]; for (final session in allSessions) { if (session.serverId == activeServerId) continue; final server = serverMap[session.serverId]; if (server == null) continue; final ctx = AuthedRequestContext( baseUrl: server.baseUrl, token: session.token, userId: session.userId, ); futures.add(() async { try { await _resolveSeriesIdentity( ctx, server.id, seriesProviderIds, cancelToken: cancelToken, ); } on CancelledException { return; } catch (e) { AppLogger.warn( _ucTag, 'prewarmSeriesIdentity on ${server.name} failed', e, ); } }()); } await Future.wait(futures); } on CancelledException { } catch (e) { AppLogger.warn(_ucTag, 'prewarmSeriesIdentity setup failed', e); } } static List _expandToCards( EmbyServer server, SessionData session, EmbyRawItem item, ) { final raw = item.extra['MediaSources']; if (raw is! List) return const []; final sources = raw .whereType>() .map(EmbyRawMediaSource.fromJson) .toList(); final cards = []; for (final s in sources) { final id = s.Id; if (id == null) continue; cards.add( CrossServerSourceCard( serverId: server.id, serverName: server.name, serverUrl: server.baseUrl, token: session.token, userId: session.userId, landingItemId: item.Id, mediaSourceId: id, item: item, mediaSource: s, quality: _extractQualityFromSource(s), ), ); } return cards; } static EmbyRawItem? _matchEpisodeBySE( List episodes, int? parentIndex, int? index, ) { if (parentIndex == null || index == null) return null; for (final it in episodes) { final ps = (it.extra['ParentIndexNumber'] as num?)?.toInt(); if (ps == parentIndex && it.IndexNumber == index) return it; } return null; } static bool _itemMatchesAnyProviderId( EmbyRawItem item, Map requestedProviderIds, ) { final normalizedRequestedProviderIds = normalizeProviderIds( requestedProviderIds, ); if (normalizedRequestedProviderIds.isEmpty) return false; final normalizedItemProviderIds = normalizedProviderIdsOfItem(item); for (final requestedEntry in normalizedRequestedProviderIds.entries) { if (normalizedItemProviderIds[requestedEntry.key] == requestedEntry.value) { return true; } } return false; } static bool _itemHasConflictingProviderId( EmbyRawItem item, Map requestedProviderIds, ) { final normalizedRequestedProviderIds = normalizeProviderIds( requestedProviderIds, ); if (normalizedRequestedProviderIds.isEmpty) return false; final normalizedItemProviderIds = normalizedProviderIdsOfItem(item); for (final requestedEntry in normalizedRequestedProviderIds.entries) { final itemProviderId = normalizedItemProviderIds[requestedEntry.key]; if (itemProviderId != null && itemProviderId != requestedEntry.value) { return true; } } return false; } static MediaQualityInfo? _extractQualityFromSource(EmbyRawMediaSource src) { final streams = src.MediaStreams ?? const []; if (streams.isEmpty || !streams.any((s) => s.Type == 'Video')) { final fallback = MediaQualityInfo.fromSourceName( src.Name, fallbackHeight: src.Height, container: src.Container, ); AppLogger.debug( 'DetailQuality', 'name-fallback sourceId=${src.Id ?? 'null'} ' 'name="${src.Name ?? 'null'}" -> ' '${fallback == null ? 'null' : '"${fallback.resolutionLabel} ${fallback.dynamicRangeLabel}"'}', ); return fallback; } return MediaQualityInfo.fromMediaSource(src); } static String? _buildProviderIdQuery(Map providerIds) { if (providerIds.isEmpty) return null; final parts = []; for (final entry in providerIds.entries) { if (entry.value.isNotEmpty) { parts.add('${entry.key}.${entry.value}'); } } if (parts.isEmpty) return null; return parts.join(','); } } class _SeriesIdentity { final String remoteSeriesId; final List seasons; const _SeriesIdentity({required this.remoteSeriesId, required this.seasons}); } class _EmptyServerSearchStreak { int count = 0; DateTime? skipUntil; } String _buildAvailabilityProviderQuery({ required String tmdbId, String? imdbId, }) { final parts = ['Tmdb.${tmdbId.trim()}']; final imdb = imdbId?.trim() ?? ''; if (imdb.isNotEmpty) parts.add('Imdb.$imdb'); return parts.join(','); } class TmdbAvailabilityUseCase extends AuthedUseCase { TmdbAvailabilityUseCase({ required super.sessionRepository, required super.serverRepository, required super.embyGateway, this.settleTimeout = const Duration(seconds: 3), }); final Duration settleTimeout; Stream> executeStream( TmdbAvailabilityReq input, { CancellationToken? cancelToken, }) { final controller = StreamController>(); Timer? settleTimer; () async { try { final tmdbId = input.tmdbId.trim(); if (tmdbId.isEmpty) { if (!controller.isClosed) controller.add(const []); return; } final isMovie = input.mediaType == 'movie'; final includeItemTypes = isMovie ? 'Movie' : 'Series'; final providerQuery = _buildAvailabilityProviderQuery( tmdbId: tmdbId, imdbId: isMovie ? input.imdbId : null, ); final allSessions = await sessionRepository.listAll(); final allServers = await serverRepository.list(); final serverMap = {for (final s in allServers) s.id: s}; final acc = []; var settled = false; void emit() { if (!controller.isClosed) controller.add(List.unmodifiable(acc)); } var pending = 0; final futures = >[]; for (final session in allSessions) { final server = serverMap[session.serverId]; if (server == null) continue; pending++; futures.add( _probeServer( server, session, includeItemTypes, providerQuery, cancelToken: cancelToken, ).then((hit) { pending--; if (hit != null) acc.add(hit); if (pending == 0) { settleTimer?.cancel(); if (!settled || hit != null) emit(); } else if (settled && hit != null) { emit(); } }), ); } if (pending == 0) { emit(); return; } settleTimer = Timer(settleTimeout, () { settled = true; emit(); }); await Future.wait(futures); } catch (e) { if (!controller.isClosed) controller.addError(e); } finally { settleTimer?.cancel(); if (!controller.isClosed) await controller.close(); } }(); return controller.stream; } Future _probeServer( EmbyServer server, SessionData session, String includeItemTypes, String providerQuery, { CancellationToken? cancelToken, }) async { final ctx = AuthedRequestContext( baseUrl: server.baseUrl, token: session.token, userId: session.userId, ); try { final items = await embyGateway.getLibraryItems( ctx: ctx, includeItemTypes: includeItemTypes, anyProviderIdEquals: providerQuery, fields: 'ProviderIds,ProductionYear,UserData', limit: 1, recursive: true, cancelToken: cancelToken, ); if (items.isEmpty) return null; final item = items.first; return TmdbLibraryHit( serverId: server.id, serverName: server.name, itemId: item.Id, itemType: item.Type ?? includeItemTypes, name: item.Name, productionYear: item.ProductionYear, playbackPositionTicks: item.UserData?.PlaybackPositionTicks, runTimeTicks: item.RunTimeTicks, ); } on CancelledException { return null; } catch (e) { AppLogger.warn(_ucTag, 'TmdbAvailability on ${server.name} failed', e); return null; } } }