1
This commit is contained in:
@@ -119,6 +119,12 @@
|
|||||||
{{ form.kind === 'aidrama91' ? '短剧单页上限 20' : '短视频单页上限 50' }}
|
{{ form.kind === 'aidrama91' ? '短剧单页上限 20' : '短视频单页上限 50' }}
|
||||||
</span>
|
</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="请求间隔">
|
||||||
|
<el-input-number v-model="form.delay" :min="0" :max="60" :step="0.1" :precision="1" />
|
||||||
|
<span style="margin-left: 8px; color: #909399; font-size: 12px">
|
||||||
|
秒;每条处理完、每翻一页各等待该时长
|
||||||
|
</span>
|
||||||
|
</el-form-item>
|
||||||
</template>
|
</template>
|
||||||
<el-form-item label="内容来源">
|
<el-form-item label="内容来源">
|
||||||
<el-select v-model="form.source_id" placeholder="采集入库时打上该来源标签" style="width: 100%">
|
<el-select v-model="form.source_id" placeholder="采集入库时打上该来源标签" style="width: 100%">
|
||||||
@@ -153,7 +159,7 @@ const syncAllLoading = ref(false)
|
|||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
kind: 'url', name: '', list_url: '', source_id: 0,
|
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([])
|
const sources = ref([])
|
||||||
@@ -206,7 +212,7 @@ function showCreate() {
|
|||||||
editing.value = null
|
editing.value = null
|
||||||
Object.assign(form, {
|
Object.assign(form, {
|
||||||
kind: 'url', name: '', list_url: '', source_id: sources.value[0]?.id ?? 0,
|
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
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
@@ -219,6 +225,7 @@ function showEdit(row) {
|
|||||||
account: row.account || '', password: '',
|
account: row.account || '', password: '',
|
||||||
category_ids_text: (row.category_ids || [4]).join(','),
|
category_ids_text: (row.category_ids || [4]).join(','),
|
||||||
page_size: row.page_size || 20,
|
page_size: row.page_size || 20,
|
||||||
|
delay: row.delay ?? 0.3,
|
||||||
})
|
})
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
@@ -246,6 +253,7 @@ async function save() {
|
|||||||
payload.account = form.account
|
payload.account = form.account
|
||||||
payload.category_ids = ids.length ? ids : [4]
|
payload.category_ids = ids.length ? ids : [4]
|
||||||
payload.page_size = form.page_size || 20
|
payload.page_size = form.page_size || 20
|
||||||
|
payload.delay = form.delay ?? 0.3
|
||||||
if (form.password) payload.password = form.password
|
if (form.password) payload.password = form.password
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+66
-17
@@ -1,7 +1,11 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
import 'package:media_kit/media_kit.dart';
|
||||||
|
|
||||||
|
import 'core/log/action_logger.dart';
|
||||||
import 'core/media/download_manager.dart';
|
import 'core/media/download_manager.dart';
|
||||||
import 'core/media/local_media_server.dart';
|
import 'core/media/local_media_server.dart';
|
||||||
import 'core/storage/local_store.dart';
|
import 'core/storage/local_store.dart';
|
||||||
@@ -13,24 +17,69 @@ import 'router.dart';
|
|||||||
|
|
||||||
/// App 入口:本地存储 + MediaKit + MaterialApp.router
|
/// App 入口:本地存储 + MediaKit + MaterialApp.router
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
// 全局异常捕获:把 Dart 侧未捕获异常上报到操作日志(仅白名单用户启用)。
|
||||||
// 版本信息(版本检查/请求头 X-App-Version 依赖,必须先初始化)
|
// 注意:原生层(mpv/内核)崩溃这里捕获不到,只能拿到 Dart/Framework 层。
|
||||||
await AppConfig.initVersion();
|
runZonedGuarded(() async {
|
||||||
// MediaKit(libmpv 底层)全局初始化:每个进程必须且仅调一次
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
MediaKit.ensureInitialized();
|
|
||||||
// 本地存储初始化(设备指纹在 splash 阶段生成)
|
|
||||||
await LocalStore.init();
|
|
||||||
// 搜索历史(本地,不上报服务端)
|
|
||||||
await SearchHistoryStore.init();
|
|
||||||
// 观看上报去重(持久化;不初始化则观看计费/游客免费额度链路失效)
|
|
||||||
await WatchDedupStore.init();
|
|
||||||
// 本地媒体服务:HLS 1G 分片缓存(回看免下载)+ 已下载视频离线播放
|
|
||||||
await LocalMediaServer.I.init();
|
|
||||||
|
|
||||||
runApp(ProviderScope(
|
// Flutter Framework 错误(build/layout/paint 阶段抛出)
|
||||||
overrides: const [],
|
FlutterError.onError = (details) {
|
||||||
child: const _AppInitializer(),
|
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 环境的单例(下载管理器扫描本地已下载列表)
|
/// 初始化需要 Provider 环境的单例(下载管理器扫描本地已下载列表)
|
||||||
|
|||||||
@@ -130,23 +130,30 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
|
|||||||
.read(feedProvider(_strategy).notifier)
|
.read(feedProvider(_strategy).notifier)
|
||||||
.events
|
.events
|
||||||
.listen(_handleEvent);
|
.listen(_handleEvent);
|
||||||
|
|
||||||
// 保留每个策略的播放进度(currentIndex 由 provider 维护):切回时跳到上次位置而非归零
|
// 保留每个策略的播放进度(currentIndex 由 provider 维护):切回时跳到上次位置而非归零
|
||||||
final targetIndex = ref.read(feedProvider(_strategy)).currentIndex;
|
final targetIndex = ref.read(feedProvider(_strategy)).currentIndex;
|
||||||
if (_pageController.hasClients && targetIndex > 0) {
|
ActionLogger.I.log('feed_switch_strategy', page: '/home', detail: {
|
||||||
_pageController.jumpToPage(targetIndex);
|
'to': next.name,
|
||||||
} else if (_pageController.hasClients) {
|
'target': targetIndex,
|
||||||
_pageController.jumpToPage(0);
|
'liked': ref.read(feedProvider(FeedStrategy.liked)).currentIndex,
|
||||||
}
|
'viewed': ref.read(feedProvider(FeedStrategy.viewed)).currentIndex,
|
||||||
// jumpToPage 后 resume + rebindCurrent:suspend 已 stop 槽位,仅 play
|
});
|
||||||
// 播不起来,必须 rebind 重新 open(有断点记录则 seek 续播)
|
// 必须等新策略的 PageView 重建完成后再跳页:同步 jumpToPage 触发的是
|
||||||
|
// 旧 PageView 的 onPageChanged(回调仍指向旧策略 notifier),会把旧策略的
|
||||||
|
// currentIndex 改成 0,导致切回时从第 1 条开始播
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted) {
|
if (!mounted) return;
|
||||||
unawaited(() async {
|
if (_pageController.hasClients) {
|
||||||
final n = ref.read(feedProvider(_strategy).notifier);
|
_pageController.jumpToPage(targetIndex);
|
||||||
await n.pool.resume();
|
|
||||||
await n.rebindCurrent();
|
|
||||||
}());
|
|
||||||
}
|
}
|
||||||
|
// 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();
|
||||||
|
}());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ name: video_app
|
|||||||
description: "短视频应用(Flutter,iOS/Android 双端)"
|
description: "短视频应用(Flutter,iOS/Android 双端)"
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
version: 1.3.28+44
|
version: 1.3.31+47
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.9.2
|
sdk: ^3.9.2
|
||||||
|
|||||||
@@ -1110,7 +1110,8 @@ class _SourceCreate(BaseModel):
|
|||||||
account: str = "" # aidrama91:源站登录账号
|
account: str = "" # aidrama91:源站登录账号
|
||||||
password: str = "" # aidrama91:源站登录密码
|
password: str = "" # aidrama91:源站登录密码
|
||||||
category_ids: list[int] = _Field(default_factory=lambda: [4]) # 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):
|
class _SourceUpdate(BaseModel):
|
||||||
@@ -1121,7 +1122,8 @@ class _SourceUpdate(BaseModel):
|
|||||||
account: str | None = None
|
account: str | None = None
|
||||||
password: str | None = None
|
password: str | None = None
|
||||||
category_ids: list[int] | 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:
|
def _source_dict(s) -> dict:
|
||||||
@@ -1137,6 +1139,7 @@ def _source_dict(s) -> dict:
|
|||||||
"account": cfg.get("account", ""),
|
"account": cfg.get("account", ""),
|
||||||
"category_ids": cfg.get("category_ids") or [],
|
"category_ids": cfg.get("category_ids") or [],
|
||||||
"page_size": cfg.get("page_size") or 20,
|
"page_size": cfg.get("page_size") or 20,
|
||||||
|
"delay": cfg.get("delay", 0.3),
|
||||||
"has_token": bool(cfg.get("token")),
|
"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 "",
|
"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"),
|
"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 = {}
|
cfg = {}
|
||||||
if req.kind in ("aidrama91", "aidrama91_vlog"):
|
if req.kind in ("aidrama91", "aidrama91_vlog"):
|
||||||
cfg = {"account": req.account, "password": req.password,
|
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(),
|
s = CollectionSource(name=req.name.strip(), kind=req.kind, list_url=req.list_url.strip(),
|
||||||
config=_json.dumps(cfg, ensure_ascii=False),
|
config=_json.dumps(cfg, ensure_ascii=False),
|
||||||
category_id=cid, source_id=req.source_id)
|
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()
|
d["name"] = d["name"].strip()
|
||||||
|
|
||||||
# 接口类源的账号/密码/分类等写入 config
|
# 接口类源的账号/密码/分类等写入 config
|
||||||
cfg_keys = ("account", "password", "category_ids", "page_size")
|
cfg_keys = ("account", "password", "category_ids", "page_size", "delay")
|
||||||
cfg = _json.loads(s.config or "{}")
|
cfg = _json.loads(s.config or "{}")
|
||||||
for k in cfg_keys:
|
for k in cfg_keys:
|
||||||
if k in d:
|
if k in d:
|
||||||
|
|||||||
@@ -484,6 +484,9 @@ async def sync_source(
|
|||||||
should_cancel: 可选回调,返回 True 时在每页/每条边界退出循环(用于手动取消)
|
should_cancel: 可选回调,返回 True 时在每页/每条边界退出循环(用于手动取消)
|
||||||
"""
|
"""
|
||||||
cfg = load_config(source)
|
cfg = load_config(source)
|
||||||
|
# 每条/每页之间的间隔可由采集源 config 的 delay 自定义(秒),未配置时用默认值
|
||||||
|
if cfg.get("delay") is not None:
|
||||||
|
delay = float(cfg["delay"])
|
||||||
stat: dict = {
|
stat: dict = {
|
||||||
"kind": KIND, "dramas_new": 0, "dramas_exist": 0,
|
"kind": KIND, "dramas_new": 0, "dramas_exist": 0,
|
||||||
"episodes_created": 0, "episodes_skipped": 0, "pages": 0, "total": 0,
|
"episodes_created": 0, "episodes_skipped": 0, "pages": 0, "total": 0,
|
||||||
@@ -748,6 +751,9 @@ async def sync_vlog_source(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""vlog 短视频翻页增量采集。should_cancel 返回 True 时每页/每条边界退出"""
|
"""vlog 短视频翻页增量采集。should_cancel 返回 True 时每页/每条边界退出"""
|
||||||
cfg = load_config(source)
|
cfg = load_config(source)
|
||||||
|
# 每条/每页之间的间隔可由采集源 config 的 delay 自定义(秒),未配置时用默认值
|
||||||
|
if cfg.get("delay") is not None:
|
||||||
|
delay = float(cfg["delay"])
|
||||||
stat: dict = {
|
stat: dict = {
|
||||||
"kind": VLOG_KIND, "videos_new": 0, "videos_exist": 0,
|
"kind": VLOG_KIND, "videos_new": 0, "videos_exist": 0,
|
||||||
"pages": 0,
|
"pages": 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user