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