Files
2026-07-14 11:11:36 +08:00

914 lines
26 KiB
Dart

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<MediaDetailRes> execute(MediaDetailReq input) async {
final base = await embyGateway.getItemDetail(await ctx(), input.itemId);
return MediaDetailRes(base: base);
}
Future<MediaDetailRes> 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<FavoriteSetRes> 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<FavoriteSetRes> 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<PlayedSetRes> 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<PlayedSetRes> 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<HideFromResumeRes> 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<MediaEpisodesRes> 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<MediaEpisodesRes> 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<SeriesSeasonsRes> execute(SeriesSeasonsReq input) async {
final seasons = await embyGateway.getSeriesSeasons(
await ctx(),
input.seriesId,
);
return SeriesSeasonsRes(seriesId: input.seriesId, items: seasons);
}
Future<SeriesSeasonsRes> 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<SimilarItemsRes> 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<AdditionalPartsRes> execute(AdditionalPartsReq input) async {
final items = await embyGateway.getAdditionalParts(
await ctx(),
input.itemId,
);
return AdditionalPartsRes(itemId: input.itemId, items: items);
}
Future<AdditionalPartsRes> 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<List<EmbyRawItem>> 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<String, _SeriesIdentity?> _seriesIdentityCache = {};
final Map<String, _EmptyServerSearchStreak> _emptyServerSearchStreaks = {};
static const _emptyServerSkipThreshold = 3;
static const _emptyServerSkipTtl = Duration(hours: 1);
String _identityCacheKey(String serverId, String seriesPidQuery) =>
'$serverId|$seriesPidQuery';
Stream<List<CrossServerSourceCard>> executeStream(
CrossServerSearchReq input, {
CancellationToken? cancelToken,
}) {
final controller = StreamController<List<CrossServerSourceCard>>();
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 = <CrossServerSourceCard>[];
var probed = 0;
var skipped = 0;
final futures = <Future<void>>[];
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<CrossServerSourceCard> 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<List<CrossServerSourceCard>> _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<List<CrossServerSourceCard>> _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 = <EmbyRawItem>[];
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 = <CrossServerSourceCard>[];
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<EmbyRawItem> _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<List<CrossServerSourceCard>> _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<String, String> 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<void> prewarmSeriesIdentity(
Map<String, String> 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 = <Future<void>>[];
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<CrossServerSourceCard> _expandToCards(
EmbyServer server,
SessionData session,
EmbyRawItem item,
) {
final raw = item.extra['MediaSources'];
if (raw is! List) return const [];
final sources = raw
.whereType<Map<String, dynamic>>()
.map(EmbyRawMediaSource.fromJson)
.toList();
final cards = <CrossServerSourceCard>[];
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<EmbyRawItem> 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<String, String> 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<String, String> 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<String, String> providerIds) {
if (providerIds.isEmpty) return null;
final parts = <String>[];
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<EmbyRawSeason> 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 = <String>['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<List<TmdbLibraryHit>> executeStream(
TmdbAvailabilityReq input, {
CancellationToken? cancelToken,
}) {
final controller = StreamController<List<TmdbLibraryHit>>();
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 = <TmdbLibraryHit>[];
var settled = false;
void emit() {
if (!controller.isClosed) controller.add(List.unmodifiable(acc));
}
var pending = 0;
final futures = <Future<void>>[];
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<TmdbLibraryHit?> _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;
}
}
}