914 lines
26 KiB
Dart
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;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|