Files
re-xxm/reaction_board/services/library.py
T
kairee 4a9c37f2f3 feat(telegram): candidate list/delete/clear commands
私聊新增 /candidate [页码](待决策列表)、/candi_del(标题按钮逐条删除,删后原地重建键盘、可翻页连续删)、/candi_clear(全部/待决策/取消)。重构 handle_callback 为前缀分派(cdel/cpage/cclr,保留 add/ign/blk/fav);repository 加 list_pending_candidates/delete_all_candidates;LibraryService 加 pending_candidates/delete_candidate/clear_candidates 并刷看板缓存。
2026-11-01 20:45:57 +08:00

370 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""库内视频管理:入库(候选确认 / Telegram 手动)、忽略、拉黑。
入库只写本地 ``video`` 表并置为 ``active``,**不再调用 B 站收藏夹写接口**
(``deal`` / ``sort``)。收藏夹由人工维护,系统仅通过 ``favsort.py`` 做重排序。
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Any
from ..bilibili.client import (
BilibiliAPIError,
BilibiliError,
BilibiliProtocolError,
)
from ..config import ConfigStore
from ..repository import Repository
from ..timeutil import shanghai_date
from ..video_ref import DEFAULT_MAX_REFS, VideoRef, extract_video_refs
@dataclass
class IngestResult:
ok: bool
aid: int | None
state: str
title: str = ""
up_name: str = ""
message: str = ""
def as_dict(self) -> dict[str, Any]:
return {
"ok": self.ok,
"aid": self.aid,
"state": self.state,
"title": self.title,
"up_name": self.up_name,
"message": self.message,
}
@dataclass
class ManualIngestResult:
refs: int = 0
added: int = 0
truncated: int = 0
results: list[IngestResult] = field(default_factory=list)
@property
def ok(self) -> bool:
return any(result.ok for result in self.results)
def as_dict(self) -> dict[str, Any]:
return {
"refs": self.refs,
"added": self.added,
"truncated": self.truncated,
"ok": self.ok,
"results": [result.as_dict() for result in self.results],
}
@dataclass
class ActionResult:
ok: bool
aid: int
action: str
state: str
message: str = ""
def as_dict(self) -> dict[str, Any]:
return {
"ok": self.ok,
"aid": self.aid,
"action": self.action,
"state": self.state,
"message": self.message,
}
class LibraryService:
def __init__(
self,
config_store: ConfigStore,
repository: Repository,
read_provider,
*,
up_profile_service=None,
clock=time.time,
logger=None,
max_refs: int = DEFAULT_MAX_REFS,
) -> None:
self.config_store = config_store
self.repo = repository
self.read_provider = read_provider
self.up_profile = up_profile_service
self._clock = clock
self.logger = logger
self.max_refs = max(1, int(max_refs))
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)
# ------------------------------------------------------------ candidate
def ingest_candidate(self, aid: int) -> IngestResult:
aid = int(aid)
candidate = self.repo.get_candidate(aid)
if candidate is None:
video = self.repo.get_video(aid)
if video is not None:
return IngestResult(
ok=True, aid=aid, state="exists",
title=str(video.get("title") or ""),
up_name=str(video.get("up_name") or ""),
message="已在库,无需重复入库",
)
return IngestResult(
ok=False, aid=aid, state="missing", message="候选不存在或已处理"
)
detail = None
try:
detail = self.read_provider.get().view_detail(aid=aid)
except BilibiliError as exc: # 网络失败时用候选元数据兜底
self._log("warning", "ingest.detail_failed", aid=aid, error=str(exc))
if detail is not None:
outcome = self.repo.ingest_video(
aid=detail.aid,
bvid=detail.bvid,
title=detail.title,
up_mid=detail.up_mid,
up_name=detail.up_name,
pubdate=detail.pubdate,
duration=detail.duration,
pages=detail.pages,
cover=detail.cover,
up_face=detail.up_face,
source="candidate:telegram",
now=self._now(),
)
self._write_snapshot(detail)
result = self._result_from_outcome(
outcome, title=detail.title, up_name=detail.up_name or ""
)
up_mid = detail.up_mid
up_name = detail.up_name or ""
else:
pubdate = int(candidate.get("pubdate") or 0)
duration = int(candidate.get("duration") or 0) or None
outcome = self.repo.ingest_video(
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=pubdate,
duration=duration,
cover=candidate.get("cover"),
source="candidate:telegram",
now=self._now(),
)
result = self._result_from_outcome(
outcome,
title=str(candidate.get("title") or ""),
up_name=str(candidate.get("up_name") or ""),
)
up_mid = candidate.get("up_mid")
up_name = str(candidate.get("up_name") or "")
self._post_ingest([(up_mid, up_name)])
self._log(
"info", "ingest.candidate",
aid=aid, state=result.state, folder=None,
)
return result
# ------------------------------------------------------------ manual
def ingest_text(self, text: object) -> ManualIngestResult:
all_refs = extract_video_refs(text, limit=None)
result = ManualIngestResult(refs=len(all_refs))
if not all_refs:
return result
if len(all_refs) > self.max_refs:
result.truncated = len(all_refs) - self.max_refs
all_refs = all_refs[: self.max_refs]
result.refs = len(all_refs)
client = self.read_provider.get()
ups: list[tuple[int | None, str]] = []
for ref in all_refs:
try:
detail = self._resolve_ref(client, ref)
except BilibiliAPIError as exc:
message = (
"无法解析该视频引用:BV 号/av 号无效或视频不存在(code=-400)"
if exc.code == -400 else str(exc)
)
result.results.append(IngestResult(
ok=False, aid=None, state="error", message=message,
))
self._log("warning", "ingest.manual_failed", error=str(exc))
continue
except BilibiliError as exc:
result.results.append(IngestResult(
ok=False, aid=None, state="error", message=str(exc),
))
self._log("warning", "ingest.manual_failed", error=str(exc))
continue
outcome = self.repo.ingest_video(
aid=detail.aid,
bvid=detail.bvid,
title=detail.title,
up_mid=detail.up_mid,
up_name=detail.up_name,
pubdate=detail.pubdate,
duration=detail.duration,
pages=detail.pages,
cover=detail.cover,
up_face=detail.up_face,
source="manual:telegram",
now=self._now(),
)
self._write_snapshot(detail)
item = self._result_from_outcome(
outcome, title=detail.title, up_name=detail.up_name or ""
)
if item.state in ("added", "recovered"):
result.added += 1
result.results.append(item)
ups.append((detail.up_mid, detail.up_name or ""))
if ups:
self._post_ingest(ups)
self._log("info", "ingest.manual", **result.as_dict())
return result
def _resolve_ref(self, client, ref: VideoRef):
if ref.kind == "bvid":
return client.view_detail(bvid=str(ref.value))
if ref.kind == "aid":
return client.view_detail(aid=int(ref.value))
if ref.kind == "short":
final = client.resolve_short_link(f"https://b23.tv/{ref.value}")
for sub in extract_video_refs(final):
if sub.kind != "short":
return self._resolve_ref(client, sub)
raise BilibiliProtocolError(f"短链未解析出视频引用: {final}")
raise ValueError(f"未知引用类型: {ref.kind}")
# ------------------------------------------------------------ actions
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} 条待决策候选",
)
# ------------------------------------------------------------ candidate admin
def pending_candidates(self) -> list[dict[str, Any]]:
return self.repo.list_pending_candidates()
def delete_candidate(self, aid: int) -> dict[str, Any] | None:
"""删除单条候选;返回被删记录(不存在则 None)。"""
aid = int(aid)
candidate = self.repo.get_candidate(aid)
if candidate is None:
return None
self.repo.delete_candidate(aid)
self.repo.bump_cache_version("videos")
self._log("info", "candidate.delete", aid=aid)
return candidate
def clear_candidates(self, *, pending_only: bool = False) -> int:
"""删除候选(``pending_only=True`` 只删待决策);返回删除数。"""
removed = self.repo.delete_all_candidates(pending_only=pending_only)
if removed:
self.repo.bump_cache_version("videos")
self._log(
"info", "candidate.clear", pending_only=pending_only, removed=removed,
)
return removed
# ------------------------------------------------------------ internals
def _result_from_outcome(self, outcome, *, title: str, up_name: str) -> IngestResult:
if outcome.created:
state, message = "added", "已入库"
elif outcome.recovered:
state, message = "recovered", "已重新入库"
else:
state, message = "exists", "已在库"
return IngestResult(
ok=True, aid=outcome.aid, state=state,
title=title, up_name=up_name, message=message,
)
def _write_snapshot(self, detail) -> None:
"""用已取到的 ``view`` 数据立即写一条当天快照(同日已有成功记录则跳过)。"""
now = self._now()
stat_date = shanghai_date(now)
existing = self.repo.get_snapshot(stat_date, detail.aid)
if existing is not None and int(existing.get("ok") or 0) == 1:
return
self.repo.upsert_snapshot(
stat_date,
detail.aid,
metrics=detail.metrics(),
ok=True,
err=None,
captured_at=now,
)
def _post_ingest(self, ups: list[tuple[int | None, str]]) -> None:
if ups and self.up_profile is not None:
mids = [mid for mid, _ in ups if isinstance(mid, int) and not isinstance(mid, bool)]
names = {mid: name for mid, name in ups if isinstance(mid, int) and not isinstance(mid, bool)}
if mids:
try:
self.up_profile.capture(mids, names=names, live_since=self._now() - 3600)
except Exception as exc: # noqa: BLE001 粉丝采集失败不影响入库
self._log("warning", "ingest.follower_failed", error=str(exc))
self.repo.bump_cache_version("videos")