Implement WBI video search with legacy fallback, classification scoring, candidate pool, permanent seen-aid dedupe, blacklist filtering, and batched Telegram notifications. Add write client for fav deal/sort, target-folder selection, insertion position calculation, read-back verification, fav_action locking, retry on failure, ignore and blacklist actions. Wire discovery, candidate cleanup, Telegram long polling, and new discover/candidates CLI commands into the scheduler. Add migration v3 for candidate duration and index stats.
235 lines
8.7 KiB
Python
235 lines
8.7 KiB
Python
# -*- 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,
|
|
)
|
|
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())
|
|
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())
|
|
return ActionResult(
|
|
ok=True, aid=aid, action="blacklist", state="rejected",
|
|
message=f"已拉黑 UP,并忽略该 UP 的 {rejected} 条待决策候选",
|
|
)
|