Initial commit
This commit is contained in:
@@ -0,0 +1,913 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user