Files
re-xxm/reaction_board/web/queries.py
T

1630 lines
66 KiB
Python

# -*- coding: utf-8 -*-
"""只读看板查询层。
所有 SQL 都在这里集中,Web 层只负责参数解析与 JSON 序列化。查询不会写入
数据库,也不会访问 B 站。
"""
from __future__ import annotations
import calendar
import json
import math
import sqlite3
import time
from datetime import date, timedelta
from typing import Any, Iterable, Mapping, Sequence
from ..analytics import (
DEFAULT_HEAT_WEIGHTS,
METRICS,
MIN_VIEW_FOR_GROWTH_RATE,
compute_growth,
content_hours,
eb_rate,
effective_shrink_minutes,
heat_scores,
interactions_per_hour,
interaction_rate,
is_mature,
normalize_cover,
score_percentiles,
views_per_hour,
)
from ..config import ConfigStore
from ..database import Database
from ..timeutil import day_bounds, next_daily_run, shanghai_date, shanghai_datetime
from .cache import DASHBOARD_CACHE, TTL_FANS, TTL_VIDEOS, build_cache_key
TRACKED_STATUSES = ("active", "unfaved")
VIDEO_SORTS = {
"heat", "view", "like", "coin", "favorite", "share", "reply", "danmaku",
"interaction", "growth", "growth_rate", "added", "published",
"efficiency", "interaction_efficiency",
}
UP_SORTS = {
"views", "count", "avg_views", "interaction", "avg_interaction", "latest",
"efficiency", "interaction_efficiency", "duration",
}
CAPACITY = 1000
CAPACITY_WARNING = 950
TRACK_FANS_NAMES = {37754047: "咻咻满"}
def _chunks(values: Sequence[int], size: int = 400) -> Iterable[Sequence[int]]:
for index in range(0, len(values), size):
yield values[index:index + size]
def _date_minus(day: str, days: int) -> str:
return (date.fromisoformat(day) - timedelta(days=int(days))).isoformat()
def _date_range(start: str, end: str) -> list[str]:
first = date.fromisoformat(start)
last = date.fromisoformat(end)
if last < first:
return []
return [
(first + timedelta(days=offset)).isoformat()
for offset in range((last - first).days + 1)
]
def _ts_date(timestamp: int | float | None) -> str | None:
if timestamp is None:
return None
return shanghai_date(float(timestamp))
def _like_pattern(value: str) -> str:
escaped = value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
return f"%{escaped}%"
def _metric_map(row: Mapping[str, Any] | None) -> dict[str, int] | None:
if row is None:
return None
result: dict[str, int] = {}
for metric in METRICS:
value = row.get(metric)
result[metric] = 0 if value is None else int(value)
return result
def _safe_float(value: Any) -> float | None:
if value is None or isinstance(value, bool):
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _with_fan_deltas(
rows: Sequence[Mapping[str, Any]], recent_start: str
) -> list[dict[str, Any]]:
"""给最近记录附加“相较上一成功日期”的增量。"""
result: list[dict[str, Any]] = []
last_ok: int | None = None
for row in rows:
day = str(row["stat_date"])
ok = int(row.get("ok") or 0) == 1
delta: int | None = None
if ok:
follower = int(row.get("follower") or 0)
if last_ok is not None:
delta = follower - last_ok
last_ok = follower
if day >= recent_start:
result.append({**dict(row), "delta": delta})
return result
def _daily_fan_series(
rows: Sequence[Mapping[str, Any]], start: str, end: str
) -> tuple[list[str], list[int | None]]:
by_date = {str(row["stat_date"]): row for row in rows}
dates = _date_range(start, end)
series: list[int | None] = []
for day in dates:
row = by_date.get(day)
if row is None or int(row.get("ok") or 0) != 1:
series.append(None)
else:
series.append(int(row["follower"] or 0))
return dates, series
def _monthly_peak_series(
rows: Sequence[Mapping[str, Any]], start: str, end: str
) -> tuple[list[str], list[int | None]]:
"""超过一年时按自然月聚合,每月取粉丝峰值所在的日期。"""
start_day = date.fromisoformat(start)
end_day = date.fromisoformat(end)
peaks: dict[tuple[int, int], tuple[str, int]] = {}
for row in rows:
if int(row.get("ok") or 0) != 1:
continue
day = date.fromisoformat(str(row["stat_date"]))
key = (day.year, day.month)
value = int(row["follower"] or 0)
current = peaks.get(key)
if current is None or value > current[1] or (
value == current[1] and str(row["stat_date"]) > current[0]
):
peaks[key] = (str(row["stat_date"]), value)
dates: list[str] = []
series: list[int | None] = []
year, month = start_day.year, start_day.month
while (year, month) <= (end_day.year, end_day.month):
peak = peaks.get((year, month))
if peak is not None:
dates.append(peak[0])
series.append(peak[1])
else:
last_day = calendar.monthrange(year, month)[1]
placeholder = date(year, month, last_day)
if placeholder < start_day:
placeholder = start_day
if placeholder > end_day:
placeholder = end_day
dates.append(placeholder.isoformat())
series.append(None)
month += 1
if month > 12:
month = 1
year += 1
return dates, series
class DashboardQueries:
def __init__(
self,
db: Database,
config_store: ConfigStore,
*,
clock=time.time,
) -> None:
self.db = db
self.config_store = config_store
self._clock = clock
self._cache_scope = str(getattr(db, "path", "db"))
def _cache_version(self, group: str) -> int:
def run(conn: sqlite3.Connection) -> int:
row = conn.execute(
"SELECT v FROM kv WHERE k = ?", (f"cache:v:{group}",)
).fetchone()
if row is None:
return 0
try:
return int(str(row["v"]))
except ValueError:
return 0
return self.db.read(run)
def _cached(self, group: str, params: dict[str, Any], ttl: float, builder):
version = self._cache_version(group)
key = build_cache_key(self._cache_scope, group, version, params)
hit = DASHBOARD_CACHE.get(key)
if hit is not None:
return hit
value = builder()
DASHBOARD_CACHE.set(key, ttl, value)
return value
# ------------------------------------------------------------ helpers
def _now(self) -> float:
return float(self._clock())
def _today(self) -> str:
return shanghai_date(self._now())
@staticmethod
def _latest_date(conn: sqlite3.Connection) -> str | None:
row = conn.execute(
"SELECT MAX(stat_date) AS d FROM snapshot WHERE ok = 1"
).fetchone()
return None if row is None or row["d"] is None else str(row["d"])
@staticmethod
def _snapshot_days(conn: sqlite3.Connection) -> int:
row = conn.execute(
"SELECT COUNT(DISTINCT stat_date) AS n FROM snapshot WHERE ok = 1"
).fetchone()
return int(row["n"] or 0)
@staticmethod
def _snapshot_map(
conn: sqlite3.Connection,
aids: Sequence[int],
*,
before: str | None = None,
before_inclusive: bool = False,
) -> dict[int, dict[str, Any]]:
if not aids:
return {}
result: dict[int, dict[str, Any]] = {}
for chunk in _chunks(list(aids)):
placeholders = ",".join("?" for _ in chunk)
if before is None:
sql = f"""
SELECT s.* FROM snapshot s
JOIN (
SELECT aid, MAX(stat_date) AS stat_date
FROM snapshot WHERE ok = 1 AND aid IN ({placeholders})
GROUP BY aid
) m ON m.aid = s.aid AND m.stat_date = s.stat_date
"""
params: list[Any] = list(chunk)
else:
operator = "<=" if before_inclusive else "<"
sql = f"""
SELECT s.* FROM snapshot s
JOIN (
SELECT aid, MAX(stat_date) AS stat_date
FROM snapshot
WHERE ok = 1 AND aid IN ({placeholders}) AND stat_date {operator} ?
GROUP BY aid
) m ON m.aid = s.aid AND m.stat_date = s.stat_date
"""
params = [*chunk, before]
for row in conn.execute(sql, params).fetchall():
data = dict(row)
result[int(data["aid"])] = data
return result
@staticmethod
def _videos(
conn: sqlite3.Connection,
*,
statuses: Sequence[str] = TRACKED_STATUSES,
folder_id: int | None = None,
q: str | None = None,
within_field: str | None = None,
within_start: str | None = None,
up_mid: int | None = None,
) -> list[dict[str, Any]]:
clauses = [f"v.status IN ({','.join('?' for _ in statuses)})"]
params: list[Any] = list(statuses)
if folder_id is not None:
clauses.append("v.folder_id = ?")
params.append(int(folder_id))
if up_mid is not None:
clauses.append("v.up_mid = ?")
params.append(int(up_mid))
if q:
pattern = _like_pattern(q)
clauses.append(
"(v.title LIKE ? ESCAPE '\\' OR v.up_name LIKE ? ESCAPE '\\')"
)
params.extend([pattern, pattern])
if within_field and within_start:
if within_field not in {"pubdate", "first_seen"}:
raise ValueError("within_field 必须是 pubdate 或 first_seen")
start_ts, _ = day_bounds(within_start)
clauses.append(f"v.{within_field} >= ?")
params.append(start_ts)
sql = (
"SELECT v.*, f.title AS folder_title, f.year AS folder_year "
"FROM video v LEFT JOIN folder f ON f.media_id = v.folder_id "
f"WHERE {' AND '.join(clauses)} ORDER BY v.aid"
)
return [dict(row) for row in conn.execute(sql, params).fetchall()]
def _global_heat(
self, conn: sqlite3.Connection
) -> tuple[list[dict[str, Any]], dict[int, dict[str, Any]], dict[int, float], dict[int, float]]:
videos = self._videos(conn)
snapshots = self._snapshot_map(conn, [int(v["aid"]) for v in videos])
rows = []
for video in videos:
snapshot = snapshots.get(int(video["aid"]))
if snapshot is None:
continue
item = {"aid": int(video["aid"])}
item.update(_metric_map(snapshot) or {})
rows.append(item)
weights = self.config_store.get().heat_weights
scores = heat_scores(rows, weights or DEFAULT_HEAT_WEIGHTS)
percentiles = score_percentiles(scores)
return videos, snapshots, scores, percentiles
@staticmethod
def _growth_for(
snapshot: Mapping[str, Any] | None,
previous: Mapping[str, Any] | None,
metric: str = "view",
):
if snapshot is None:
return compute_growth(None, None, None)
current = snapshot.get(metric)
before = None if previous is None else previous.get(metric)
gap = None
if previous is not None and snapshot.get("stat_date") and previous.get("stat_date"):
try:
gap = (
date.fromisoformat(str(snapshot["stat_date"]))
- date.fromisoformat(str(previous["stat_date"]))
).days
except ValueError:
gap = None
return compute_growth(current, before, gap)
def _item(
self,
video: Mapping[str, Any],
snapshot: Mapping[str, Any] | None,
*,
heat: float | None = None,
percentile: float | None = None,
previous: Mapping[str, Any] | None = None,
include_growth: bool = False,
) -> dict[str, Any]:
metrics = _metric_map(snapshot)
duration = video.get("duration")
hours = content_hours(duration)
item: dict[str, Any] = {
"aid": int(video["aid"]),
"bvid": video.get("bvid"),
"title": video.get("title") or "(无标题)",
"up_mid": video.get("up_mid"),
"up_name": video.get("up_name"),
"pubdate": int(video.get("pubdate") or 0),
"duration": duration,
"pages": video.get("pages"),
"content_hours": None if hours is None else round(hours, 3),
"up_follower": video.get("up_follower"),
"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")),
"folder_id": video.get("folder_id"),
"folder_title": video.get("folder_title"),
"folder_year": video.get("folder_year"),
"status": video.get("status"),
"first_seen": int(video.get("first_seen") or 0),
"attr": video.get("attr"),
"metrics": metrics,
"snapshot_date": None if snapshot is None else snapshot.get("stat_date"),
"heat": None if heat is None else round(float(heat), 3),
"heat_percentile": percentile,
"interaction_rate": None if metrics is None else interaction_rate(metrics),
"efficiency": (
None if metrics is None
else views_per_hour(metrics.get("view"), duration)
),
"interaction_efficiency": (
None if metrics is None
else interactions_per_hour(metrics, duration)
),
}
if include_growth:
item["growth"] = self._growth_for(snapshot, previous).as_dict()
return item
# ------------------------------------------------------------ overview
def overview(self) -> dict[str, Any]:
return self._cached("videos", {}, TTL_VIDEOS, self._overview_uncached)
def _overview_uncached(self) -> dict[str, Any]:
def run(conn: sqlite3.Connection) -> dict[str, Any]:
latest = self._latest_date(conn)
snapshot_days = self._snapshot_days(conn)
counts_rows = conn.execute(
"SELECT status, COUNT(*) AS n FROM video GROUP BY status"
).fetchall()
by_status = {str(row["status"]): int(row["n"]) for row in counts_rows}
videos = self._videos(conn)
aids = [int(video["aid"]) for video in videos]
snapshots = self._snapshot_map(conn, aids)
previous = self._snapshot_map(conn, aids, before=latest or self._today())
totals = {metric: 0 for metric in METRICS}
for video in videos:
metrics = _metric_map(snapshots.get(int(video["aid"])))
if metrics:
for metric in METRICS:
totals[metric] += metrics[metric]
view_delta: int | None = None
available_deltas = 0
view_delta_sum = 0
for video in videos:
aid = int(video["aid"])
snapshot = snapshots.get(aid)
prior = previous.get(aid)
if snapshot is None or prior is None:
continue
growth = self._growth_for(snapshot, prior)
if growth.available and growth.delta is not None:
view_delta_sum += growth.delta
available_deltas += 1
if available_deltas:
view_delta = view_delta_sum
first_seen_row = conn.execute(
"SELECT MIN(first_seen) AS ts FROM video"
).fetchone()
first_seen_ts = first_seen_row["ts"] if first_seen_row else None
first_seen_date = _ts_date(first_seen_ts)
initial_import_count = 0
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')",
(int(first_seen_ts),),
).fetchone()[0])
start_ts, end_ts = day_bounds(self._today())
added_today = int(conn.execute(
"SELECT COUNT(*) FROM video WHERE first_seen >= ? AND first_seen < ?",
(start_ts, end_ts),
).fetchone()[0])
is_first_day = snapshot_days <= 1
if is_first_day:
added_today = 0
snapshot_health = {"total": 0, "ok": 0, "failed": 0}
if latest:
rows = conn.execute(
"SELECT ok, COUNT(*) AS n FROM snapshot WHERE stat_date = ? GROUP BY ok",
(latest,),
).fetchall()
by_ok = {int(row["ok"]): int(row["n"]) for row in rows}
snapshot_health = {
"total": sum(by_ok.values()),
"ok": by_ok.get(1, 0),
"failed": by_ok.get(0, 0),
}
candidate_pending = int(conn.execute(
"SELECT COUNT(*) FROM candidate WHERE decision IS NULL"
).fetchone()[0])
candidate_total = int(conn.execute(
"SELECT COUNT(*) FROM candidate"
).fetchone()[0])
cfg = self.config_store.get()
fans = self._fans_overview(conn, cfg.track_fans, latest, snapshot_days)
folders = self._folders_overview(conn)
tasks = self._task_records(conn)
auth = {
"ok": self._kv(conn, "auth:ok"),
"message": self._kv(conn, "auth:message"),
"last_check": self._kv(conn, "auth:last_check"),
"mid": self._kv(conn, "auth:mid"),
}
preview = self._kv(conn, "preview:seeded") == "1"
snapshot_completed_today = self._kv(
conn, f"snapshot:completed:{self._today()}"
) == "1"
return {
"generated_at": int(self._now()),
"today": self._today(),
"data_date": latest,
"snapshot_days": snapshot_days,
"first_seen_date": first_seen_date,
"is_first_day": is_first_day,
"initial_import_count": initial_import_count,
"added_today": added_today,
"counts": {
**by_status,
"active_ups": int(conn.execute(
"SELECT COUNT(DISTINCT up_mid) FROM video "
"WHERE status IN ('active','unfaved') AND up_mid IS NOT NULL"
).fetchone()[0]),
"candidates_pending": candidate_pending,
"candidates_total": candidate_total,
},
"totals": totals,
"interactions": totals["like"] + totals["coin"] + totals["favorite"],
"view_delta": view_delta,
"view_delta_available": view_delta is not None,
"snapshot_health": snapshot_health,
"snapshot_completed_today": snapshot_completed_today,
"fans": fans,
"folders": folders,
"tasks": tasks,
"auth": auth,
"preview": preview,
"capacity": CAPACITY,
"capacity_warning": CAPACITY_WARNING,
}
return self.db.read(run)
@staticmethod
def _kv(conn: sqlite3.Connection, key: str) -> str | None:
row = conn.execute("SELECT v FROM kv WHERE k = ?", (key,)).fetchone()
return None if row is None else str(row["v"])
def _task_records(self, conn: sqlite3.Connection) -> dict[str, Any]:
rows = conn.execute(
"SELECT k, v FROM kv WHERE k LIKE 'task:last:%' ORDER BY k"
).fetchall()
result: dict[str, Any] = {}
for row in rows:
name = str(row["k"]).removeprefix("task:last:")
try:
value = json.loads(str(row["v"]))
except (TypeError, json.JSONDecodeError):
value = {"raw": row["v"]}
result[name] = value
return result
def _folders_overview(self, conn: sqlite3.Connection) -> list[dict[str, Any]]:
rows = conn.execute(
"""
SELECT f.*,
(SELECT COUNT(*) FROM folder_resource fr
WHERE fr.media_id = f.media_id AND fr.active = 1) AS local_count
FROM folder f ORDER BY f.year DESC, f.media_id DESC
"""
).fetchall()
result = []
for row in rows:
media_count = row["media_count"]
count = int(media_count) if media_count is not None else int(row["local_count"] or 0)
result.append({
"media_id": int(row["media_id"]),
"year": int(row["year"]),
"title": row["title"] or "",
"media_count": count,
"local_count": int(row["local_count"] or 0),
"last_ids_at": row["last_ids_at"],
"last_full_at": row["last_full_at"],
"enabled": bool(row["enabled"]),
"capacity": CAPACITY,
"ratio": min(1.0, count / CAPACITY),
"warning": count >= CAPACITY_WARNING,
})
return result
def _fans_overview(
self,
conn: sqlite3.Connection,
mid_list: Sequence[int],
latest: str | None,
snapshot_days: int,
) -> dict[str, Any] | None:
mids = list(mid_list) or [
int(row["mid"])
for row in conn.execute("SELECT DISTINCT mid FROM up_stat ORDER BY mid").fetchall()
]
if not mids:
return None
mid = int(mids[0])
rows = conn.execute(
"SELECT * FROM up_stat WHERE mid = ? ORDER BY stat_date", (mid,)
).fetchall()
if not rows:
return None
ok_rows = [dict(row) for row in rows if int(row["ok"]) == 1]
if not ok_rows:
return None
current = ok_rows[-1]
previous = ok_rows[-2] if len(ok_rows) >= 2 else None
if previous is not None:
try:
gap = (
date.fromisoformat(str(current["stat_date"]))
- date.fromisoformat(str(previous["stat_date"]))
).days
except ValueError:
gap = 0
if gap != 1:
previous = None
baseline = None
if latest:
cutoff = _date_minus(latest, 7)
by_date = {str(row["stat_date"]): row for row in ok_rows}
baseline = by_date.get(cutoff)
first = ok_rows[0]
return {
"mid": mid,
"name": TRACK_FANS_NAMES.get(mid, f"UP {mid}"),
"stat_date": current["stat_date"],
"follower": current["follower"],
"following": current["following"],
"delta_1d": None if previous is None else int(current["follower"] or 0) - int(previous["follower"] or 0),
"delta_7d": None if baseline is None else int(current["follower"] or 0) - int(baseline["follower"] or 0),
"delta_cum": int(current["follower"] or 0) - int(first["follower"] or 0),
"first_date": first["stat_date"],
"snapshot_days": snapshot_days,
"points": [
{
"date": row["stat_date"],
"follower": row["follower"] if int(row["ok"]) == 1 else None,
"ok": bool(row["ok"]),
}
for row in rows[-30:]
],
}
# ------------------------------------------------------------ videos
def list_videos(
self,
*,
sort: str = "heat",
within_days: int | None = None,
growth_days: int = 7,
folder_id: int | None = None,
q: str | None = None,
page: int = 1,
page_size: int = 20,
) -> dict[str, Any]:
params = {
"sort": sort,
"within_days": within_days,
"growth_days": growth_days,
"folder_id": folder_id,
"q": q or "",
"page": page,
"page_size": page_size,
}
return self._cached(
"videos", params, TTL_VIDEOS, lambda: self._list_videos_uncached(
sort=sort,
within_days=within_days,
growth_days=growth_days,
folder_id=folder_id,
q=q,
page=page,
page_size=page_size,
),
)
def _list_videos_uncached(
self,
*,
sort: str = "heat",
within_days: int | None = None,
growth_days: int = 7,
folder_id: int | None = None,
q: str | None = None,
page: int = 1,
page_size: int = 20,
) -> dict[str, Any]:
if sort not in VIDEO_SORTS:
raise ValueError(f"不支持的排序: {sort}")
if growth_days < 1 or growth_days > 365:
raise ValueError("growth_days 必须在 1~365 之间")
page = max(1, int(page))
page_size = max(1, min(int(page_size), 200))
def run(conn: sqlite3.Connection) -> dict[str, Any]:
latest = self._latest_date(conn) or self._today()
analytics = self.config_store.get().analytics
duration_sorts = {"efficiency", "interaction_efficiency"}
min_view_for_ranking = max(0, int(analytics.min_view_for_ranking))
within_field = None
if within_days is not None:
if within_days < 1:
raise ValueError("within_days 必须大于 0")
within_field = "first_seen" if sort == "added" else "pubdate"
videos = self._videos(
conn,
folder_id=folder_id,
q=q,
within_field=within_field,
within_start=(
_date_minus(latest, int(within_days) - 1)
if within_days is not None else None
),
)
aids = [int(video["aid"]) for video in videos]
snapshots = self._snapshot_map(conn, aids)
videos_all, snapshots_all, heat, percentiles = self._global_heat(conn)
previous: dict[int, dict[str, Any]] = {}
if sort in {"growth", "growth_rate", "interaction"}:
baseline_date = _date_minus(latest, growth_days)
previous = self._snapshot_map(conn, aids, before=baseline_date, before_inclusive=True)
elif sort in {"heat", "view", "like", "coin", "favorite", "share", "reply", "danmaku"}:
previous = {}
items = [
self._item(
video,
snapshots.get(int(video["aid"])),
heat=heat.get(int(video["aid"])),
percentile=percentiles.get(int(video["aid"])),
previous=previous.get(int(video["aid"])),
include_growth=sort in {"growth", "growth_rate"},
)
for video in videos
]
if sort in duration_sorts:
latest_start, _ = day_bounds(latest)
minimum_seconds = max(0, int(analytics.min_duration_seconds))
items = [
item for item in items
if item["metrics"] is not None
and int(item["duration"] or 0) >= minimum_seconds
and int((item["metrics"] or {}).get("view", 0)) >= min_view_for_ranking
and is_mature(
item["pubdate"], latest_start, analytics.maturity_days
)
]
kappa_hours = max(0, int(analytics.video_shrink_minutes)) / 60.0
total_hours = 0.0
total_views = 0.0
total_interactions = 0.0
for video in videos_all:
hours = content_hours(video.get("duration"))
if hours is None:
continue
metrics = _metric_map(snapshots_all.get(int(video["aid"])))
if metrics is None:
continue
total_hours += hours
total_views += metrics["view"]
total_interactions += (
metrics["like"] + metrics["coin"] + metrics["favorite"]
)
prior_views = total_views / total_hours if total_hours > 0 else 0.0
prior_interactions = (
total_interactions / total_hours if total_hours > 0 else 0.0
)
for item in items:
hours = content_hours(item.get("duration")) or 0.0
metrics = item["metrics"] or {}
denominator = hours + kappa_hours
if hours <= 0 or denominator <= 0:
item["efficiency_score"] = None
item["interaction_efficiency_score"] = None
continue
view = int(metrics.get("view", 0))
interactions = (
int(metrics.get("like", 0))
+ int(metrics.get("coin", 0))
+ int(metrics.get("favorite", 0))
)
item["efficiency_score"] = (
view + kappa_hours * prior_views
) / denominator
item["interaction_efficiency_score"] = (
interactions + kappa_hours * prior_interactions
) / denominator
if sort == "heat":
items = [
item for item in items
if int((item["metrics"] or {}).get("view", 0)) >= min_view_for_ranking
]
if sort == "interaction":
items = [item for item in items if item["interaction_rate"] is not None]
if sort in {"growth", "growth_rate"}:
items = [
item for item in items
if item.get("growth", {}).get("available")
]
if sort == "growth_rate":
items = [
item for item in items
if (item["metrics"] or {}).get("view", 0) >= MIN_VIEW_FOR_GROWTH_RATE
]
def sort_key(item: Mapping[str, Any]):
if sort == "added":
return (int(item["first_seen"]), int(item["pubdate"]))
if sort == "published":
return (int(item["pubdate"]), int(item["first_seen"]))
if sort == "heat":
return item["heat"] if item["heat"] is not None else float("-inf")
if sort == "interaction":
return item["interaction_rate"] if item["interaction_rate"] is not None else float("-inf")
if sort in {"efficiency", "interaction_efficiency"}:
field = (
"efficiency_score" if sort == "efficiency"
else "interaction_efficiency_score"
)
value = item.get(field)
return value if value is not None else float("-inf")
if sort in {"growth", "growth_rate"}:
field = "delta" if sort == "growth" else "rate"
value = item.get("growth", {}).get(field)
return value if value is not None else float("-inf")
value = (item["metrics"] or {}).get(sort)
return value if value is not None else -1
items.sort(key=lambda item: item["aid"])
items.sort(key=sort_key, reverse=True)
total = len(items)
growth_available = any(
item.get("growth", {}).get("available") for item in items
)
start = (page - 1) * page_size
items = items[start:start + page_size]
available = True
reason = None
if sort in {"growth", "growth_rate"} and not growth_available:
available = False
reason = "需要至少两天的快照才能计算增长"
duration_filter = None
if sort in duration_sorts:
duration_filter = {
"min_duration_seconds": max(0, int(analytics.min_duration_seconds)),
"maturity_days": max(0, int(analytics.maturity_days)),
"min_view": min_view_for_ranking,
"shrink_minutes": max(0, int(analytics.video_shrink_minutes)),
}
if not items:
available = False
reason = (
f"仅统计时长 ≥ {duration_filter['min_duration_seconds']} 秒、"
f"发布满 {duration_filter['maturity_days']} 天、"
f"播放 ≥ {duration_filter['min_view']} 的视频"
)
return {
"sort": sort,
"within_days": within_days,
"growth_days": growth_days,
"page": page,
"page_size": page_size,
"total": total,
"pages": max(1, math.ceil(total / page_size)) if total else 0,
"items": items,
"available": available,
"reason": reason,
"duration_filter": duration_filter,
}
return self.db.read(run)
def video_detail(self, aid: int) -> dict[str, Any] | None:
aid = int(aid)
def run(conn: sqlite3.Connection) -> dict[str, Any] | None:
video_rows = self._videos(conn)
video = next((item for item in video_rows if int(item["aid"]) == aid), None)
if video is None:
row = conn.execute(
"SELECT v.*, f.title AS folder_title, f.year AS folder_year "
"FROM video v LEFT JOIN folder f ON f.media_id = v.folder_id "
"WHERE v.aid = ?",
(aid,),
).fetchone()
if row is None:
return None
video = dict(row)
snapshots = self._snapshot_map(conn, [aid])
snapshot = snapshots.get(aid)
previous = self._snapshot_map(conn, [aid], before=snapshot["stat_date"] if snapshot else None).get(aid)
_, _, heat, percentiles = self._global_heat(conn)
growth = self._growth_for(snapshot, previous)
item = self._item(
video,
snapshot,
heat=heat.get(aid),
percentile=percentiles.get(aid),
)
item["growth"] = growth.as_dict()
item["snapshot_count"] = int(conn.execute(
"SELECT COUNT(*) FROM snapshot WHERE aid = ?", (aid,)
).fetchone()[0])
item["blacklisted"] = bool(conn.execute(
"SELECT 1 FROM up_blacklist WHERE mid = ?", (video.get("up_mid"),)
).fetchone()) if video.get("up_mid") is not None else False
item["url"] = (
f"https://www.bilibili.com/video/{video.get('bvid')}"
if video.get("bvid") else None
)
return item
return self.db.read(run)
def video_trend(self, aid: int, *, days: int | None = 30) -> dict[str, Any]:
aid = int(aid)
if days is not None and days < 1:
raise ValueError("days 必须大于 0")
def run(conn: sqlite3.Connection) -> dict[str, Any]:
latest = self._latest_date(conn)
rows = conn.execute(
"SELECT * FROM snapshot WHERE aid = ? ORDER BY stat_date", (aid,)
).fetchall()
points = [dict(row) for row in rows]
if latest is None or not points:
return {
"aid": aid,
"days": days,
"dates": [],
"series": {metric: [] for metric in METRICS},
"points": [],
"available": False,
}
requested_start = (
_date_minus(latest, days - 1) if days is not None
else str(points[0]["stat_date"])
)
first_point = str(points[0]["stat_date"])
start = max(requested_start, first_point)
dates = _date_range(start, latest)
by_date = {str(point["stat_date"]): point for point in points}
series: dict[str, list[int | None]] = {}
for metric in METRICS:
values: list[int | None] = []
for day in dates:
point = by_date.get(day)
if point is None or int(point["ok"]) != 1 or point.get(metric) is None:
values.append(None)
else:
values.append(int(point[metric]))
series[metric] = values
filtered_points = [
point for point in points if str(point["stat_date"]) in set(dates)
]
return {
"aid": aid,
"days": days,
"dates": dates,
"series": series,
"points": filtered_points,
"available": any(point["ok"] == 1 for point in filtered_points),
}
return self.db.read(run)
# ------------------------------------------------------------ ups
def list_ups(
self,
*,
sort: str = "avg_views",
within_days: int | None = None,
q: str | None = None,
min_videos: int = 1,
page: int = 1,
page_size: int = 20,
) -> dict[str, Any]:
params = {
"sort": sort,
"within_days": within_days,
"q": q or "",
"min_videos": min_videos,
"page": page,
"page_size": page_size,
}
return self._cached(
"videos", params, TTL_VIDEOS, lambda: self._list_ups_uncached(
sort=sort,
within_days=within_days,
q=q,
min_videos=min_videos,
page=page,
page_size=page_size,
),
)
def _list_ups_uncached(
self,
*,
sort: str = "avg_views",
within_days: int | None = None,
q: str | None = None,
min_videos: int = 1,
page: int = 1,
page_size: int = 20,
) -> dict[str, Any]:
if sort not in UP_SORTS:
raise ValueError(f"不支持的 UP 排序: {sort}")
page = max(1, int(page))
page_size = max(1, min(int(page_size), 200))
min_videos = max(1, int(min_videos))
def run(conn: sqlite3.Connection) -> dict[str, Any]:
latest = self._latest_date(conn) or self._today()
videos = self._videos(
conn,
q=q,
within_field="pubdate" if within_days is not None else None,
within_start=(
_date_minus(latest, int(within_days) - 1)
if within_days is not None else None
),
)
snapshots = self._snapshot_map(conn, [int(video["aid"]) for video in videos])
groups: dict[int, dict[str, Any]] = {}
for video in videos:
mid = video.get("up_mid")
if mid is None:
continue
mid = int(mid)
group = groups.setdefault(mid, {
"up_mid": mid,
"up_name": video.get("up_name") or f"UP {mid}",
"videos": [],
})
metrics = _metric_map(snapshots.get(int(video["aid"])))
group["videos"].append((video, metrics))
items = []
for group in groups.values():
group_videos = group["videos"]
metrics_list = [metrics for _, metrics in group_videos if metrics]
total_views = sum(metrics["view"] for metrics in metrics_list)
total_interactions = sum(
metrics["like"] + metrics["coin"] + metrics["favorite"]
for metrics in metrics_list
)
rates = [
rate for rate in (
interaction_rate(metrics) for metrics in metrics_list
) if rate is not None
]
exposure_seconds = sum(
int(video.get("duration") or 0)
for video, metrics in group_videos
if metrics is not None and int(video.get("duration") or 0) > 0
)
exposure_minutes = exposure_seconds / 60.0
best = max(
group_videos,
key=lambda pair: (pair[1] or {}).get("view", 0),
default=None,
)
items.append({
"up_mid": group["up_mid"],
"up_name": group["up_name"],
"video_count": len(group_videos),
"total_views": total_views,
"avg_views": round(total_views / len(metrics_list), 1) if metrics_list else None,
"total_interactions": total_interactions,
"avg_interaction": round(sum(rates) / len(rates), 6) if rates else None,
"latest_pubdate": max(int(video["pubdate"]) for video, _ in group_videos),
"first_pubdate": min(int(video["pubdate"]) for video, _ in group_videos),
"_exposure_minutes": exposure_minutes,
"best_video": None if best is None else {
"aid": int(best[0]["aid"]),
"title": best[0].get("title"),
"cover": normalize_cover(best[0].get("cover")),
"view": (best[1] or {}).get("view"),
},
})
total_minutes = sum(item["_exposure_minutes"] for item in items)
total_views_all = sum(item["total_views"] for item in items)
total_interactions_all = sum(item["total_interactions"] for item in items)
prior_views = (
total_views_all / total_minutes if total_minutes > 0 else 0.0
)
prior_interactions = (
total_interactions_all / total_minutes if total_minutes > 0 else 0.0
)
shrink_minutes = effective_shrink_minutes(
item["_exposure_minutes"] for item in items
)
for item in items:
minutes = item.pop("_exposure_minutes")
item["total_duration"] = int(round(minutes * 60))
item["content_hours"] = round(minutes / 60.0, 2)
item["views_per_hour"] = (
round(item["total_views"] / minutes * 60.0, 1)
if minutes > 0 else None
)
item["interactions_per_hour"] = (
round(item["total_interactions"] / minutes * 60.0, 1)
if minutes > 0 else None
)
item["efficiency"] = (
round(
eb_rate(
item["total_views"], minutes,
prior_views, shrink_minutes,
) * 60.0,
2,
)
if minutes > 0 else None
)
item["interaction_efficiency"] = (
round(
eb_rate(
item["total_interactions"], minutes,
prior_interactions, shrink_minutes,
) * 60.0,
2,
)
if minutes > 0 else None
)
items = [item for item in items if item["video_count"] >= min_videos]
sort_map = {
"views": lambda item: item["total_views"],
"count": lambda item: item["video_count"],
"avg_views": lambda item: item["avg_views"] or 0,
"interaction": lambda item: item["total_interactions"],
"avg_interaction": lambda item: item["avg_interaction"] or 0,
"latest": lambda item: item["latest_pubdate"],
"duration": lambda item: item["total_duration"],
"efficiency": lambda item: item["efficiency"] or 0,
"interaction_efficiency": lambda item: item["interaction_efficiency"] or 0,
}
key = sort_map[sort]
items.sort(key=lambda item: (key(item), item["up_mid"]), reverse=True)
total = len(items)
start = (page - 1) * page_size
items = items[start:start + page_size]
return {
"sort": sort,
"within_days": within_days,
"min_videos": min_videos,
"page": page,
"page_size": page_size,
"total": total,
"pages": max(1, math.ceil(total / page_size)) if total else 0,
"items": items,
}
return self.db.read(run)
def up_detail(self, mid: int) -> dict[str, Any] | None:
mid = int(mid)
def run(conn: sqlite3.Connection) -> dict[str, Any] | None:
latest = self._latest_date(conn) or self._today()
videos = self._videos(conn, up_mid=mid)
if not videos:
row = conn.execute(
"SELECT 1 FROM up_blacklist WHERE mid = ?", (mid,)
).fetchone()
if row is None:
return None
return {
"up_mid": mid,
"up_name": f"UP {mid}",
"videos": [],
"video_count": 0,
"blacklisted": True,
"series": {"dates": [], "views": []},
}
snapshots = self._snapshot_map(conn, [int(video["aid"]) for video in videos])
items = [
self._item(video, snapshots.get(int(video["aid"])))
for video in videos
]
metrics_list = [item["metrics"] for item in items if item["metrics"]]
rates = [item["interaction_rate"] for item in items if item["interaction_rate"] is not None]
total_views = sum(metrics["view"] for metrics in metrics_list)
total_interactions = sum(
metrics["like"] + metrics["coin"] + metrics["favorite"]
for metrics in metrics_list
)
exposure_seconds = sum(
int(item["duration"] or 0)
for item in items
if item["metrics"] is not None and int(item["duration"] or 0) > 0
)
exposure_minutes = exposure_seconds / 60.0
profile = conn.execute(
"SELECT * FROM up_profile WHERE mid = ?", (mid,)
).fetchone()
follower_item = next(
(item for item in items if item.get("up_follower") is not None), None
)
aids = [int(video["aid"]) for video in videos]
rows = conn.execute(
f"SELECT stat_date, SUM(view) AS view FROM snapshot "
f"WHERE ok = 1 AND aid IN ({','.join('?' for _ in aids)}) "
"GROUP BY stat_date ORDER BY stat_date",
aids,
).fetchall()
view_by_date = {str(row["stat_date"]): int(row["view"] or 0) for row in rows}
dates = _date_range(min(view_by_date), latest) if view_by_date else []
items.sort(key=lambda item: ((item["metrics"] or {}).get("view", 0), item["pubdate"]), reverse=True)
folder_titles = sorted({
str(item["folder_title"]) for item in items if item.get("folder_title")
})
return {
"up_mid": mid,
"up_name": items[0].get("up_name") or f"UP {mid}",
"video_count": len(items),
"total_views": total_views,
"avg_views": round(total_views / len(metrics_list), 1) if metrics_list else None,
"total_interactions": total_interactions,
"avg_interaction": round(sum(rates) / len(rates), 6) if rates else None,
"total_duration": int(round(exposure_minutes * 60)),
"content_hours": round(exposure_minutes / 60.0, 2),
"views_per_hour": (
round(total_views / exposure_minutes * 60.0, 1)
if exposure_minutes > 0 else None
),
"interactions_per_hour": (
round(total_interactions / exposure_minutes * 60.0, 1)
if exposure_minutes > 0 else None
),
"up_follower": None if follower_item is None else follower_item["up_follower"],
"up_follower_at": None if follower_item is None else follower_item["up_follower_at"],
"up_follower_backfilled": bool(
follower_item is not None
and follower_item.get("up_follower_backfilled")
),
"follower_first": None if profile is None else profile["follower_first"],
"follower_latest": None if profile is None else profile["follower_latest"],
"first_pubdate": min(item["pubdate"] for item in items),
"latest_pubdate": max(item["pubdate"] for item in items),
"folders": folder_titles,
"blacklisted": bool(conn.execute(
"SELECT 1 FROM up_blacklist WHERE mid = ?", (mid,)
).fetchone()),
"space_url": f"https://space.bilibili.com/{mid}",
"videos": items,
"series": {
"dates": dates,
"views": [view_by_date.get(day) for day in dates],
},
}
return self.db.read(run)
# ------------------------------------------------------------ fans
def fans(
self,
mid: int | None = None,
*,
days: int = 90,
start: str | None = None,
end: str | None = None,
recent_days: int = 14,
) -> dict[str, Any]:
params = {
"mid": mid if mid is not None else "",
"days": days,
"start": start or "",
"end": end or "",
"recent_days": recent_days,
}
return self._cached(
"fans", params, TTL_FANS, lambda: self._fans_uncached(
mid=mid,
days=days,
start=start,
end=end,
recent_days=recent_days,
),
)
def _fans_uncached(
self,
mid: int | None = None,
*,
days: int = 90,
start: str | None = None,
end: str | None = None,
recent_days: int = 14,
) -> dict[str, Any]:
if days < 1:
raise ValueError("days 必须大于 0")
if recent_days < 1 or recent_days > 3650:
raise ValueError("recent_days 必须在 1~3650 之间")
for label, value in (("start", start), ("end", end)):
if value:
try:
date.fromisoformat(value)
except ValueError as exc:
raise ValueError(f"{label} 必须是 YYYY-MM-DD") from exc
if start and end and start > end:
raise ValueError("start 不能晚于 end")
def run(conn: sqlite3.Connection) -> dict[str, Any]:
cfg = self.config_store.get()
mids = list(cfg.track_fans)
if not mids:
mids = [
int(row["mid"]) for row in conn.execute(
"SELECT DISTINCT mid FROM up_stat ORDER BY mid"
).fetchall()
]
selected = int(mid) if mid is not None else (mids[0] if mids else None)
options = [
{"mid": value, "name": TRACK_FANS_NAMES.get(value, f"UP {value}")}
for value in mids
]
if selected is None:
return {
"mid": None, "name": None, "available": False,
"options": options, "dates": [], "series": [],
"points": [], "recent_points": [], "days": days,
"mode": "daily", "range_start": None, "range_end": None,
"span_days": 0, "recent_days": recent_days,
}
rows = [
dict(row) for row in conn.execute(
"SELECT * FROM up_stat WHERE mid = ? ORDER BY stat_date",
(selected,),
).fetchall()
]
if not rows:
return {
"mid": selected,
"name": TRACK_FANS_NAMES.get(selected, f"UP {selected}"),
"available": False,
"options": options,
"dates": [],
"series": [],
"points": [],
"recent_points": [],
"days": days,
"mode": "daily", "range_start": None, "range_end": None,
"span_days": 0, "recent_days": recent_days,
}
latest = max(str(row["stat_date"]) for row in rows if int(row["ok"]) == 1) if any(int(row["ok"]) == 1 for row in rows) else str(rows[-1]["stat_date"])
first_date = str(rows[0]["stat_date"])
recent_start = _date_minus(latest, recent_days - 1)
recent_points = _with_fan_deltas(rows, recent_start)
if start or end:
range_start = max(str(start or first_date), first_date)
range_end = min(str(end or latest), latest)
if range_start > range_end:
raise ValueError("所选日期范围内没有粉丝数据")
else:
range_start = max(_date_minus(latest, days - 1), first_date)
range_end = latest
span_days = (
date.fromisoformat(range_end) - date.fromisoformat(range_start)
).days + 1
mode = "daily" if span_days <= 366 else "monthly"
points = [
row for row in rows
if range_start <= str(row["stat_date"]) <= range_end
]
if mode == "daily":
dates, series = _daily_fan_series(points, range_start, range_end)
else:
dates, series = _monthly_peak_series(
points, range_start, range_end
)
ok_rows = [row for row in rows if int(row["ok"]) == 1]
current = ok_rows[-1] if ok_rows else rows[-1]
previous = ok_rows[-2] if len(ok_rows) >= 2 else None
if previous is not None:
try:
gap = (
date.fromisoformat(str(current["stat_date"]))
- date.fromisoformat(str(previous["stat_date"]))
).days
except ValueError:
gap = 0
if gap != 1:
previous = None
cutoff = _date_minus(latest, 7)
by_date = {str(row["stat_date"]): row for row in ok_rows}
baseline = by_date.get(cutoff)
first = ok_rows[0] if ok_rows else rows[0]
return {
"mid": selected,
"name": TRACK_FANS_NAMES.get(selected, f"UP {selected}"),
"available": bool(ok_rows),
"options": options,
"days": days,
"mode": mode,
"range_start": range_start,
"range_end": range_end,
"span_days": span_days,
"dates": dates,
"series": series,
"points": points,
"recent_points": recent_points,
"recent_days": recent_days,
"stat_date": current["stat_date"],
"follower": current.get("follower"),
"following": current.get("following"),
"delta_1d": None if previous is None else int(current.get("follower") or 0) - int(previous.get("follower") or 0),
"delta_7d": None if baseline is None else int(current.get("follower") or 0) - int(baseline.get("follower") or 0),
"delta_cum": int(current.get("follower") or 0) - int(first.get("follower") or 0),
"first_date": first["stat_date"],
}
return self.db.read(run)
# ------------------------------------------------------------ candidates
def about(self) -> dict[str, Any]:
"""数据说明页所需的动态口径;不包含任何凭据。"""
def run(conn: sqlite3.Connection) -> dict[str, Any]:
cfg = self.config_store.get()
latest = self._latest_date(conn)
first_row = conn.execute(
"SELECT MIN(stat_date) AS d FROM snapshot WHERE ok = 1"
).fetchone()
first_date = None if first_row is None else first_row["d"]
counts = {
str(row["status"]): int(row["n"])
for row in conn.execute(
"SELECT status, COUNT(*) AS n FROM video GROUP BY status"
).fetchall()
}
follower_row = conn.execute(
"""
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
"""
).fetchone()
follower_total = int(follower_row["total"] or 0)
follower_covered = int(follower_row["covered"] or 0)
profile_count = int(conn.execute(
"SELECT COUNT(*) FROM up_profile"
).fetchone()[0])
preview_row = conn.execute(
"SELECT v FROM kv WHERE k = 'preview:seeded'"
).fetchone()
fans_start_date = None
track_fans = [
int(mid) for mid in cfg.track_fans if not isinstance(mid, bool)
]
if track_fans:
placeholders = ",".join("?" for _ in track_fans)
row = conn.execute(
f"SELECT MIN(stat_date) AS d FROM up_stat "
f"WHERE ok = 1 AND mid IN ({placeholders})",
track_fans,
).fetchone()
fans_start_date = None if row is None else row["d"]
return {
"data_date": latest,
"snapshot_days": self._snapshot_days(conn),
"snapshot_first_date": first_date,
"fans_start_date": fans_start_date,
"track_fans": track_fans,
"schema_version": int(self.db.schema_version or 0),
"preview": bool(preview_row and str(preview_row["v"]) == "1"),
"counts": counts,
"library": {
"active_videos": counts.get("active", 0),
"unfaved_videos": counts.get("unfaved", 0),
"removed_videos": counts.get("removed", 0),
},
"analytics": {
"min_duration_seconds": cfg.analytics.min_duration_seconds,
"maturity_days": cfg.analytics.maturity_days,
"min_view_for_ranking": cfg.analytics.min_view_for_ranking,
"video_shrink_minutes": cfg.analytics.video_shrink_minutes,
"shrink_minutes": cfg.analytics.shrink_minutes,
},
"heat_weights": {
metric: float(value)
for metric, value in (cfg.heat_weights or {}).items()
},
"followers": {
"videos_total": follower_total,
"videos_covered": follower_covered,
"videos_missing": max(0, follower_total - follower_covered),
"ups_tracked": profile_count,
},
"folders": [
{
"year": folder.year,
"media_id": folder.media_id,
"title": folder.title,
}
for folder in cfg.folders
],
}
return self.db.read(run)
def candidates(self) -> dict[str, Any]:
def run(conn: sqlite3.Connection) -> dict[str, Any]:
cfg = self.config_store.get()
now = int(self._now())
ttl = int(cfg.schedule.candidate_ttl_days)
rows = conn.execute(
"SELECT * FROM candidate ORDER BY "
"CASE WHEN decision IS NULL THEN 0 ELSE 1 END, "
"COALESCE(score, 0) DESC, first_seen DESC, aid"
).fetchall()
items = []
for row in rows:
data = dict(row)
first_seen = int(data.get("first_seen") or 0)
expires_at = first_seen + ttl * 86400
items.append({
**data,
"cover": normalize_cover(data.get("cover")),
"expires_at": expires_at,
"days_left": max(0, math.ceil((expires_at - now) / 86400)),
"url": (
f"https://www.bilibili.com/video/{data.get('bvid')}"
if data.get("bvid") else None
),
})
pending = sum(1 for item in items if item.get("decision") is None)
return {
"items": items,
"total": len(items),
"pending": pending,
"ttl_days": ttl,
"telegram_enabled": bool(cfg.telegram.enabled),
"now": now,
}
return self.db.read(run)
# ------------------------------------------------------------ system
def system(self) -> dict[str, Any]:
def run(conn: sqlite3.Connection) -> dict[str, Any]:
cfg = self.config_store.get()
latest = self._latest_date(conn)
snapshot_days = self._snapshot_days(conn)
health = {"total": 0, "ok": 0, "failed": 0}
if latest:
rows = conn.execute(
"SELECT ok, COUNT(*) AS n FROM snapshot WHERE stat_date = ? GROUP BY ok",
(latest,),
).fetchall()
by_ok = {int(row["ok"]): int(row["n"]) for row in rows}
health = {
"total": sum(by_ok.values()),
"ok": by_ok.get(1, 0),
"failed": by_ok.get(0, 0),
}
folders = self._folders_overview(conn)
blacklist = [
dict(row) for row in conn.execute(
"""
SELECT b.*,
(SELECT COUNT(*) FROM video v WHERE v.up_mid = b.mid
AND v.status IN ('active','unfaved')) AS video_count
FROM up_blacklist b ORDER BY b.created_at, b.mid
"""
).fetchall()
]
tasks = self._task_records(conn)
now = self._now()
next_runs = {
"discover": int(now + cfg.schedule.discover_minutes * 60),
"folder_sync": int(now + cfg.schedule.folder_sync_minutes * 60),
"snapshot": int(next_daily_run(now, cfg.schedule.snapshot_at)),
}
for name, key, interval_minutes in (
("discover", "discovery", cfg.schedule.discover_minutes),
("folder_sync", "folder_sync", cfg.schedule.folder_sync_minutes),
):
record = tasks.get(key)
if isinstance(record, dict) and record.get("finished_at"):
interval = int(interval_minutes) * 60
candidate = int(record["finished_at"]) + interval
while candidate <= now:
candidate += interval
next_runs[name] = candidate
errors = [
{"task": name, **record}
for name, record in tasks.items()
if isinstance(record, dict) and record.get("ok") is False
]
counts = {str(row["status"]): int(row["n"]) for row in conn.execute(
"SELECT status, COUNT(*) AS n FROM video GROUP BY status"
).fetchall()}
return {
"generated_at": int(now),
"today": self._today(),
"data_date": latest,
"snapshot_days": snapshot_days,
"snapshot_health": health,
"counts": counts,
"folders": folders,
"blacklist": blacklist,
"tasks": tasks,
"errors": errors,
"next_runs": next_runs,
"auth": {
"ok": self._kv(conn, "auth:ok"),
"message": self._kv(conn, "auth:message"),
"last_check": self._kv(conn, "auth:last_check"),
"mid": self._kv(conn, "auth:mid"),
},
"backup": {
"last_completed": self._kv(conn, "snapshot:last_completed"),
"last_attempt": self._kv(conn, "snapshot:last_attempt"),
"ok": self._kv(conn, f"snapshot:backup:{latest}" if latest else "snapshot:backup"),
"error": self._kv(conn, f"snapshot:backup_error:{latest}" if latest else "snapshot:backup_error"),
},
"preview": self._kv(conn, "preview:seeded") == "1",
"schedule": {
"discover_minutes": cfg.schedule.discover_minutes,
"folder_sync_minutes": cfg.schedule.folder_sync_minutes,
"snapshot_at": cfg.schedule.snapshot_at,
"candidate_ttl_days": cfg.schedule.candidate_ttl_days,
},
"telegram_enabled": bool(cfg.telegram.enabled),
}
return self.db.read(run)