feat(web): up avatars, follower count, and dedupe views/avg secondary
This commit is contained in:
1 parent
1ed3746f4b
commit
2ff3e7952b
23 files changed
+202
-51
No files matched your search
+15
-5
@@ -431,7 +431,7 @@ P3 实现说明:
|
|||||||
每天 `00:01`(`Asia/Shanghai`)执行:
|
每天 `00:01`(`Asia/Shanghai`)执行:
|
||||||
|
|
||||||
- 对 `status IN ('active', 'unfaved')` 的视频逐条请求 `view?aid=`;
|
- 对 `status IN ('active', 'unfaved')` 的视频逐条请求 `view?aid=`;
|
||||||
- 成功写入播放、点赞、收藏、投币、转发、评论、弹幕七项数据;
|
- 成功写入播放、点赞、收藏、投币、转发、评论、弹幕七项数据,并把 `view` 的 `owner.face` 写入 `video.up_face`(UP 头像,供 UP 列表/详情展示);
|
||||||
- `-404` / `62002` / `62012` 时标记 `removed`(62002/62012 为当前实测返回);
|
- `-404` / `62002` / `62012` 时标记 `removed`(62002/62012 为当前实测返回);
|
||||||
- 其他错误最多重试 3 次,仍失败则写入 `ok=0` 与错误信息,不写伪造的零值;
|
- 其他错误最多重试 3 次,仍失败则写入 `ok=0` 与错误信息,不写伪造的零值;
|
||||||
- 每条视频请求之间随机等待 1.0~1.6 秒;
|
- 每条视频请求之间随机等待 1.0~1.6 秒;
|
||||||
@@ -519,8 +519,8 @@ P3 实现说明:
|
|||||||
| `GET` | `/api/videos/{aid}` | 视频详情(含分 P 数、入库时粉丝数、内容/互动效率) |
|
| `GET` | `/api/videos/{aid}` | 视频详情(含分 P 数、入库时粉丝数、内容/互动效率) |
|
||||||
| `GET` | `/api/videos/{aid}/trend` | 七项趋势数据 |
|
| `GET` | `/api/videos/{aid}/trend` | 七项趋势数据 |
|
||||||
| `GET` | `/api/rankings` | 兼容旧链接;`scope=up` 时为 UP 聚合榜(`year` 可用) |
|
| `GET` | `/api/rankings` | 兼容旧链接;`scope=up` 时为 UP 聚合榜(`year` 可用) |
|
||||||
| `GET` | `/api/ups` | UP 聚合榜单(含总时长、播放·小时、内容效率;`sort` / `within_days` / `year`) |
|
| `GET` | `/api/ups` | UP 聚合榜单(含总时长、播放·小时、内容效率、UP 头像 `up_face`、最新粉丝数 `follower_latest`;`sort` / `within_days` / `year`) |
|
||||||
| `GET` | `/api/ups/{mid}` | UP 详情、聚合趋势和视频列表 |
|
| `GET` | `/api/ups/{mid}` | UP 详情、聚合趋势、头像和视频列表 |
|
||||||
| `GET` | `/api/fans` | 粉丝曲线与增量 |
|
| `GET` | `/api/fans` | 粉丝曲线与增量 |
|
||||||
| `GET` | `/api/candidates` | 候选池(只读) |
|
| `GET` | `/api/candidates` | 候选池(只读) |
|
||||||
| `GET` | `/api/system` | 最近任务、下次任务、备份、黑名单 |
|
| `GET` | `/api/system` | 最近任务、下次任务、备份、黑名单 |
|
||||||
@@ -532,10 +532,10 @@ P3 实现说明:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 首页 | 最新动态优先:顶部四个可点击数据卡片(今日入库→视频榜按入库排序、播放增量→视频榜按 1 日增长排序、粉丝日增→粉丝页、待确认候选→候选页);下方为最近发布、增长动态、热度速览、历史收藏夹与采集状态。 |
|
| 首页 | 最新动态优先:顶部四个可点击数据卡片(今日入库→视频榜按入库排序、播放增量→视频榜按 1 日增长排序、粉丝日增→粉丝页、待确认候选→候选页);下方为最近发布、增长动态、热度速览、历史收藏夹与采集状态。 |
|
||||||
| 视频(`/videos`) | 内容效率(默认)、综合热度、发布时间、入库时间、播放/投币/收藏/评论、互动率、播放增量、播放增速;工具条为「排序」下拉 + 时间下拉 + 右侧放大镜搜索框(一行展示),时间支持全部/7 天/30 天/90 天/1 年/年份。 |
|
| 视频(`/videos`) | 内容效率(默认)、综合热度、发布时间、入库时间、播放/投币/收藏/评论、互动率、播放增量、播放增速;工具条为「排序」下拉 + 时间下拉 + 右侧放大镜搜索框(一行展示),时间支持全部/7 天/30 天/90 天/1 年/年份。 |
|
||||||
| UP主(`/ups`) | 独立入口,不再与视频混排;排序下拉含内容效率、互动效率、内容量、总播放、视频数、平均播放、总互动、最近发布;不提供按视频数筛选。 |
|
| UP主(`/ups`) | 独立入口,不再与视频混排;卡片显示 UP 头像、视频数与总时长、最新粉丝数,以及当前排序的主/副指标(「总播放」排序时副指标为「平均播放」);排序下拉含内容效率、互动效率、内容量、总播放、视频数、平均播放、总互动、最近发布;不提供按视频数筛选。 |
|
||||||
| 数据说明(`/about`) | 数据来源与更新、发布时间/入库时间/快照日期、指标公式、时长与成熟窗口筛选、粉丝快照覆盖与已知局限;数值由 `/api/about` 动态读取。 |
|
| 数据说明(`/about`) | 数据来源与更新、发布时间/入库时间/快照日期、指标公式、时长与成熟窗口筛选、粉丝快照覆盖与已知局限;数值由 `/api/about` 动态读取。 |
|
||||||
| 视频详情 | 元数据、分 P 数、七项指标、内容/互动效率、入库时粉丝数、单指标趋势、快照表、同 UP 对比。 |
|
| 视频详情 | 元数据、分 P 数、七项指标、内容/互动效率、入库时粉丝数、单指标趋势、快照表、同 UP 对比。 |
|
||||||
| UP 页 | 该 UP 的全部视频、聚合趋势、单视频对比、总时长与播放·小时、入库时与当前粉丝数。 |
|
| UP 页 | 该 UP 的头像、全部视频、聚合趋势、单视频对比、总时长与播放·小时、入库时与当前粉丝数。 |
|
||||||
| 粉丝页 | 粉丝总数、日增、7 日净增、累计增长(标注起始日期);支持预设和自选日期,范围超过一年时按每月峰值日期聚合;最近记录默认 14 天并显示“较上一成功日”增量;不叠加视频数据。 |
|
| 粉丝页 | 粉丝总数、日增、7 日净增、累计增长(标注起始日期);支持预设和自选日期,范围超过一年时按每月峰值日期聚合;最近记录默认 14 天并显示“较上一成功日”增量;不叠加视频数据。 |
|
||||||
| 候选池 | 待确认候选、规则命中、剩余保留时间;操作指向 Telegram/CLI。 |
|
| 候选池 | 待确认候选、规则命中、剩余保留时间;操作指向 Telegram/CLI。 |
|
||||||
| 系统状态 | 最近扫描、同步、快照、下次运行、备份、鉴权和黑名单。 |
|
| 系统状态 | 最近扫描、同步、快照、下次运行、备份、鉴权和黑名单。 |
|
||||||
@@ -620,6 +620,7 @@ uv run python tests/test_moves.py
|
|||||||
|
|
||||||
- 标题归一化、时长解析、分类打分;
|
- 标题归一化、时长解析、分类打分;
|
||||||
- 视频引用解析(BV/av/URL/`b23.tv` 短链)与 `view?bvid=` 契约;
|
- 视频引用解析(BV/av/URL/`b23.tv` 短链)与 `view?bvid=` 契约;
|
||||||
|
- UP 头像与粉丝数列:`view`/`view_detail` 的 `owner.face` 解析、入库与快照写入 `video.up_face`、`/api/ups` 的 `up_face`/`follower_latest` 映射;
|
||||||
- 黑名单只影响发现;
|
- 黑名单只影响发现;
|
||||||
- `infos` 静默缺失资源的识别;
|
- `infos` 静默缺失资源的识别;
|
||||||
- `active`、`unfaved`、`removed`、`dropped` 状态迁移;
|
- `active`、`unfaved`、`removed`、`dropped` 状态迁移;
|
||||||
@@ -747,6 +748,15 @@ uv run python tests/test_moves.py
|
|||||||
- 看板:系统页与首页把「收藏夹同步/容量」改为「历史收藏夹」;候选页文案改为「入库」。
|
- 看板:系统页与首页把「收藏夹同步/容量」改为「历史收藏夹」;候选页文案改为「入库」。
|
||||||
- 自动化测试:新增 `tests/test_video_ref.py`、`tests/test_library.py`,重写 `test_telegram.py`,删除 `test_folder_sync.py` / `test_fav_position.py` / `test_favorites.py`,并更新 `test_bilibili_client.py`、`test_cli.py`、`test_migrations.py`、`test_database.py`、`test_web_queries.py`、`test_web_server.py`、`web_fixtures.py`。
|
- 自动化测试:新增 `tests/test_video_ref.py`、`tests/test_library.py`,重写 `test_telegram.py`,删除 `test_folder_sync.py` / `test_fav_position.py` / `test_favorites.py`,并更新 `test_bilibili_client.py`、`test_cli.py`、`test_migrations.py`、`test_database.py`、`test_web_queries.py`、`test_web_server.py`、`web_fixtures.py`。
|
||||||
|
|
||||||
|
### 第 11 轮:UP 头像与 UP 列表信息完善 ✅(2026-11-01)
|
||||||
|
|
||||||
|
- 迁移 v6:`video.up_face`(来自 `view` 的 `owner.face`);入库与每日快照都会刷新该字段;
|
||||||
|
- `ViewStat` / `VideoDetail` 增加 UP `mid/name/face` 解析;
|
||||||
|
- UP 列表卡片由视频封面改为 UP 头像(无头像时显示首字母占位)、常驻显示最新粉丝数;「总播放」排序时副指标显示「平均播放」,避免与主指标重复;
|
||||||
|
- UP 详情页补充头像;
|
||||||
|
- `/api/ups` 返回 `up_face` 与 `follower_latest`,`/api/ups/{mid}` 返回 `up_face`;
|
||||||
|
- 自动化测试更新:`test_migrations.py`、`test_database.py`、`test_web_queries.py`、`test_bilibili_client.py`、`test_library.py`、`test_snapshot.py`、`web_fixtures.py`。
|
||||||
|
|
||||||
### P4:可选部署
|
### P4:可选部署
|
||||||
|
|
||||||
- 反向代理、TLS、远程访问鉴权;
|
- 反向代理、TLS、远程访问鉴权;
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ uv run python app.py serve --preview --config data/preview/config.json
|
|||||||
- 首页默认“最新动态”:顶部四个数据卡片(今日入库 / 播放增量 / 粉丝日增 / 待确认候选)可点击跳转(视频榜按入库或增长排序、粉丝页、候选页);下方为最近发布、增长动态、热度速览、历史收藏夹与采集状态;
|
- 首页默认“最新动态”:顶部四个数据卡片(今日入库 / 播放增量 / 粉丝日增 / 待确认候选)可点击跳转(视频榜按入库或增长排序、粉丝页、候选页);下方为最近发布、增长动态、热度速览、历史收藏夹与采集状态;
|
||||||
- 「视频」与「UP主」是两个独立入口;工具条顺序为「排序」下拉 → 时间下拉(无需 label)→ 右侧自适应搜索框(最大 320px,放大镜按钮在输入框内),三者在 PC 与移动端都保持一行;
|
- 「视频」与「UP主」是两个独立入口;工具条顺序为「排序」下拉 → 时间下拉(无需 label)→ 右侧自适应搜索框(最大 320px,放大镜按钮在输入框内),三者在 PC 与移动端都保持一行;
|
||||||
- 视频排序:内容效率(默认)、综合热度、发布时间、入库时间、播放/投币/收藏/评论、互动率、播放增量/播放增速;UP 排序:内容效率、互动效率、内容量、总播放、视频数、平均播放、总互动、最近发布;
|
- 视频排序:内容效率(默认)、综合热度、发布时间、入库时间、播放/投币/收藏/评论、互动率、播放增量/播放增速;UP 排序:内容效率、互动效率、内容量、总播放、视频数、平均播放、总互动、最近发布;
|
||||||
|
- UP 卡片显示 UP 头像(无头像时显示首字母占位)、视频数/总时长与最新粉丝数;「总播放」排序时副指标显示「平均播放」(相当于平均播放榜,主副指标对调);UP 详情页也显示头像;
|
||||||
- 时间筛选:全部 / 近 7 天 / 近 30 天 / 近 90 天 / 近 1 年 / 已入库的年份;不再提供按收藏夹过滤,UP 页也不再按视频数筛选;
|
- 时间筛选:全部 / 近 7 天 / 近 30 天 / 近 90 天 / 近 1 年 / 已入库的年份;不再提供按收藏夹过滤,UP 页也不再按视频数筛选;
|
||||||
- 内容效率 = 播放 / 内容小时;单条视频用 60 分钟先验暴露量向全库均值收缩,只统计时长 ≥ 600 秒、发布满 2 天、播放 ≥ 2000 的视频(综合热度榜同样要求播放 ≥ 2000);UP 内容效率用经验贝叶斯收缩,避免极小样本冲榜;
|
- 内容效率 = 播放 / 内容小时;单条视频用 60 分钟先验暴露量向全库均值收缩,只统计时长 ≥ 600 秒、发布满 2 天、播放 ≥ 2000 的视频(综合热度榜同样要求播放 ≥ 2000);UP 内容效率用经验贝叶斯收缩,避免极小样本冲榜;
|
||||||
- 「数据说明」(`/about`)说明数据来源、发布时间/入库时间/快照日期、指标公式、筛选口径与已知局限,数值由 `/api/about` 动态读取;
|
- 「数据说明」(`/about`)说明数据来源、发布时间/入库时间/快照日期、指标公式、筛选口径与已知局限,数值由 `/api/about` 动态读取;
|
||||||
|
|||||||
@@ -523,6 +523,7 @@ CREATE TABLE kv (k TEXT PRIMARY KEY, v TEXT);
|
|||||||
|
|
||||||
- `video.pages`:分 P 数(来自 `infos` 的 `page`),只用于详情展示与包装诊断,不参与排序;
|
- `video.pages`:分 P 数(来自 `infos` 的 `page`),只用于详情展示与包装诊断,不参与排序;
|
||||||
- `video.up_follower` / `up_follower_at` / `up_follower_backfilled`:视频**入库时**的 UP 粉丝数快照;
|
- `video.up_follower` / `up_follower_at` / `up_follower_backfilled`:视频**入库时**的 UP 粉丝数快照;
|
||||||
|
- `video.up_face`(第 11 轮,schema v6):UP 头像 URL,来自 `view` 的 `owner.face`;入库与每日快照都会刷新,UP 列表/详情据此显示头像;
|
||||||
- `up_profile(mid, name, follower_first, following_first, first_captured_at, follower_latest, last_refreshed)`:每个 UP 的首次/最近粉丝数。`follower_first` 不可被覆盖;
|
- `up_profile(mid, name, follower_first, following_first, first_captured_at, follower_latest, last_refreshed)`:每个 UP 的首次/最近粉丝数。`follower_first` 不可被覆盖;
|
||||||
- 采集:收藏夹同步结束时对仍缺粉丝数的 UP 调一次 `relation/stat`(匿名可用);`first_seen >= 本次运行开始前 1 小时` 的视频记为实时采集,更早的记为回填;
|
- 采集:收藏夹同步结束时对仍缺粉丝数的 UP 调一次 `relation/stat`(匿名可用);`first_seen >= 本次运行开始前 1 小时` 的视频记为实时采集,更早的记为回填;
|
||||||
- 回填:`python app.py backfill-followers [--limit N] [--dry-run]`,只补 `up_follower IS NULL` 的追踪视频;
|
- 回填:`python app.py backfill-followers [--limit N] [--dry-run]`,只补 `up_follower IS NULL` 的追踪视频;
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ class InfosResult:
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ViewStat:
|
class ViewStat:
|
||||||
"""``x/web-interface/view`` 的七项统计。"""
|
"""``x/web-interface/view`` 的七项统计(含 UP 元数据)。"""
|
||||||
|
|
||||||
aid: int
|
aid: int
|
||||||
bvid: str | None
|
bvid: str | None
|
||||||
@@ -109,6 +109,9 @@ class ViewStat:
|
|||||||
share: int
|
share: int
|
||||||
reply: int
|
reply: int
|
||||||
danmaku: int
|
danmaku: int
|
||||||
|
up_mid: int | None = None
|
||||||
|
up_name: str | None = None
|
||||||
|
up_face: str | None = None
|
||||||
|
|
||||||
def metrics(self) -> dict[str, int]:
|
def metrics(self) -> dict[str, int]:
|
||||||
return {
|
return {
|
||||||
@@ -138,10 +141,28 @@ class ViewStat:
|
|||||||
bvid = item.get("bvid")
|
bvid = item.get("bvid")
|
||||||
if not isinstance(bvid, str) or not bvid:
|
if not isinstance(bvid, str) or not bvid:
|
||||||
bvid = None
|
bvid = None
|
||||||
|
owner = item.get("owner") or {}
|
||||||
|
if not isinstance(owner, Mapping):
|
||||||
|
owner = {}
|
||||||
|
raw_mid = owner.get("mid")
|
||||||
|
up_mid = (
|
||||||
|
raw_mid
|
||||||
|
if isinstance(raw_mid, int) and not isinstance(raw_mid, bool)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
up_name = owner.get("name")
|
||||||
|
if not isinstance(up_name, str) or not up_name:
|
||||||
|
up_name = None
|
||||||
|
up_face = owner.get("face")
|
||||||
|
if not isinstance(up_face, str) or not up_face:
|
||||||
|
up_face = None
|
||||||
return cls(
|
return cls(
|
||||||
aid=aid,
|
aid=aid,
|
||||||
bvid=bvid,
|
bvid=bvid,
|
||||||
title=str(item.get("title") or ""),
|
title=str(item.get("title") or ""),
|
||||||
|
up_mid=up_mid,
|
||||||
|
up_name=up_name,
|
||||||
|
up_face=up_face,
|
||||||
**metrics,
|
**metrics,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -163,6 +184,7 @@ class VideoDetail:
|
|||||||
pages: int | None
|
pages: int | None
|
||||||
cover: str | None
|
cover: str | None
|
||||||
stat: ViewStat
|
stat: ViewStat
|
||||||
|
up_face: str | None = None
|
||||||
|
|
||||||
def metrics(self) -> dict[str, int]:
|
def metrics(self) -> dict[str, int]:
|
||||||
return self.stat.metrics()
|
return self.stat.metrics()
|
||||||
@@ -170,18 +192,6 @@ class VideoDetail:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_api(cls, item: Mapping[str, Any]) -> "VideoDetail":
|
def from_api(cls, item: Mapping[str, Any]) -> "VideoDetail":
|
||||||
stat = ViewStat.from_api(item)
|
stat = ViewStat.from_api(item)
|
||||||
owner = item.get("owner") or {}
|
|
||||||
if not isinstance(owner, Mapping):
|
|
||||||
owner = {}
|
|
||||||
raw_mid = owner.get("mid")
|
|
||||||
up_mid = (
|
|
||||||
raw_mid
|
|
||||||
if isinstance(raw_mid, int) and not isinstance(raw_mid, bool)
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
up_name = owner.get("name")
|
|
||||||
if not isinstance(up_name, str) or not up_name:
|
|
||||||
up_name = None
|
|
||||||
|
|
||||||
raw_duration = item.get("duration")
|
raw_duration = item.get("duration")
|
||||||
duration = (
|
duration = (
|
||||||
@@ -219,13 +229,14 @@ class VideoDetail:
|
|||||||
aid=stat.aid,
|
aid=stat.aid,
|
||||||
bvid=stat.bvid,
|
bvid=stat.bvid,
|
||||||
title=stat.title,
|
title=stat.title,
|
||||||
up_mid=up_mid,
|
up_mid=stat.up_mid,
|
||||||
up_name=up_name,
|
up_name=stat.up_name,
|
||||||
pubdate=pubdate,
|
pubdate=pubdate,
|
||||||
duration=duration,
|
duration=duration,
|
||||||
pages=pages,
|
pages=pages,
|
||||||
cover=cover,
|
cover=cover,
|
||||||
stat=stat,
|
stat=stat,
|
||||||
|
up_face=stat.up_face,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -216,6 +216,14 @@ MIGRATIONS: tuple[Migration, ...] = (
|
|||||||
"UPDATE video SET status = 'active' WHERE status = 'unfaved'",
|
"UPDATE video SET status = 'active' WHERE status = 'unfaved'",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Migration(
|
||||||
|
version=6,
|
||||||
|
name="video up face",
|
||||||
|
statements=(
|
||||||
|
# UP 头像 URL(来自 view 的 owner.face),用于 UP 列表/详情展示。
|
||||||
|
"ALTER TABLE video ADD COLUMN up_face TEXT",
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -422,6 +422,7 @@ class Repository:
|
|||||||
duration: int | None = None,
|
duration: int | None = None,
|
||||||
pages: int | None = None,
|
pages: int | None = None,
|
||||||
cover: str | None = None,
|
cover: str | None = None,
|
||||||
|
up_face: str | None = None,
|
||||||
source: str = "manual",
|
source: str = "manual",
|
||||||
now: int | None = None,
|
now: int | None = None,
|
||||||
) -> IngestOutcome:
|
) -> IngestOutcome:
|
||||||
@@ -441,9 +442,9 @@ class Repository:
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO video(aid, bvid, title, up_mid, up_name, pubdate,
|
INSERT INTO video(aid, bvid, title, up_mid, up_name, pubdate,
|
||||||
duration, pages, cover, folder_id, fav_time, status,
|
duration, pages, cover, up_face, folder_id, fav_time, status,
|
||||||
source, first_seen, last_sync, attr)
|
source, first_seen, last_sync, attr)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, 'active', ?, ?, ?, 0)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, 'active', ?, ?, ?, 0)
|
||||||
ON CONFLICT(aid) DO UPDATE SET
|
ON CONFLICT(aid) DO UPDATE SET
|
||||||
bvid = COALESCE(excluded.bvid, video.bvid),
|
bvid = COALESCE(excluded.bvid, video.bvid),
|
||||||
title = CASE
|
title = CASE
|
||||||
@@ -459,6 +460,7 @@ class Repository:
|
|||||||
duration = COALESCE(excluded.duration, video.duration),
|
duration = COALESCE(excluded.duration, video.duration),
|
||||||
pages = COALESCE(excluded.pages, video.pages),
|
pages = COALESCE(excluded.pages, video.pages),
|
||||||
cover = COALESCE(excluded.cover, video.cover),
|
cover = COALESCE(excluded.cover, video.cover),
|
||||||
|
up_face = COALESCE(excluded.up_face, video.up_face),
|
||||||
status = 'active',
|
status = 'active',
|
||||||
source = COALESCE(video.source, excluded.source),
|
source = COALESCE(video.source, excluded.source),
|
||||||
last_sync = excluded.last_sync
|
last_sync = excluded.last_sync
|
||||||
@@ -473,6 +475,7 @@ class Repository:
|
|||||||
None if duration is None else int(duration),
|
None if duration is None else int(duration),
|
||||||
None if pages is None else int(pages),
|
None if pages is None else int(pages),
|
||||||
cover,
|
cover,
|
||||||
|
up_face,
|
||||||
source,
|
source,
|
||||||
now,
|
now,
|
||||||
now,
|
now,
|
||||||
@@ -488,6 +491,16 @@ class Repository:
|
|||||||
)
|
)
|
||||||
return self.db.write(run)
|
return self.db.write(run)
|
||||||
|
|
||||||
|
def apply_video_up_face(self, aid: int, face: str | None) -> None:
|
||||||
|
"""用 ``view`` 返回的 UP 头像刷新视频行;仅在变化时写入。"""
|
||||||
|
if not isinstance(face, str) or not face.strip():
|
||||||
|
return
|
||||||
|
face = face.strip()
|
||||||
|
self.db.write(lambda conn: conn.execute(
|
||||||
|
"UPDATE video SET up_face = ? WHERE aid = ? AND (up_face IS NULL OR up_face != ?)",
|
||||||
|
(face, int(aid), face),
|
||||||
|
))
|
||||||
|
|
||||||
# ------------------------------------------------------- up profile
|
# ------------------------------------------------------- up profile
|
||||||
|
|
||||||
def apply_up_follower(
|
def apply_up_follower(
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ class LibraryService:
|
|||||||
duration=detail.duration,
|
duration=detail.duration,
|
||||||
pages=detail.pages,
|
pages=detail.pages,
|
||||||
cover=detail.cover,
|
cover=detail.cover,
|
||||||
|
up_face=detail.up_face,
|
||||||
source="candidate:telegram",
|
source="candidate:telegram",
|
||||||
now=self._now(),
|
now=self._now(),
|
||||||
)
|
)
|
||||||
@@ -206,6 +207,7 @@ class LibraryService:
|
|||||||
duration=detail.duration,
|
duration=detail.duration,
|
||||||
pages=detail.pages,
|
pages=detail.pages,
|
||||||
cover=detail.cover,
|
cover=detail.cover,
|
||||||
|
up_face=detail.up_face,
|
||||||
source="manual:telegram",
|
source="manual:telegram",
|
||||||
now=self._now(),
|
now=self._now(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -211,6 +211,8 @@ class SnapshotService:
|
|||||||
err=None,
|
err=None,
|
||||||
captured_at=self._now(),
|
captured_at=self._now(),
|
||||||
)
|
)
|
||||||
|
if getattr(stat, "up_face", None):
|
||||||
|
self.repo.apply_video_up_face(aid, stat.up_face)
|
||||||
summary.success += 1
|
summary.success += 1
|
||||||
else:
|
else:
|
||||||
summary.failed += 1
|
summary.failed += 1
|
||||||
|
|||||||
@@ -387,6 +387,7 @@ class DashboardQueries:
|
|||||||
"up_follower_at": video.get("up_follower_at"),
|
"up_follower_at": video.get("up_follower_at"),
|
||||||
"up_follower_backfilled": bool(video.get("up_follower_backfilled") or 0),
|
"up_follower_backfilled": bool(video.get("up_follower_backfilled") or 0),
|
||||||
"cover": normalize_cover(video.get("cover")),
|
"cover": normalize_cover(video.get("cover")),
|
||||||
|
"up_face": normalize_cover(video.get("up_face")),
|
||||||
"folder_id": video.get("folder_id"),
|
"folder_id": video.get("folder_id"),
|
||||||
"folder_title": video.get("folder_title"),
|
"folder_title": video.get("folder_title"),
|
||||||
"folder_year": video.get("folder_year"),
|
"folder_year": video.get("folder_year"),
|
||||||
@@ -1074,7 +1075,10 @@ class DashboardQueries:
|
|||||||
"up_mid": mid,
|
"up_mid": mid,
|
||||||
"up_name": video.get("up_name") or f"UP {mid}",
|
"up_name": video.get("up_name") or f"UP {mid}",
|
||||||
"videos": [],
|
"videos": [],
|
||||||
|
"face": None,
|
||||||
})
|
})
|
||||||
|
if group["face"] is None and video.get("up_face"):
|
||||||
|
group["face"] = video.get("up_face")
|
||||||
metrics = _metric_map(snapshots.get(int(video["aid"])))
|
metrics = _metric_map(snapshots.get(int(video["aid"])))
|
||||||
group["videos"].append((video, metrics))
|
group["videos"].append((video, metrics))
|
||||||
items = []
|
items = []
|
||||||
@@ -1105,6 +1109,8 @@ class DashboardQueries:
|
|||||||
items.append({
|
items.append({
|
||||||
"up_mid": group["up_mid"],
|
"up_mid": group["up_mid"],
|
||||||
"up_name": group["up_name"],
|
"up_name": group["up_name"],
|
||||||
|
"up_face": normalize_cover(group["face"]),
|
||||||
|
"follower_latest": None,
|
||||||
"video_count": len(group_videos),
|
"video_count": len(group_videos),
|
||||||
"total_views": total_views,
|
"total_views": total_views,
|
||||||
"avg_views": round(total_views / len(metrics_list), 1) if metrics_list else None,
|
"avg_views": round(total_views / len(metrics_list), 1) if metrics_list else None,
|
||||||
@@ -1166,6 +1172,20 @@ class DashboardQueries:
|
|||||||
if minutes > 0 else None
|
if minutes > 0 else None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
mids = [item["up_mid"] for item in items]
|
||||||
|
if mids:
|
||||||
|
placeholders = ",".join("?" for _ in mids)
|
||||||
|
profile_rows = conn.execute(
|
||||||
|
f"SELECT mid, follower_latest FROM up_profile "
|
||||||
|
f"WHERE mid IN ({placeholders})",
|
||||||
|
mids,
|
||||||
|
).fetchall()
|
||||||
|
followers = {
|
||||||
|
int(row["mid"]): row["follower_latest"] for row in profile_rows
|
||||||
|
}
|
||||||
|
for item in items:
|
||||||
|
item["follower_latest"] = followers.get(item["up_mid"])
|
||||||
|
|
||||||
items = [item for item in items if item["video_count"] >= min_videos]
|
items = [item for item in items if item["video_count"] >= min_videos]
|
||||||
sort_map = {
|
sort_map = {
|
||||||
"views": lambda item: item["total_views"],
|
"views": lambda item: item["total_views"],
|
||||||
@@ -1256,6 +1276,9 @@ class DashboardQueries:
|
|||||||
return {
|
return {
|
||||||
"up_mid": mid,
|
"up_mid": mid,
|
||||||
"up_name": items[0].get("up_name") or f"UP {mid}",
|
"up_name": items[0].get("up_name") or f"UP {mid}",
|
||||||
|
"up_face": next(
|
||||||
|
(item["up_face"] for item in items if item.get("up_face")), None
|
||||||
|
),
|
||||||
"video_count": len(items),
|
"video_count": len(items),
|
||||||
"total_views": total_views,
|
"total_views": total_views,
|
||||||
"avg_views": round(total_views / len(metrics_list), 1) if metrics_list else None,
|
"avg_views": round(total_views / len(metrics_list), 1) if metrics_list else None,
|
||||||
|
|||||||
@@ -19,6 +19,17 @@ export function rankBadge(rank) {
|
|||||||
return `<span class="rank-badge${top}">${rank}</span>`;
|
return `<span class="rank-badge${top}">${rank}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function avatar(item, className = "avatar") {
|
||||||
|
const url = coverUrl(item?.up_face || item?.avatar);
|
||||||
|
const name = escapeHtml(item?.up_name || "UP");
|
||||||
|
if (!url) {
|
||||||
|
const initial = String(item?.up_name || "U").trim().slice(0, 1).toUpperCase() || "U";
|
||||||
|
return `<div class="${className} avatar-placeholder" aria-hidden="true">${escapeHtml(initial)}</div>`;
|
||||||
|
}
|
||||||
|
return `<div class="${className}"><img src="${escapeAttr(url)}" alt="${name}"
|
||||||
|
loading="lazy" referrerpolicy="no-referrer"></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
export function videoCard(item, {
|
export function videoCard(item, {
|
||||||
rank = null, primary = "view", primaryLabel = "", primaryText = null,
|
rank = null, primary = "view", primaryLabel = "", primaryText = null,
|
||||||
showStatus = false,
|
showStatus = false,
|
||||||
@@ -64,27 +75,29 @@ export function videoCard(item, {
|
|||||||
|
|
||||||
export function upCard(item, {
|
export function upCard(item, {
|
||||||
rank = null, primary = "efficiency", primaryLabel = "播放/小时", primaryText = null,
|
rank = null, primary = "efficiency", primaryLabel = "播放/小时", primaryText = null,
|
||||||
|
secondaryLabel = "总播放", secondaryText = null,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const best = item.best_video || {};
|
|
||||||
const displayCover = cover(
|
|
||||||
{ title: best.title || item.up_name, cover: best.cover },
|
|
||||||
"video-thumb up-thumb",
|
|
||||||
);
|
|
||||||
const value = primaryText !== null ? primaryText : item[primary];
|
const value = primaryText !== null ? primaryText : item[primary];
|
||||||
|
const secondary = secondaryText !== null ? secondaryText : formatCount(item.total_views);
|
||||||
const hours = item.content_hours;
|
const hours = item.content_hours;
|
||||||
|
const follower = item.follower_latest;
|
||||||
|
const followerText = follower === null || follower === undefined
|
||||||
|
? "粉丝 —"
|
||||||
|
: `粉丝 ${formatCount(follower)}`;
|
||||||
return `
|
return `
|
||||||
<a class="up-card" href="/up/${item.up_mid}">
|
<a class="up-card" href="/up/${item.up_mid}">
|
||||||
${rankBadge(rank)}
|
${rankBadge(rank)}
|
||||||
${displayCover}
|
${avatar(item, "up-avatar")}
|
||||||
<div class="up-card-body">
|
<div class="up-card-body">
|
||||||
<h3 class="video-title">${escapeHtml(item.up_name || `UP ${item.up_mid}`)}</h3>
|
<h3 class="video-title">${escapeHtml(item.up_name || `UP ${item.up_mid}`)}</h3>
|
||||||
<div class="video-meta">
|
<div class="video-meta">
|
||||||
<span>${formatNumber(item.video_count)} 条视频</span>
|
<span>${formatNumber(item.video_count)} 条视频</span>
|
||||||
<span>${hours === null || hours === undefined ? "时长未知" : `${formatNumber(hours)} 小时`}</span>
|
<span>${hours === null || hours === undefined ? "时长未知" : `${formatNumber(hours)} 小时`}</span>
|
||||||
|
<span>${followerText}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="video-stats">
|
<div class="video-stats">
|
||||||
<span class="primary-stat"><b>${escapeHtml(value ?? "—")}</b><small>${escapeHtml(primaryLabel)}</small></span>
|
<span class="primary-stat"><b>${escapeHtml(value ?? "—")}</b><small>${escapeHtml(primaryLabel)}</small></span>
|
||||||
<span class="secondary-stat">总播放 ${formatCount(item.total_views)}</span>
|
<span class="secondary-stat">${escapeHtml(secondaryLabel)} ${escapeHtml(secondary)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="card-arrow" aria-hidden="true">›</span>
|
<span class="card-arrow" aria-hidden="true">›</span>
|
||||||
|
|||||||
@@ -353,6 +353,25 @@ a.live-chip:hover { border-color: color-mix(in srgb, var(--accent) 55%, var(--bo
|
|||||||
.cover-placeholder img.image-error { opacity: 0; }
|
.cover-placeholder img.image-error { opacity: 0; }
|
||||||
.video-card-body { grid-column: 3; grid-row: 1; min-width: 0; }
|
.video-card-body { grid-column: 3; grid-row: 1; min-width: 0; }
|
||||||
.up-card-body { grid-column: 3; grid-row: 1; min-width: 0; }
|
.up-card-body { grid-column: 3; grid-row: 1; min-width: 0; }
|
||||||
|
.up-avatar {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1;
|
||||||
|
justify-self: center;
|
||||||
|
align-self: center;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--surface-2);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.up-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.up-card.no-rank > .up-avatar { grid-column: 1; }
|
||||||
|
.avatar-placeholder { text-transform: uppercase; }
|
||||||
.video-title {
|
.video-title {
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
@@ -717,6 +736,21 @@ a.live-chip:hover { border-color: color-mix(in srgb, var(--accent) 55%, var(--bo
|
|||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
.hero-cover { width: 100%; aspect-ratio: 16 / 9; border-radius: 12px; }
|
.hero-cover { width: 100%; aspect-ratio: 16 / 9; border-radius: 12px; }
|
||||||
|
.up-hero-head { display: flex; align-items: center; gap: 14px; }
|
||||||
|
.hero-avatar {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--surface-2);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.hero-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
.detail-hero-body h1 { font-size: 19px; line-height: 1.35; letter-spacing: -0.3px; }
|
.detail-hero-body h1 { font-size: 19px; line-height: 1.35; letter-spacing: -0.3px; }
|
||||||
.detail-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; margin-top: 8px; color: var(--muted); font-size: 12px; }
|
.detail-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; margin-top: 8px; color: var(--muted); font-size: 12px; }
|
||||||
.compact-hero { padding: 18px; }
|
.compact-hero { padding: 18px; }
|
||||||
|
|||||||
@@ -133,6 +133,14 @@ function upPrimary(item, sort) {
|
|||||||
return { label: "播放/小时", text: formatCount(item.efficiency) };
|
return { label: "播放/小时", text: formatCount(item.efficiency) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function upSecondary(item, sort) {
|
||||||
|
// “总播放”排序时主指标已是总播放,副指标改为平均播放,避免重复。
|
||||||
|
if (sort === "views") {
|
||||||
|
return { label: "平均播放", text: formatCount(item.avg_views) };
|
||||||
|
}
|
||||||
|
return { label: "总播放", text: formatCount(item.total_views) };
|
||||||
|
}
|
||||||
|
|
||||||
function listHtml(data, scope, sort, startRank) {
|
function listHtml(data, scope, sort, startRank) {
|
||||||
if (!data.items.length) {
|
if (!data.items.length) {
|
||||||
return emptyState(
|
return emptyState(
|
||||||
@@ -143,8 +151,11 @@ function listHtml(data, scope, sort, startRank) {
|
|||||||
if (scope === "up") {
|
if (scope === "up") {
|
||||||
return `<div class="rank-list">${data.items.map((item, index) => {
|
return `<div class="rank-list">${data.items.map((item, index) => {
|
||||||
const primary = upPrimary(item, sort);
|
const primary = upPrimary(item, sort);
|
||||||
|
const secondary = upSecondary(item, sort);
|
||||||
return upCard(item, {
|
return upCard(item, {
|
||||||
rank: startRank + index, primaryLabel: primary.label, primaryText: primary.text,
|
rank: startRank + index,
|
||||||
|
primaryLabel: primary.label, primaryText: primary.text,
|
||||||
|
secondaryLabel: secondary.label, secondaryText: secondary.text,
|
||||||
});
|
});
|
||||||
}).join("")}</div>`;
|
}).join("")}</div>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { api } from "../api.js";
|
import { api } from "../api.js";
|
||||||
import {
|
import {
|
||||||
badge, emptyState, sectionHeader, videoCard,
|
avatar, badge, emptyState, sectionHeader, videoCard,
|
||||||
} from "../components.js";
|
} from "../components.js";
|
||||||
import { barChart, lineChart, themeColor } from "../charts.js";
|
import { barChart, lineChart, themeColor } from "../charts.js";
|
||||||
import {
|
import {
|
||||||
@@ -43,6 +43,8 @@ export async function renderUp(root, { params, query }) {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="detail-hero compact-hero">
|
<div class="detail-hero compact-hero">
|
||||||
|
<div class="up-hero-head">
|
||||||
|
${avatar(up, "hero-avatar")}
|
||||||
<div class="detail-hero-body">
|
<div class="detail-hero-body">
|
||||||
<h1>${escapeHtml(up.up_name || `UP ${mid}`)}</h1>
|
<h1>${escapeHtml(up.up_name || `UP ${mid}`)}</h1>
|
||||||
<div class="detail-meta">
|
<div class="detail-meta">
|
||||||
@@ -53,6 +55,7 @@ export async function renderUp(root, { params, query }) {
|
|||||||
<p class="section-note">首次发布 ${escapeHtml(formatDate(up.first_pubdate))} · 最近发布 ${escapeHtml(formatDate(up.latest_pubdate))}</p>
|
<p class="section-note">首次发布 ${escapeHtml(formatDate(up.first_pubdate))} · 最近发布 ${escapeHtml(formatDate(up.latest_pubdate))}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mini-stats up-stats">
|
<div class="mini-stats up-stats">
|
||||||
<div><span>视频数</span><strong>${formatCount(up.video_count)}</strong></div>
|
<div><span>视频数</span><strong>${formatCount(up.video_count)}</strong></div>
|
||||||
|
|||||||
+1
-1
@@ -182,7 +182,7 @@ VIEW_PAYLOAD: dict[str, Any] = {
|
|||||||
{"cid": 43184885058, "page": 1, "from": "vupload", "part": "第一部分"},
|
{"cid": 43184885058, "page": 1, "from": "vupload", "part": "第一部分"},
|
||||||
{"cid": 43184885059, "page": 2, "from": "vupload", "part": "第二部分"},
|
{"cid": 43184885059, "page": 2, "from": "vupload", "part": "第二部分"},
|
||||||
],
|
],
|
||||||
"owner": {"mid": 434442072, "name": "_-抱抱-_", "face": ""},
|
"owner": {"mid": 434442072, "name": "_-抱抱-_", "face": "http://i1.hdslb.com/bfs/face/example.jpg"},
|
||||||
"stat": {
|
"stat": {
|
||||||
"aid": 117530286889638,
|
"aid": 117530286889638,
|
||||||
"view": 819,
|
"view": 819,
|
||||||
|
|||||||
@@ -353,6 +353,7 @@ def test_view_detail_by_aid_and_bvid():
|
|||||||
assert detail.pages == 2
|
assert detail.pages == 2
|
||||||
assert detail.cover and detail.cover.startswith("http")
|
assert detail.cover and detail.cover.startswith("http")
|
||||||
assert detail.metrics()["view"] == 819
|
assert detail.metrics()["view"] == 819
|
||||||
|
assert detail.up_face and detail.up_face.startswith("http")
|
||||||
assert "aid=117530286889638" in transport.calls[0]["url"]
|
assert "aid=117530286889638" in transport.calls[0]["url"]
|
||||||
|
|
||||||
transport = QueueTransport([http_json(VIEW_PAYLOAD)])
|
transport = QueueTransport([http_json(VIEW_PAYLOAD)])
|
||||||
@@ -373,6 +374,7 @@ def test_view_detail_fallbacks_and_required_arg():
|
|||||||
assert detail.duration == 0
|
assert detail.duration == 0
|
||||||
assert detail.pages is None
|
assert detail.pages is None
|
||||||
assert detail.cover is None
|
assert detail.cover is None
|
||||||
|
assert detail.up_face is None
|
||||||
|
|
||||||
client, _ = make_client(QueueTransport([]))
|
client, _ = make_client(QueueTransport([]))
|
||||||
for kwargs in ({}, {"aid": 1, "bvid": "BV1xvxi6LEZD"}):
|
for kwargs in ({}, {"aid": 1, "bvid": "BV1xvxi6LEZD"}):
|
||||||
|
|||||||
+1
-1
@@ -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": 5' in output
|
assert '"schema_version": 6' in output
|
||||||
|
|
||||||
db = Database(Path(tmp) / "app.db")
|
db = Database(Path(tmp) / "app.db")
|
||||||
db.initialize()
|
db.initialize()
|
||||||
|
|||||||
@@ -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 == 5
|
assert db.schema_version == 6
|
||||||
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 == 5
|
assert db2.schema_version == 6
|
||||||
assert db2.read(lambda c: c.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]) == 5
|
assert db2.read(lambda c: c.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]) == 6
|
||||||
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, 5]
|
assert applied == [1, 2, 3, 4, 5, 6]
|
||||||
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()
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ def detail(
|
|||||||
data["pubdate"] = pubdate
|
data["pubdate"] = pubdate
|
||||||
data["ctime"] = pubdate
|
data["ctime"] = pubdate
|
||||||
data["duration"] = duration
|
data["duration"] = duration
|
||||||
data["owner"] = {"mid": up_mid, "name": up_name, "face": ""}
|
data["owner"] = {"mid": up_mid, "name": up_name, "face": "http://i1.hdslb.com/bfs/face/u.jpg"}
|
||||||
data["pages"] = [{"cid": 1, "page": i + 1} for i in range(pages)]
|
data["pages"] = [{"cid": 1, "page": i + 1} for i in range(pages)]
|
||||||
return VideoDetail.from_api(data)
|
return VideoDetail.from_api(data)
|
||||||
|
|
||||||
@@ -150,6 +150,7 @@ def test_ingest_candidate_added_and_snapshot():
|
|||||||
assert video["source"] == "candidate:telegram"
|
assert video["source"] == "candidate:telegram"
|
||||||
assert video["folder_id"] is None
|
assert video["folder_id"] is None
|
||||||
assert video["up_mid"] == 100
|
assert video["up_mid"] == 100
|
||||||
|
assert video["up_face"] == "http://i1.hdslb.com/bfs/face/u.jpg"
|
||||||
snap = repo.get_snapshot(DATE, 9)
|
snap = repo.get_snapshot(DATE, 9)
|
||||||
assert snap is not None and snap["ok"] == 1 and snap["view"] == 819
|
assert snap is not None and snap["ok"] == 1 and snap["view"] == 819
|
||||||
assert ups.calls == [([100], {100: "UP100"})]
|
assert ups.calls == [([100], {100: "UP100"})]
|
||||||
|
|||||||
@@ -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, 5]
|
assert applied == [1, 2, 3, 4, 5, 6]
|
||||||
assert current_version(conn) == 5
|
assert current_version(conn) == 6
|
||||||
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 = {
|
||||||
@@ -54,7 +54,7 @@ def test_fresh_and_idempotent():
|
|||||||
}
|
}
|
||||||
assert {
|
assert {
|
||||||
"pages", "duration", "up_follower", "up_follower_at",
|
"pages", "duration", "up_follower", "up_follower_at",
|
||||||
"up_follower_backfilled",
|
"up_follower_backfilled", "up_face",
|
||||||
} <= video_columns
|
} <= video_columns
|
||||||
profile_columns = {
|
profile_columns = {
|
||||||
row[1] for row in conn.execute("PRAGMA table_info(up_profile)")
|
row[1] for row in conn.execute("PRAGMA table_info(up_profile)")
|
||||||
@@ -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] == 5
|
assert row[0] == 6
|
||||||
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()
|
||||||
|
|
||||||
v6 = Migration(6, "add probe", ("CREATE TABLE probe (id INTEGER PRIMARY KEY)",))
|
v7 = Migration(7, "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 + (v6,),
|
migrations=MIGRATIONS + (v7,),
|
||||||
)
|
)
|
||||||
assert applied == [6]
|
assert applied == [7]
|
||||||
backups = list(backup_dir.glob("app-pre-v5-*.db"))
|
backups = list(backup_dir.glob("app-pre-v6-*.db"))
|
||||||
assert len(backups) == 1
|
assert len(backups) == 1
|
||||||
check = sqlite3.connect(backups[0])
|
check = sqlite3.connect(backups[0])
|
||||||
try:
|
try:
|
||||||
@@ -174,7 +174,7 @@ def test_v5_normalizes_unfaved():
|
|||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
applied = run_migrations(conn)
|
applied = run_migrations(conn)
|
||||||
assert applied == [5]
|
assert applied == [5, 6]
|
||||||
rows = dict(conn.execute("SELECT aid, status FROM video").fetchall())
|
rows = dict(conn.execute("SELECT aid, status FROM video").fetchall())
|
||||||
assert rows == {1: "active", 2: "dropped"}
|
assert rows == {1: "active", 2: "dropped"}
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -153,6 +153,9 @@ def test_snapshot_success_resume_and_force():
|
|||||||
assert (row["view"], row["like"], row["favorite"], row["coin"],
|
assert (row["view"], row["like"], row["favorite"], row["coin"],
|
||||||
row["share"], row["reply"], row["danmaku"]) == (100, 10, 3, 2, 1, 4, 5)
|
row["share"], row["reply"], row["danmaku"]) == (100, 10, 3, 2, 1, 4, 5)
|
||||||
assert row["ok"] == 1 and row["src"] == "view"
|
assert row["ok"] == 1 and row["src"] == "view"
|
||||||
|
assert repo.get_video(1)["up_face"] == (
|
||||||
|
"http://i1.hdslb.com/bfs/face/example.jpg"
|
||||||
|
)
|
||||||
fan = repo.get_up_stat(DATE, 37754047)
|
fan = repo.get_up_stat(DATE, 37754047)
|
||||||
assert fan["follower"] == 2767268 and fan["ok"] == 1
|
assert fan["follower"] == 2767268 and fan["ok"] == 1
|
||||||
assert repo.kv_get(service.completed_key(DATE)) == "1"
|
assert repo.kv_get(service.completed_key(DATE)) == "1"
|
||||||
|
|||||||
@@ -182,6 +182,8 @@ def test_up_queries():
|
|||||||
assert ups["items"][0]["video_count"] == 2
|
assert ups["items"][0]["video_count"] == 2
|
||||||
assert ups["items"][0]["total_views"] == 6400
|
assert ups["items"][0]["total_views"] == 6400
|
||||||
assert ups["items"][0]["avg_views"] == 3200.0
|
assert ups["items"][0]["avg_views"] == 3200.0
|
||||||
|
assert ups["items"][0]["up_face"] == "https://i0.hdslb.com/bfs/face/a.jpg"
|
||||||
|
assert ups["items"][0]["follower_latest"] == 12345
|
||||||
|
|
||||||
one = q.list_ups(min_videos=2)
|
one = q.list_ups(min_videos=2)
|
||||||
assert one["total"] == 1
|
assert one["total"] == 1
|
||||||
@@ -190,6 +192,7 @@ def test_up_queries():
|
|||||||
assert detail["video_count"] == 2
|
assert detail["video_count"] == 2
|
||||||
assert detail["total_views"] == 6400
|
assert detail["total_views"] == 6400
|
||||||
assert detail["blacklisted"] is False
|
assert detail["blacklisted"] is False
|
||||||
|
assert detail["up_face"] == "https://i0.hdslb.com/bfs/face/a.jpg"
|
||||||
assert detail["series"]["dates"] == list(DATES)
|
assert detail["series"]["dates"] == list(DATES)
|
||||||
assert detail["series"]["views"] == [6000, 6200, 6400]
|
assert detail["series"]["views"] == [6000, 6200, 6400]
|
||||||
assert detail["space_url"].endswith("/1")
|
assert detail["space_url"].endswith("/1")
|
||||||
@@ -454,7 +457,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"] == 5
|
assert about["schema_version"] == 6
|
||||||
assert about["analytics"] == {
|
assert about["analytics"] == {
|
||||||
"min_duration_seconds": 600,
|
"min_duration_seconds": 600,
|
||||||
"maturity_days": 2,
|
"maturity_days": 2,
|
||||||
@@ -464,8 +467,8 @@ def test_about_payload():
|
|||||||
}
|
}
|
||||||
assert about["heat_weights"]["view"] == 0.30
|
assert about["heat_weights"]["view"] == 0.30
|
||||||
assert about["followers"]["videos_total"] == 3
|
assert about["followers"]["videos_total"] == 3
|
||||||
assert about["followers"]["videos_covered"] == 0
|
assert about["followers"]["videos_covered"] == 3
|
||||||
assert about["followers"]["videos_missing"] == 3
|
assert about["followers"]["videos_missing"] == 0
|
||||||
assert about["folders"][0]["media_id"] == 3700023351
|
assert about["folders"][0]["media_id"] == 3700023351
|
||||||
assert about["preview"] is False
|
assert about["preview"] is False
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -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"] == 5
|
assert payload["data"]["schema_version"] == 6
|
||||||
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")
|
||||||
|
|||||||
@@ -106,6 +106,16 @@ def build_db(tmp: str | Path) -> Path:
|
|||||||
)
|
)
|
||||||
repo.set_video_status(103, "unfaved")
|
repo.set_video_status(103, "unfaved")
|
||||||
|
|
||||||
|
# UP 头像与最新粉丝数(UP 列表/详情展示用)
|
||||||
|
repo.apply_up_follower(
|
||||||
|
mid=1, name="UP A", follower=12345, following=5, now=ts("2026-10-30")
|
||||||
|
)
|
||||||
|
repo.apply_up_follower(
|
||||||
|
mid=2, name="UP B", follower=678, following=1, now=ts("2026-10-31")
|
||||||
|
)
|
||||||
|
repo.apply_video_up_face(101, "//i0.hdslb.com/bfs/face/a.jpg")
|
||||||
|
repo.apply_video_up_face(103, "//i0.hdslb.com/bfs/face/b.jpg")
|
||||||
|
|
||||||
metrics = {
|
metrics = {
|
||||||
101: {"view": 1000, "like": 100, "coin": 20, "favorite": 30, "share": 2, "reply": 10, "danmaku": 12},
|
101: {"view": 1000, "like": 100, "coin": 20, "favorite": 30, "share": 2, "reply": 10, "danmaku": 12},
|
||||||
102: {"view": 5000, "like": 200, "coin": 50, "favorite": 100, "share": 10, "reply": 30, "danmaku": 40},
|
102: {"view": 5000, "like": 200, "coin": 50, "favorite": 100, "share": 10, "reply": 30, "danmaku": 40},
|
||||||
|
|||||||
Reference in new issue
Block a user