feat(web): up avatars, follower count, and dedupe views/avg secondary
This commit is contained in:
1 parent
1ed3746f4b
commit
2ff3e7952b
23 files changed
+209
-58
No files matched your search
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user