refactor(sync): drop favorite-folder tracking and deal

This commit is contained in:
kairee committed 2026-11-01 13:59:10 +08:00
1 parent 62abbd7ea1
commit 6c4287b43a
21 files changed
+114 -1490

No files matched your search

+4 -17
View File
@@ -1,5 +1,8 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""B 站收藏夹写客户端(``deal`` / ``sort``)。 """B 站收藏夹写客户端(``sort``)。
收藏夹重排序是本项目**唯一**使用 ``bilibili.write`` 凭据的写操作,且频率极低。
加入收藏夹(``deal``)等写操作已移除,收藏夹由人工维护。
只应使用 ``bilibili.write`` 凭据构造。 只应使用 ``bilibili.write`` 凭据构造。
""" """
@@ -39,22 +42,6 @@ class BilibiliWriteClient(BilibiliClient):
raise self._api_error(payload) raise self._api_error(payload)
return payload return payload
def deal(self, aid: int, folder_id: int) -> dict[str, Any]:
return self.post_form(
f"{BASE}/x/v3/fav/resource/deal",
{
"rid": int(aid),
"type": 2,
"add_media_ids": int(folder_id),
"del_media_ids": "",
"csrf": self.csrf,
},
referer=(
f"https://space.bilibili.com/{self.mid}/favlist"
f"?fid={int(folder_id)}&ftype=create"
),
)
def sort(self, media_id: int, sort_spec: str) -> dict[str, Any]: def sort(self, media_id: int, sort_spec: str) -> dict[str, Any]:
return self.post_form( return self.post_form(
f"{BASE}/x/v3/fav/resource/sort", f"{BASE}/x/v3/fav/resource/sort",
+39 -95
View File
@@ -2,7 +2,7 @@
"""看板命令行入口。 """看板命令行入口。
python app.py init # 建库 / 迁移 / 写入黑名单种子(离线) python app.py init # 建库 / 迁移 / 写入黑名单种子(离线)
python app.py sync # 收藏夹只读同步(首次导入或增量) python app.py ingest # 手动入库 B 站视频链接 / BV 号 / av 号(只写本地库)
python app.py snapshot # 采集当日七项快照与粉丝数 python app.py snapshot # 采集当日七项快照与粉丝数
python app.py discover # 执行一次搜索发现并写入候选池 python app.py discover # 执行一次搜索发现并写入候选池
python app.py candidates # 查看候选池(离线) python app.py candidates # 查看候选池(离线)
@@ -10,6 +10,7 @@
python app.py run # 按配置周期常驻运行(--once 只跑一次) python app.py run # 按配置周期常驻运行(--once 只跑一次)
python app.py status # 查看库内状态(离线) python app.py status # 查看库内状态(离线)
收藏夹重排序使用独立的 ``favsort.py``(唯一使用 ``bilibili.write`` 的功能)。
P2 起 ``python app.py`` 将在此基础上启动本地看板服务。 P2 起 ``python app.py`` 将在此基础上启动本地看板服务。
""" """
@@ -27,7 +28,6 @@ from .bilibili.auth_flow import AuthService
from .bilibili.client import BilibiliClient, Pacer from .bilibili.client import BilibiliClient, Pacer
from .bilibili.provider import ClientProvider from .bilibili.provider import ClientProvider
from .bilibili.search import SearchClient from .bilibili.search import SearchClient
from .bilibili.write_client import BilibiliWriteClient
from .config import ConfigError, ConfigStore from .config import ConfigError, ConfigStore
from .database import Database from .database import Database
from .logging_json import JsonLogger from .logging_json import JsonLogger
@@ -35,8 +35,7 @@ from .notify.telegram import TelegramClient
from .repository import Repository from .repository import Repository
from .scheduler import ScheduledTask, Scheduler from .scheduler import ScheduledTask, Scheduler
from .services.discovery import DiscoveryService from .services.discovery import DiscoveryService
from .services.favorites import FavoriteActionService from .services.library import LibraryService
from .services.folder_sync import FolderSyncService
from .services.snapshot import SnapshotService from .services.snapshot import SnapshotService
from .services.telegram_poller import TelegramPoller from .services.telegram_poller import TelegramPoller
from .services.up_profile import UpProfileService from .services.up_profile import UpProfileService
@@ -66,20 +65,6 @@ def _build_parser() -> argparse.ArgumentParser:
sub = parser.add_subparsers(dest="command", required=False) sub = parser.add_subparsers(dest="command", required=False)
sub.add_parser("init", parents=[common], help="建库、迁移并写入黑名单种子") sub.add_parser("init", parents=[common], help="建库、迁移并写入黑名单种子")
sync = sub.add_parser("sync", parents=[common], help="同步已配置的年度收藏夹")
sync.add_argument(
"--full", action="store_true", help="强制全量刷新元数据(忽略 6 小时周期)"
)
sync.add_argument(
"--allow-empty-folder",
action="store_true",
help="允许把远端 0 条视为真实清空(默认按异常跳过差集)",
)
sync.add_argument(
"--no-capture-followers",
action="store_true",
help="跳过同步末尾的 UP 粉丝采集(改用 backfill-followers 单独回填)",
)
snapshot = sub.add_parser( snapshot = sub.add_parser(
"snapshot", parents=[common], help="采集当日七项快照与粉丝数" "snapshot", parents=[common], help="采集当日七项快照与粉丝数"
) )
@@ -103,6 +88,14 @@ def _build_parser() -> argparse.ArgumentParser:
default=None, default=None,
help="只查看指定决策状态的候选", help="只查看指定决策状态的候选",
) )
ingest = sub.add_parser(
"ingest", parents=[common],
help="手动入库:B 站视频链接、BV 号或 av 号(只写本地库)",
)
ingest.add_argument(
"refs", nargs="+", metavar="REF",
help="视频链接、BV 号(BV…)、av 号(av12345)或含它们的字符串",
)
auth = sub.add_parser("auth", parents=[common], help="扫码授权并更新指定账号 Cookie") auth = sub.add_parser("auth", parents=[common], help="扫码授权并更新指定账号 Cookie")
auth.add_argument("--account", choices=("read", "write"), default="read", auth.add_argument("--account", choices=("read", "write"), default="read",
help="要授权的账号角色(默认 read)") help="要授权的账号角色(默认 read)")
@@ -112,15 +105,10 @@ def _build_parser() -> argparse.ArgumentParser:
auth_check = sub.add_parser("auth-check", parents=[common], help="检查账号登录态") auth_check = sub.add_parser("auth-check", parents=[common], help="检查账号登录态")
auth_check.add_argument("--account", choices=("read", "write", "all"), default="read", auth_check.add_argument("--account", choices=("read", "write", "all"), default="read",
help="要检查的账号角色(默认 read)") help="要检查的账号角色(默认 read)")
run = sub.add_parser("run", parents=[common], help="按配置周期常驻运行同步与快照") run = sub.add_parser("run", parents=[common], help="按配置周期常驻运行发现与快照")
run.add_argument( run.add_argument(
"--once", action="store_true", help="只执行一次到期任务后退出" "--once", action="store_true", help="只执行一次到期任务后退出"
) )
run.add_argument(
"--allow-empty-folder",
action="store_true",
help="允许把远端 0 条视为真实清空(默认按异常跳过差集)",
)
serve = sub.add_parser("serve", parents=[common], help="启动只读本地看板(不运行调度)") serve = sub.add_parser("serve", parents=[common], help="启动只读本地看板(不运行调度)")
serve.add_argument( serve.add_argument(
"--preview", action="store_true", "--preview", action="store_true",
@@ -209,32 +197,30 @@ def cmd_init(args, logger: JsonLogger) -> int:
return 0 return 0
def cmd_sync(args, logger: JsonLogger) -> int: def cmd_ingest(args, logger: JsonLogger) -> int:
store = ConfigStore(args.config, require_auth=True) store = ConfigStore(args.config, require_auth=False)
cfg = store.get() cfg = store.get()
db = _open_db(cfg, logger) db = _open_db(cfg, logger)
try: try:
repo = Repository(db) repo = Repository(db)
client = _client(cfg.bilibili.read, logger) client = _client(cfg.bilibili.read, logger)
service = FolderSyncService( service = LibraryService(
store, store, repo, lambda: client, logger=logger
repo,
client,
logger=logger,
allow_empty_folders=args.allow_empty_folder,
capture_followers=not getattr(args, "no_capture_followers", False),
) )
started = int(time.time()) text = " ".join(args.refs)
summary = service.run(full_refresh=args.full) result = service.ingest_text(text)
payload = summary.as_dict() payload = result.as_dict()
payload["database"] = str(cfg.storage.database_path) payload["database"] = str(cfg.storage.database_path)
payload["counts"] = repo.counts()
_record_task( _record_task(
repo, "folder_sync", ok=summary.ok, started_at=started, repo, "ingest", ok=bool(result.results) and not any(
finished_at=int(time.time()), payload=payload, item.state == "error" for item in result.results
),
payload=payload,
) )
_print(payload) _print(payload)
return 0 if summary.ok else 1 return 0 if result.results and not any(
item.state == "error" for item in result.results
) else 1
finally: finally:
db.close() db.close()
@@ -403,7 +389,9 @@ def cmd_run(args, logger: JsonLogger, *, serve_web: bool = False) -> int:
web_server = None web_server = None
try: try:
repo = Repository(db) repo = Repository(db)
folder_provider = ClientProvider( if cfg.up_blacklist_seed:
repo.seed_blacklist(cfg.up_blacklist_seed)
read_provider = ClientProvider(
store, "read", lambda credentials: _client(credentials, logger) store, "read", lambda credentials: _client(credentials, logger)
) )
snapshot_provider = ClientProvider( snapshot_provider = ClientProvider(
@@ -413,34 +401,22 @@ def cmd_run(args, logger: JsonLogger, *, serve_web: bool = False) -> int:
credentials, logger, pacer=Pacer(1.0, 1.6), retries=1 credentials, logger, pacer=Pacer(1.0, 1.6), retries=1
), ),
) )
folder_service = FolderSyncService(
store,
repo,
client_provider=folder_provider,
logger=logger,
allow_empty_folders=args.allow_empty_folder,
)
snapshot_service = SnapshotService( snapshot_service = SnapshotService(
store, repo, client_provider=snapshot_provider, logger=logger store, repo, client_provider=snapshot_provider, logger=logger
) )
write_provider = ClientProvider( up_profile_service = UpProfileService(repo, read_provider.get, logger=logger)
library_service = LibraryService(
store, store,
"write", repo,
lambda credentials: BilibiliWriteClient( read_provider,
cookie=credentials.cookie, up_profile_service=up_profile_service,
csrf=credentials.csrf,
mid=credentials.mid,
logger=logger, logger=logger,
),
)
favorite_service = FavoriteActionService(
store, repo, write_provider, logger=logger
) )
notifier = _telegram(cfg, logger) notifier = _telegram(cfg, logger)
discovery_service = DiscoveryService( discovery_service = DiscoveryService(
store, store,
repo, repo,
SearchClient(folder_provider.get), SearchClient(read_provider.get),
notifier=notifier, notifier=notifier,
logger=logger, logger=logger,
) )
@@ -513,29 +489,6 @@ def cmd_run(args, logger: JsonLogger, *, serve_web: bool = False) -> int:
logger.info("snapshot.summary", **payload) logger.info("snapshot.summary", **payload)
return summary return summary
def run_sync():
started = int(time.time())
try:
summary = folder_service.run()
except Exception as exc: # noqa: BLE001
_record_task(
repo, "folder_sync", ok=False, started_at=started,
finished_at=int(time.time()), error=str(exc),
)
raise
payload = summary.as_dict()
_record_task(
repo, "folder_sync", ok=summary.ok, started_at=started,
finished_at=int(time.time()), payload=payload,
error=None if summary.ok else ";".join(summary.errors),
)
last["folder"] = summary
logger.info("folder_sync.summary", **payload)
# 新入库/恢复的视频立即补当天快照,而不是等到次日。
if summary.added > 0:
run_snapshot()
return summary
def run_config_watch(): def run_config_watch():
before = store.revision before = store.revision
store.get() store.get()
@@ -547,11 +500,6 @@ def cmd_run(args, logger: JsonLogger, *, serve_web: bool = False) -> int:
snapshot_at = f"{hh:02d}:{mm:02d}" snapshot_at = f"{hh:02d}:{mm:02d}"
scheduler = Scheduler( scheduler = Scheduler(
[ [
ScheduledTask(
"folder_sync",
float(cfg.schedule.folder_sync_minutes * 60),
run_sync,
),
ScheduledTask( ScheduledTask(
"snapshot", "snapshot",
86400.0, 86400.0,
@@ -579,7 +527,7 @@ def cmd_run(args, logger: JsonLogger, *, serve_web: bool = False) -> int:
daemon=True, daemon=True,
).start() ).start()
if notifier is not None and not args.once: if notifier is not None and not args.once:
poller = TelegramPoller(notifier, favorite_service, logger=logger) poller = TelegramPoller(notifier, library_service, logger=logger)
threading.Thread( threading.Thread(
target=poller.run_forever, target=poller.run_forever,
name="telegram-poller", name="telegram-poller",
@@ -592,18 +540,15 @@ def cmd_run(args, logger: JsonLogger, *, serve_web: bool = False) -> int:
else: else:
logger.info( logger.info(
"scheduler.start", "scheduler.start",
folder_sync_seconds=cfg.schedule.folder_sync_minutes * 60,
snapshot_at=snapshot_at, snapshot_at=snapshot_at,
) )
scheduler.run_forever() scheduler.run_forever()
folder_summary = last.get("folder")
snapshot_summary = last.get("snapshot") snapshot_summary = last.get("snapshot")
discover_summary = last.get("discover") discover_summary = last.get("discover")
folder_ok = folder_summary is None or getattr(folder_summary, "ok", True)
snapshot_ok = snapshot_summary is None or getattr(snapshot_summary, "completed", True) snapshot_ok = snapshot_summary is None or getattr(snapshot_summary, "completed", True)
discover_ok = discover_summary is None or not getattr(discover_summary, "errors", []) discover_ok = discover_summary is None or not getattr(discover_summary, "errors", [])
return 0 if folder_ok and snapshot_ok and discover_ok else 1 return 0 if snapshot_ok and discover_ok else 1
finally: finally:
if web_server is not None: if web_server is not None:
try: try:
@@ -703,16 +648,15 @@ def main(argv: Sequence[str] | None = None) -> int:
# 无子命令时启动完整服务:只读 Web + 调度 + Telegram。 # 无子命令时启动完整服务:只读 Web + 调度 + Telegram。
args.command = "run" args.command = "run"
args.once = False args.once = False
args.allow_empty_folder = False
return cmd_run(args, logger, serve_web=True) return cmd_run(args, logger, serve_web=True)
if args.command == "serve": if args.command == "serve":
return cmd_serve(args, logger) return cmd_serve(args, logger)
if args.command == "init": if args.command == "init":
return cmd_init(args, logger) return cmd_init(args, logger)
if args.command == "sync":
return cmd_sync(args, logger)
if args.command == "snapshot": if args.command == "snapshot":
return cmd_snapshot(args, logger) return cmd_snapshot(args, logger)
if args.command == "ingest":
return cmd_ingest(args, logger)
if args.command == "backfill-followers": if args.command == "backfill-followers":
return cmd_backfill_followers(args, logger) return cmd_backfill_followers(args, logger)
if args.command == "discover": if args.command == "discover":
-14
View File
@@ -68,8 +68,6 @@ DEFAULT_CONFIG: dict[str, Any] = {
"up_blacklist_seed": [], "up_blacklist_seed": [],
"schedule": { "schedule": {
"discover_minutes": 60, "discover_minutes": 60,
"folder_sync_minutes": 60,
"full_folder_refresh_hours": 6,
"snapshot_at": "00:01", "snapshot_at": "00:01",
"candidate_ttl_days": 7, "candidate_ttl_days": 7,
}, },
@@ -151,8 +149,6 @@ class FolderConfig:
@dataclass(frozen=True) @dataclass(frozen=True)
class ScheduleConfig: class ScheduleConfig:
discover_minutes: int discover_minutes: int
folder_sync_minutes: int
full_folder_refresh_hours: int
snapshot_at: str snapshot_at: str
candidate_ttl_days: int candidate_ttl_days: int
@@ -370,16 +366,6 @@ def _build_config(data: Mapping[str, Any], path: Path) -> AppConfig:
discover_minutes=_as_int( discover_minutes=_as_int(
sched_raw.get("discover_minutes", 60), "schedule.discover_minutes", minimum=1 sched_raw.get("discover_minutes", 60), "schedule.discover_minutes", minimum=1
), ),
folder_sync_minutes=_as_int(
sched_raw.get("folder_sync_minutes", 60),
"schedule.folder_sync_minutes",
minimum=1,
),
full_folder_refresh_hours=_as_int(
sched_raw.get("full_folder_refresh_hours", 6),
"schedule.full_folder_refresh_hours",
minimum=1,
),
snapshot_at=snapshot_at, snapshot_at=snapshot_at,
candidate_ttl_days=_as_int( candidate_ttl_days=_as_int(
sched_raw.get("candidate_ttl_days", 7), sched_raw.get("candidate_ttl_days", 7),
-82
View File
@@ -1,82 +0,0 @@
# -*- coding: utf-8 -*-
"""新视频在收藏夹中的插入位置计算(纯函数)。"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Mapping, Sequence
@dataclass(frozen=True)
class VideoMeta:
up_mid: int | None
pubdate: int
@dataclass(frozen=True)
class InsertionPlan:
target_position: int
moves: tuple[tuple[int, int], ...] # (pred_aid, moved_aid)
@property
def needs_head_move(self) -> bool:
return len(self.moves) == 2
def plan_insertion(
current_ids: Sequence[int],
new_aid: int,
metadata: Mapping[int, VideoMeta],
new_meta: VideoMeta,
) -> InsertionPlan:
"""按“同 UP 连续、组间按最早投稿倒序”计算插入点。
``current_ids`` 可以包含 ``new_aid``(通常在末尾),函数会先排除它。
"""
ordered = [int(aid) for aid in current_ids if int(aid) != int(new_aid)]
if not ordered:
return InsertionPlan(target_position=1, moves=())
groups: list[dict] = []
for index, aid in enumerate(ordered):
meta = metadata.get(aid)
up_mid = meta.up_mid if meta else None
if up_mid is not None and groups and groups[-1]["up_mid"] == up_mid:
groups[-1]["aids"].append(aid)
else:
groups.append({"up_mid": up_mid, "aids": [aid], "start": index})
for group in groups:
keys = [
metadata[aid].pubdate
for aid in group["aids"]
if aid in metadata and metadata[aid].pubdate > 0
]
group["key"] = min(keys) if keys else 0
insert_index: int | None = None
if new_meta.up_mid is not None:
for group in groups:
if group["up_mid"] == new_meta.up_mid:
insert_index = group["start"]
break
if insert_index is None:
for group in groups:
if group["key"] < new_meta.pubdate:
insert_index = group["start"]
break
if insert_index is None:
insert_index = len(ordered)
target_position = insert_index + 1
if insert_index == 0:
first = ordered[0]
return InsertionPlan(
target_position=1,
moves=((first, int(new_aid)), (int(new_aid), first)),
)
predecessor = ordered[insert_index - 1]
return InsertionPlan(
target_position=target_position,
moves=((predecessor, int(new_aid)),),
)
+9
View File
@@ -207,6 +207,15 @@ MIGRATIONS: tuple[Migration, ...] = (
""", """,
), ),
), ),
Migration(
version=5,
name="stop favorite-folder tracking",
statements=(
# 不再追踪收藏夹后,“取消收藏”状态失去语义;历史 unfaved 行归一到 active。
# 表结构与历史数据保留,仅停止写入。
"UPDATE video SET status = 'active' WHERE status = 'unfaved'",
),
),
) )
-237
View File
@@ -1,237 +0,0 @@
# -*- coding: utf-8 -*-
"""候选收藏、忽略、拉黑动作。"""
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Any
from ..bilibili.models import FavResource
from ..config import ConfigStore
from ..fav_position import VideoMeta, plan_insertion
from ..repository import Repository
from ..timeutil import shanghai_datetime
@dataclass
class ActionResult:
ok: bool
aid: int
action: str
state: str
position: int | None = None
already_in_folder: bool = False
message: str = ""
def as_dict(self) -> dict[str, Any]:
return {
"ok": self.ok,
"aid": self.aid,
"action": self.action,
"state": self.state,
"position": self.position,
"already_in_folder": self.already_in_folder,
"message": self.message,
}
class FavoriteActionService:
def __init__(
self,
config_store: ConfigStore,
repository: Repository,
write_provider,
*,
clock=time.time,
logger=None,
) -> None:
self.config_store = config_store
self.repo = repository
self.write_provider = write_provider
self._clock = clock
self.logger = logger
def _now(self) -> int:
return int(self._clock())
def _log(self, level: str, event: str, **fields) -> None:
if self.logger is not None:
getattr(self.logger, level)(event, **fields)
def _target_folder(self, candidate: dict[str, Any]):
cfg = self.config_store.get()
up_mid = candidate.get("up_mid")
if isinstance(up_mid, int) and not isinstance(up_mid, bool):
folder_id = self.repo.folder_for_up(up_mid)
if folder_id is not None:
row = self.repo.get_folder(folder_id)
if row is not None and row.enabled:
return row
year = shanghai_datetime(self._clock()).year
folder = cfg.folder_for_year(year)
if folder is not None:
return folder
latest = cfg.latest_folder()
if latest is not None:
return latest
raise RuntimeError("没有可用的目标收藏夹,请检查 folders 配置")
def favorite(self, aid: int) -> ActionResult:
aid = int(aid)
candidate = self.repo.get_candidate(aid)
if candidate is None:
return ActionResult(
ok=False, aid=aid, action="favorite", state="missing",
message="候选不存在或已处理",
)
try:
folder = self._target_folder(candidate)
except Exception as exc: # noqa: BLE001
return ActionResult(
ok=False, aid=aid, action="favorite", state="failed",
message=str(exc),
)
token, state = self.repo.begin_fav_action(
aid, folder.media_id, now=self._now()
)
if state == "done":
action = self.repo.get_fav_action(token) or {}
return ActionResult(
ok=True, aid=aid, action="favorite", state="done",
position=action.get("position"),
message="已在收藏夹",
)
if state == "busy":
return ActionResult(
ok=False, aid=aid, action="favorite", state="busy",
message="该候选正在处理中,请稍后重试",
)
already = False
try:
client = self.write_provider.get()
ids = client.fav_ids(folder.media_id)
if aid in {item.aid for item in ids}:
already = True
position = [item.aid for item in ids].index(aid) + 1
else:
client.deal(aid, folder.media_id)
ids = client.fav_ids(folder.media_id)
aid_order = [item.aid for item in ids]
if aid not in aid_order:
raise RuntimeError("deal 成功但回读收藏夹未找到该视频")
metadata = {
item_aid: VideoMeta(
up_mid=(info.get("up_mid") if info else None),
pubdate=int((info or {}).get("pubdate") or 0),
)
for item_aid, info in self.repo.video_metadata(aid_order).items()
}
plan = plan_insertion(
aid_order,
aid,
metadata,
VideoMeta(
up_mid=candidate.get("up_mid"),
pubdate=int(candidate.get("pubdate") or 0),
),
)
type_map = {item.aid: item.type for item in ids}
for predecessor, moved in plan.moves:
client.sort(
folder.media_id,
f"{predecessor}:{type_map.get(predecessor, 2)}:"
f"{moved}:{type_map.get(moved, 2)}",
)
final_order = [item.aid for item in client.fav_ids(folder.media_id)]
if aid not in final_order:
raise RuntimeError("排序后回读收藏夹未找到该视频")
position = final_order.index(aid) + 1
if position != plan.target_position:
raise RuntimeError(
f"回读位置不符:期望 #{plan.target_position},实际 #{position}"
)
resource = FavResource(
aid=aid,
bvid=candidate.get("bvid"),
title=str(candidate.get("title") or "(无标题)"),
up_mid=candidate.get("up_mid"),
up_name=candidate.get("up_name"),
pubdate=int(candidate.get("pubdate") or 0),
duration=None,
cover=candidate.get("cover"),
attr=0,
fav_time=None,
)
self.repo.upsert_from_fav(
resource, folder.media_id, was_added=True, now=self._now()
)
self.repo.delete_candidate(aid)
self.repo.finish_fav_action(token, position=position, now=self._now())
self._log(
"info", "favorite.done",
aid=aid, folder_id=folder.media_id, position=position,
already=already,
)
self.repo.bump_cache_version("videos")
return ActionResult(
ok=True, aid=aid, action="favorite", state="done",
position=position, already_in_folder=already,
message="已在收藏夹" if already else "已加入收藏夹",
)
except Exception as exc: # noqa: BLE001 失败保留候选,允许重试
self.repo.fail_fav_action(token, error=str(exc), now=self._now())
self._log(
"error", "favorite.failed",
aid=aid, folder_id=folder.media_id, error=str(exc),
)
return ActionResult(
ok=False, aid=aid, action="favorite", state="failed",
message=str(exc),
)
def ignore(self, aid: int) -> ActionResult:
aid = int(aid)
candidate = self.repo.get_candidate(aid)
if candidate is None:
return ActionResult(
ok=False, aid=aid, action="ignore", state="missing",
message="候选不存在或已处理",
)
self.repo.mark_candidate_decision(aid, "rejected", now=self._now())
self.repo.bump_cache_version("videos")
return ActionResult(
ok=True, aid=aid, action="ignore", state="rejected",
message="已忽略",
)
def blacklist(self, aid: int) -> ActionResult:
aid = int(aid)
candidate = self.repo.get_candidate(aid)
if candidate is None:
return ActionResult(
ok=False, aid=aid, action="blacklist", state="missing",
message="候选不存在或已处理",
)
up_mid = candidate.get("up_mid")
if not isinstance(up_mid, int) or isinstance(up_mid, bool):
return ActionResult(
ok=False, aid=aid, action="blacklist", state="failed",
message="候选缺少 UP mid,无法拉黑",
)
self.repo.add_blacklist(
up_mid,
name=candidate.get("up_name"),
reason="telegram",
now=self._now(),
)
rejected = self.repo.reject_pending_for_up(up_mid, now=self._now())
self.repo.mark_candidate_decision(aid, "rejected", now=self._now())
self.repo.bump_cache_version("videos")
return ActionResult(
ok=True, aid=aid, action="blacklist", state="rejected",
message=f"已拉黑 UP,并忽略该 UP 的 {rejected} 条待决策候选",
)
-335
View File
@@ -1,335 +0,0 @@
# -*- coding: utf-8 -*-
"""收藏夹同步(库的真相源)。
每次运行:
1. 读取每个配置年度夹的全部 aid;
2. 与本地 ``folder_resource`` 做差集;
3. 新增资源按 200 条分块拉取 ``infos`` 并入库;
4. 从收藏夹移除的资源标记 ``unfaved``(不删除历史);
5. 每 ``full_folder_refresh_hours`` 小时全量刷新一次元数据并识别失效资源;
6. 容量接近上限时预警,但绝不自动换夹。
黑名单只在发现流程生效,本服务不读取 ``up_blacklist``。
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Any
from ..config import AppConfig, ConfigStore, FolderConfig
from ..repository import Repository
from .up_profile import UpProfileService
CAPACITY_WARN = 950
@dataclass
class FolderSyncResult:
media_id: int
year: int
title: str
remote_count: int = 0
added: int = 0
removed: int = 0
refreshed: int = 0
missing: int = 0
full_refresh: bool = False
warning: str | None = None
error: str | None = None
def as_dict(self) -> dict[str, Any]:
return {
"media_id": self.media_id,
"year": self.year,
"title": self.title,
"remote_count": self.remote_count,
"added": self.added,
"removed": self.removed,
"refreshed": self.refreshed,
"missing": self.missing,
"full_refresh": self.full_refresh,
"warning": self.warning,
"error": self.error,
}
@dataclass
class SyncSummary:
started_at: int
finished_at: int = 0
folders: list[FolderSyncResult] = field(default_factory=list)
blacklist_seeded: int = 0
followers_updated: int = 0
followers_failed: int = 0
@property
def added(self) -> int:
return sum(f.added for f in self.folders)
@property
def removed(self) -> int:
return sum(f.removed for f in self.folders)
@property
def refreshed(self) -> int:
return sum(f.refreshed for f in self.folders)
@property
def missing(self) -> int:
return sum(f.missing for f in self.folders)
@property
def errors(self) -> list[str]:
return [f"folder {f.media_id}: {f.error}" for f in self.folders if f.error]
@property
def warnings(self) -> list[str]:
return [f"folder {f.media_id}: {f.warning}" for f in self.folders if f.warning]
@property
def ok(self) -> bool:
return not self.errors
def as_dict(self) -> dict[str, Any]:
return {
"started_at": self.started_at,
"finished_at": self.finished_at,
"elapsed": max(0, self.finished_at - self.started_at),
"added": self.added,
"removed": self.removed,
"refreshed": self.refreshed,
"missing": self.missing,
"blacklist_seeded": self.blacklist_seeded,
"followers_updated": self.followers_updated,
"followers_failed": self.followers_failed,
"folders": [f.as_dict() for f in self.folders],
"errors": self.errors,
"warnings": self.warnings,
"ok": self.ok,
}
class FolderSyncService:
def __init__(
self,
config_store: ConfigStore,
repository: Repository,
client=None,
*,
client_provider=None,
clock=time.time,
logger=None,
capacity_warn: int = CAPACITY_WARN,
allow_empty_folders: bool = False,
up_profile_service: UpProfileService | None = None,
capture_followers: bool = True,
) -> None:
if client is None and client_provider is None:
raise ValueError("必须提供 client 或 client_provider")
self.config_store = config_store
self.repo = repository
self.client = client
self.client_provider = client_provider
self._clock = clock
self.logger = logger
self.capacity_warn = capacity_warn
self.allow_empty_folders = allow_empty_folders
self.capture_followers = capture_followers
self.up_profile = up_profile_service or UpProfileService(
repository, self._client, clock=clock, logger=logger
)
def _client(self):
if self.client_provider is not None:
return self.client_provider.get()
return self.client
def _now(self) -> int:
return int(self._clock())
def _log(self, level: str, event: str, **fields) -> None:
if self.logger is not None:
getattr(self.logger, level)(event, **fields)
def run(self, *, full_refresh: bool = False) -> SyncSummary:
cfg = self.config_store.get()
client = self._client()
now = self._now()
summary = SyncSummary(started_at=now)
self._log(
"info", "folder_sync.start", folders=len(cfg.folders), full=full_refresh
)
for folder in cfg.folders:
self.repo.ensure_folder(folder, now=now)
self.repo.disable_folders_except(f.media_id for f in cfg.folders)
if cfg.up_blacklist_seed:
summary.blacklist_seeded = self.repo.seed_blacklist(
cfg.up_blacklist_seed, now=now
)
for folder in cfg.folders:
try:
result = self._sync_folder(
client, cfg, folder, now=now, force_full=full_refresh
)
except Exception as exc: # noqa: BLE001 单个夹失败不阻塞其它夹
result = FolderSyncResult(
media_id=folder.media_id,
year=folder.year,
title=folder.title,
error=str(exc),
)
self._log(
"error",
"folder_sync.folder_error",
media_id=folder.media_id,
error=str(exc),
)
else:
self._log("info", "folder_sync.folder", **result.as_dict())
if result.warning:
self._log(
"warning",
"folder_sync.capacity",
media_id=folder.media_id,
warning=result.warning,
)
summary.folders.append(result)
if self.capture_followers:
try:
missing = self.repo.up_mids_missing_follower()
if missing:
capture = self.up_profile.capture(
[int(row["up_mid"]) for row in missing],
names={
int(row["up_mid"]): str(row.get("up_name") or "")
for row in missing
},
live_since=summary.started_at - 3600,
)
summary.followers_updated = capture.updated_videos
summary.followers_failed = capture.failed
except Exception as exc: # noqa: BLE001 粉丝采集失败不影响收藏夹同步
summary.followers_failed += 1
self._log(
"warning", "folder_sync.followers_failed", error=str(exc)
)
summary.finished_at = self._now()
self._log(
"info" if summary.ok else "error",
"folder_sync.done",
added=summary.added,
removed=summary.removed,
refreshed=summary.refreshed,
missing=summary.missing,
errors=len(summary.errors),
elapsed=max(0, summary.finished_at - summary.started_at),
)
if summary.added or summary.removed or summary.refreshed or summary.followers_updated:
self.repo.bump_cache_version("videos")
return summary
def _sync_folder(
self,
client,
cfg: AppConfig,
folder: FolderConfig,
*,
now: int,
force_full: bool,
) -> FolderSyncResult:
row = self.repo.get_folder(folder.media_id)
full_due = (
force_full
or row is None
or row.last_full_at is None
or now - int(row.last_full_at)
>= cfg.schedule.full_folder_refresh_hours * 3600
)
resources = client.fav_ids(folder.media_id)
remote_map = {item.aid: item for item in resources if item.type == 2}
remote = set(remote_map)
local = self.repo.folder_active_aids(folder.media_id)
added = remote - local
removed = local - remote
if not remote and local and not self.allow_empty_folders:
raise RuntimeError(
f"收藏夹 {folder.media_id} 远端返回 0 条,但本地仍有 {len(local)} 条;"
f"疑似接口异常,已跳过差集。确认收藏夹确实被清空后,"
f"可使用 --allow-empty-folder 重试"
)
result = FolderSyncResult(
media_id=folder.media_id,
year=folder.year,
title=folder.title or (row.title if row else ""),
remote_count=len(remote),
added=len(added),
removed=len(removed),
full_refresh=full_due,
)
targets = set(added)
if full_due:
targets |= remote
if targets:
infos = client.fav_infos(
folder.media_id,
sorted(targets),
folder_mid=cfg.bilibili.read.mid,
)
for aid, resource in infos.items.items():
if aid not in remote:
continue
outcome = self.repo.upsert_from_fav(
resource, folder.media_id, was_added=aid in added, now=now
)
if aid in added:
# 人工已加入收藏夹:候选池不再保留同一条视频
self.repo.delete_candidate(aid)
if outcome.moved_folder:
self._log(
"warning",
"folder_sync.multi_folder",
aid=aid,
previous_folder_id=outcome.previous_folder_id,
folder_id=folder.media_id,
)
for aid in infos.missing:
source = remote_map.get(aid)
self.repo.mark_missing_resource(
aid,
folder_id=folder.media_id,
bvid=source.bvid if source else None,
now=now,
)
result.refreshed = len(infos.items)
result.missing = len(infos.missing)
for aid in removed:
self.repo.mark_resource_removed(folder.media_id, aid, now=now)
self.repo.touch_folder_resources(folder.media_id, now=now)
self.repo.finish_folder_sync(
folder,
media_count=len(remote),
full_refresh=full_due,
now=now,
)
if len(remote) >= self.capacity_warn:
result.warning = (
f"收藏夹「{result.title or folder.media_id}」已有 {len(remote)} 条,"
f"达到预警线 {self.capacity_warn};请人工准备下一年度收藏夹并更新配置,"
f"系统不会自动换夹"
)
return result
-3
View File
@@ -1611,12 +1611,10 @@ class DashboardQueries:
now = self._now() now = self._now()
next_runs = { next_runs = {
"discover": int(now + cfg.schedule.discover_minutes * 60), "discover": int(now + cfg.schedule.discover_minutes * 60),
"folder_sync": int(now + cfg.schedule.folder_sync_minutes * 60),
"snapshot": int(next_daily_run(now, cfg.schedule.snapshot_at)), "snapshot": int(next_daily_run(now, cfg.schedule.snapshot_at)),
} }
for name, key, interval_minutes in ( for name, key, interval_minutes in (
("discover", "discovery", cfg.schedule.discover_minutes), ("discover", "discovery", cfg.schedule.discover_minutes),
("folder_sync", "folder_sync", cfg.schedule.folder_sync_minutes),
): ):
record = tasks.get(key) record = tasks.get(key)
if isinstance(record, dict) and record.get("finished_at"): if isinstance(record, dict) and record.get("finished_at"):
@@ -1660,7 +1658,6 @@ class DashboardQueries:
"preview": self._kv(conn, "preview:seeded") == "1", "preview": self._kv(conn, "preview:seeded") == "1",
"schedule": { "schedule": {
"discover_minutes": cfg.schedule.discover_minutes, "discover_minutes": cfg.schedule.discover_minutes,
"folder_sync_minutes": cfg.schedule.folder_sync_minutes,
"snapshot_at": cfg.schedule.snapshot_at, "snapshot_at": cfg.schedule.snapshot_at,
"candidate_ttl_days": cfg.schedule.candidate_ttl_days, "candidate_ttl_days": cfg.schedule.candidate_ttl_days,
}, },
@@ -30,7 +30,7 @@ function candidateCard(item) {
</div> </div>
<div class="candidate-actions"> <div class="candidate-actions">
${item.url ? `<a class="button ghost" href="${escapeAttr(item.url)}" target="_blank" rel="noreferrer">打开 B 站</a>` : ""} ${item.url ? `<a class="button ghost" href="${escapeAttr(item.url)}" target="_blank" rel="noreferrer">打开 B 站</a>` : ""}
<span class="readonly-note">请在 Telegram 中收藏 / 忽略 / 拉黑</span> <span class="readonly-note">请在 Telegram 中入库 / 忽略 / 拉黑</span>
</div> </div>
</div> </div>
</article>`; </article>`;
@@ -51,7 +51,7 @@ export async function renderCandidates(root) {
${data.items.length ? `<div class="notice-row"> ${data.items.length ? `<div class="notice-row">
<strong>候选处理</strong> <strong>候选处理</strong>
<span>${data.telegram_enabled ? "请在 Telegram 消息中收藏、忽略或拉黑。" : "Telegram 未启用,请使用 CLI 或数据库处理候选。"}</span> <span>${data.telegram_enabled ? "请在 Telegram 消息中入库、忽略或拉黑。" : "Telegram 未启用,请使用 CLI 或数据库处理候选。"}</span>
</div>` : ""} </div>` : ""}
<section class="panel"> <section class="panel">
+3 -3
View File
@@ -30,7 +30,8 @@ function heatStrip(items) {
function capacity(folders) { function capacity(folders) {
if (!folders.length) return ""; if (!folders.length) return "";
return `<section class="panel capacity-panel"> return `<section class="panel capacity-panel">
${sectionHeader("年度收藏夹")} ${sectionHeader("历史收藏夹")}
<p class="section-note">系统已不再同步收藏夹;下列统计仅保留历史来源。</p>
${folders.map((folder) => ` ${folders.map((folder) => `
<div class="capacity-row"> <div class="capacity-row">
<div class="capacity-label"> <div class="capacity-label">
@@ -39,7 +40,6 @@ function capacity(folders) {
</div> </div>
<div class="capacity-track"><span style="width:${Math.min(100, folder.ratio * 100)}%" <div class="capacity-track"><span style="width:${Math.min(100, folder.ratio * 100)}%"
class="${folder.warning ? "warning" : ""}"></span></div> class="${folder.warning ? "warning" : ""}"></span></div>
${folder.warning ? `<p class="warning-text">接近容量上限,请准备新建年度收藏夹。</p>` : ""}
</div>`).join("")} </div>`).join("")}
</section>`; </section>`;
} }
@@ -95,7 +95,7 @@ export async function renderHome(root) {
${addedNote} ${addedNote}
<div class="video-list"> <div class="video-list">
${added.items.length ? added.items.map((item) => videoCard(item)).join("") ${added.items.length ? added.items.map((item) => videoCard(item)).join("")
: emptyState("最近 7 天没有新入库视频", "收藏夹同步或搜索确认后会出现在这里。")} : emptyState("最近 7 天没有新入库视频", "搜索发现确认或 Telegram 私聊手动入库后会出现在这里。")}
</div> </div>
</section> </section>
+5 -6
View File
@@ -47,7 +47,6 @@ export async function renderSystem(root) {
<div><span>采集成功</span><strong>${formatCount(health.ok)}</strong></div> <div><span>采集成功</span><strong>${formatCount(health.ok)}</strong></div>
<div><span>采集失败</span><strong class="${health.failed ? "danger" : ""}">${formatCount(health.failed)}</strong></div> <div><span>采集失败</span><strong class="${health.failed ? "danger" : ""}">${formatCount(health.failed)}</strong></div>
<div><span>在库</span><strong>${formatCount(counts.active)}</strong></div> <div><span>在库</span><strong>${formatCount(counts.active)}</strong></div>
<div><span>已取消收藏</span><strong>${formatCount(counts.unfaved)}</strong></div>
<div><span>已失效</span><strong>${formatCount(counts.removed)}</strong></div> <div><span>已失效</span><strong>${formatCount(counts.removed)}</strong></div>
<div><span>已移出库</span><strong>${formatCount(counts.dropped)}</strong></div> <div><span>已移出库</span><strong>${formatCount(counts.dropped)}</strong></div>
</div> </div>
@@ -60,16 +59,16 @@ export async function renderSystem(root) {
<section class="panel"> <section class="panel">
${sectionHeader("预计下次运行")} ${sectionHeader("预计下次运行")}
<div class="status-grid"> <div class="status-grid">
<div><span>收藏夹同步</span><strong>${escapeHtml(formatDateTime(next.folder_sync))}</strong></div>
<div><span>搜索发现</span><strong>${escapeHtml(formatDateTime(next.discover))}</strong></div> <div><span>搜索发现</span><strong>${escapeHtml(formatDateTime(next.discover))}</strong></div>
<div><span>每日快照</span><strong>${escapeHtml(formatDateTime(next.snapshot))}</strong></div> <div><span>每日快照</span><strong>${escapeHtml(formatDateTime(next.snapshot))}</strong></div>
<div><span>调度参数</span><strong>${system.schedule.discover_minutes} / ${system.schedule.folder_sync_minutes} 分钟</strong></div> <div><span>发现周期</span><strong>${system.schedule.discover_minutes} 分钟</strong></div>
</div> </div>
<p class="section-note">预计时间按配置和最近任务完成时间推算;实际以常驻进程为准。</p> <p class="section-note">预计时间按配置和最近任务完成时间推算;实际以常驻进程为准。</p>
</section> </section>
<section class="panel"> <section class="panel">
${sectionHeader("收藏夹容量")} ${sectionHeader("历史收藏夹")}
<p class="section-note">系统已不再同步收藏夹;下列统计仅保留历史来源。收藏夹由人工维护,重排序使用 favsort.py。</p>
${(system.folders || []).map((folder) => ` ${(system.folders || []).map((folder) => `
<div class="capacity-row"> <div class="capacity-row">
<div class="capacity-label"> <div class="capacity-label">
@@ -78,8 +77,8 @@ export async function renderSystem(root) {
</div> </div>
<div class="capacity-track"><span style="width:${Math.min(100, folder.ratio * 100)}%" <div class="capacity-track"><span style="width:${Math.min(100, folder.ratio * 100)}%"
class="${folder.warning ? "warning" : ""}"></span></div> class="${folder.warning ? "warning" : ""}"></span></div>
<p class="section-note">最近同步 ${escapeHtml(formatDateTime(folder.last_ids_at))}</p> <p class="section-note">历史快照 ${escapeHtml(formatDateTime(folder.last_ids_at))}</p>
</div>`).join("") || emptyState("未配置收藏夹")} </div>`).join("") || emptyState("无历史收藏夹记录")}
</section> </section>
<section class="panel"> <section class="panel">
+12 -12
View File
@@ -51,7 +51,7 @@ def test_init_and_status():
path = write_config(tmp) path = write_config(tmp)
code, output = run_cli(["init", "--config", str(path)]) code, output = run_cli(["init", "--config", str(path)])
assert code == 0 assert code == 0
assert '"schema_version": 4' in output assert '"schema_version": 5' in output
db = Database(Path(tmp) / "app.db") db = Database(Path(tmp) / "app.db")
db.initialize() db.initialize()
@@ -79,14 +79,14 @@ def test_missing_config():
assert "缺少配置文件" in payload["error"] assert "缺少配置文件" in payload["error"]
def test_sync_requires_auth_without_network(): def test_ingest_without_refs_is_offline():
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
path = write_config(tmp, cookie="", csrf="") path = write_config(tmp, cookie="", csrf="")
code, output = run_cli(["sync", "--config", str(path)]) code, output = run_cli(["ingest", "--config", str(path), "没有视频"])
assert code == 2 assert code == 1
payload = json.loads(output.strip().splitlines()[-1]) data = json.loads(output[output.rindex("{\n"):])
assert "cookie" in payload["error"] assert data["refs"] == 0
assert not (Path(tmp) / "app.db").exists() assert data["results"] == []
def test_snapshot_offline_empty_db(): def test_snapshot_offline_empty_db():
@@ -180,15 +180,15 @@ def test_run_once_offline():
cli._client = original cli._client = original
cli.DiscoveryService = original_discovery cli.DiscoveryService = original_discovery
assert code == 0 assert code == 0
assert '"name": "folder_sync"' in output
assert '"name": "snapshot"' in output assert '"name": "snapshot"' in output
assert '"name": "folder_sync"' not in output
assert selected and all(credentials.mid == 1 for credentials in selected) assert selected and all(credentials.mid == 1 for credentials in selected)
db = Database(Path(tmp) / "app.db") db = Database(Path(tmp) / "app.db")
db.initialize() db.initialize()
try: try:
repo = Repository(db) repo = Repository(db)
for name in ("folder_sync", "snapshot", "discovery", "candidate_cleanup"): for name in ("snapshot", "discovery", "candidate_cleanup"):
assert repo.kv_get(f"task:last:{name}"), name assert repo.kv_get(f"task:last:{name}"), name
finally: finally:
db.close() db.close()
@@ -229,7 +229,7 @@ def test_default_command_starts_web_and_scheduler():
original = cli.cmd_run original = cli.cmd_run
def fake_run(args, logger, *, serve_web=False): def fake_run(args, logger, *, serve_web=False):
calls.append((args.command, serve_web, args.once, args.allow_empty_folder)) calls.append((args.command, serve_web, args.once))
return 0 return 0
cli.cmd_run = fake_run cli.cmd_run = fake_run
@@ -238,7 +238,7 @@ def test_default_command_starts_web_and_scheduler():
finally: finally:
cli.cmd_run = original cli.cmd_run = original
assert code == 0 assert code == 0
assert calls == [("run", True, False, False)] assert calls == [("run", True, False)]
def test_snapshot_backup_failure_returns_error_and_retries(): def test_snapshot_backup_failure_returns_error_and_retries():
@@ -335,7 +335,7 @@ def test_backfill_followers_dry_run():
def main_test(): def main_test():
test_init_and_status() test_init_and_status()
test_missing_config() test_missing_config()
test_sync_requires_auth_without_network() test_ingest_without_refs_is_offline()
test_snapshot_offline_empty_db() test_snapshot_offline_empty_db()
test_auth_check_without_cookie() test_auth_check_without_cookie()
test_auth_check_all_and_auth_dispatch() test_auth_check_all_and_auth_dispatch()
+4 -4
View File
@@ -24,7 +24,7 @@ def test_write_read_transaction():
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
db = make_db(tmp) db = make_db(tmp)
try: try:
assert db.schema_version == 4 assert db.schema_version == 5
db.write(lambda c: c.execute("INSERT INTO kv(k, v) VALUES ('a', '1')")) db.write(lambda c: c.execute("INSERT INTO kv(k, v) VALUES ('a', '1')"))
assert db.read(lambda c: c.execute("SELECT v FROM kv WHERE k='a'").fetchone()[0]) == "1" assert db.read(lambda c: c.execute("SELECT v FROM kv WHERE k='a'").fetchone()[0]) == "1"
@@ -94,8 +94,8 @@ def test_close_and_reinitialize():
db2 = make_db(tmp) db2 = make_db(tmp)
try: try:
assert db2.schema_version == 4 assert db2.schema_version == 5
assert db2.read(lambda c: c.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]) == 4 assert db2.read(lambda c: c.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]) == 5
finally: finally:
db2.close() db2.close()
@@ -123,7 +123,7 @@ def test_migration_backup_on_existing_db():
db = Database(path, backup_dir=Path(tmp) / "backup") db = Database(path, backup_dir=Path(tmp) / "backup")
try: try:
applied = db.initialize() applied = db.initialize()
assert applied == [1, 2, 3, 4] assert applied == [1, 2, 3, 4, 5]
assert len(list((Path(tmp) / "backup").glob("app-pre-v0-*.db"))) == 1 assert len(list((Path(tmp) / "backup").glob("app-pre-v0-*.db"))) == 1
finally: finally:
db.close() db.close()
-80
View File
@@ -1,80 +0,0 @@
# -*- coding: utf-8 -*-
"""新视频插入位置纯函数测试: uv run python tests/test_fav_position.py"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.stdout.reconfigure(encoding="utf-8")
from reaction_board.fav_position import VideoMeta, plan_insertion # noqa: E402
def meta(up_mid, pubdate):
return VideoMeta(up_mid=up_mid, pubdate=pubdate)
def test_empty_and_single():
assert plan_insertion([], 9, {}, meta(100, 200)).moves == ()
assert plan_insertion([], 9, {}, meta(100, 200)).target_position == 1
plan = plan_insertion([1], 9, {1: meta(200, 50)}, meta(100, 200))
assert plan.target_position == 1
assert plan.moves == ((1, 9), (9, 1))
def test_same_up_group():
current = [1, 2, 3, 4]
metadata = {
1: meta(100, 100),
2: meta(100, 90),
3: meta(200, 80),
4: meta(200, 70),
}
# 同 UP 组在中间:插到组头
plan = plan_insertion(current, 9, metadata, meta(200, 95))
assert plan.target_position == 3
assert plan.moves == ((2, 9),)
# 同 UP 组在首位:两次移动
plan = plan_insertion(current, 9, metadata, meta(100, 200))
assert plan.target_position == 1
assert plan.moves == ((1, 9), (9, 1))
def test_new_group_by_earliest_pubdate():
current = [1, 2, 3, 4]
metadata = {
1: meta(100, 100),
2: meta(100, 90),
3: meta(200, 80),
4: meta(200, 70),
}
# 新 UP 的组键 85,应插到组键 70 的组之前
plan = plan_insertion(current, 9, metadata, meta(300, 85))
assert plan.target_position == 3
assert plan.moves == ((2, 9),)
# 比所有组都旧:追加到末尾
plan = plan_insertion(current, 9, metadata, meta(300, 60))
assert plan.target_position == 5
assert plan.moves == ((4, 9),)
def test_excludes_new_aid_if_present():
current = [1, 9, 2]
metadata = {1: meta(100, 100), 2: meta(100, 90)}
plan = plan_insertion(current, 9, metadata, meta(100, 95))
assert plan.target_position == 1
assert plan.moves == ((1, 9), (9, 1))
def main():
test_empty_and_single()
test_same_up_group()
test_new_group_by_earliest_pubdate()
test_excludes_new_aid_if_present()
print("OK: 新视频插入位置计算测试全部通过")
if __name__ == "__main__":
main()
-211
View File
@@ -1,211 +0,0 @@
# -*- coding: utf-8 -*-
"""收藏/忽略/拉黑动作测试(假写客户端,不联网): uv run python tests/test_favorites.py"""
import json
import os
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.stdout.reconfigure(encoding="utf-8")
from reaction_board.bilibili.client import BilibiliAPIError # noqa: E402
from reaction_board.bilibili.models import FavResource # noqa: E402
from reaction_board.config import ConfigStore # noqa: E402
from reaction_board.database import Database # noqa: E402
from reaction_board.repository import Repository # noqa: E402
from reaction_board.services.favorites import FavoriteActionService # noqa: E402
NOW = 1_793_444_393
FOLDER_ID = 3700023351
@dataclass
class FakeFavId:
aid: int
type: int = 2
class FakeWriteClient:
def __init__(self, order, *, fail_deal=False):
self.order = list(order)
self.fail_deal = fail_deal
self.dealt = []
self.sorts = []
def fav_ids(self, media_id):
return [FakeFavId(aid) for aid in self.order]
def deal(self, aid, folder_id):
if self.fail_deal:
raise BilibiliAPIError(-1, "收藏夹已满")
self.dealt.append((aid, folder_id))
self.order.append(aid)
def sort(self, media_id, spec):
predecessor, _, moved, _ = (int(part) for part in spec.split(":"))
self.sorts.append(spec)
self.order.remove(moved)
index = self.order.index(predecessor)
self.order.insert(index + 1, moved)
class FakeProvider:
def __init__(self, client):
self.client = client
def get(self):
return self.client
def write_config(tmp):
data = {
"bilibili": {
"read": {"mid": 1, "csrf": "r", "cookie": "read-cookie"},
"write": {"mid": 2, "csrf": "w", "cookie": "write-cookie"},
},
"storage": {
"database_path": str(Path(tmp) / "app.db"),
"backup_dir": str(Path(tmp) / "backup"),
},
"folders": [{"year": 2026, "media_id": FOLDER_ID, "title": "2026"}],
"discover": {"keywords": ["x"], "pages": 1, "window_days": 14},
}
path = Path(tmp) / "config.json"
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
return path
def add_video(repo, aid, up_mid, pubdate):
repo.upsert_from_fav(
FavResource(
aid=aid, bvid=f"BV{aid}", title=f"视频{aid}", up_mid=up_mid,
up_name=f"UP{up_mid}", pubdate=pubdate, duration=600,
cover=None, attr=0, fav_time=None,
),
FOLDER_ID,
was_added=True,
)
def add_candidate(repo, aid, up_mid=100, pubdate=NOW - 3600):
return repo.upsert_candidate(
aid=aid,
bvid=f"BV{aid}",
title=f"咻咻满 reaction {aid}",
up_mid=up_mid,
up_name=f"UP{up_mid}",
pubdate=pubdate,
cover="https://example/cover.jpg",
score=3,
channel="search:test#p1",
now=NOW,
)
def setup(tmp, order, *, fail_deal=False):
path = write_config(tmp)
store = ConfigStore(path, env={})
db = Database(Path(tmp) / "app.db", backup_dir=Path(tmp) / "backup")
db.initialize()
repo = Repository(db, clock=lambda: NOW)
add_video(repo, 1, 100, 1_792_000_000)
add_video(repo, 2, 200, 1_791_000_000)
add_candidate(repo, 9, up_mid=100, pubdate=1_793_000_000)
client = FakeWriteClient(order, fail_deal=fail_deal)
service = FavoriteActionService(
store, repo, FakeProvider(client), clock=lambda: NOW
)
return db, repo, client, service
def test_favorite_inserts_and_positions():
with tempfile.TemporaryDirectory() as tmp:
db, repo, client, service = setup(tmp, [1, 2])
try:
result = service.favorite(9)
assert result.ok is True
assert result.position == 1
assert result.already_in_folder is False
assert client.dealt == [(9, FOLDER_ID)]
assert client.sorts == ["1:2:9:2", "9:2:1:2"]
assert client.order == [9, 1, 2]
assert repo.get_candidate(9) is None
video = repo.get_video(9)
assert video["status"] == "active"
assert video["folder_id"] == FOLDER_ID
action = repo.get_fav_action("fav:9")
assert action["state"] == "done"
assert action["position"] == 1
finally:
db.close()
def test_favorite_already_in_folder_skips_writes():
with tempfile.TemporaryDirectory() as tmp:
db, repo, client, service = setup(tmp, [1, 9, 2])
try:
result = service.favorite(9)
assert result.ok is True
assert result.already_in_folder is True
assert result.position == 2
assert client.dealt == []
assert client.sorts == []
assert repo.get_candidate(9) is None
finally:
db.close()
def test_favorite_failure_keeps_candidate_and_allows_retry():
with tempfile.TemporaryDirectory() as tmp:
db, repo, client, service = setup(tmp, [1, 2], fail_deal=True)
try:
result = service.favorite(9)
assert result.ok is False
assert "收藏夹已满" in result.message
assert repo.get_candidate(9) is not None
action = repo.get_fav_action("fav:9")
assert action["state"] == "failed"
assert "收藏夹已满" in action["err"]
client.fail_deal = False
retry = service.favorite(9)
assert retry.ok is True
assert retry.position == 1
assert repo.get_candidate(9) is None
finally:
db.close()
def test_ignore_and_blacklist():
with tempfile.TemporaryDirectory() as tmp:
db, repo, client, service = setup(tmp, [1, 2])
try:
add_candidate(repo, 11, up_mid=300)
add_candidate(repo, 12, up_mid=300)
ignored = service.ignore(11)
assert ignored.ok is True
assert repo.get_candidate(11)["decision"] == "rejected"
result = service.blacklist(12)
assert result.ok is True
assert repo.is_blacklisted(300)
assert repo.get_candidate(12)["decision"] == "rejected"
# 已有视频不受拉黑影响
assert repo.get_video(1)["status"] == "active"
finally:
db.close()
def main():
test_favorite_inserts_and_positions()
test_favorite_already_in_folder_skips_writes()
test_favorite_failure_keeps_candidate_and_allows_retry()
test_ignore_and_blacklist()
print("OK: 收藏、忽略、拉黑与失败重试测试全部通过")
if __name__ == "__main__":
main()
-373
View File
@@ -1,373 +0,0 @@
# -*- coding: utf-8 -*-
"""收藏夹同步集成测试(用真实接口形状的 transport Mock,不联网):
uv run python tests/test_folder_sync.py
"""
import json
import os
import sys
import tempfile
import urllib.parse
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.stdout.reconfigure(encoding="utf-8")
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from fixtures import info_for # noqa: E402
from reaction_board.bilibili.client import ( # noqa: E402
BilibiliClient,
HTTPResponse,
Pacer,
TransportError,
)
from reaction_board.config import ConfigStore # noqa: E402
from reaction_board.database import Database # noqa: E402
from reaction_board.repository import Repository # noqa: E402
from reaction_board.services.folder_sync import FolderSyncService # noqa: E402
F1 = 4451053851
F2 = 3700023351
def http_json(payload):
return HTTPResponse(
200, {"content-type": "application/json"},
json.dumps(payload, ensure_ascii=False).encode("utf-8"),
)
class FakeFavTransport:
def __init__(self, folders, infos, *, fail_media=None, omit_ids_data=None):
self.folders = {k: list(v) for k, v in folders.items()}
self.infos = dict(infos)
self.fail_media = fail_media
self.omit_ids_data = omit_ids_data
self.ids_calls = []
self.info_calls = []
self.relation_calls = []
def request(self, method, url, headers, data=None, timeout=30):
query = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
if "/x/relation/stat" in url:
mid = int(query["vmid"][0])
self.relation_calls.append(mid)
return http_json({
"code": 0, "message": "0", "ttl": 1,
"data": {"mid": mid, "following": 5, "follower": 1000 + mid},
})
if "/x/v3/fav/resource/ids" in url:
media_id = int(query["media_id"][0])
self.ids_calls.append(media_id)
if self.fail_media == media_id:
raise TransportError("ids 网络错误")
if self.omit_ids_data == media_id:
return http_json({"code": 0, "message": "OK", "ttl": 1})
data_rows = [
{"id": aid, "type": 2, "bv_id": f"BV{aid}", "bvid": f"BV{aid}"}
for aid in self.folders.get(media_id, [])
]
return http_json({"code": 0, "message": "0", "ttl": 1, "data": data_rows})
if "/x/v3/fav/resource/infos" in url:
media_id = int(query["folder_id"][0])
resources = query["resources"][0]
aids = [int(part.split(":")[0]) for part in resources.split(",")]
self.info_calls.append((media_id, aids))
rows = [self.infos[aid] for aid in aids if aid in self.infos]
return http_json({"code": 0, "message": "0", "ttl": 1, "data": rows})
if "/x/v3/fav/folder/created/list-all" in url:
return http_json({
"code": 0, "message": "0", "ttl": 1,
"data": {"count": 0, "list": []},
})
raise AssertionError(f"未预期的 URL: {url}")
def write_config(tmp, folders):
data = {
"bilibili": {
"read": {"mid": 28802351, "csrf": "csrf-secret", "cookie": "SESSDATA=secret; bili_jct=csrf-secret; buvid3=device"},
"write": {"mid": 2, "csrf": "write-csrf", "cookie": "write-cookie"},
},
"storage": {
"database_path": str(Path(tmp) / "app.db"),
"backup_dir": str(Path(tmp) / "backup"),
},
"folders": folders,
"up_blacklist_seed": [999],
"schedule": {
"discover_minutes": 60,
"folder_sync_minutes": 60,
"full_folder_refresh_hours": 6,
"snapshot_at": "00:01",
"candidate_ttl_days": 7,
},
}
path = Path(tmp) / "config.json"
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
return path
def make_service(tmp, transport, clock, *, capacity_warn=950):
folders = [
{"year": 2025, "media_id": F1, "title": "2025"},
{"year": 2026, "media_id": F2, "title": "2026"},
]
path = write_config(tmp, folders)
store = ConfigStore(path, env={}, require_auth=True)
db = Database(Path(tmp) / "app.db", backup_dir=Path(tmp) / "backup")
db.initialize()
repo = Repository(db, clock=lambda: clock[0])
client = BilibiliClient(
cookie="SESSDATA=secret; bvid3=device",
csrf="csrf",
mid=28802351,
transport=transport,
pacer=Pacer(0, 0),
retries=1,
)
service = FolderSyncService(
store, repo, client, clock=lambda: clock[0], capacity_warn=capacity_warn
)
return db, repo, service
def valid(aid, *, up_mid=100, title=None):
return info_for(aid, title=title or f"视频{aid}", up_mid=up_mid, up_name="UP")
def test_initial_import_and_incremental_skip():
with tempfile.TemporaryDirectory() as tmp:
transport = FakeFavTransport(
{F1: [1101, 1102, 1103, 1104], F2: []},
{
1101: valid(1101),
1102: valid(1102, up_mid=999), # 黑名单 UP 也必须正常入库
1103: valid(1103, title="已失效", up_mid=100),
},
)
# 1103 标记为 attr=9;1104 静默缺失
transport.infos[1103]["attr"] = 9
clock = [1_800_000_000]
db, repo, service = make_service(tmp, transport, clock)
try:
repo.upsert_candidate(
aid=1101, bvid="BV1101", title="咻咻满 reaction",
up_mid=100, up_name="UP", pubdate=1_799_000_000,
cover=None, score=3, channel="search:test#p1",
now=1_799_999_000,
)
summary = service.run()
assert summary.ok
assert summary.added == 4
assert summary.missing == 1
assert summary.refreshed == 3
# 人工加入收藏夹后,同 aid 候选自动移除
assert repo.get_candidate(1101) is None
assert repo.seed_blacklist([999], now=0) == 0 # 已由 service 写入
assert repo.get_video(1101)["status"] == "active"
assert repo.get_video(1102)["status"] == "active" # 黑名单不影响收藏夹导入
assert repo.get_video(1103)["status"] == "removed"
assert repo.get_video(1104)["status"] == "removed"
assert repo.get_video(1104)["bvid"] == "BV1104"
assert repo.folder_active_aids(F1) == {1101, 1102, 1103, 1104}
assert repo.is_blacklisted(999)
# 新入库视频记录入库时粉丝数;失效视频不再采集
assert summary.followers_updated == 2
assert summary.followers_failed == 0
assert repo.get_video(1101)["up_follower"] == 1100
assert repo.get_video(1101)["up_follower_backfilled"] == 0
assert repo.get_video(1102)["up_follower"] == 1999
assert sorted(transport.relation_calls) == [100, 999]
folder = repo.get_folder(F1)
assert folder.media_count == 4
assert folder.last_full_at == 1_800_000_000
# 6 小时内再次运行:没有新增,不请求 infos
clock[0] += 60
info_before = len(transport.info_calls)
relation_before = len(transport.relation_calls)
summary2 = service.run()
assert summary2.added == 0
assert summary2.refreshed == 0
assert len(transport.info_calls) == info_before
assert len(transport.relation_calls) == relation_before
assert summary2.folders[0].full_refresh is False
finally:
db.close()
def test_diff_add_and_remove():
with tempfile.TemporaryDirectory() as tmp:
transport = FakeFavTransport(
{F1: [1101, 1102], F2: []},
{1101: valid(1101), 1102: valid(1102)},
)
clock = [1_800_000_000]
db, repo, service = make_service(tmp, transport, clock)
try:
service.run()
assert repo.get_video(1101)["status"] == "active"
assert repo.get_video(1102)["status"] == "active"
transport.folders[F1] = [1102, 1103]
transport.infos[1103] = valid(1103)
clock[0] += 3600
summary = service.run()
assert summary.added == 1
assert summary.removed == 1
assert summary.refreshed == 1
assert repo.get_video(1101)["status"] == "unfaved"
assert repo.get_video(1102)["status"] == "active"
assert repo.get_video(1103)["status"] == "active"
assert repo.folder_active_aids(F1) == {1102, 1103}
assert repo.get_folder(F1).media_count == 2
finally:
db.close()
def test_full_refresh_marks_invalid_and_missing():
with tempfile.TemporaryDirectory() as tmp:
transport = FakeFavTransport(
{F1: [1101, 1102], F2: []},
{1101: valid(1101), 1102: valid(1102)},
)
clock = [1_800_000_000]
db, repo, service = make_service(tmp, transport, clock)
try:
service.run()
assert repo.get_video(1101)["status"] == "active"
assert repo.get_video(1102)["status"] == "active"
# 6 小时后:1101 失效,1102 静默缺失
clock[0] += 6 * 3600 + 1
transport.infos[1101]["attr"] = 9
del transport.infos[1102]
summary = service.run()
assert summary.folders[0].full_refresh is True
assert summary.missing == 1
assert repo.get_video(1101)["status"] == "removed"
assert repo.get_video(1102)["status"] == "removed"
assert repo.get_folder(F1).last_full_at == clock[0]
# 全量刷新请求了夹内全部 aid
assert set(transport.info_calls[-1][1]) == {1101, 1102}
finally:
db.close()
def test_capacity_warning_does_not_move_folder():
with tempfile.TemporaryDirectory() as tmp:
transport = FakeFavTransport(
{F1: [1, 2, 3], F2: []},
{i: valid(i) for i in (1, 2, 3)},
)
clock = [1_800_000_000]
db, repo, service = make_service(tmp, transport, clock, capacity_warn=3)
try:
summary = service.run()
assert summary.folders[0].warning
assert "预警线 3" in summary.folders[0].warning
assert "不会自动换夹" in summary.folders[0].warning
# 视频仍在原夹,不被移动
assert all(repo.get_video(i)["folder_id"] == F1 for i in (1, 2, 3))
finally:
db.close()
def test_folder_error_does_not_block_other_folders():
with tempfile.TemporaryDirectory() as tmp:
transport = FakeFavTransport(
{F1: [1101], F2: [2201]},
{1101: valid(1101), 2201: valid(2201)},
fail_media=F1,
)
clock = [1_800_000_000]
db, repo, service = make_service(tmp, transport, clock)
try:
summary = service.run()
assert summary.ok is False
assert len(summary.errors) == 1 and str(F1) in summary.errors[0]
assert summary.folders[0].error
assert summary.folders[1].error is None
assert repo.get_video(2201)["status"] == "active"
finally:
db.close()
def test_blacklist_seed_only_once():
with tempfile.TemporaryDirectory() as tmp:
transport = FakeFavTransport({F1: [1101], F2: []}, {1101: valid(1101)})
clock = [1_800_000_000]
db, repo, service = make_service(tmp, transport, clock)
try:
first = service.run()
assert first.blacklist_seeded == 1
clock[0] += 60
second = service.run()
assert second.blacklist_seeded == 0
assert len(repo.blacklist()) == 1
finally:
db.close()
def test_empty_remote_requires_explicit_opt_in():
with tempfile.TemporaryDirectory() as tmp:
transport = FakeFavTransport(
{F1: [1101, 1102], F2: []},
{1101: valid(1101), 1102: valid(1102)},
)
clock = [1_800_000_000]
db, repo, service = make_service(tmp, transport, clock)
try:
service.run()
transport.folders[F1] = []
clock[0] += 3600
summary = service.run()
assert summary.ok is False
assert "疑似接口异常" in summary.folders[0].error
assert repo.get_video(1101)["status"] == "active"
assert repo.folder_active_aids(F1) == {1101, 1102}
service.allow_empty_folders = True
summary = service.run()
assert summary.ok is True
assert repo.get_video(1101)["status"] == "unfaved"
assert repo.folder_active_aids(F1) == set()
finally:
db.close()
def test_missing_ids_data_aborts_folder():
with tempfile.TemporaryDirectory() as tmp:
transport = FakeFavTransport(
{F1: [1101], F2: []},
{1101: valid(1101)},
omit_ids_data=F1,
)
clock = [1_800_000_000]
db, repo, service = make_service(tmp, transport, clock)
try:
summary = service.run()
assert summary.ok is False
assert "data" in summary.folders[0].error
assert repo.get_video(1101) is None
finally:
db.close()
def main():
test_initial_import_and_incremental_skip()
test_diff_add_and_remove()
test_full_refresh_marks_invalid_and_missing()
test_capacity_warning_does_not_move_folder()
test_folder_error_does_not_block_other_folders()
test_blacklist_seed_only_once()
test_empty_remote_requires_explicit_opt_in()
test_missing_ids_data_aborts_folder()
print("OK: 收藏夹初始导入、差集同步、失效识别与容量预警测试全部通过")
if __name__ == "__main__":
main()
+30 -7
View File
@@ -37,8 +37,8 @@ def test_fresh_and_idempotent():
conn = sqlite3.connect(Path(tmp) / "app.db") conn = sqlite3.connect(Path(tmp) / "app.db")
try: try:
applied = run_migrations(conn) applied = run_migrations(conn)
assert applied == [1, 2, 3, 4] assert applied == [1, 2, 3, 4, 5]
assert current_version(conn) == 4 assert current_version(conn) == 5
assert EXPECTED_TABLES <= table_names(conn) assert EXPECTED_TABLES <= table_names(conn)
assert "schema_version" in table_names(conn) assert "schema_version" in table_names(conn)
columns = { columns = {
@@ -66,7 +66,7 @@ def test_fresh_and_idempotent():
# 再跑一次不应重复应用 # 再跑一次不应重复应用
assert run_migrations(conn) == [] assert run_migrations(conn) == []
row = conn.execute("SELECT COUNT(*) FROM schema_version").fetchone() row = conn.execute("SELECT COUNT(*) FROM schema_version").fetchone()
assert row[0] == 4 assert row[0] == 5
finally: finally:
conn.close() conn.close()
@@ -81,15 +81,15 @@ def test_backup_before_upgrade():
conn.execute("INSERT INTO kv(k, v) VALUES ('hello', 'world')") conn.execute("INSERT INTO kv(k, v) VALUES ('hello', 'world')")
conn.commit() conn.commit()
v5 = Migration(5, "add probe", ("CREATE TABLE probe (id INTEGER PRIMARY KEY)",)) v6 = Migration(6, "add probe", ("CREATE TABLE probe (id INTEGER PRIMARY KEY)",))
applied = run_migrations( applied = run_migrations(
conn, conn,
backup_dir=backup_dir, backup_dir=backup_dir,
now=1_800_000_000, now=1_800_000_000,
migrations=MIGRATIONS + (v5,), migrations=MIGRATIONS + (v6,),
) )
assert applied == [5] assert applied == [6]
backups = list(backup_dir.glob("app-pre-v4-*.db")) backups = list(backup_dir.glob("app-pre-v5-*.db"))
assert len(backups) == 1 assert len(backups) == 1
check = sqlite3.connect(backups[0]) check = sqlite3.connect(backups[0])
try: try:
@@ -159,12 +159,35 @@ def test_validate_migration_order():
conn.close() conn.close()
def test_v5_normalizes_unfaved():
with tempfile.TemporaryDirectory() as tmp:
conn = sqlite3.connect(Path(tmp) / "app.db")
try:
run_migrations(conn, migrations=MIGRATIONS[:4])
conn.execute(
"INSERT INTO video(aid, title, pubdate, status, first_seen) "
"VALUES (1, 't', 1, 'unfaved', 1)"
)
conn.execute(
"INSERT INTO video(aid, title, pubdate, status, first_seen) "
"VALUES (2, 't', 1, 'dropped', 1)"
)
conn.commit()
applied = run_migrations(conn)
assert applied == [5]
rows = dict(conn.execute("SELECT aid, status FROM video").fetchall())
assert rows == {1: "active", 2: "dropped"}
finally:
conn.close()
def main(): def main():
test_fresh_and_idempotent() test_fresh_and_idempotent()
test_backup_before_upgrade() test_backup_before_upgrade()
test_failed_migration_rolls_back() test_failed_migration_rolls_back()
test_prune_backups() test_prune_backups()
test_validate_migration_order() test_validate_migration_order()
test_v5_normalizes_unfaved()
print("OK: SQLite 迁移、备份与回滚测试全部通过") print("OK: SQLite 迁移、备份与回滚测试全部通过")
+1 -2
View File
@@ -342,7 +342,6 @@ def test_fans_candidates_system_and_export():
assert system["errors"] == [] assert system["errors"] == []
assert system["preview"] is False assert system["preview"] is False
assert system["next_runs"]["snapshot"] > 0 assert system["next_runs"]["snapshot"] > 0
assert system["next_runs"]["folder_sync"] > FIXED_NOW
assert system["next_runs"]["discover"] > FIXED_NOW assert system["next_runs"]["discover"] > FIXED_NOW
finally: finally:
db.close() db.close()
@@ -455,7 +454,7 @@ def test_about_payload():
assert about["snapshot_days"] == 3 assert about["snapshot_days"] == 3
assert about["snapshot_first_date"] == DATES[0] assert about["snapshot_first_date"] == DATES[0]
assert about["fans_start_date"] == DATES[0] assert about["fans_start_date"] == DATES[0]
assert about["schema_version"] == 4 assert about["schema_version"] == 5
assert about["analytics"] == { assert about["analytics"] == {
"min_duration_seconds": 600, "min_duration_seconds": 600,
"maturity_days": 2, "maturity_days": 2,
+1 -1
View File
@@ -75,7 +75,7 @@ def test_readonly_routes_and_static():
assert status == 200 and payload["ok"] is True assert status == 200 and payload["ok"] is True
status, _, payload = running.json("/api/about") status, _, payload = running.json("/api/about")
assert status == 200 assert status == 200
assert payload["data"]["schema_version"] == 4 assert payload["data"]["schema_version"] == 5
assert payload["data"]["analytics"]["min_duration_seconds"] == 600 assert payload["data"]["analytics"]["min_duration_seconds"] == 600
status, _, overview = running.json("/api/overview") status, _, overview = running.json("/api/overview")
-2
View File
@@ -48,8 +48,6 @@ def write_config(
"up_blacklist_seed": [], "up_blacklist_seed": [],
"schedule": { "schedule": {
"discover_minutes": 60, "discover_minutes": 60,
"folder_sync_minutes": 60,
"full_folder_refresh_hours": 6,
"snapshot_at": "00:01", "snapshot_at": "00:01",
"candidate_ttl_days": 7, "candidate_ttl_days": 7,
}, },
+4 -4
View File
@@ -271,12 +271,12 @@ def _seed_task_records(
"backup_path": f"data/backup/app-{latest_day.strftime('%Y%m%d')}.db", "backup_path": f"data/backup/app-{latest_day.strftime('%Y%m%d')}.db",
}, },
}, },
"folder_sync": { "ingest": {
"name": "folder_sync", "ok": True, "name": "ingest", "ok": True,
"started_at": int(base + 20 * 3600), "started_at": int(base + 20 * 3600),
"finished_at": int(base + 20 * 3600 + 8), "finished_at": int(base + 20 * 3600 + 6),
"error": None, "error": None,
"summary": {"folders": 2, "added": 65, "removed": 0, "ok": True}, "summary": {"refs": 2, "added": 2, "ok": True},
}, },
"discovery": { "discovery": {
"name": "discovery", "ok": True, "name": "discovery", "ok": True,