diff --git a/admin/src/views/CollectSources.vue b/admin/src/views/CollectSources.vue index 643a30b..b239e33 100644 --- a/admin/src/views/CollectSources.vue +++ b/admin/src/views/CollectSources.vue @@ -119,6 +119,12 @@ {{ form.kind === 'aidrama91' ? '短剧单页上限 20' : '短视频单页上限 50' }} + + + + 秒;每条处理完、每翻一页各等待该时长 + + @@ -153,7 +159,7 @@ const syncAllLoading = ref(false) const form = reactive({ kind: 'url', name: '', list_url: '', source_id: 0, - account: '', password: '', category_ids_text: '4', page_size: 20, + account: '', password: '', category_ids_text: '4', page_size: 20, delay: 0.3, }) const sources = ref([]) @@ -206,7 +212,7 @@ function showCreate() { editing.value = null Object.assign(form, { kind: 'url', name: '', list_url: '', source_id: sources.value[0]?.id ?? 0, - account: '', password: '', category_ids_text: '4', page_size: 20, + account: '', password: '', category_ids_text: '4', page_size: 20, delay: 0.3, }) dialogVisible.value = true } @@ -219,6 +225,7 @@ function showEdit(row) { account: row.account || '', password: '', category_ids_text: (row.category_ids || [4]).join(','), page_size: row.page_size || 20, + delay: row.delay ?? 0.3, }) dialogVisible.value = true } @@ -246,6 +253,7 @@ async function save() { payload.account = form.account payload.category_ids = ids.length ? ids : [4] payload.page_size = form.page_size || 20 + payload.delay = form.delay ?? 0.3 if (form.password) payload.password = form.password } diff --git a/app/lib/main.dart b/app/lib/main.dart index 773715e..b167104 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -1,7 +1,11 @@ +import 'dart:async'; +import 'dart:ui' as ui; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:media_kit/media_kit.dart'; +import 'core/log/action_logger.dart'; import 'core/media/download_manager.dart'; import 'core/media/local_media_server.dart'; import 'core/storage/local_store.dart'; @@ -13,24 +17,69 @@ import 'router.dart'; /// App 入口:本地存储 + MediaKit + MaterialApp.router Future main() async { - WidgetsFlutterBinding.ensureInitialized(); - // 版本信息(版本检查/请求头 X-App-Version 依赖,必须先初始化) - await AppConfig.initVersion(); - // MediaKit(libmpv 底层)全局初始化:每个进程必须且仅调一次 - MediaKit.ensureInitialized(); - // 本地存储初始化(设备指纹在 splash 阶段生成) - await LocalStore.init(); - // 搜索历史(本地,不上报服务端) - await SearchHistoryStore.init(); - // 观看上报去重(持久化;不初始化则观看计费/游客免费额度链路失效) - await WatchDedupStore.init(); - // 本地媒体服务:HLS 1G 分片缓存(回看免下载)+ 已下载视频离线播放 - await LocalMediaServer.I.init(); + // 全局异常捕获:把 Dart 侧未捕获异常上报到操作日志(仅白名单用户启用)。 + // 注意:原生层(mpv/内核)崩溃这里捕获不到,只能拿到 Dart/Framework 层。 + runZonedGuarded(() async { + WidgetsFlutterBinding.ensureInitialized(); - runApp(ProviderScope( - overrides: const [], - child: const _AppInitializer(), - )); + // Flutter Framework 错误(build/layout/paint 阶段抛出) + FlutterError.onError = (details) { + FlutterError.presentError(details); + ActionLogger.I.log( + 'crash_flutter_error', + page: 'app', + detail: { + 'exception': details.exceptionAsString(), + 'library': details.library ?? '', + 'stack': (details.stack ?? StackTrace.empty).toString().split('\n').take(20).join('\n'), + }, + ); + unawaited(ActionLogger.I.flush()); + }; + + // Dart 异步/平台通道未捕获错误 + ui.PlatformDispatcher.instance.onError = (error, stack) { + ActionLogger.I.log( + 'crash_platform_error', + page: 'app', + detail: { + 'exception': error.toString(), + 'stack': stack.toString().split('\n').take(20).join('\n'), + }, + ); + unawaited(ActionLogger.I.flush()); + return true; + }; + + // 版本信息(版本检查/请求头 X-App-Version 依赖,必须先初始化) + await AppConfig.initVersion(); + // MediaKit(libmpv 底层)全局初始化:每个进程必须且仅调一次 + MediaKit.ensureInitialized(); + // 本地存储初始化(设备指纹在 splash 阶段生成) + await LocalStore.init(); + // 搜索历史(本地,不上报服务端) + await SearchHistoryStore.init(); + // 观看上报去重(持久化;不初始化则观看计费/游客免费额度链路失效) + await WatchDedupStore.init(); + // 本地媒体服务:HLS 1G 分片缓存(回看免下载)+ 已下载视频离线播放 + await LocalMediaServer.I.init(); + + runApp(ProviderScope( + overrides: const [], + child: const _AppInitializer(), + )); + }, (error, stack) { + // Zone 内未捕获错误(一般与 PlatformDispatcher 重叠,双保险) + ActionLogger.I.log( + 'crash_zone', + page: 'app', + detail: { + 'exception': error.toString(), + 'stack': stack.toString().split('\n').take(20).join('\n'), + }, + ); + unawaited(ActionLogger.I.flush()); + }); } /// 初始化需要 Provider 环境的单例(下载管理器扫描本地已下载列表) diff --git a/app/lib/screens/feed/feed_screen.dart b/app/lib/screens/feed/feed_screen.dart index dd7fe7b..671603b 100644 --- a/app/lib/screens/feed/feed_screen.dart +++ b/app/lib/screens/feed/feed_screen.dart @@ -130,23 +130,30 @@ class _FeedScreenState extends ConsumerState .read(feedProvider(_strategy).notifier) .events .listen(_handleEvent); + // 保留每个策略的播放进度(currentIndex 由 provider 维护):切回时跳到上次位置而非归零 final targetIndex = ref.read(feedProvider(_strategy)).currentIndex; - if (_pageController.hasClients && targetIndex > 0) { - _pageController.jumpToPage(targetIndex); - } else if (_pageController.hasClients) { - _pageController.jumpToPage(0); - } - // jumpToPage 后 resume + rebindCurrent:suspend 已 stop 槽位,仅 play - // 播不起来,必须 rebind 重新 open(有断点记录则 seek 续播) + ActionLogger.I.log('feed_switch_strategy', page: '/home', detail: { + 'to': next.name, + 'target': targetIndex, + 'liked': ref.read(feedProvider(FeedStrategy.liked)).currentIndex, + 'viewed': ref.read(feedProvider(FeedStrategy.viewed)).currentIndex, + }); + // 必须等新策略的 PageView 重建完成后再跳页:同步 jumpToPage 触发的是 + // 旧 PageView 的 onPageChanged(回调仍指向旧策略 notifier),会把旧策略的 + // currentIndex 改成 0,导致切回时从第 1 条开始播 WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - unawaited(() async { - final n = ref.read(feedProvider(_strategy).notifier); - await n.pool.resume(); - await n.rebindCurrent(); - }()); + if (!mounted) return; + if (_pageController.hasClients) { + _pageController.jumpToPage(targetIndex); } + // jumpToPage 后 resume + rebindCurrent:suspend 已 stop 槽位,仅 play + // 播不起来,必须 rebind 重新 open(有断点记录则 seek 续播) + unawaited(() async { + final n = ref.read(feedProvider(_strategy).notifier); + await n.pool.resume(); + await n.rebindCurrent(); + }()); }); } diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 0139af9..8388ea4 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -2,7 +2,7 @@ name: video_app description: "短视频应用(Flutter,iOS/Android 双端)" publish_to: 'none' -version: 1.3.28+44 +version: 1.3.31+47 environment: sdk: ^3.9.2 diff --git a/server/app/api/v1/admin.py b/server/app/api/v1/admin.py index 40f10c8..e86de39 100644 --- a/server/app/api/v1/admin.py +++ b/server/app/api/v1/admin.py @@ -1110,7 +1110,8 @@ class _SourceCreate(BaseModel): account: str = "" # aidrama91:源站登录账号 password: str = "" # aidrama91:源站登录密码 category_ids: list[int] = _Field(default_factory=lambda: [4]) # aidrama91:源站分类 - page_size: int = _Field(20, ge=1, le=20) + page_size: int = _Field(20, ge=1, le=50) + delay: float = _Field(0.3, ge=0, le=60) # 每条/每页之间的等待秒数 class _SourceUpdate(BaseModel): @@ -1121,7 +1122,8 @@ class _SourceUpdate(BaseModel): account: str | None = None password: str | None = None category_ids: list[int] | None = None - page_size: int | None = _Field(None, ge=1, le=20) + page_size: int | None = _Field(None, ge=1, le=50) + delay: float | None = _Field(None, ge=0, le=60) def _source_dict(s) -> dict: @@ -1137,6 +1139,7 @@ def _source_dict(s) -> dict: "account": cfg.get("account", ""), "category_ids": cfg.get("category_ids") or [], "page_size": cfg.get("page_size") or 20, + "delay": cfg.get("delay", 0.3), "has_token": bool(cfg.get("token")), "last_sync_at": s.last_sync_at.strftime("%Y-%m-%d %H:%M:%S") if s.last_sync_at else "", "created_at": s.created_at.strftime("%Y-%m-%d %H:%M:%S"), @@ -1187,7 +1190,8 @@ async def create_source(req: _SourceCreate, request: Request, db: AsyncSession = cfg = {} if req.kind in ("aidrama91", "aidrama91_vlog"): cfg = {"account": req.account, "password": req.password, - "category_ids": req.category_ids or [4], "page_size": req.page_size} + "category_ids": req.category_ids or [4], "page_size": req.page_size, + "delay": req.delay} s = CollectionSource(name=req.name.strip(), kind=req.kind, list_url=req.list_url.strip(), config=_json.dumps(cfg, ensure_ascii=False), category_id=cid, source_id=req.source_id) @@ -1223,7 +1227,7 @@ async def update_source(source_id: int, req: _SourceUpdate, db: AsyncSession = D d["name"] = d["name"].strip() # 接口类源的账号/密码/分类等写入 config - cfg_keys = ("account", "password", "category_ids", "page_size") + cfg_keys = ("account", "password", "category_ids", "page_size", "delay") cfg = _json.loads(s.config or "{}") for k in cfg_keys: if k in d: diff --git a/server/app/services/aidrama91_service.py b/server/app/services/aidrama91_service.py index c66ca67..2e37576 100644 --- a/server/app/services/aidrama91_service.py +++ b/server/app/services/aidrama91_service.py @@ -484,6 +484,9 @@ async def sync_source( should_cancel: 可选回调,返回 True 时在每页/每条边界退出循环(用于手动取消) """ cfg = load_config(source) + # 每条/每页之间的间隔可由采集源 config 的 delay 自定义(秒),未配置时用默认值 + if cfg.get("delay") is not None: + delay = float(cfg["delay"]) stat: dict = { "kind": KIND, "dramas_new": 0, "dramas_exist": 0, "episodes_created": 0, "episodes_skipped": 0, "pages": 0, "total": 0, @@ -748,6 +751,9 @@ async def sync_vlog_source( ) -> dict: """vlog 短视频翻页增量采集。should_cancel 返回 True 时每页/每条边界退出""" cfg = load_config(source) + # 每条/每页之间的间隔可由采集源 config 的 delay 自定义(秒),未配置时用默认值 + if cfg.get("delay") is not None: + delay = float(cfg["delay"]) stat: dict = { "kind": VLOG_KIND, "videos_new": 0, "videos_exist": 0, "pages": 0,