Initial commit

This commit is contained in:
admin1
2026-07-14 11:11:36 +08:00
commit 656499cf94
604 changed files with 119518 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
import '../contracts/error.dart';
class DomainError implements Exception {
final ErrorCode code;
final String message;
final bool retryable;
final Object? details;
DomainError(this.code, this.message, {this.retryable = false, this.details});
@override
String toString() => 'DomainError(${code.value}): $message';
}
@@ -0,0 +1,29 @@
import '../../contracts/trakt/trakt_response.dart';
import '../../contracts/trakt/trakt_sync_job.dart';
abstract class AuthedTraktGateway {
Future<bool> isConnected();
Future<TraktScrobbleOutcome> scrobble(
TraktSyncJobType type,
Map<String, dynamic> body,
);
Future<TraktScrobbleOutcome> syncHistory(
TraktSyncJobType type,
Map<String, dynamic> body,
);
Future<List<Map<String, dynamic>>> fetchPlayback();
Future<List<Map<String, dynamic>>> fetchCalendarShows();
Future<List<Map<String, dynamic>>> fetchCalendarMovies();
}
@@ -0,0 +1,31 @@
import '../../contracts/danmaku.dart';
abstract class DanmakuGateway {
Future<List<DanmakuMatch>> match(
String baseUrl,
String fileName,
int durationSec,
);
Future<List<DanmakuMatch>> searchEpisodes(
String baseUrl,
String anime,
int? episode,
);
Future<List<DanmakuComment>> fetchComments(
String baseUrl,
int episodeId, {
int chConvert = 0,
});
Future<List<DanmakuBangumiEpisode>> getBangumiEpisodes(
String baseUrl,
int animeId,
);
}
@@ -0,0 +1,10 @@
import '../../contracts/danmaku.dart';
abstract class DanmakuSourcesStore {
Future<List<DanmakuSource>> list();
Future<void> replaceAll(List<DanmakuSource> sources);
}
+171
View File
@@ -0,0 +1,171 @@
import '../../contracts/cancellation.dart';
import '../../contracts/library.dart';
import '../../contracts/playback.dart';
import '../../contracts/server.dart';
class AuthedRequestContext {
final String baseUrl;
final String token;
final String userId;
const AuthedRequestContext({
required this.baseUrl,
required this.token,
required this.userId,
});
}
class EmbyAuthResult {
final String accessToken;
final String userId;
final String userName;
const EmbyAuthResult({
required this.accessToken,
required this.userId,
required this.userName,
});
}
abstract class EmbyGateway {
Future<ProbePublicInfoRes> probePublicInfo(String baseUrl);
Future<EmbyAuthResult> authenticateByName({
required String baseUrl,
required String username,
required String password,
});
Future<void> changePassword({
required AuthedRequestContext ctx,
required String currentPassword,
required String newPassword,
});
Future<EmbyRawLibraries> getUserViews(AuthedRequestContext ctx);
Future<LibraryLatestRes> getLatestItems(
AuthedRequestContext ctx,
String parentId, {
int? limit,
CancellationToken? cancelToken,
});
Future<LibraryResumeRes> getResumeItems(
AuthedRequestContext ctx, {
int? limit,
});
Future<List<EmbyRawItem>> getLibraryItems({
required AuthedRequestContext ctx,
String? parentId,
String? includeItemTypes,
List<String>? genreIds,
String? searchTerm,
String? anyProviderIdEquals,
List<String>? personIds,
String? fields,
String? enableImageTypes,
bool? groupProgramsBySeries,
int? imageTypeLimit,
int? startIndex,
int? limit,
bool? recursive,
String? sortBy,
SortOrder? sortOrder,
String? filters,
CancellationToken? cancelToken,
});
Future<List<EmbyRawItem>> getFavoriteItems({
required AuthedRequestContext ctx,
required String includeItemTypes,
required String sortBy,
SortOrder? sortOrder,
});
Future<ItemCountsRes> getItemCounts(AuthedRequestContext ctx);
Future<EmbyRawItem> getItemDetail(AuthedRequestContext ctx, String itemId);
Future<List<EmbyRawItem>> getSeriesNextUp(
AuthedRequestContext ctx,
String seriesId, {
int? limit,
});
Future<List<EmbyRawSeason>> getSeriesSeasons(
AuthedRequestContext ctx,
String seriesId, {
CancellationToken? cancelToken,
});
Future<List<EmbyRawItem>> getSeriesEpisodes(
AuthedRequestContext ctx,
String seriesId,
String seasonId, {
CancellationToken? cancelToken,
});
Future<List<EmbyRawItem>> getSimilarItems(
AuthedRequestContext ctx,
String itemId, {
int? limit,
});
Future<List<EmbyRawItem>> getAdditionalParts(
AuthedRequestContext ctx,
String itemId,
);
Future<List<EmbyRawItem>> getSpecialFeatures(
AuthedRequestContext ctx,
String itemId,
);
Future<bool> setFavoriteStatus({
required AuthedRequestContext ctx,
required String itemId,
required bool isFavorite,
});
Future<bool> setPlayedStatus({
required AuthedRequestContext ctx,
required String itemId,
required bool played,
});
Future<bool> hideFromResume({
required AuthedRequestContext ctx,
required String itemId,
required bool hide,
});
Future<void> reportPlaybackStarted({
required AuthedRequestContext ctx,
required PlaybackReportPayload payload,
});
Future<void> reportPlaybackProgress({
required AuthedRequestContext ctx,
required PlaybackReportPayload payload,
});
Future<void> reportPlaybackStopped({
required AuthedRequestContext ctx,
required PlaybackReportPayload payload,
});
Future<Map<String, dynamic>> fetchPlaybackInfo({
required AuthedRequestContext ctx,
required String itemId,
String? mediaSourceId,
int startTimeTicks = 0,
bool isPlayback = false,
required Map<String, dynamic> deviceProfile,
});
}
@@ -0,0 +1,3 @@
abstract class ScriptWidgetRemoteGateway {
Future<String> fetchText(String url);
}
@@ -0,0 +1,23 @@
import '../../contracts/script_widget.dart';
abstract class ScriptWidgetRepository {
Future<List<InstalledScriptWidget>> listWidgets();
Future<InstalledScriptWidget?> findWidget(String widgetId);
Future<void> upsertWidget(InstalledScriptWidget widget);
Future<void> deleteWidget(String widgetId);
Future<List<ScriptWidgetSubscription>> listSubscriptions();
Future<void> upsertSubscription(ScriptWidgetSubscription subscription);
Future<void> replaceAllWidgets(List<InstalledScriptWidget> widgets);
Future<void> replaceAllSubscriptions(
List<ScriptWidgetSubscription> subscriptions,
);
}
@@ -0,0 +1,19 @@
import '../../contracts/script_widget.dart';
abstract class ScriptWidgetRuntime {
Future<ScriptWidgetManifest> inspectWidget(String scriptSource);
Future<ScriptWidgetManifest> loadWidget(String scriptSource);
bool isWidgetLoaded(String widgetId);
Future<Object?> invokeModuleFunction(
String widgetId,
String functionName,
Map<String, Object?> params,
);
Future<void> unloadWidget(String widgetId);
Future<void> dispose();
}
@@ -0,0 +1,14 @@
import '../../contracts/server.dart';
abstract class ServerRepository {
Future<List<EmbyServer>> list();
Future<EmbyServer?> findById(String id);
Future<EmbyServer?> findByBaseUrl(String baseUrl);
Future<EmbyServer> create(EmbyServer server);
Future<EmbyServer> update(EmbyServer server);
Future<void> deleteById(String id);
Future<void> touchConnectedAt(String id, String updatedAt);
Future<void> replaceAll(List<EmbyServer> servers);
}
@@ -0,0 +1,28 @@
library;
class ServerSyncSnapshot {
final String? blob;
const ServerSyncSnapshot({this.blob});
bool get isEmpty => blob == null || blob!.trim().isEmpty;
}
abstract class ServerSyncGateway {
Future<ServerSyncSnapshot> pull({
required String baseUrl,
required String accessToken,
});
Future<void> push({
required String baseUrl,
required String accessToken,
required String blob,
});
}
@@ -0,0 +1,18 @@
import '../../contracts/auth.dart';
abstract class SessionRepository {
Future<SessionData> set(SessionData session);
Future<void> setMany(List<SessionData> sessions);
Future<void> replaceAll(List<SessionData> sessions, {String? activeServerId});
Future<SessionData?> getByServerId(String serverId);
Future<SessionData?> getActive();
Future<String?> getActiveServerId();
Future<List<SessionData>> listAll();
Future<void> setActive(String serverId);
Future<void> clear([String? serverId]);
}
@@ -0,0 +1,148 @@
library;
class SmLoginResult {
final bool ok;
final String email;
final String accessToken;
final String refreshToken;
final int expiresIn;
final bool isVip;
final int? vipExpiresAt;
final bool vipPermanent;
final bool needsVerification;
final String? message;
const SmLoginResult({
required this.ok,
this.email = '',
this.accessToken = '',
this.refreshToken = '',
this.expiresIn = 0,
this.isVip = false,
this.vipExpiresAt,
this.vipPermanent = false,
this.needsVerification = false,
this.message,
});
factory SmLoginResult.failure(
String message, {
bool needsVerification = false,
}) => SmLoginResult(
ok: false,
needsVerification: needsVerification,
message: message,
);
}
class SmTokenRefreshResult {
final bool ok;
final String accessToken;
final String refreshToken;
final int expiresIn;
final bool isVip;
final int? vipExpiresAt;
final bool vipPermanent;
final bool hasVipInfo;
final bool invalidRefreshToken;
final String? message;
const SmTokenRefreshResult({
required this.ok,
this.accessToken = '',
this.refreshToken = '',
this.expiresIn = 0,
this.isVip = false,
this.vipExpiresAt,
this.vipPermanent = false,
this.hasVipInfo = false,
this.invalidRefreshToken = false,
this.message,
});
factory SmTokenRefreshResult.failure(
String message, {
bool invalidRefreshToken = false,
}) => SmTokenRefreshResult(
ok: false,
invalidRefreshToken: invalidRefreshToken,
message: message,
);
}
class SmActionResult {
final bool ok;
final String? message;
const SmActionResult({required this.ok, this.message});
factory SmActionResult.success() => const SmActionResult(ok: true);
factory SmActionResult.failure(String message) =>
SmActionResult(ok: false, message: message);
}
abstract class SmAccountGateway {
Future<SmLoginResult> login({
required String baseUrl,
required String email,
required String password,
});
Future<SmActionResult> registerStart({
required String baseUrl,
required String email,
});
Future<SmLoginResult> registerVerify({
required String baseUrl,
required String email,
required String password,
required String code,
});
Future<SmActionResult> resendCode({
required String baseUrl,
required String email,
});
Future<void> logout({required String baseUrl, required String refreshToken});
Future<SmTokenRefreshResult> refreshAccessToken({
required String baseUrl,
required String refreshToken,
});
}
+126
View File
@@ -0,0 +1,126 @@
import '../../contracts/tmdb.dart';
class TmdbRequestConfig {
final String apiBaseUrl;
final String imageBaseUrl;
final String accessToken;
final String apiKey;
final Future<String?> Function()? onUnauthorized;
const TmdbRequestConfig({
required this.apiBaseUrl,
required this.imageBaseUrl,
this.accessToken = '',
this.apiKey = '',
this.onUnauthorized,
});
}
abstract class TmdbGateway {
Future<TmdbCreditsRes?> getCredits(
TmdbRequestConfig config,
String tmdbId,
String mediaType, {
required String language,
});
Future<TmdbRecommendationsRes?> getRecommendations(
TmdbRequestConfig config,
String tmdbId,
String mediaType, {
required String language,
});
Future<TmdbSeasonDetail?> getSeasonDetail(
TmdbRequestConfig config,
String tmdbId,
int seasonNumber, {
required String language,
});
Future<TmdbImageSet?> getImages(
TmdbRequestConfig config,
String tmdbId,
String mediaType, {
required String language,
});
Future<TmdbEnrichedDetail?> getEnrichedDetail(
TmdbRequestConfig config,
String tmdbId,
String mediaType, {
required String language,
List<String> appendToResponse,
});
Future<TmdbVideosRes?> getVideos(
TmdbRequestConfig config,
String tmdbId,
String mediaType, {
required String language,
});
Future<TmdbPersonDetail?> getPersonDetail(
TmdbRequestConfig config,
int personId, {
required String language,
});
Future<TmdbPersonCreditsRes?> getPersonCredits(
TmdbRequestConfig config,
int personId, {
required String language,
});
Future<TmdbMediaDetail?> getMediaDetail(
TmdbRequestConfig config,
String tmdbId,
String mediaType, {
required String language,
});
Future<TmdbRecommendationsRes?> getTrending(
TmdbRequestConfig config, {
required String language,
String mediaType,
String timeWindow,
});
Future<TmdbRecommendationsRes?> getPopularMovies(
TmdbRequestConfig config, {
required String language,
});
Future<TmdbRecommendationsRes?> getPopularTv(
TmdbRequestConfig config, {
required String language,
});
Future<TmdbRecommendationsRes?> getAiringTodayTv(
TmdbRequestConfig config, {
required String language,
});
Future<TmdbRecommendationsRes?> getTopRatedMovies(
TmdbRequestConfig config, {
required String language,
});
Future<TmdbRecommendationsRes?> getDiscover(
TmdbRequestConfig config,
String mediaType, {
required String language,
String sortBy = 'popularity.desc',
String? withGenres,
String? voteCountGte,
});
Future<bool> testConnection(
TmdbRequestConfig config, {
required String language,
});
}
+14
View File
@@ -0,0 +1,14 @@
import '../../contracts/trakt/trakt_token.dart';
import '../../contracts/trakt/trakt_user.dart';
abstract class TraktGateway {
Future<TraktToken> exchangeCode(String code);
Future<TraktToken> refreshToken(String refreshToken);
Future<TraktUser> getMe(String accessToken);
}
+161
View File
@@ -0,0 +1,161 @@
import '../../contracts/contracts.dart';
import '../errors.dart';
import '../ports/emby_gateway.dart';
import '../ports/server_repository.dart';
import '../ports/session_repository.dart';
class AuthByNameUseCase {
final ServerRepository serverRepository;
final SessionRepository sessionRepository;
final EmbyGateway embyGateway;
final DateTime Function() now;
AuthByNameUseCase({
required this.serverRepository,
required this.sessionRepository,
required this.embyGateway,
DateTime Function()? now,
}) : now = now ?? DateTime.now;
Future<void> execute(AuthByNameReq input) async {
final username = input.username.trim();
final password = input.password;
if (username.isEmpty || password.isEmpty) {
throw DomainError(ErrorCode.invalidParams, '用户名或密码不能为空');
}
final server = await serverRepository.findById(input.serverId);
if (server == null) {
throw DomainError(
ErrorCode.storeNotFound,
'服务器不存在',
details: {'serverId': input.serverId},
);
}
final auth = await embyGateway.authenticateByName(
baseUrl: server.baseUrl,
username: username,
password: password,
);
final nowIso = now().toUtc().toIso8601String();
await sessionRepository.set(
SessionData(
serverId: server.id,
token: auth.accessToken,
userId: auth.userId,
userName: auth.userName,
password: password,
createdAt: nowIso,
),
);
await serverRepository.touchConnectedAt(server.id, nowIso);
}
}
class LogoutUseCase {
final SessionRepository sessionRepository;
LogoutUseCase({required this.sessionRepository});
Future<void> execute([String? serverId]) => sessionRepository.clear(serverId);
}
class ListSessionsUseCase {
final SessionRepository sessionRepository;
final ServerRepository serverRepository;
ListSessionsUseCase({
required this.sessionRepository,
required this.serverRepository,
});
Future<SessionListRes> execute() async {
final sessions = await sessionRepository.listAll();
final activeServerId = await sessionRepository.getActiveServerId();
final result = <AuthedSession>[];
for (final session in sessions) {
final server = await serverRepository.findById(session.serverId);
if (server != null) {
result.add(
AuthedSession(
serverId: session.serverId,
serverName: server.name,
serverUrl: server.baseUrl,
token: session.token,
userId: session.userId,
userName: session.userName,
password: session.password,
isActive: session.serverId == activeServerId,
),
);
}
}
return SessionListRes(sessions: result, activeServerId: activeServerId);
}
}
class SwitchSessionUseCase {
final SessionRepository sessionRepository;
SwitchSessionUseCase({required this.sessionRepository});
Future<void> execute(String serverId) async {
final session = await sessionRepository.getByServerId(serverId);
if (session == null) {
throw DomainError(
ErrorCode.storeNotFound,
'该服务器没有可用会话',
details: {'serverId': serverId},
);
}
await sessionRepository.setActive(serverId);
}
}
class ChangePasswordUseCase {
final SessionRepository sessionRepository;
final ServerRepository serverRepository;
final EmbyGateway embyGateway;
ChangePasswordUseCase({
required this.sessionRepository,
required this.serverRepository,
required this.embyGateway,
});
Future<void> execute({
required String serverId,
required String currentPassword,
required String newPassword,
}) async {
if (currentPassword.isEmpty || newPassword.isEmpty) {
throw DomainError(ErrorCode.invalidParams, '密码不能为空');
}
final session = await sessionRepository.getByServerId(serverId);
if (session == null) {
throw DomainError(ErrorCode.storeNotFound, '该服务器没有可用会话');
}
final server = await serverRepository.findById(serverId);
if (server == null) {
throw DomainError(ErrorCode.storeNotFound, '服务器不存在');
}
final ctx = AuthedRequestContext(
baseUrl: server.baseUrl,
token: session.token,
userId: session.userId,
);
await embyGateway.changePassword(
ctx: ctx,
currentPassword: currentPassword,
newPassword: newPassword,
);
await sessionRepository.setMany([session.copyWith(password: newPassword)]);
}
}
@@ -0,0 +1,186 @@
import 'dart:convert';
import '../../contracts/contracts.dart';
import '../errors.dart';
import '../ports/danmaku_sources_store.dart';
import '../ports/script_widget_repository.dart';
import '../ports/server_repository.dart';
import '../ports/session_repository.dart';
const int kBackupSchemaVersion = 1;
class BackupExportResult {
final String json;
final int serverCount;
final int sessionCount;
final int danmakuSourceCount;
final int scriptWidgetCount;
final int scriptWidgetSubscriptionCount;
const BackupExportResult({
required this.json,
required this.serverCount,
required this.sessionCount,
required this.danmakuSourceCount,
required this.scriptWidgetCount,
required this.scriptWidgetSubscriptionCount,
});
}
class BackupImportResult {
final int serverCount;
final int sessionCount;
final int danmakuSourceCount;
final int scriptWidgetCount;
final int scriptWidgetSubscriptionCount;
const BackupImportResult({
required this.serverCount,
required this.sessionCount,
required this.danmakuSourceCount,
required this.scriptWidgetCount,
required this.scriptWidgetSubscriptionCount,
});
}
class ExportBackupUseCase {
final ServerRepository serverRepository;
final SessionRepository sessionRepository;
final DanmakuSourcesStore danmakuSourcesStore;
final ScriptWidgetRepository scriptWidgetRepository;
final DateTime Function() now;
ExportBackupUseCase({
required this.serverRepository,
required this.sessionRepository,
required this.danmakuSourcesStore,
required this.scriptWidgetRepository,
DateTime Function()? now,
}) : now = (now ?? DateTime.now);
Future<BackupExportResult> execute() async {
final servers = await serverRepository.list();
final sessions = await sessionRepository.listAll();
final danmakuSources = await danmakuSourcesStore.list();
final scriptWidgets = await scriptWidgetRepository.listWidgets();
final scriptWidgetSubscriptions =
await scriptWidgetRepository.listSubscriptions();
final bundle = BackupBundle(
version: kBackupSchemaVersion,
exportedAt: now().toUtc().toIso8601String(),
servers: servers,
sessions: sessions,
danmakuSources: danmakuSources,
scriptWidgets: scriptWidgets,
scriptWidgetSubscriptions: scriptWidgetSubscriptions,
);
final json = const JsonEncoder.withIndent(' ').convert(bundle.toJson());
return BackupExportResult(
json: json,
serverCount: servers.length,
sessionCount: sessions.length,
danmakuSourceCount: danmakuSources.length,
scriptWidgetCount: scriptWidgets.length,
scriptWidgetSubscriptionCount: scriptWidgetSubscriptions.length,
);
}
}
class ImportBackupUseCase {
final ServerRepository serverRepository;
final SessionRepository sessionRepository;
final DanmakuSourcesStore danmakuSourcesStore;
final ScriptWidgetRepository scriptWidgetRepository;
ImportBackupUseCase({
required this.serverRepository,
required this.sessionRepository,
required this.danmakuSourcesStore,
required this.scriptWidgetRepository,
});
Future<BackupImportResult> execute(String rawJson) async {
final BackupBundle bundle;
try {
final decoded = jsonDecode(rawJson);
if (decoded is! Map<String, dynamic>) {
throw DomainError(ErrorCode.invalidParams, '备份文件格式不正确');
}
bundle = BackupBundle.fromJson(decoded);
} on DomainError {
rethrow;
} catch (e) {
throw DomainError(
ErrorCode.invalidParams,
'备份文件解析失败',
details: {'reason': e.toString()},
);
}
if (bundle.version != kBackupSchemaVersion) {
throw DomainError(
ErrorCode.invalidParams,
'不支持的备份文件版本:${bundle.version}',
details: {'expected': kBackupSchemaVersion, 'got': bundle.version},
);
}
for (final server in bundle.servers) {
await serverRepository.update(server);
}
await sessionRepository.setMany(bundle.sessions);
final mergedSources = mergeDanmakuSourcesByUrl(
local: await danmakuSourcesStore.list(),
remote: bundle.danmakuSources,
);
await danmakuSourcesStore.replaceAll(mergedSources);
for (final scriptWidget in bundle.scriptWidgets) {
await scriptWidgetRepository.upsertWidget(scriptWidget);
}
for (final subscription in bundle.scriptWidgetSubscriptions) {
await scriptWidgetRepository.upsertSubscription(subscription);
}
return BackupImportResult(
serverCount: bundle.servers.length,
sessionCount: bundle.sessions.length,
danmakuSourceCount: mergedSources.length,
scriptWidgetCount: bundle.scriptWidgets.length,
scriptWidgetSubscriptionCount: bundle.scriptWidgetSubscriptions.length,
);
}
}
List<DanmakuSource> mergeDanmakuSourcesByUrl({
required List<DanmakuSource> local,
required List<DanmakuSource> remote,
}) {
final localByUrl = <String, DanmakuSource>{
for (final s in local) s.url.trim(): s,
};
final out = <DanmakuSource>[];
final seen = <String>{};
for (final r in remote) {
final url = r.url.trim();
if (url.isEmpty || seen.contains(url)) continue;
seen.add(url);
final hit = localByUrl[url];
if (hit != null) {
out.add(hit.copyWith(name: hit.name.trim().isEmpty ? r.name : hit.name));
} else {
out.add(
DanmakuSource(
id: r.id.trim().isEmpty
? 'src_${url.hashCode.toUnsigned(32).toRadixString(16)}'
: r.id,
name: r.name,
url: url,
),
);
}
}
return out;
}
@@ -0,0 +1,346 @@
import 'dart:async';
import '../../../shared/utils/app_logger.dart';
import '../../contracts/contracts.dart';
import '../ports/emby_gateway.dart';
import '../ports/server_repository.dart';
import '../ports/session_repository.dart';
import 'usecase_helpers.dart';
const _ucTag = 'UseCase';
Stream<GlobalSearchServerResult> _fanOutPerServer({
required SessionRepository sessionRepository,
required ServerRepository serverRepository,
required Future<List<EmbyRawItem>?> Function(AuthedRequestContext context)
fetchItems,
required String logTag,
}) {
final controller = StreamController<GlobalSearchServerResult>();
() async {
try {
final allSessions = await sessionRepository.listAll();
final allServers = await serverRepository.list();
final serverMap = {for (final s in allServers) s.id: s};
final futures = allSessions.map((session) async {
final server = serverMap[session.serverId];
if (server == null) return;
final context = AuthedRequestContext(
baseUrl: server.baseUrl,
token: session.token,
userId: session.userId,
);
try {
final items = await fetchItems(context);
if (items == null || items.isEmpty) return;
controller.add(
GlobalSearchServerResult(
serverId: server.id,
serverName: server.name,
serverUrl: server.baseUrl,
token: session.token,
userId: session.userId,
items: items,
),
);
} catch (e) {
AppLogger.warn(_ucTag, '$logTag on ${server.name} failed', e);
}
});
await Future.wait(futures);
} catch (e) {
controller.addError(e);
} finally {
controller.close();
}
}();
return controller.stream;
}
class GetLibraryViewsUseCase extends AuthedUseCase {
GetLibraryViewsUseCase({
required super.sessionRepository,
required super.serverRepository,
required super.embyGateway,
});
Future<LibraryViewsRes> execute() async {
return embyGateway.getUserViews(await ctx());
}
}
class GetLibraryResumeUseCase extends AuthedUseCase {
GetLibraryResumeUseCase({
required super.sessionRepository,
required super.serverRepository,
required super.embyGateway,
});
Future<LibraryResumeRes> execute() async {
return embyGateway.getResumeItems(await ctx(), limit: 20);
}
}
class GetLibraryLatestUseCase extends AuthedUseCase {
GetLibraryLatestUseCase({
required super.sessionRepository,
required super.serverRepository,
required super.embyGateway,
});
Future<LibraryLatestRes> execute(
LibraryLatestReq input, {
CancellationToken? cancelToken,
}) async {
return embyGateway.getLatestItems(
await ctx(),
input.parentId,
limit: input.limit ?? 16,
cancelToken: cancelToken,
);
}
}
class GetLibraryItemsUseCase extends AuthedUseCase {
GetLibraryItemsUseCase({
required super.sessionRepository,
required super.serverRepository,
required super.embyGateway,
});
Future<LibraryItemsRes> execute(LibraryItemsReq input) async {
final items = await embyGateway.getLibraryItems(
ctx: await ctx(),
parentId: input.parentId,
includeItemTypes: input.includeItemTypes,
genreIds: input.genreIds,
searchTerm: input.searchTerm,
anyProviderIdEquals: input.anyProviderIdEquals,
fields: input.fields,
enableImageTypes: input.enableImageTypes,
groupProgramsBySeries: input.groupProgramsBySeries,
imageTypeLimit: input.imageTypeLimit,
startIndex: input.startIndex,
limit: input.limit,
recursive: input.recursive,
sortBy: input.sortBy,
sortOrder: input.sortOrder,
);
return LibraryItemsRes(
parentId: input.parentId ?? '__global__',
items: items,
);
}
}
class GetHomeBannerUseCase extends AuthedUseCase {
GetHomeBannerUseCase({
required super.sessionRepository,
required super.serverRepository,
required super.embyGateway,
});
Future<List<EmbyRawItem>> execute({int fetchLimit = 24, int take = 8}) async {
final items = await embyGateway.getLibraryItems(
ctx: await ctx(),
includeItemTypes: 'Movie,Series',
recursive: true,
sortBy: 'DateCreated',
sortOrder: SortOrder.descending,
fields:
'BasicSyncInfo,CommunityRating,ProductionYear,Overview,Genres,ProviderIds,UserData',
enableImageTypes: 'Backdrop,Logo,Primary,Thumb',
imageTypeLimit: 2,
limit: fetchLimit,
);
return items
.where((it) => (it.BackdropImageTags ?? const []).isNotEmpty)
.take(take)
.toList(growable: false);
}
}
class GlobalKeywordSearchUseCase extends AuthedUseCase {
GlobalKeywordSearchUseCase({
required super.sessionRepository,
required super.serverRepository,
required super.embyGateway,
});
static const _defaultTypes = 'Movie,Series';
Stream<GlobalSearchServerResult> executeStream(GlobalSearchReq input) {
final controller = StreamController<GlobalSearchServerResult>();
() async {
final keyword = input.keyword.trim();
if (keyword.isEmpty) {
controller.close();
return;
}
try {
var sessions = await sessionRepository.listAll();
if (input.serverId != null) {
sessions = sessions
.where((s) => s.serverId == input.serverId)
.toList();
}
final allServers = await serverRepository.list();
final serverMap = {for (final s in allServers) s.id: s};
final types = input.includeItemTypes ?? _defaultTypes;
final futures = sessions.map((session) async {
final result = await _searchOne(
session: session,
server: serverMap[session.serverId],
keyword: keyword,
types: types,
limit: input.limitPerServer ?? 30,
);
if (result != null) controller.add(result);
});
await Future.wait(futures);
} catch (error, stackTrace) {
controller.addError(error, stackTrace);
} finally {
controller.close();
}
}();
return controller.stream;
}
Future<GlobalSearchServerResult?> _searchOne({
required SessionData session,
required EmbyServer? server,
required String keyword,
required String types,
required int limit,
}) async {
if (server == null) return null;
final c = AuthedRequestContext(
baseUrl: server.baseUrl,
token: session.token,
userId: session.userId,
);
try {
final items = await embyGateway.getLibraryItems(
ctx: c,
searchTerm: keyword,
recursive: true,
includeItemTypes: types,
limit: limit,
sortBy: 'SortName',
sortOrder: SortOrder.ascending,
);
if (items.isEmpty) return null;
return GlobalSearchServerResult(
serverId: server.id,
serverName: server.name,
serverUrl: server.baseUrl,
token: session.token,
userId: session.userId,
items: items,
);
} catch (e) {
AppLogger.warn(_ucTag, 'GlobalKeywordSearch on ${server.name} failed', e);
return null;
}
}
}
class GetAggregatedResumeUseCase extends AuthedUseCase {
GetAggregatedResumeUseCase({
required super.sessionRepository,
required super.serverRepository,
required super.embyGateway,
});
Stream<GlobalSearchServerResult> executeStream({int? limitPerServer}) {
return _fanOutPerServer(
sessionRepository: sessionRepository,
serverRepository: serverRepository,
logTag: 'AggregatedResume',
fetchItems: (context) async {
final res = await embyGateway.getResumeItems(
context,
limit: limitPerServer ?? 20,
);
return res.Items;
},
);
}
}
class GetAggregatedFavoritesUseCase extends AuthedUseCase {
static const _defaultTypes = 'Movie,Series,Episode';
GetAggregatedFavoritesUseCase({
required super.sessionRepository,
required super.serverRepository,
required super.embyGateway,
});
Stream<GlobalSearchServerResult> executeStream({
String includeItemTypes = _defaultTypes,
int? limitPerServer,
}) {
final types = includeItemTypes.trim().isEmpty
? _defaultTypes
: includeItemTypes;
return _fanOutPerServer(
sessionRepository: sessionRepository,
serverRepository: serverRepository,
logTag: 'AggregatedFavorites',
fetchItems: (context) async {
final items = await embyGateway.getFavoriteItems(
ctx: context,
includeItemTypes: types,
sortBy: 'SortName',
sortOrder: SortOrder.ascending,
);
return limitPerServer == null
? items
: items.take(limitPerServer).toList(growable: false);
},
);
}
}
class GetAggregatedRecentUseCase extends AuthedUseCase {
static const _defaultTypes = 'Movie,Series';
GetAggregatedRecentUseCase({
required super.sessionRepository,
required super.serverRepository,
required super.embyGateway,
});
Stream<GlobalSearchServerResult> executeStream({
String includeItemTypes = _defaultTypes,
int? limitPerServer,
}) {
return _fanOutPerServer(
sessionRepository: sessionRepository,
serverRepository: serverRepository,
logTag: 'AggregatedRecent',
fetchItems: (context) async {
return embyGateway.getLibraryItems(
ctx: context,
includeItemTypes: includeItemTypes,
fields:
'BasicSyncInfo,CollectionType,PrimaryImageAspectRatio,UserData,'
'CommunityRating,ProviderIds,ProductionYear,ChildCount,Container,'
'CanDelete,DateCreated',
sortBy: 'DateCreated',
sortOrder: SortOrder.descending,
recursive: true,
limit: limitPerServer ?? 20,
);
},
);
}
}
@@ -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;
}
}
}
@@ -0,0 +1,628 @@
import 'dart:convert';
import '../../../contracts/danmaku.dart';
import '../../../contracts/error.dart';
import '../../../contracts/script_widget.dart';
import '../../../../shared/utils/app_logger.dart';
import '../../../../shared/utils/async_lock.dart';
import '../../errors.dart';
import '../../ports/danmaku_sources_store.dart';
import '../../ports/script_widget_remote_gateway.dart';
import '../../ports/script_widget_repository.dart';
import '../../ports/script_widget_runtime.dart';
const String scriptWidgetHostVersion = '0.0.2';
class ScriptWidgetContentSection {
final String title;
final List<ScriptVideoItem> items;
const ScriptWidgetContentSection({required this.title, required this.items});
}
class ScriptWidgetService {
static const String _tag = 'ScriptWidgetService';
final ScriptWidgetRepository repository;
final ScriptWidgetRemoteGateway remoteGateway;
final ScriptWidgetRuntime runtime;
final DanmakuSourcesStore? danmakuSourcesStore;
final Future<void> Function()? onDanmakuSourcesChanged;
final Map<String, AsyncLock> _widgetLocks = <String, AsyncLock>{};
ScriptWidgetService({
required this.repository,
required this.remoteGateway,
required this.runtime,
this.danmakuSourcesStore,
this.onDanmakuSourcesChanged,
});
Future<List<InstalledScriptWidget>> listWidgets() => repository.listWidgets();
Future<List<ScriptWidgetSubscription>> listSubscriptions() =>
repository.listSubscriptions();
Future<InstalledScriptWidget> requireWidget(String widgetId) async {
final widget = await repository.findWidget(widgetId);
if (widget == null) {
throw DomainError(ErrorCode.storeNotFound, '未找到模块:$widgetId');
}
return widget;
}
Future<InstalledScriptWidget> installFromUrl(String url) async {
final scriptSource = await remoteGateway.fetchText(url);
return installFromSource(scriptSource, sourceUrl: url);
}
Future<InstalledScriptWidget> installFromSource(
String scriptSource, {
String sourceUrl = '',
}) async {
final manifest = await runtime.inspectWidget(scriptSource);
_assertCompatible(manifest);
return _runWidgetOperation(
manifest.id,
() =>
_installInspectedWidget(manifest, scriptSource, sourceUrl: sourceUrl),
);
}
Future<ScriptWidgetSubscription> importSubscription(String url) async {
final rawCatalog = await remoteGateway.fetchText(url);
return importSubscriptionSource(rawCatalog, sourceId: url);
}
Future<ScriptWidgetSubscription> importSubscriptionSource(
String rawCatalog, {
required String sourceId,
}) async {
final decoded = jsonDecode(rawCatalog);
if (decoded is! Map) {
throw DomainError(ErrorCode.invalidParams, '订阅文件不是有效的 JSON 对象');
}
final decodedCatalog = ScriptWidgetCatalog.fromJson(
Map<String, dynamic>.from(decoded),
);
if (decodedCatalog.title.trim().isEmpty) {
throw DomainError(ErrorCode.invalidParams, '订阅缺少 title');
}
final catalog = decodedCatalog.copyWith(
widgets: decodedCatalog.widgets
.map((entry) => _resolveCatalogEntryUrl(entry, sourceId))
.toList(growable: false),
);
final subscription = ScriptWidgetSubscription(
url: sourceId,
catalog: catalog,
refreshedAt: DateTime.now().toUtc(),
);
await repository.upsertSubscription(subscription);
return subscription;
}
Future<InstalledScriptWidget> refreshWidget(String widgetId) async {
final widget = await requireWidget(widgetId);
if (widget.sourceUrl.trim().isEmpty) {
throw DomainError(ErrorCode.invalidParams, '本地导入模块没有可用的更新地址');
}
final scriptSource = await remoteGateway.fetchText(widget.sourceUrl);
final manifest = await runtime.inspectWidget(scriptSource);
if (manifest.id != widgetId) {
throw DomainError(
ErrorCode.invalidParams,
'模块更新的 ID 不匹配:期望 $widgetId,实际为 ${manifest.id}',
);
}
_assertCompatible(manifest);
return _runWidgetOperation(
widgetId,
() => _installInspectedWidget(
manifest,
scriptSource,
sourceUrl: widget.sourceUrl,
),
);
}
Future<void> setWidgetEnabled(String widgetId, bool enabled) async {
await _runWidgetOperation(widgetId, () async {
final widget = await requireWidget(widgetId);
final updated = widget.copyWith(
enabled: enabled,
updatedAt: DateTime.now().toUtc(),
);
await repository.upsertWidget(updated);
await _synchronizeDanmakuSource(updated);
});
}
Future<void> updateGlobalParams(
String widgetId,
Map<String, Object?> globalParams,
) async {
await _runWidgetOperation(widgetId, () async {
final widget = await requireWidget(widgetId);
await repository.upsertWidget(
widget.copyWith(
globalParams: Map<String, Object?>.unmodifiable(globalParams),
updatedAt: DateTime.now().toUtc(),
),
);
});
}
Future<void> deleteWidget(String widgetId) async {
await _runWidgetOperation(widgetId, () async {
await repository.deleteWidget(widgetId);
await runtime.unloadWidget(widgetId);
final sourceStore = danmakuSourcesStore;
if (sourceStore != null) {
final sources = await sourceStore.list();
await sourceStore.replaceAll(
sources.where((source) => source.url != 'jsw://$widgetId').toList(),
);
await onDanmakuSourcesChanged?.call();
}
});
}
Future<Object?> invoke(
String widgetId,
String functionName,
Map<String, Object?> params,
) async {
return _runWidgetOperation(widgetId, () async {
final widget = await requireWidget(widgetId);
if (!widget.enabled) {
throw DomainError(
ErrorCode.invalidParams,
'模块已停用:${widget.manifest.title}',
);
}
await _ensureLoaded(widget);
final rawResult = await runtime.invokeModuleFunction(
widgetId,
functionName,
<String, Object?>{...widget.globalParams, ...params},
);
AppLogger.debug(
_tag,
'[$widgetId] $functionName -> ${_summarizeInvocationResult(rawResult)}',
);
return rawResult;
});
}
Future<List<ScriptWidgetContentSection>> loadModuleSections(
String widgetId,
ScriptWidgetModule module,
Map<String, Object?> params,
) async {
final resolvedParams = <String, Object?>{
...buildScriptWidgetParameterDefaults(module.params),
...params,
};
final rawResult = await invoke(
widgetId,
module.functionName,
resolvedParams,
);
return decodeVideoSections(
rawResult,
fallbackTitle: module.title,
preserveSections: module.sectionMode,
);
}
Future<List<ScriptVideoItem>> search(
String widgetId,
Map<String, Object?> params,
) async {
final widget = await requireWidget(widgetId);
final searchModule = widget.manifest.search;
if (searchModule == null) return const <ScriptVideoItem>[];
final rawResult = await invoke(widgetId, searchModule.functionName, params);
return decodeVideoItems(rawResult);
}
Future<ScriptVideoItem?> loadDetail(String widgetId, String link) async {
final rawResult = await invoke(widgetId, 'loadDetail', <String, Object?>{
'__forwardRawArgument': link,
'link': link,
});
final items = decodeVideoItems(rawResult);
return items.isEmpty ? null : items.first;
}
Future<List<ScriptVideoResource>> loadResources(
String widgetId,
Map<String, Object?> params,
) async {
final widget = await requireWidget(widgetId);
final resourceModule = widget.manifest.modules
.where(
(module) => module.id == 'loadResource' || module.type == 'stream',
)
.firstOrNull;
if (resourceModule == null) return const <ScriptVideoResource>[];
final rawResult = await invoke(
widgetId,
resourceModule.functionName,
params,
);
return _decodeMapList(rawResult)
.map(ScriptVideoResource.fromJson)
.where((resource) => resource.url.trim().isNotEmpty)
.toList(growable: false);
}
Future<void> _ensureLoaded(InstalledScriptWidget widget) async {
final widgetId = widget.manifest.id;
if (runtime.isWidgetLoaded(widgetId)) return;
final loadedManifest = await runtime.loadWidget(widget.scriptSource);
if (loadedManifest.id != widgetId) {
await runtime.unloadWidget(loadedManifest.id);
throw DomainError(
ErrorCode.invalidParams,
'已安装模块的脚本 ID 不匹配:期望 $widgetId,实际为 ${loadedManifest.id}',
);
}
}
Future<InstalledScriptWidget> _installInspectedWidget(
ScriptWidgetManifest manifest,
String scriptSource, {
required String sourceUrl,
}) async {
final existingWidget = await repository.findWidget(manifest.id);
final existingDanmakuSources = danmakuSourcesStore == null
? null
: await danmakuSourcesStore!.list();
final now = DateTime.now().toUtc();
final globalParams = <String, Object?>{
for (final parameter in manifest.globalParams)
parameter.name:
existingWidget?.globalParams[parameter.name] ?? parameter.value,
};
final installedWidget = InstalledScriptWidget(
manifest: manifest,
scriptSource: scriptSource,
sourceUrl: sourceUrl,
enabled: existingWidget?.enabled ?? true,
globalParams: globalParams,
installedAt: existingWidget?.installedAt ?? now,
updatedAt: now,
);
try {
final loadedManifest = await runtime.loadWidget(scriptSource);
if (loadedManifest.id != manifest.id) {
await runtime.unloadWidget(loadedManifest.id);
throw DomainError(
ErrorCode.invalidParams,
'模块脚本在检查与加载期间返回了不同的 ID',
details: <String, Object?>{
'inspectedId': manifest.id,
'loadedId': loadedManifest.id,
},
);
}
await repository.upsertWidget(installedWidget);
await _synchronizeDanmakuSource(installedWidget);
return installedWidget;
} catch (error, stackTrace) {
await _restoreInstallation(
manifest.id,
existingWidget,
existingDanmakuSources,
);
Error.throwWithStackTrace(error, stackTrace);
}
}
Future<void> _restoreInstallation(
String widgetId,
InstalledScriptWidget? existingWidget,
List<DanmakuSource>? existingDanmakuSources,
) async {
await _ignoreRollbackFailure(() async {
if (existingWidget == null) {
await repository.deleteWidget(widgetId);
} else {
await repository.upsertWidget(existingWidget);
}
});
await _ignoreRollbackFailure(() async {
if (existingWidget == null) {
await runtime.unloadWidget(widgetId);
} else {
var restoredExistingRuntime = false;
try {
final restoredManifest = await runtime.loadWidget(
existingWidget.scriptSource,
);
restoredExistingRuntime = restoredManifest.id == widgetId;
if (!restoredExistingRuntime) {
await runtime.unloadWidget(restoredManifest.id);
}
} finally {
if (!restoredExistingRuntime) {
await runtime.unloadWidget(widgetId);
}
}
}
});
final sourceStore = danmakuSourcesStore;
if (sourceStore != null && existingDanmakuSources != null) {
await _ignoreRollbackFailure(() async {
await sourceStore.replaceAll(existingDanmakuSources);
await onDanmakuSourcesChanged?.call();
});
}
}
Future<T> _runWidgetOperation<T>(
String widgetId,
Future<T> Function() operation,
) {
return _widgetLocks.putIfAbsent(widgetId, AsyncLock.new).run(operation);
}
Future<void> _ignoreRollbackFailure(Future<void> Function() rollback) async {
try {
await rollback();
} catch (_) {}
}
ScriptWidgetCatalogEntry _resolveCatalogEntryUrl(
ScriptWidgetCatalogEntry entry,
String sourceId,
) {
final entryUri = Uri.tryParse(entry.url);
if (entryUri == null || entryUri.hasScheme) return entry;
final sourceUri = Uri.tryParse(sourceId);
if (sourceUri == null || !sourceUri.hasScheme) return entry;
return entry.copyWith(url: sourceUri.resolveUri(entryUri).toString());
}
void _assertCompatible(ScriptWidgetManifest manifest) {
if (_compareVersions(manifest.requiredVersion, scriptWidgetHostVersion) >
0) {
throw DomainError(
ErrorCode.invalidParams,
'模块需要 ForwardWidgets ${manifest.requiredVersion},当前兼容版本为 $scriptWidgetHostVersion',
);
}
if (manifest.modules.any((module) => module.requiresWebView)) {
throw DomainError(ErrorCode.invalidParams, '当前版本暂不支持 requiresWebView 模块');
}
}
Future<void> _synchronizeDanmakuSource(InstalledScriptWidget widget) async {
final sourceStore = danmakuSourcesStore;
if (sourceStore == null) return;
final hasDanmaku = widget.manifest.modules.any(
(module) => module.type == 'danmu',
);
final sourceUrl = 'jsw://${widget.manifest.id}';
final sources = await sourceStore.list();
sources.removeWhere((source) => source.url == sourceUrl);
if (hasDanmaku) {
sources.add(
DanmakuSource(
id: 'script-widget-${widget.manifest.id}',
name: widget.manifest.title,
url: sourceUrl,
enabled: widget.enabled,
),
);
}
await sourceStore.replaceAll(sources);
await onDanmakuSourcesChanged?.call();
}
}
List<ScriptVideoItem> decodeVideoItems(Object? rawResult) {
final maps = _decodeMapList(rawResult);
if (maps.isEmpty) {
if (_isExplicitlyEmptyModuleResult(rawResult)) {
return const <ScriptVideoItem>[];
}
throw DomainError(
ErrorCode.invalidParams,
'模块返回了无法识别的内容格式',
details: <String, Object?>{
'resultType': rawResult.runtimeType.toString(),
'result': _summarizeModuleResult(rawResult),
},
);
}
final items = <ScriptVideoItem>[];
final decodingErrors = <String>[];
for (final map in maps) {
if (map['items'] is List && !map.containsKey('id')) {
try {
items.addAll(decodeVideoItems(map['items']));
} catch (error) {
decodingErrors.add(error.toString());
}
continue;
}
try {
items.add(ScriptVideoItem.fromJson(_normalizeVideoItemMap(map)));
} catch (error) {
decodingErrors.add(error.toString());
}
}
if (items.isEmpty && decodingErrors.isNotEmpty) {
throw DomainError(
ErrorCode.invalidParams,
'模块返回了内容,但字段格式与 ForwardWidgets 契约不兼容',
details: <String, Object?>{
'itemCount': maps.length,
'firstItem': _summarizeModuleResult(maps.first),
'firstError': decodingErrors.first,
},
);
}
return items;
}
Map<String, dynamic> _normalizeVideoItemMap(Map<String, dynamic> source) {
final normalized = Map<String, dynamic>.from(source);
final fallbackId =
normalized['link'] ??
normalized['videoUrl'] ??
normalized['url'] ??
normalized['title'];
normalized['id'] = normalized['id'] ?? fallbackId ?? '';
const stringFieldNames = <String>{
'id',
'type',
'title',
'coverUrl',
'posterPath',
'posterUrl',
'poster_url',
'detailPoster',
'backdropPath',
'releaseDate',
'mediaType',
'genreTitle',
'durationText',
'previewUrl',
'videoUrl',
'link',
'description',
'playerType',
};
for (final fieldName in stringFieldNames) {
final value = normalized[fieldName];
if (value != null) normalized[fieldName] = value.toString();
}
normalized['title'] ??= '';
normalized['type'] ??= 'link';
final subtitle = normalized['subTitle'] ?? normalized['subtitle'];
if (subtitle != null &&
(normalized['description'] == null ||
normalized['description'].toString().isEmpty)) {
normalized['description'] = subtitle.toString();
}
return normalized;
}
bool _isExplicitlyEmptyModuleResult(Object? rawResult) {
if (rawResult is List) return rawResult.isEmpty;
if (rawResult is Map) return rawResult.isEmpty;
return false;
}
String _summarizeModuleResult(Object? rawResult) {
final summary = rawResult?.toString() ?? 'null';
const maximumSummaryLength = 500;
if (summary.length <= maximumSummaryLength) return summary;
return '${summary.substring(0, maximumSummaryLength)}';
}
List<ScriptWidgetContentSection> decodeVideoSections(
Object? rawResult, {
required String fallbackTitle,
required bool preserveSections,
}) {
if (preserveSections && rawResult is List) {
final sections = <ScriptWidgetContentSection>[];
for (final rawSection in rawResult.whereType<Map>()) {
final section = Map<String, dynamic>.from(rawSection);
final rawItems =
section['items'] ?? section['data'] ?? section['results'];
if (rawItems == null) continue;
final items = decodeVideoItems(rawItems);
if (items.isEmpty) continue;
sections.add(
ScriptWidgetContentSection(
title:
section['title']?.toString() ??
section['name']?.toString() ??
fallbackTitle,
items: items,
),
);
}
if (sections.isNotEmpty) return sections;
}
final items = decodeVideoItems(rawResult);
if (items.isEmpty) return const <ScriptWidgetContentSection>[];
return <ScriptWidgetContentSection>[
ScriptWidgetContentSection(title: fallbackTitle, items: items),
];
}
List<Map<String, dynamic>> _decodeMapList(Object? rawResult) {
if (rawResult is String) {
final trimmedResult = rawResult.trim();
if (trimmedResult.isEmpty) return const <Map<String, dynamic>>[];
try {
return _decodeMapList(jsonDecode(trimmedResult));
} catch (_) {
return const <Map<String, dynamic>>[];
}
}
if (rawResult is Map) {
final map = Map<String, dynamic>.from(rawResult);
final nested = map['items'] ?? map['results'] ?? map['data'];
if (!map.containsKey('id') && nested != null) {
return _decodeMapList(nested);
}
return <Map<String, dynamic>>[map];
}
if (rawResult is! List) return const <Map<String, dynamic>>[];
return rawResult
.whereType<Map>()
.map(Map<String, dynamic>.from)
.toList(growable: false);
}
int _compareVersions(String left, String right) {
final leftParts = _versionParts(left);
final rightParts = _versionParts(right);
final length = leftParts.length > rightParts.length
? leftParts.length
: rightParts.length;
for (var index = 0; index < length; index++) {
final leftValue = index < leftParts.length ? leftParts[index] : 0;
final rightValue = index < rightParts.length ? rightParts[index] : 0;
if (leftValue != rightValue) return leftValue.compareTo(rightValue);
}
return 0;
}
List<int> _versionParts(String version) {
return version
.split('.')
.map((part) => int.tryParse(RegExp(r'\d+').stringMatch(part) ?? '') ?? 0)
.toList(growable: false);
}
String _summarizeInvocationResult(Object? rawResult) {
if (rawResult == null) return 'null';
if (rawResult is List) {
return 'List(length=${rawResult.length})';
}
if (rawResult is Map) {
return 'Map(keys=${rawResult.keys.take(6).toList()}, size=${rawResult.length})';
}
final summary = rawResult.toString();
const maximumSummaryLength = 200;
if (summary.length <= maximumSummaryLength) {
return '${rawResult.runtimeType} $summary';
}
return '${rawResult.runtimeType} ${summary.substring(0, maximumSummaryLength)}';
}
@@ -0,0 +1,33 @@
import '../../contracts/library.dart';
import '../ports/emby_gateway.dart';
typedef SeriesResumeTarget = ({String episodeId, String? seasonId});
Future<SeriesResumeTarget?> resolveSeriesResumeTarget(
EmbyGateway gateway,
AuthedRequestContext ctx,
String seriesId,
) async {
final nextUp = await gateway.getSeriesNextUp(ctx, seriesId, limit: 1);
if (nextUp.isNotEmpty) {
final ep = nextUp.first;
return (episodeId: ep.Id, seasonId: ep.SeasonId);
}
final lastPlayed = await gateway.getLibraryItems(
ctx: ctx,
parentId: seriesId,
includeItemTypes: 'Episode',
recursive: true,
filters: 'IsPlayed',
sortBy: 'DatePlayed',
sortOrder: SortOrder.descending,
limit: 1,
);
if (lastPlayed.isNotEmpty) {
final ep = lastPlayed.first;
return (episodeId: ep.Id, seasonId: ep.SeasonId);
}
return null;
}
@@ -0,0 +1,281 @@
import 'dart:convert';
import '../../contracts/auth.dart';
import '../../contracts/backup.dart';
import '../../contracts/error.dart';
import '../../contracts/script_widget.dart';
import '../../contracts/server.dart';
import '../errors.dart';
import '../ports/danmaku_sources_store.dart';
import '../ports/emby_gateway.dart';
import '../ports/script_widget_repository.dart';
import '../ports/server_repository.dart';
import '../ports/server_sync_gateway.dart';
import '../ports/session_repository.dart';
import 'backup_usecases.dart';
class ServerSyncUploadResult {
final int serverCount;
final int sessionCount;
final int danmakuSourceCount;
final int scriptWidgetCount;
final int scriptWidgetSubscriptionCount;
const ServerSyncUploadResult({
required this.serverCount,
required this.sessionCount,
required this.danmakuSourceCount,
required this.scriptWidgetCount,
required this.scriptWidgetSubscriptionCount,
});
}
class ServerSyncPullResult {
final bool empty;
final int serverCount;
final int sessionCount;
final int danmakuSourceCount;
final int scriptWidgetCount;
final int scriptWidgetSubscriptionCount;
const ServerSyncPullResult({
required this.empty,
required this.serverCount,
required this.sessionCount,
required this.danmakuSourceCount,
required this.scriptWidgetCount,
required this.scriptWidgetSubscriptionCount,
});
}
class UploadServerSyncUseCase {
final ExportBackupUseCase export;
final ServerSyncGateway gateway;
UploadServerSyncUseCase({required this.export, required this.gateway});
Future<ServerSyncUploadResult> execute({
required String baseUrl,
required String accessToken,
}) async {
final exported = await export.execute();
await gateway.push(
baseUrl: baseUrl,
accessToken: accessToken,
blob: exported.json,
);
return ServerSyncUploadResult(
serverCount: exported.serverCount,
sessionCount: exported.sessionCount,
danmakuSourceCount: exported.danmakuSourceCount,
scriptWidgetCount: exported.scriptWidgetCount,
scriptWidgetSubscriptionCount: exported.scriptWidgetSubscriptionCount,
);
}
}
class PullServerSyncUseCase {
final ServerRepository serverRepository;
final SessionRepository sessionRepository;
final DanmakuSourcesStore danmakuSourcesStore;
final ScriptWidgetRepository scriptWidgetRepository;
final ServerSyncGateway gateway;
PullServerSyncUseCase({
required this.serverRepository,
required this.sessionRepository,
required this.danmakuSourcesStore,
required this.scriptWidgetRepository,
required this.gateway,
});
Future<ServerSyncPullResult> execute({
required String baseUrl,
required String accessToken,
}) async {
final snapshot = await gateway.pull(
baseUrl: baseUrl,
accessToken: accessToken,
);
if (snapshot.isEmpty) {
return const ServerSyncPullResult(
empty: true,
serverCount: 0,
sessionCount: 0,
danmakuSourceCount: 0,
scriptWidgetCount: 0,
scriptWidgetSubscriptionCount: 0,
);
}
final bundle = _parseBundle(snapshot.blob!);
final prevActive = await sessionRepository.getActiveServerId();
final oldServers = await serverRepository.list();
final oldSessions = await sessionRepository.listAll();
final oldScriptWidgets = await scriptWidgetRepository.listWidgets();
final oldScriptWidgetSubscriptions =
await scriptWidgetRepository.listSubscriptions();
final keepActive =
prevActive != null &&
bundle.sessions.any((s) => s.serverId == prevActive);
final nextActive = keepActive
? prevActive
: (bundle.sessions.isNotEmpty ? bundle.sessions.first.serverId : null);
try {
await serverRepository.replaceAll(bundle.servers);
await sessionRepository.replaceAll(
bundle.sessions,
activeServerId: nextActive,
);
} catch (_) {
await _restoreLocalSnapshot(
servers: oldServers,
sessions: oldSessions,
activeServerId: prevActive,
);
rethrow;
}
final mergedSources = mergeDanmakuSourcesByUrl(
local: await danmakuSourcesStore.list(),
remote: bundle.danmakuSources,
);
await danmakuSourcesStore.replaceAll(mergedSources);
try {
await scriptWidgetRepository.replaceAllWidgets(bundle.scriptWidgets);
await scriptWidgetRepository.replaceAllSubscriptions(
bundle.scriptWidgetSubscriptions,
);
} catch (_) {
await _restoreScriptWidgets(
widgets: oldScriptWidgets,
subscriptions: oldScriptWidgetSubscriptions,
);
rethrow;
}
return ServerSyncPullResult(
empty: false,
serverCount: bundle.servers.length,
sessionCount: bundle.sessions.length,
danmakuSourceCount: mergedSources.length,
scriptWidgetCount: bundle.scriptWidgets.length,
scriptWidgetSubscriptionCount: bundle.scriptWidgetSubscriptions.length,
);
}
BackupBundle _parseBundle(String rawJson) {
final BackupBundle bundle;
try {
final decoded = jsonDecode(rawJson);
if (decoded is! Map<String, dynamic>) {
throw DomainError(ErrorCode.invalidParams, '云端同步数据格式不正确');
}
bundle = BackupBundle.fromJson(decoded);
} on DomainError {
rethrow;
} catch (e) {
throw DomainError(
ErrorCode.invalidParams,
'云端同步数据解析失败',
details: {'reason': e.toString()},
);
}
if (bundle.version != kBackupSchemaVersion) {
throw DomainError(
ErrorCode.invalidParams,
'不支持的同步数据版本:${bundle.version}',
details: {'expected': kBackupSchemaVersion, 'got': bundle.version},
);
}
return bundle;
}
Future<void> _restoreLocalSnapshot({
required List<EmbyServer> servers,
required List<SessionData> sessions,
required String? activeServerId,
}) async {
try {
await serverRepository.replaceAll(servers);
await sessionRepository.replaceAll(
sessions,
activeServerId: activeServerId,
);
} catch (_) {}
}
Future<void> _restoreScriptWidgets({
required List<InstalledScriptWidget> widgets,
required List<ScriptWidgetSubscription> subscriptions,
}) async {
try {
await scriptWidgetRepository.replaceAllWidgets(widgets);
await scriptWidgetRepository.replaceAllSubscriptions(subscriptions);
} catch (_) {}
}
}
class RepairSessionsUseCase {
final ServerRepository serverRepository;
final SessionRepository sessionRepository;
final EmbyGateway embyGateway;
RepairSessionsUseCase({
required this.serverRepository,
required this.sessionRepository,
required this.embyGateway,
});
Future<int> execute() async {
final sessions = await sessionRepository.listAll();
var repaired = 0;
for (final session in sessions) {
final server = await serverRepository.findById(session.serverId);
if (server == null) continue;
final ctx = AuthedRequestContext(
baseUrl: server.baseUrl,
token: session.token,
userId: session.userId,
);
try {
await embyGateway.getItemCounts(ctx);
} on DomainError catch (e) {
if (e.code != ErrorCode.authInvalidCredentials &&
e.code != ErrorCode.authForbidden) {
continue;
}
if (await _reauth(server.baseUrl, session)) repaired++;
} catch (_) {
continue;
}
}
return repaired;
}
Future<bool> _reauth(String baseUrl, SessionData session) async {
final pwd = session.password;
if (pwd == null || pwd.isEmpty) return false;
try {
final auth = await embyGateway.authenticateByName(
baseUrl: baseUrl,
username: session.userName,
password: pwd,
);
await sessionRepository.setMany([
session.copyWith(token: auth.accessToken, userId: auth.userId),
]);
return true;
} catch (_) {
return false;
}
}
}
@@ -0,0 +1,179 @@
import 'package:uuid/uuid.dart';
import '../../contracts/contracts.dart';
import '../errors.dart';
import '../ports/emby_gateway.dart';
import '../ports/server_repository.dart';
import '../ports/session_repository.dart';
const _uuid = Uuid();
class ListServersUseCase {
final ServerRepository serverRepository;
ListServersUseCase({required this.serverRepository});
Future<List<EmbyServer>> execute() => serverRepository.list();
}
class ReorderServersUseCase {
final ServerRepository serverRepository;
ReorderServersUseCase({required this.serverRepository});
Future<void> execute(List<EmbyServer> orderedServers) {
return serverRepository.replaceAll(orderedServers);
}
}
class ProbeServerUseCase {
final EmbyGateway embyGateway;
ProbeServerUseCase({required this.embyGateway});
Future<ProbePublicInfoRes> execute(EmbyServerProbeReq input) async {
final baseUrl = input.baseUrl.trim();
if (baseUrl.isEmpty) {
throw DomainError(ErrorCode.invalidParams, '服务器地址不能为空');
}
return embyGateway.probePublicInfo(baseUrl);
}
}
class SaveServerUseCase {
final ServerRepository serverRepository;
final DateTime Function() now;
final String Function() idGenerator;
SaveServerUseCase({
required this.serverRepository,
DateTime Function()? now,
String Function()? idGenerator,
}) : now = (now ?? DateTime.now),
idGenerator = (idGenerator ?? _defaultIdGenerator);
static String _defaultIdGenerator() => _uuid.v4();
Future<EmbyServer> execute(EmbyServerSaveReq input) async {
final name = input.name.trim();
if (name.isEmpty) {
throw DomainError(ErrorCode.invalidParams, '服务器名称不能为空');
}
final baseUrl = _normalizeBaseUrl(input.baseUrl);
final nowIso = now().toUtc().toIso8601String();
final duplicated = await serverRepository.findByBaseUrl(baseUrl);
if (duplicated != null && duplicated.id != input.id) {
throw DomainError(
ErrorCode.storeConflict,
'服务器地址已存在',
details: {'baseUrl': baseUrl},
);
}
final iconUrl = input.iconUrl.trim();
if (input.id != null) {
final current = await serverRepository.findById(input.id!);
if (current == null) {
throw DomainError(
ErrorCode.storeNotFound,
'服务器不存在',
details: {'id': input.id},
);
}
return serverRepository.update(
current.copyWith(
name: name,
baseUrl: baseUrl,
updatedAt: nowIso,
iconUrl: iconUrl,
),
);
}
return serverRepository.create(
EmbyServer(
id: idGenerator(),
name: name,
baseUrl: baseUrl,
createdAt: nowIso,
updatedAt: nowIso,
iconUrl: iconUrl,
),
);
}
String _normalizeBaseUrl(String raw) {
final trimmed = raw.trim();
if (trimmed.isEmpty) {
throw DomainError(ErrorCode.invalidParams, '服务器地址不能为空');
}
Uri parsed;
try {
parsed = Uri.parse(trimmed);
} catch (_) {
throw DomainError(
ErrorCode.invalidParams,
'服务器地址格式不正确',
details: {'baseUrl': raw},
);
}
if (parsed.scheme != 'http' && parsed.scheme != 'https') {
throw DomainError(
ErrorCode.invalidParams,
'仅支持 HTTP/HTTPS 协议',
details: {'baseUrl': raw},
);
}
final origin =
'${parsed.scheme}://${parsed.host}'
'${parsed.hasPort ? ':${parsed.port}' : ''}';
final cleanPath = parsed.path.replaceAll(RegExp(r'/+$'), '');
final normalizedPath = cleanPath.isEmpty ? '/' : cleanPath;
if (RegExp(r'/emby$', caseSensitive: false).hasMatch(normalizedPath)) {
return '$origin$normalizedPath';
}
if (normalizedPath == '/') {
return '$origin/emby';
}
return '$origin$normalizedPath/emby';
}
}
class TogglePauseServerUseCase {
final ServerRepository serverRepository;
TogglePauseServerUseCase({required this.serverRepository});
Future<EmbyServer> execute(String id) async {
final current = await serverRepository.findById(id);
if (current == null) {
throw DomainError(ErrorCode.storeNotFound, '服务器不存在', details: {'id': id});
}
return serverRepository.update(current.copyWith(paused: !current.paused));
}
}
class DeleteServerUseCase {
final ServerRepository serverRepository;
final SessionRepository sessionRepository;
DeleteServerUseCase({
required this.serverRepository,
required this.sessionRepository,
});
Future<void> execute(String id) async {
final exists = await serverRepository.findById(id);
if (exists == null) {
throw DomainError(ErrorCode.storeNotFound, '服务器不存在', details: {'id': id});
}
await serverRepository.deleteById(id);
await sessionRepository.clear(id);
}
}
@@ -0,0 +1,82 @@
import '../../contracts/contracts.dart';
import '../errors.dart';
import '../ports/emby_gateway.dart';
import '../ports/server_repository.dart';
import '../ports/session_repository.dart';
Future<AuthedRequestContext> resolveAuthedContext({
required SessionRepository sessionRepository,
required ServerRepository serverRepository,
}) async {
final session = await sessionRepository.getActive();
if (session == null) {
throw DomainError(ErrorCode.storeNotFound, '没有活跃会话');
}
final server = await serverRepository.findById(session.serverId);
if (server == null) {
throw DomainError(
ErrorCode.storeNotFound,
'服务器不存在',
details: {'serverId': session.serverId},
);
}
return AuthedRequestContext(
baseUrl: server.baseUrl,
token: session.token,
userId: session.userId,
);
}
Future<AuthedRequestContext?> resolveAuthedContextForServer({
required SessionRepository sessionRepository,
required ServerRepository serverRepository,
required String serverId,
}) async {
final session = await sessionRepository.getByServerId(serverId);
if (session == null) return null;
final server = await serverRepository.findById(serverId);
if (server == null) return null;
return AuthedRequestContext(
baseUrl: server.baseUrl,
token: session.token,
userId: session.userId,
);
}
abstract class AuthedUseCase {
AuthedUseCase({
required this.sessionRepository,
required this.serverRepository,
required this.embyGateway,
});
final SessionRepository sessionRepository;
final ServerRepository serverRepository;
final EmbyGateway embyGateway;
Future<AuthedRequestContext> ctx() => resolveAuthedContext(
sessionRepository: sessionRepository,
serverRepository: serverRepository,
);
Future<AuthedRequestContext> requireCtxForServer(String serverId) async {
final ctx = await resolveAuthedContextForServer(
sessionRepository: sessionRepository,
serverRepository: serverRepository,
serverId: serverId,
);
if (ctx == null) {
throw DomainError(
ErrorCode.storeNotFound,
'服务器无活跃会话',
details: {'serverId': serverId},
);
}
return ctx;
}
}