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
@@ -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)}';
}