From 6c73904b25b1c33b2f910aba128d1ecd3d4a33bb Mon Sep 17 00:00:00 2001 From: bot-ci Date: Tue, 3 Nov 2026 23:01:15 +0800 Subject: [PATCH 1/2] feat(snapshot): refresh API video metadata; keep removed visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 入库 / 小时近新片 / 全量快照成功时以 view_detail 覆盖 title、duration、 pages、cover、bvid、up_*、pubdate 等接口字段,并照旧写七项快照。 原站失效仅标 removed,不删行、不空写元数据;列表与详情继续展示, 快照队列仍跳过 removed。 --- reaction_board/repository.py | 70 +++++++++++++++++++++++ reaction_board/services/library.py | 32 ++++++++++- reaction_board/services/snapshot.py | 47 ++++++++++------ reaction_board/web/queries.py | 5 +- tests/fixtures.py | 30 ++++++++++ tests/test_repository.py | 68 ++++++++++++++++++++++ tests/test_snapshot.py | 87 +++++++++++++++++++++++++++-- tests/test_web_queries.py | 24 +++++--- 8 files changed, 329 insertions(+), 34 deletions(-) diff --git a/reaction_board/repository.py b/reaction_board/repository.py index 23be4d2..a20d000 100644 --- a/reaction_board/repository.py +++ b/reaction_board/repository.py @@ -410,6 +410,75 @@ class Repository: return [dict(row) for row in rows] return self.db.read(run) + def refresh_video_metadata( + self, + *, + aid: int, + bvid: str | None, + title: str, + up_mid: int | None, + up_name: str | None, + pubdate: int, + duration: int | None = None, + pages: int | None = None, + cover: str | None = None, + up_face: str | None = None, + now: int | None = None, + ) -> bool: + """用接口返回字段覆盖更新已有视频元数据;不改 status,空值不冲掉旧值。 + + 入库与快照成功路径共用。视频不存在时返回 False。 + """ + now = self._now() if now is None else int(now) + aid = int(aid) + + def run(conn: sqlite3.Connection) -> bool: + existing = conn.execute( + "SELECT aid FROM video WHERE aid = ?", (aid,) + ).fetchone() + if existing is None: + return False + conn.execute( + """ + UPDATE video SET + bvid = COALESCE(?, bvid), + title = CASE + WHEN ? != '' THEN ? + ELSE title + END, + up_mid = COALESCE(?, up_mid), + up_name = COALESCE(?, up_name), + pubdate = CASE + WHEN ? > 0 THEN ? + ELSE pubdate + END, + duration = COALESCE(?, duration), + pages = COALESCE(?, pages), + cover = COALESCE(?, cover), + up_face = COALESCE(?, up_face), + last_sync = ? + WHERE aid = ? + """, + ( + bvid, + title or "", + title or "(无标题)", + up_mid, + up_name, + int(pubdate or 0), + int(pubdate or 0), + None if duration is None else int(duration), + None if pages is None else int(pages), + cover, + up_face, + now, + aid, + ), + ) + return True + + return self.db.write(run) + def ingest_video( self, *, @@ -430,6 +499,7 @@ class Repository: 不写 B 站收藏夹、不设置 ``folder_id``;同时删除同 aid 的候选行。 既有的 ``removed`` / ``dropped`` / ``unfaved`` 状态都会被重新激活。 + 已存在时仍以本次接口字段为准刷新元数据。 """ now = self._now() if now is None else int(now) aid = int(aid) diff --git a/reaction_board/services/library.py b/reaction_board/services/library.py index 6291b26..985ed23 100644 --- a/reaction_board/services/library.py +++ b/reaction_board/services/library.py @@ -116,11 +116,37 @@ class LibraryService: if candidate is None: video = self.repo.get_video(aid) if video is not 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)) + return IngestResult( + ok=True, aid=aid, state="exists", + title=str(video.get("title") or ""), + up_name=str(video.get("up_name") or ""), + message="已在库,无需重复入库", + ) + self.repo.refresh_video_metadata( + 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, + now=self._now(), + ) + self._write_snapshot(detail) + self.repo.bump_cache_version("videos") return IngestResult( ok=True, aid=aid, state="exists", - title=str(video.get("title") or ""), - up_name=str(video.get("up_name") or ""), - message="已在库,无需重复入库", + title=detail.title, + up_name=detail.up_name or "", + message="已在库,已刷新元数据", ) return IngestResult( ok=False, aid=aid, state="missing", message="候选不存在或已处理" diff --git a/reaction_board/services/snapshot.py b/reaction_board/services/snapshot.py index be8204e..ff0467c 100644 --- a/reaction_board/services/snapshot.py +++ b/reaction_board/services/snapshot.py @@ -3,12 +3,13 @@ 规则(DEVELOPMENT.md §7.4): -* 对 ``active`` / ``unfaved`` 视频逐条请求 ``view``; -* 成功写入七项指标;``ok=1`` 的同日快照直接跳过(断点续跑;含小时任务 1 小时内刚刷新的); +* 对 ``active`` / ``unfaved`` 视频逐条请求 ``view_detail``(完整元数据 + 七项); +* 成功时以接口字段覆盖更新 ``video`` 元数据(title/duration/pages/cover/UP 等),并写入七项快照; +* ``ok=1`` 的同日快照直接跳过(断点续跑;含小时任务 1 小时内刚刷新的); * 午夜 ``seed_at`` 用上一完成日 ok=1 行克隆出当日壳(``snapshot:seeded`` / ``fans:seeded``),不推进完成日; * 晚间 ``snapshot_at`` 全量必须 force 打穿 seed,成功后才写完成日并清除 seeded; * ``run_recent_videos`` 只刷新近 N 天新片的当日行,不改完成日 / 备份; -* 视频不可访问时标记 ``removed``; +* 视频不可访问时标记 ``removed``(保留元数据与历史快照,站内继续展示,后续采集跳过); * 其他错误最多重试 3 次,仍失败写 ``ok=0`` 占位,不写假零值; * 对 ``track_fans`` 逐个请求 ``relation/stat``(独立任务,不影响视频完成日); * 视频采集全部成功后写入当日完成标记并生成日备份。 @@ -224,7 +225,7 @@ class SnapshotService: force: bool = False, keep_scraped: bool = False, ) -> SnapshotSummary: - """采集视频七项指标并在全部成功后标记完成日 / 备份。 + """采集视频元数据 + 七项指标,并在全部成功后标记完成日 / 备份。 ``force``:忽略完成标记,重跑。 ``keep_scraped``:force 时仍跳过非 seed 的 ok=1(seal 断点续跑); @@ -264,7 +265,7 @@ class SnapshotService: continue # force 且非 keep_scraped:CLI 全量重采,不 skip - stat, error, unavailable = self._fetch_view(client, aid) + detail, error, unavailable = self._fetch_view_detail(client, aid) if unavailable: summary.removed += 1 self.repo.set_video_status(aid, "removed", now=now) @@ -272,17 +273,16 @@ class SnapshotService: "warning", "snapshot.removed", stat_date=stat_date, aid=aid, error=error, ) - elif stat is not None: + elif detail is not None: + self._apply_detail(detail, now=self._now()) self.repo.upsert_snapshot( stat_date, aid, - metrics=stat.metrics(), + metrics=detail.metrics(), ok=True, err=None, captured_at=self._now(), ) - if getattr(stat, "up_face", None): - self.repo.apply_video_up_face(aid, stat.up_face) summary.success += 1 else: summary.failed += 1 @@ -369,7 +369,7 @@ class SnapshotService: videos = self.repo.video_aids_recent_for_snapshot(since_pubdate=since_pubdate) summary.total = len(videos) for index, aid in enumerate(videos, 1): - stat, error, unavailable = self._fetch_view(client, aid) + detail, error, unavailable = self._fetch_view_detail(client, aid) if unavailable: summary.removed += 1 self.repo.set_video_status(aid, "removed", now=now) @@ -377,17 +377,16 @@ class SnapshotService: "warning", "snapshot.recent_removed", stat_date=stat_date, aid=aid, error=error, ) - elif stat is not None: + elif detail is not None: + self._apply_detail(detail, now=self._now()) self.repo.upsert_snapshot( stat_date, aid, - metrics=stat.metrics(), + metrics=detail.metrics(), ok=True, err=None, captured_at=self._now(), ) - if getattr(stat, "up_face", None): - self.repo.apply_video_up_face(aid, stat.up_face) summary.success += 1 else: summary.failed += 1 @@ -693,11 +692,27 @@ class SnapshotService: ) return state.is_login, state.message - def _fetch_view(self, client, aid: int): + def _apply_detail(self, detail, *, now: int) -> None: + """快照成功:以 view_detail 字段覆盖 video 元数据(不改 status)。""" + self.repo.refresh_video_metadata( + 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, + now=now, + ) + + def _fetch_view_detail(self, client, aid: int): last_error = "" for attempt in range(1, self.retries + 1): try: - return client.view(aid), None, False + return client.view_detail(aid=aid), None, False except BilibiliAPIError as exc: last_error = str(exc) if exc.code in VIDEO_UNAVAILABLE_CODES: diff --git a/reaction_board/web/queries.py b/reaction_board/web/queries.py index c87e558..0b72c1a 100644 --- a/reaction_board/web/queries.py +++ b/reaction_board/web/queries.py @@ -45,6 +45,8 @@ from ..timeutil import ( from .cache import DASHBOARD_CACHE, TTL_FANS, TTL_VIDEOS, build_cache_key TRACKED_STATUSES = ("active", "unfaved") +# 站内列表可见:含原站失效 removed(继续展示、badge 可标 status-removed);不含人工 dropped +VISIBLE_STATUSES = ("active", "unfaved", "removed") VIDEO_SORTS = { "heat", "view", "like", "coin", "favorite", "share", "reply", "danmaku", "interaction", "growth", "growth_rate", "added", "published", @@ -955,6 +957,7 @@ class DashboardQueries: within_start = _date_minus(latest, int(within_days) - 1) videos = self._videos( conn, + statuses=VISIBLE_STATUSES, folder_id=folder_id, q=q, within_field=within_field, @@ -1157,7 +1160,7 @@ class DashboardQueries: aid = int(aid) def run(conn: sqlite3.Connection) -> dict[str, Any] | None: - video_rows = self._videos(conn) + video_rows = self._videos(conn, statuses=VISIBLE_STATUSES) video = next((item for item in video_rows if int(item["aid"]) == aid), None) if video is None: row = conn.execute( diff --git a/tests/fixtures.py b/tests/fixtures.py index 65fcd52..cc4f68d 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -256,11 +256,41 @@ def view_for( reply: int = 16, danmaku: int = 7, bvid: str | None = None, + title: str | None = None, + duration: int | None = None, + pages: int | None = None, + cover: str | None = None, + up_mid: int | None = None, + up_name: str | None = None, + up_face: str | None = None, + pubdate: int | None = None, ) -> dict[str, Any]: item = copy.deepcopy(VIEW_PAYLOAD) data = item["data"] data["aid"] = aid data["bvid"] = bvid or f"BV{aid}" + if title is not None: + data["title"] = title + if duration is not None: + data["duration"] = duration + if cover is not None: + data["pic"] = cover + if pubdate is not None: + data["pubdate"] = pubdate + data["ctime"] = pubdate + if pages is not None: + data["videos"] = pages + data["pages"] = [ + {"cid": 1000 + i, "page": i + 1, "from": "vupload", "part": f"P{i + 1}"} + for i in range(max(1, int(pages))) + ] + owner = data.setdefault("owner", {}) + if up_mid is not None: + owner["mid"] = up_mid + if up_name is not None: + owner["name"] = up_name + if up_face is not None: + owner["face"] = up_face data["stat"].update({ "aid": aid, "view": view, diff --git a/tests/test_repository.py b/tests/test_repository.py index 8c37ca2..4293e89 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -408,6 +408,72 @@ def test_up_follower_snapshot_and_backfill(): db.close() +def test_refresh_video_metadata_overwrites_api_fields_keeps_nulls(): + with tempfile.TemporaryDirectory() as tmp: + db, repo = setup(tmp) + try: + repo.upsert_from_fav(resource(9, pages=1), F1.media_id, was_added=True) + ok = repo.refresh_video_metadata( + aid=9, + bvid="BVnew9", + title="刷新后", + up_mid=200, + up_name="新名", + pubdate=1_710_000_000, + duration=999, + pages=5, + cover="http://example/new.png", + up_face="http://example/face.png", + now=1_800_000_100, + ) + assert ok is True + video = repo.get_video(9) + assert video["title"] == "刷新后" + assert video["duration"] == 999 + assert video["pages"] == 5 + assert video["bvid"] == "BVnew9" + assert video["up_mid"] == 200 + assert video["cover"] == "http://example/new.png" + assert video["status"] == "active" + # 空值不冲掉 + ok = repo.refresh_video_metadata( + aid=9, + bvid=None, + title="", + up_mid=None, + up_name=None, + pubdate=0, + duration=None, + pages=None, + cover=None, + up_face=None, + now=1_800_000_200, + ) + assert ok is True + video = repo.get_video(9) + assert video["title"] == "刷新后" + assert video["duration"] == 999 + assert video["pages"] == 5 + assert video["bvid"] == "BVnew9" + # 不存在的 aid + assert repo.refresh_video_metadata( + aid=404, + bvid=None, title="x", up_mid=None, up_name=None, pubdate=1, + ) is False + # removed 后 refresh 也不改 status + repo.set_video_status(9, "removed") + repo.refresh_video_metadata( + aid=9, bvid="BVkeep", title="仍在", up_mid=200, up_name="新名", + pubdate=1_710_000_000, duration=1000, pages=6, + ) + video = repo.get_video(9) + assert video["status"] == "removed" + assert video["title"] == "仍在" + assert video["pages"] == 6 + finally: + db.close() + + def main(): test_upsert_and_folder_resource() test_invalid_attr_and_missing() @@ -422,8 +488,10 @@ def main(): test_pending_snapshot_aids() test_upsert_snapshot_preserves_view_open() test_up_follower_snapshot_and_backfill() + test_refresh_video_metadata_overwrites_api_fields_keeps_nulls() print("OK: 收藏夹/视频状态机与黑名单测试全部通过") + if __name__ == "__main__": main() diff --git a/tests/test_snapshot.py b/tests/test_snapshot.py index 79da107..3d7204a 100644 --- a/tests/test_snapshot.py +++ b/tests/test_snapshot.py @@ -216,14 +216,86 @@ def test_unavailable_marks_removed(): try: add_video(repo, 1) add_video(repo, 2) + before = repo.get_video(1) result = service.run(date=DATE) assert result.removed == 2 assert result.failed == 0 assert result.completed is True - assert repo.get_video(1)["status"] == "removed" - assert repo.get_video(2)["status"] == "removed" + video1 = repo.get_video(1) + video2 = repo.get_video(2) + assert video1["status"] == "removed" + assert video2["status"] == "removed" + # 失效只改 status,不得抹元数据 / 不得写空快照 + assert video1["title"] == before["title"] + assert video1["duration"] == before["duration"] + assert video1["bvid"] == before["bvid"] assert repo.get_snapshot(DATE, 1) is None assert repo.get_snapshot(DATE, 2) is None + # removed 不再进入后续快照队列 + assert 1 not in repo.video_aids_for_snapshot() + assert 2 not in repo.video_aids_for_snapshot() + finally: + db.close() + + +def test_snapshot_refreshes_video_metadata(): + """快照成功路径以 view_detail 覆盖 pages/duration/title 等元数据。""" + with tempfile.TemporaryDirectory() as tmp: + transport = SnapshotTransport({ + 1: view_for( + 1, view=111, title="新标题P3", duration=3600, pages=3, + cover="http://example/new.jpg", up_name="新UP", + ), + }) + db, repo, service = make_service(tmp, transport, track_fans=()) + try: + add_video(repo, 1) + old = repo.get_video(1) + assert old["title"] == "视频1" + assert old["duration"] == 600 + assert old.get("pages") in (None, 0) or old["pages"] != 3 + result = service.run_videos(date=DATE) + assert result.success == 1 + video = repo.get_video(1) + assert video["title"] == "新标题P3" + assert video["duration"] == 3600 + assert video["pages"] == 3 + assert video["cover"] == "http://example/new.jpg" + assert video["up_name"] == "新UP" + assert video["status"] == "active" + snap = repo.get_snapshot(DATE, 1) + assert snap["view"] == 111 and snap["ok"] == 1 + finally: + db.close() + + +def test_recent_snapshot_refreshes_metadata(): + with tempfile.TemporaryDirectory() as tmp: + now = 1_800_000_000 + transport = SnapshotTransport({ + 7: view_for(7, view=77, title="近新片加P", duration=1200, pages=2, + pubdate=now - 3600), + }) + db, repo, service = make_service(tmp, transport, track_fans=()) + try: + repo.upsert_from_fav( + FavResource( + aid=7, bvid="BV7", title="旧标题", up_mid=100, + up_name="UP", pubdate=now - 3600, duration=300, + cover=None, attr=0, fav_time=None, pages=1, + ), + FOLDER.media_id, + was_added=True, + ) + result = service.run_recent_videos(date=DATE, recent_days=2) + assert result.success == 1 + video = repo.get_video(7) + assert video["title"] == "近新片加P" + assert video["duration"] == 1200 + assert video["pages"] == 2 + assert repo.get_snapshot(DATE, 7)["view"] == 77 + # recent 不推进完成日 + assert repo.kv_get("snapshot:last_completed") in (None, "") finally: db.close() @@ -440,11 +512,12 @@ def test_run_recent_videos_refreshes_without_completion_markers(): """小时任务只刷新近窗新片,不写完成日 / 备份。""" clock = 1_800_000_000 with tempfile.TemporaryDirectory() as tmp: + # view_for 须带与库内一致的 pubdate,否则快照回写会把近窗片挤出窗口 transport = SnapshotTransport({ - 1: view_for(1, view=111), - 2: view_for(2, view=222), - 3: view_for(3, view=333), - 4: view_for(4, view=444), + 1: view_for(1, view=111, pubdate=clock - 3600), + 2: view_for(2, view=222, pubdate=clock - 86400), + 3: view_for(3, view=333, pubdate=clock - 10 * 86400), + 4: view_for(4, view=444, pubdate=clock - 10 * 86400), }) db, repo, service = make_service(tmp, transport, track_fans=()) try: @@ -776,6 +849,8 @@ def main(): test_snapshot_success_resume_and_force() test_resume_skips_ok_and_retries_failed() test_unavailable_marks_removed() + test_snapshot_refreshes_video_metadata() + test_recent_snapshot_refreshes_metadata() test_other_error_writes_failed_placeholder() test_fan_failure_placeholder() test_auth_invalid_is_warned_but_snapshot_continues() diff --git a/tests/test_web_queries.py b/tests/test_web_queries.py index 56a0e9b..8dbdffb 100644 --- a/tests/test_web_queries.py +++ b/tests/test_web_queries.py @@ -68,13 +68,16 @@ def test_video_listing_filters_and_sorting(): heat = q.list_videos(sort="heat") aids = [item["aid"] for item in heat["items"]] - assert 104 not in aids, "removed 视频不应进入榜单" + # 热度榜仍按门槛过滤;104 removed 无成功快照,不进热度 + assert 104 not in aids # 综合热度榜应用最小播放门槛 2000:101(1200)与 103(120)被排除 assert aids == [102], aids assert heat["items"][0]["heat_percentile"] == 100.0 views = q.list_videos(sort="view") - assert [item["aid"] for item in views["items"]] == [102, 101, 103] + # removed(104) 站内继续展示;无指标排在末尾 + assert [item["aid"] for item in views["items"]] == [102, 101, 103, 104] + assert views["items"][-1]["status"] == "removed" interactions = q.list_videos(sort="interaction") assert interactions["items"][0]["aid"] == 101 @@ -91,14 +94,19 @@ def test_video_listing_filters_and_sorting(): assert [item["aid"] for item in searched["items"]] == [102] folder = q.list_videos(sort="view", folder_id=3700023351) - assert folder["total"] == 3 + assert folder["total"] == 4 # 含 removed 104 folder = q.list_videos(sort="view", folder_id=999) assert folder["total"] == 0 page = q.list_videos(sort="view", page=2, page_size=1) - assert page["total"] == 3 - assert page["pages"] == 3 + assert page["total"] == 4 + assert page["pages"] == 4 assert [item["aid"] for item in page["items"]] == [101] + + detail = q.video_detail(104) + assert detail is not None + assert detail["status"] == "removed" + assert detail["aid"] == 104 finally: db.close() @@ -457,7 +465,7 @@ def test_year_filter_and_published_sort(): db, q = setup(tmp) try: by_year = q.list_videos(sort="view", year=2026) - assert [item["aid"] for item in by_year["items"]] == [102, 101, 103] + assert [item["aid"] for item in by_year["items"]] == [102, 101, 103, 104] assert by_year["year"] == 2026 none = q.list_videos(sort="view", year=2025) @@ -465,10 +473,10 @@ def test_year_filter_and_published_sort(): assert none["total"] == 0 published = q.list_videos(sort="published") - assert [item["aid"] for item in published["items"]] == [103, 101, 102] + assert [item["aid"] for item in published["items"]] == [103, 101, 102, 104] added = q.list_videos(sort="added") - assert added["total"] == 3 + assert added["total"] == 4 # 含 removed ups = q.list_ups(sort="efficiency", year=2026) assert ups["year"] == 2026 and ups["total"] == 2 From 0c660c647415f4418dd42ac33f761ac57e15d3f0 Mon Sep 17 00:00:00 2001 From: bot-ci Date: Tue, 3 Nov 2026 23:45:44 +0800 Subject: [PATCH 2/2] feat(web): include removed in visible stats; cover bang badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 站内展示/统计统一 VISIBLE(含 removed):热度、UP 列表、总览 active_ups/初导入计数、说明页粉丝覆盖、黑名单视频数。采集队列仍 只 active/unfaved。失效片封面右上角感叹号徽标,点击/tap 提示 「该视频已失效」;手动/候选入库仍可复活 removed→active。 --- reaction_board/web/queries.py | 15 ++++----- reaction_board/web/static/app.js | 9 ++++++ reaction_board/web/static/components.js | 9 ++++++ reaction_board/web/static/style.css | 26 +++++++++++++++ reaction_board/web/static/views/video.js | 6 +++- tests/test_removed_cover_mark.py | 34 ++++++++++++++++++++ tests/test_web_queries.py | 40 ++++++++++++++++++------ 7 files changed, 122 insertions(+), 17 deletions(-) create mode 100644 tests/test_removed_cover_mark.py diff --git a/reaction_board/web/queries.py b/reaction_board/web/queries.py index 0b72c1a..59ba2fa 100644 --- a/reaction_board/web/queries.py +++ b/reaction_board/web/queries.py @@ -44,9 +44,10 @@ from ..timeutil import ( ) from .cache import DASHBOARD_CACHE, TTL_FANS, TTL_VIDEOS, build_cache_key -TRACKED_STATUSES = ("active", "unfaved") -# 站内列表可见:含原站失效 removed(继续展示、badge 可标 status-removed);不含人工 dropped +TRACKED_STATUSES = ("active", "unfaved") # 采集队列口径;站内展示勿用 +# 站内展示/统计:含原站失效 removed(继续展示);不含人工 dropped VISIBLE_STATUSES = ("active", "unfaved", "removed") +_VISIBLE_IN = ",".join(f"'{s}'" for s in VISIBLE_STATUSES) VIDEO_SORTS = { "heat", "view", "like", "coin", "favorite", "share", "reply", "danmaku", "interaction", "growth", "growth_rate", "added", "published", @@ -342,7 +343,7 @@ class DashboardQueries: def _videos( conn: sqlite3.Connection, *, - statuses: Sequence[str] = TRACKED_STATUSES, + statuses: Sequence[str] = VISIBLE_STATUSES, folder_id: int | None = None, q: str | None = None, q_fields: Sequence[str] | None = None, @@ -611,7 +612,7 @@ class DashboardQueries: if first_seen_ts is not None: initial_import_count = int(conn.execute( "SELECT COUNT(*) FROM video WHERE first_seen = ? " - "AND status IN ('active','unfaved')", + f"AND status IN ({_VISIBLE_IN})", (int(first_seen_ts),), ).fetchone()[0]) @@ -682,7 +683,7 @@ class DashboardQueries: "total": sum(by_status.values()), "active_ups": int(conn.execute( "SELECT COUNT(DISTINCT up_mid) FROM video " - "WHERE status IN ('active','unfaved') AND up_mid IS NOT NULL" + f"WHERE status IN ({_VISIBLE_IN}) AND up_mid IS NOT NULL" ).fetchone()[0]), "candidates_pending": candidate_pending, "candidates_total": candidate_total, @@ -1755,7 +1756,7 @@ class DashboardQueries: SELECT COUNT(*) AS total, SUM(CASE WHEN up_follower IS NOT NULL THEN 1 ELSE 0 END) AS covered FROM video - WHERE status IN ('active', 'unfaved') AND up_mid IS NOT NULL + WHERE status IN ('active', 'unfaved', 'removed') AND up_mid IS NOT NULL """ ).fetchone() follower_total = int(follower_row["total"] or 0) @@ -1967,7 +1968,7 @@ class DashboardQueries: """ SELECT b.*, (SELECT COUNT(*) FROM video v WHERE v.up_mid = b.mid - AND v.status IN ('active','unfaved')) AS video_count + AND v.status IN ('active','unfaved','removed')) AS video_count FROM up_blacklist b ORDER BY b.created_at, b.mid """ ).fetchall() diff --git a/reaction_board/web/static/app.js b/reaction_board/web/static/app.js index 67a17a3..50d059d 100644 --- a/reaction_board/web/static/app.js +++ b/reaction_board/web/static/app.js @@ -134,6 +134,15 @@ function toast(message, kind = "info") { }, 2600); } + +document.addEventListener("click", (event) => { + const mark = event.target.closest("[data-removed-tip]"); + if (!mark) return; + event.preventDefault(); + event.stopPropagation(); + toast("该视频已失效"); +}, true); + window.addEventListener("error", (event) => { const target = event.target; if (target instanceof HTMLImageElement) { diff --git a/reaction_board/web/static/components.js b/reaction_board/web/static/components.js index 327221c..18bcb82 100644 --- a/reaction_board/web/static/components.js +++ b/reaction_board/web/static/components.js @@ -13,6 +13,13 @@ export function cover(item, className = "thumb") { loading="lazy" referrerpolicy="no-referrer">`; } + +export function removedCoverMark(item) { + if (item?.status !== "removed") return ""; + return ``; +} + export function rankBadge(rank, className = "rank-badge") { if (!rank) return ""; const top = rank <= 3 ? ` rank-${rank}` : ""; @@ -73,6 +80,7 @@ export function videoCard(item, {
${cover(item, "video-thumb")} ${durationBadge} + ${removedCoverMark(item)}

${title}

@@ -150,6 +158,7 @@ export function mediaCard(item, { rank = null, highlight = "" } = {}) {
${cover(item, "media-thumb")} ${durationBadge} + ${removedCoverMark(item)}
${escapeHtml(item.title || "(无标题)")}