feat(web): up avatars, follower count, and dedupe views/avg secondary

This commit is contained in:
kairee committed 2026-11-01 14:34:23 +08:00
1 parent 1ed3746f4b
commit 2ff3e7952b
23 files changed
+209 -58

No files matched your search

+26 -15
View File
@@ -97,7 +97,7 @@ class InfosResult:
@dataclass(frozen=True)
class ViewStat:
"""``x/web-interface/view`` 的七项统计。"""
"""``x/web-interface/view`` 的七项统计(含 UP 元数据)。"""
aid: int
bvid: str | None
@@ -109,6 +109,9 @@ class ViewStat:
share: int
reply: int
danmaku: int
up_mid: int | None = None
up_name: str | None = None
up_face: str | None = None
def metrics(self) -> dict[str, int]:
return {
@@ -138,10 +141,28 @@ class ViewStat:
bvid = item.get("bvid")
if not isinstance(bvid, str) or not bvid:
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(
aid=aid,
bvid=bvid,
title=str(item.get("title") or ""),
up_mid=up_mid,
up_name=up_name,
up_face=up_face,
**metrics,
)
@@ -163,6 +184,7 @@ class VideoDetail:
pages: int | None
cover: str | None
stat: ViewStat
up_face: str | None = None
def metrics(self) -> dict[str, int]:
return self.stat.metrics()
@@ -170,18 +192,6 @@ class VideoDetail:
@classmethod
def from_api(cls, item: Mapping[str, Any]) -> "VideoDetail":
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")
duration = (
@@ -219,13 +229,14 @@ class VideoDetail:
aid=stat.aid,
bvid=stat.bvid,
title=stat.title,
up_mid=up_mid,
up_name=up_name,
up_mid=stat.up_mid,
up_name=stat.up_name,
pubdate=pubdate,
duration=duration,
pages=pages,
cover=cover,
stat=stat,
up_face=stat.up_face,
)
+8
View File
@@ -216,6 +216,14 @@ MIGRATIONS: tuple[Migration, ...] = (
"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",
),
),
)
+15 -2
View File
@@ -422,6 +422,7 @@ class Repository:
duration: int | None = None,
pages: int | None = None,
cover: str | None = None,
up_face: str | None = None,
source: str = "manual",
now: int | None = None,
) -> IngestOutcome:
@@ -441,9 +442,9 @@ class Repository:
conn.execute(
"""
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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, 'active', ?, ?, ?, 0)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, 'active', ?, ?, ?, 0)
ON CONFLICT(aid) DO UPDATE SET
bvid = COALESCE(excluded.bvid, video.bvid),
title = CASE
@@ -459,6 +460,7 @@ class Repository:
duration = COALESCE(excluded.duration, video.duration),
pages = COALESCE(excluded.pages, video.pages),
cover = COALESCE(excluded.cover, video.cover),
up_face = COALESCE(excluded.up_face, video.up_face),
status = 'active',
source = COALESCE(video.source, excluded.source),
last_sync = excluded.last_sync
@@ -473,6 +475,7 @@ class Repository:
None if duration is None else int(duration),
None if pages is None else int(pages),
cover,
up_face,
source,
now,
now,
@@ -488,6 +491,16 @@ class Repository:
)
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
def apply_up_follower(
+2
View File
@@ -137,6 +137,7 @@ class LibraryService:
duration=detail.duration,
pages=detail.pages,
cover=detail.cover,
up_face=detail.up_face,
source="candidate:telegram",
now=self._now(),
)
@@ -206,6 +207,7 @@ class LibraryService:
duration=detail.duration,
pages=detail.pages,
cover=detail.cover,
up_face=detail.up_face,
source="manual:telegram",
now=self._now(),
)
+2
View File
@@ -211,6 +211,8 @@ class SnapshotService:
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
+23
View File
@@ -387,6 +387,7 @@ class DashboardQueries:
"up_follower_at": video.get("up_follower_at"),
"up_follower_backfilled": bool(video.get("up_follower_backfilled") or 0),
"cover": normalize_cover(video.get("cover")),
"up_face": normalize_cover(video.get("up_face")),
"folder_id": video.get("folder_id"),
"folder_title": video.get("folder_title"),
"folder_year": video.get("folder_year"),
@@ -1074,7 +1075,10 @@ class DashboardQueries:
"up_mid": mid,
"up_name": video.get("up_name") or f"UP {mid}",
"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"])))
group["videos"].append((video, metrics))
items = []
@@ -1105,6 +1109,8 @@ class DashboardQueries:
items.append({
"up_mid": group["up_mid"],
"up_name": group["up_name"],
"up_face": normalize_cover(group["face"]),
"follower_latest": None,
"video_count": len(group_videos),
"total_views": total_views,
"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
)
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]
sort_map = {
"views": lambda item: item["total_views"],
@@ -1256,6 +1276,9 @@ class DashboardQueries:
return {
"up_mid": 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),
"total_views": total_views,
"avg_views": round(total_views / len(metrics_list), 1) if metrics_list else None,
+20 -7
View File
@@ -19,6 +19,17 @@ export function rankBadge(rank) {
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, {
rank = null, primary = "view", primaryLabel = "", primaryText = null,
showStatus = false,
@@ -64,27 +75,29 @@ export function videoCard(item, {
export function upCard(item, {
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 secondary = secondaryText !== null ? secondaryText : formatCount(item.total_views);
const hours = item.content_hours;
const follower = item.follower_latest;
const followerText = follower === null || follower === undefined
? "粉丝 —"
: `粉丝 ${formatCount(follower)}`;
return `
<a class="up-card" href="/up/${item.up_mid}">
${rankBadge(rank)}
${displayCover}
${avatar(item, "up-avatar")}
<div class="up-card-body">
<h3 class="video-title">${escapeHtml(item.up_name || `UP ${item.up_mid}`)}</h3>
<div class="video-meta">
<span>${formatNumber(item.video_count)} 条视频</span>
<span>${hours === null || hours === undefined ? "时长未知" : `${formatNumber(hours)} 小时`}</span>
<span>${followerText}</span>
</div>
<div class="video-stats">
<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>
<span class="card-arrow" aria-hidden="true">›</span>
+34
View File
@@ -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; }
.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-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 {
display: -webkit-box;
-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);
}
.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-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; }
+12 -1
View File
@@ -133,6 +133,14 @@ function upPrimary(item, sort) {
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) {
if (!data.items.length) {
return emptyState(
@@ -143,8 +151,11 @@ function listHtml(data, scope, sort, startRank) {
if (scope === "up") {
return `<div class="rank-list">${data.items.map((item, index) => {
const primary = upPrimary(item, sort);
const secondary = upSecondary(item, sort);
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>`;
}
+11 -8
View File
@@ -1,6 +1,6 @@
import { api } from "../api.js";
import {
badge, emptyState, sectionHeader, videoCard,
avatar, badge, emptyState, sectionHeader, videoCard,
} from "../components.js";
import { barChart, lineChart, themeColor } from "../charts.js";
import {
@@ -43,14 +43,17 @@ export async function renderUp(root, { params, query }) {
</header>
<div class="detail-hero compact-hero">
<div class="detail-hero-body">
<h1>${escapeHtml(up.up_name || `UP ${mid}`)}</h1>
<div class="detail-meta">
<span>mid ${up.up_mid}</span>
${up.blacklisted ? badge("黑名单 · 仅影响发现", "warning") : ""}
${(up.folders || []).map((title) => badge(title)).join("")}
<div class="up-hero-head">
${avatar(up, "hero-avatar")}
<div class="detail-hero-body">
<h1>${escapeHtml(up.up_name || `UP ${mid}`)}</h1>
<div class="detail-meta">
<span>mid ${up.up_mid}</span>
${up.blacklisted ? badge("黑名单 · 仅影响发现", "warning") : ""}
${(up.folders || []).map((title) => badge(title)).join("")}
</div>
<p class="section-note">首次发布 ${escapeHtml(formatDate(up.first_pubdate))} · 最近发布 ${escapeHtml(formatDate(up.latest_pubdate))}</p>
</div>
<p class="section-note">首次发布 ${escapeHtml(formatDate(up.first_pubdate))} · 最近发布 ${escapeHtml(formatDate(up.latest_pubdate))}</p>
</div>
</div>