diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index af854d2..9ef4534 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -526,11 +526,11 @@ P3 实现说明: | 方法 | 路径 | 用途 | |---|---|---| | `GET` | `/api/overview` | 首页动态、总览指标、容量、采集状态 | -| `GET` | `/api/videos` | 视频分页、筛选和排序(支持 added/published/heat/七项/互动/增长/内容效率) | +| `GET` | `/api/videos` | 视频分页、筛选和排序(`sort` / `within_days` / `year` / `q`;支持 added/published/heat/七项/互动/增长/内容效率) | | `GET` | `/api/videos/{aid}` | 视频详情(含分 P 数、入库时粉丝数、内容/互动效率) | | `GET` | `/api/videos/{aid}/trend` | 七项趋势数据 | -| `GET` | `/api/rankings` | 兼容旧链接;`scope=up` 时为 UP 聚合榜 | -| `GET` | `/api/ups` | UP 聚合榜单(含总时长、播放·小时、内容效率) | +| `GET` | `/api/rankings` | 兼容旧链接;`scope=up` 时为 UP 聚合榜(`year` 可用) | +| `GET` | `/api/ups` | UP 聚合榜单(含总时长、播放·小时、内容效率;`sort` / `within_days` / `year`) | | `GET` | `/api/ups/{mid}` | UP 详情、聚合趋势和视频列表 | | `GET` | `/api/fans` | 粉丝曲线与增量 | | `GET` | `/api/candidates` | 候选池(只读) | @@ -542,8 +542,8 @@ P3 实现说明: | 页面 | 内容 | |---|---| | 首页 | 最新动态优先:今日增量、最新入库、最近发布、增长动态、热度速览、容量与采集状态。 | -| 视频(`/videos`) | 内容效率(默认)、综合热度、播放/投币/收藏/评论、互动率、播放增量、播放增速;筛选与搜索。 | -| UP主(`/ups`) | 独立入口,不再与视频混排;视频数、总时长、播放·小时、互动·小时等可分别排序。 | +| 视频(`/videos`) | 内容效率(默认)、综合热度、发布时间、入库时间、播放/投币/收藏/评论、互动率、播放增量、播放增速;排序与时间筛选为下拉框,时间支持全部/7 天/30 天/90 天/1 年/年份。 | +| UP主(`/ups`) | 独立入口,不再与视频混排;排序下拉含内容效率、互动效率、内容量、总播放、视频数、平均播放、总互动、最近发布。 | | 数据说明(`/about`) | 数据来源与更新、发布时间/入库时间/快照日期、指标公式、时长与成熟窗口筛选、粉丝快照覆盖与已知局限;数值由 `/api/about` 动态读取。 | | 视频详情 | 元数据、分 P 数、七项指标、内容/互动效率、入库时粉丝数、单指标趋势、快照表、同 UP 对比。 | | UP 页 | 该 UP 的全部视频、聚合趋势、单视频对比、总时长与播放·小时、入库时与当前粉丝数。 | diff --git a/README.md b/README.md index d5cb27f..8a18ae8 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,9 @@ uv run python app.py serve --preview --config data/preview/config.json ``` - 首页默认“最新动态”:今日增量、最新入库、最近发布、增长动态、热度速览、收藏夹容量与采集状态; -- 「视频」与「UP主」是两个独立入口:视频榜支持内容效率(默认)、综合热度、播放/投币/收藏/评论、互动率、播放增量/播放增速;UP 榜展示视频数、总时长、总播放、播放·小时、互动·小时等,可分别排序; +- 「视频」与「UP主」是两个独立入口;搜索框固定在约 230px,排序与时间筛选都是下拉框,可放在同一行; +- 视频排序:内容效率(默认)、综合热度、发布时间、入库时间、播放/投币/收藏/评论、互动率、播放增量/播放增速;UP 排序:内容效率、互动效率、内容量、总播放、视频数、平均播放、总互动、最近发布; +- 时间筛选:全部 / 近 7 天 / 近 30 天 / 近 90 天 / 近 1 年 / 已入库的年份;不再提供按收藏夹过滤; - 内容效率 = 播放 / 内容小时;单条视频用 60 分钟先验暴露量向全库均值收缩,只统计时长 ≥ 600 秒、发布满 2 天、播放 ≥ 2000 的视频(综合热度榜同样要求播放 ≥ 2000);UP 内容效率用经验贝叶斯收缩,避免极小样本冲榜; - 「数据说明」(`/about`)说明数据来源、发布时间/入库时间/快照日期、指标公式、筛选口径与已知局限,数值由 `/api/about` 动态读取; - 视频详情提供分 P 数、七项指标、内容/互动效率、入库时粉丝数、单指标趋势、快照表和同 UP 对比;UP 页提供聚合趋势、单视频对比与入库时/当前粉丝数; diff --git a/project.md b/project.md index 8984683..7230174 100644 --- a/project.md +++ b/project.md @@ -701,8 +701,8 @@ UP 内容效率 = EB 收缩后的 Σview / Σ内容小时 | 页面 | 内容 | |---|---| | 首页 | 最新动态优先:今日增量、最新入库、最近发布、增长动态、热度速览、收藏夹容量、采集状态 | -| **视频** | Tab:内容效率/综合热度/播放/投币/收藏/评论/互动率/播放增量/播放增速;筛选与搜索 | -| **UP主** | 独立入口;列:视频数、总时长、总播放、播放·小时、互动·小时等,可分别排序 | +| **视频** | 排序下拉:内容效率(默认)/综合热度/发布时间/入库时间/播放/投币/收藏/评论/互动率/播放增量/播放增速;时间下拉:全部/7 天/30 天/90 天/1 年/年份;搜索框约 230px;不再按收藏夹过滤 | +| **UP主** | 独立入口;排序下拉:内容效率/互动效率/内容量/总播放/视频数/平均播放/总互动/最近发布;时间下拉同上 | | 数据说明 | 数据来源与更新节奏、三个时间概念、指标公式、时长/成熟窗口筛选、粉丝快照覆盖与已知局限;数值由 `/api/about` 动态读取当前配置 | | 视频详情 | 元数据 + 分 P 数 + 七项指标 + 内容/互动效率 + 入库时粉丝数 + 单指标趋势 + 同 UP 对比 + 快照表 | | UP 页 | 该 UP 全部视频、聚合趋势、单视频对比、总时长/播放·小时、入库时与当前粉丝数 | @@ -843,6 +843,7 @@ storage:SQLite WAL,单写线程 | 40 | **导航拆分与数据说明页** | `/videos`、`/ups`、`/about` 均返回 200;`/api/about` 返回 schema 4、当前阈值、权重与粉丝覆盖度 | | 41 | **全量离线测试** | 32 个 `tests/test_*.py` 全部通过(含新增 `test_up_profile.py` 与迁移/数据库断言更新) | | 42 | **内容效率噪声修正** | 预览库实测:视频级 EB 收缩(κ=60 分钟)+ 播放 ≥ 2000 后,效率榜 Top10 全为 ≥110 分钟视频;「什么哦吼吼」4 条不再入榜;综合热度榜同样应用 2000 门槛(637 条) | +| 43 | **榜单交互改版** | 排序/时间改为下拉;时间筛选实测 `year=2026` 640 条、`year=2025` 156 条(合计 796);`sort=published` 可用;`/api/overview` 返回 years `[2026,2025]`;`year=1800` 返回 400 | **未验证(需授权或外部配合)**: @@ -867,7 +868,7 @@ P0、P1、P2、P3 与第 9 轮已实施;P2 交付了只读本地看板与可 1. schema v4:`video.pages`、`video.up_follower*`、`up_profile`; 2. 入库时采集 UP 粉丝快照 + `python app.py backfill-followers` 历史回填(基于粉丝数的榜单暂不做); 3. 时长效率榜(内容效率/互动效率)与 UP 内容效率(EB 收缩),过滤 < 600 秒与未满 2 天的视频; -4. 主导航拆分:「榜单」→「视频」+「UP主」,两者独立页面;新增「数据说明」页(`/about` + `/api/about`); +4. 主导航拆分:「榜单」→「视频」+「UP主」,两者独立页面;新增「数据说明」页(`/about` + `/api/about`);排序/时间改为下拉筛选,支持按发布时间排序与按年份过滤; 5. 列表精简:卡片只保留主指标 + 互动率,七项明细与分 P、粉丝快照移到详情页。 仍待处理: diff --git a/reaction_board/web/queries.py b/reaction_board/web/queries.py index 973b968..2141971 100644 --- a/reaction_board/web/queries.py +++ b/reaction_board/web/queries.py @@ -286,6 +286,7 @@ class DashboardQueries: q: str | None = None, within_field: str | None = None, within_start: str | None = None, + within_end: str | None = None, up_mid: int | None = None, ) -> list[dict[str, Any]]: clauses = [f"v.status IN ({','.join('?' for _ in statuses)})"] @@ -302,12 +303,17 @@ class DashboardQueries: "(v.title LIKE ? ESCAPE '\\' OR v.up_name LIKE ? ESCAPE '\\')" ) params.extend([pattern, pattern]) - if within_field and within_start: + if within_field: 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) + if within_start: + start_ts, _ = day_bounds(within_start) + clauses.append(f"v.{within_field} >= ?") + params.append(start_ts) + if within_end: + end_ts, _ = day_bounds(within_end) + clauses.append(f"v.{within_field} < ?") + params.append(end_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 " @@ -490,6 +496,14 @@ class DashboardQueries: cfg = self.config_store.get() fans = self._fans_overview(conn, cfg.track_fans, latest, snapshot_days) folders = self._folders_overview(conn) + pub_years = sorted( + { + int(shanghai_date(int(video["pubdate"]))[:4]) + for video in videos + if int(video.get("pubdate") or 0) > 0 + }, + reverse=True, + ) tasks = self._task_records(conn) auth = { "ok": self._kv(conn, "auth:ok"), @@ -527,6 +541,7 @@ class DashboardQueries: "snapshot_completed_today": snapshot_completed_today, "fans": fans, "folders": folders, + "years": pub_years, "tasks": tasks, "auth": auth, "preview": preview, @@ -650,6 +665,7 @@ class DashboardQueries: *, sort: str = "heat", within_days: int | None = None, + year: int | None = None, growth_days: int = 7, folder_id: int | None = None, q: str | None = None, @@ -659,6 +675,7 @@ class DashboardQueries: params = { "sort": sort, "within_days": within_days, + "year": year, "growth_days": growth_days, "folder_id": folder_id, "q": q or "", @@ -669,6 +686,7 @@ class DashboardQueries: "videos", params, TTL_VIDEOS, lambda: self._list_videos_uncached( sort=sort, within_days=within_days, + year=year, growth_days=growth_days, folder_id=folder_id, q=q, @@ -682,6 +700,7 @@ class DashboardQueries: *, sort: str = "heat", within_days: int | None = None, + year: int | None = None, growth_days: int = 7, folder_id: int | None = None, q: str | None = None, @@ -701,19 +720,24 @@ class DashboardQueries: 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: + within_start: str | None = None + within_end: str | None = None + if year is not None: + within_field = "pubdate" + within_start = f"{int(year)}-01-01" + within_end = f"{int(year) + 1}-01-01" + elif within_days is not None: if within_days < 1: raise ValueError("within_days 必须大于 0") within_field = "first_seen" if sort == "added" else "pubdate" + within_start = _date_minus(latest, int(within_days) - 1) 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 - ), + within_start=within_start, + within_end=within_end, ) aids = [int(video["aid"]) for video in videos] snapshots = self._snapshot_map(conn, aids) @@ -862,6 +886,7 @@ class DashboardQueries: return { "sort": sort, "within_days": within_days, + "year": year, "growth_days": growth_days, "page": page, "page_size": page_size, @@ -973,6 +998,7 @@ class DashboardQueries: *, sort: str = "avg_views", within_days: int | None = None, + year: int | None = None, q: str | None = None, min_videos: int = 1, page: int = 1, @@ -981,6 +1007,7 @@ class DashboardQueries: params = { "sort": sort, "within_days": within_days, + "year": year, "q": q or "", "min_videos": min_videos, "page": page, @@ -990,6 +1017,7 @@ class DashboardQueries: "videos", params, TTL_VIDEOS, lambda: self._list_ups_uncached( sort=sort, within_days=within_days, + year=year, q=q, min_videos=min_videos, page=page, @@ -1002,6 +1030,7 @@ class DashboardQueries: *, sort: str = "avg_views", within_days: int | None = None, + year: int | None = None, q: str | None = None, min_videos: int = 1, page: int = 1, @@ -1015,14 +1044,24 @@ class DashboardQueries: def run(conn: sqlite3.Connection) -> dict[str, Any]: latest = self._latest_date(conn) or self._today() + if year is not None: + within_field = "pubdate" + within_start: str | None = f"{int(year)}-01-01" + within_end: str | None = f"{int(year) + 1}-01-01" + elif within_days is not None: + within_field = "pubdate" + within_start = _date_minus(latest, int(within_days) - 1) + within_end = None + else: + within_field = None + within_start = None + within_end = None 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 - ), + within_field=within_field, + within_start=within_start, + within_end=within_end, ) snapshots = self._snapshot_map(conn, [int(video["aid"]) for video in videos]) groups: dict[int, dict[str, Any]] = {} @@ -1147,6 +1186,7 @@ class DashboardQueries: return { "sort": sort, "within_days": within_days, + "year": year, "min_videos": min_videos, "page": page, "page_size": page_size, diff --git a/reaction_board/web/server.py b/reaction_board/web/server.py index 93de511..996c40f 100644 --- a/reaction_board/web/server.py +++ b/reaction_board/web/server.py @@ -294,6 +294,7 @@ border-color:#3a2c22;color:#f3e9dc}button{background:#e9b97c;color:#2a1a10}} self._send_json(HTTPStatus.OK, queries.list_videos( sort=_param(query, "sort", "heat"), within_days=_optional_int(query, "within_days"), + year=_year_param(query), growth_days=_int_param(query, "growth_days", 7, 1, 365), folder_id=_optional_int(query, "folder"), q=_optional_str(query, "q"), @@ -326,6 +327,7 @@ border-color:#3a2c22;color:#f3e9dc}button{background:#e9b97c;color:#2a1a10}} self._send_json(HTTPStatus.OK, queries.list_ups( sort=_param(query, "sort", "efficiency"), within_days=_optional_int(query, "within_days"), + year=_year_param(query), q=_optional_str(query, "q"), min_videos=_int_param(query, "min_videos", 1, 1, 10000), page=_int_param(query, "page", 1, 1, 1_000_000), @@ -341,6 +343,7 @@ border-color:#3a2c22;color:#f3e9dc}button{background:#e9b97c;color:#2a1a10}} self._send_json(HTTPStatus.OK, queries.list_videos( sort=_param(query, "sort", "heat"), within_days=_optional_int(query, "within_days"), + year=_year_param(query), growth_days=_int_param(query, "growth_days", 7, 1, 365), folder_id=_optional_int(query, "folder"), q=_optional_str(query, "q"), @@ -356,6 +359,7 @@ border-color:#3a2c22;color:#f3e9dc}button{background:#e9b97c;color:#2a1a10}} self._send_json(HTTPStatus.OK, queries.list_ups( sort=_param(query, "sort", "efficiency"), within_days=_optional_int(query, "within_days"), + year=_year_param(query), q=_optional_str(query, "q"), min_videos=_int_param(query, "min_videos", 1, 1, 10000), page=_int_param(query, "page", 1, 1, 1_000_000), @@ -526,6 +530,15 @@ def _optional_int(query: Mapping[str, list[str]], name: str) -> int | None: raise ApiError(HTTPStatus.BAD_REQUEST, "bad_request", f"{name} 必须是整数") from exc +def _year_param(query: Mapping[str, list[str]]) -> int | None: + value = _optional_int(query, "year") + if value is None: + return None + if value < 2000 or value > 2100: + raise ApiError(HTTPStatus.BAD_REQUEST, "bad_request", "year 必须在 2000~2100 之间") + return value + + def _int_param( query: Mapping[str, list[str]], name: str, diff --git a/reaction_board/web/static/style.css b/reaction_board/web/static/style.css index 75cf45c..9bfb798 100644 --- a/reaction_board/web/static/style.css +++ b/reaction_board/web/static/style.css @@ -520,10 +520,20 @@ h1, h2, h3, p { margin: 0; } align-items: center; gap: 8px; } -.rank-search { display: flex; flex: 1; min-width: 0; gap: 6px; } -.rank-search .search-input { flex: 1; min-width: 0; } +.rank-search { display: flex; flex: 0 0 auto; width: 230px; gap: 6px; } +.rank-search .search-input { flex: 1 1 auto; min-width: 0; } +.select-label { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--muted); + font-size: 12.5px; + white-space: nowrap; +} +.rank-toolbar .select { max-width: 220px; width: auto; } @media (max-width: 619px) { - .rank-search { flex-basis: 100%; } + .rank-search { width: 230px; } + .rank-toolbar .select { max-width: 100%; } } .search-input, .select { min-height: 38px; diff --git a/reaction_board/web/static/views/home.js b/reaction_board/web/static/views/home.js index a329c22..c0cf43b 100644 --- a/reaction_board/web/static/views/home.js +++ b/reaction_board/web/static/views/home.js @@ -102,7 +102,7 @@ export async function renderHome(root) {

最近发布

- 查看全部 + 查看全部

按视频发布时间排序,与入库时间无关。

diff --git a/reaction_board/web/static/views/rankings.js b/reaction_board/web/static/views/rankings.js index f2eaa1c..350d76f 100644 --- a/reaction_board/web/static/views/rankings.js +++ b/reaction_board/web/static/views/rankings.js @@ -8,6 +8,8 @@ import { replaceQuery } from "../router.js"; const VIDEO_SORTS = [ ["efficiency", "内容效率"], ["heat", "综合热度"], + ["published", "发布时间"], + ["added", "入库时间"], ["view", "播放"], ["coin", "投币"], ["favorite", "收藏"], @@ -26,6 +28,13 @@ const UP_SORTS = [ ["interaction", "总互动"], ["latest", "最近发布"], ]; +const TIME_RANGES = [ + ["all", "全部时间"], + ["7", "近 7 天"], + ["30", "近 30 天"], + ["90", "近 90 天"], + ["365", "近 1 年"], +]; const VIDEO_METRIC_PRIMARY = new Set(["view", "coin", "favorite", "reply"]); const OVERVIEW_TTL = 60_000; let overviewCache = { at: 0, promise: null }; @@ -43,8 +52,22 @@ async function getOverview() { } } -function controls({ scope, sort, within, growthDays, q, folder, minVideos, folders }) { +function sortOptions(scope, sort) { const sorts = scope === "up" ? UP_SORTS : VIDEO_SORTS; + return sorts.map(([value, label]) => + ``).join(""); +} + +function timeOptions(within, year, years) { + const selected = year ? `year:${year}` : (within || "all"); + const base = TIME_RANGES.map(([value, label]) => + ``).join(""); + const yearOptions = (years || []).map((value) => + ``).join(""); + return base + yearOptions; +} + +function controls({ scope, sort, within, year, growthDays, q, minVideos, years }) { return `
-
-
- ${sorts.map(([value, label]) => chip(label, sort === value, "sort", value)).join("")} -
-
-
- ${[["all", "全部发布时间"], ["7", "近 7 天"], ["30", "近 30 天"], ["90", "近 90 天"]] - .map(([value, label]) => chip(label, String(within || "all") === value, "within", value)).join("")} -
- ${scope === "video" && folders.length ? `` : ""} - ${scope === "up" ? `` : ""} + + + ${scope === "up" ? `` : ""}
${scope === "video" && (sort === "growth" || sort === "growth_rate") ? `
@@ -150,19 +167,23 @@ function durationNote(data) { 发布满 ${filter.maturity_days} 天、播放 ≥ ${filter.min_view} 的视频${shrink};时长缺失的视频不参与排序。
`; } -function bindControls(root, scope) { - root.querySelectorAll("[data-sort]").forEach((button) => button.addEventListener("click", () => { - replaceQuery({ sort: button.dataset.sort, page: null }); - })); - root.querySelectorAll("[data-within]").forEach((button) => button.addEventListener("click", () => { - replaceQuery({ within_days: button.dataset.within === "all" ? null : button.dataset.within, page: null }); - })); +function bindControls(root) { + root.querySelector("#rank-sort")?.addEventListener("change", (event) => { + replaceQuery({ sort: event.target.value, page: null }); + }); + root.querySelector("#rank-time")?.addEventListener("change", (event) => { + const value = event.target.value; + if (value.startsWith("year:")) { + replaceQuery({ year: value.slice(5), within_days: null, page: null }); + } else if (value === "all") { + replaceQuery({ year: null, within_days: null, page: null }); + } else { + replaceQuery({ year: null, within_days: value, page: null }); + } + }); root.querySelectorAll("[data-growth-days]").forEach((button) => button.addEventListener("click", () => { replaceQuery({ growth_days: button.dataset.growthDays, page: null }); })); - root.querySelector("#folder-filter")?.addEventListener("change", (event) => { - replaceQuery({ folder: event.target.value || null, page: null }); - }); root.querySelector("#min-videos")?.addEventListener("change", (event) => { replaceQuery({ min_videos: event.target.value, page: null }); }); @@ -179,23 +200,23 @@ function bindControls(root, scope) { } async function renderScope(root, { query }, scope) { - const defaultSort = "efficiency"; - const sort = query.get("sort") || defaultSort; - const within = query.get("within_days") || "all"; + const sort = query.get("sort") || "efficiency"; + const within = query.get("within_days") || ""; + const year = query.get("year") || ""; const growthDays = query.get("growth_days") || "7"; const q = query.get("q") || ""; - const folder = query.get("folder") || ""; const minVideos = query.get("min_videos") || "1"; const page = Math.max(1, Number(query.get("page") || 1)); const overview = await getOverview(); - const folders = overview.folders || []; + const years = overview.years || []; const params = { sort, page, page_size: 20, - within_days: within === "all" ? null : within, + within_days: within || null, + year: year || null, growth_days: growthDays, q, }; @@ -203,7 +224,7 @@ async function renderScope(root, { query }, scope) { if (scope === "up") { data = await api("/api/ups", { ...params, min_videos: minVideos }); } else { - data = await api("/api/videos", { ...params, folder: folder || null }); + data = await api("/api/videos", params); } const startRank = (data.page - 1) * data.page_size + 1; @@ -214,7 +235,7 @@ async function renderScope(root, { query }, scope) {

${title} 共 ${formatCount(data.total)} ${unit}

- ${controls({ scope, sort, within, growthDays, q, folder, minVideos, folders })} + ${controls({ scope, sort, within, year, growthDays, q, minVideos, years })} ${durationNote(data)} ${data.available === false ? `
${sort.startsWith("growth") ? "增长数据尚不可用" : "榜单筛选后为空"} @@ -224,7 +245,7 @@ async function renderScope(root, { query }, scope) { ${pager(data.page, data.pages)}
`; - bindControls(root, scope); + bindControls(root); } export function renderVideos(root, context) { @@ -233,4 +254,4 @@ export function renderVideos(root, context) { export function renderUps(root, context) { return renderScope(root, context, "up"); -} +} \ No newline at end of file diff --git a/tests/test_web_queries.py b/tests/test_web_queries.py index 36bdbb6..f4b2f7c 100644 --- a/tests/test_web_queries.py +++ b/tests/test_web_queries.py @@ -53,6 +53,7 @@ def test_overview(): assert overview["fans"]["follower"] == 1020 assert overview["fans"]["delta_1d"] == 10 assert overview["preview"] is False + assert overview["years"] == [2026] assert "snapshot" in overview["tasks"] finally: db.close() @@ -419,6 +420,32 @@ def test_duration_efficiency_queries(): db.close() +def test_year_filter_and_published_sort(): + with tempfile.TemporaryDirectory() as tmp: + db, q = setup(tmp) + try: + by_year = q.list_videos(sort="view", year=2026) + assert [item["aid"] for item in by_year["items"]] == [102, 101, 103] + assert by_year["year"] == 2026 + + none = q.list_videos(sort="view", year=2025) + assert none["items"] == [] + assert none["total"] == 0 + + published = q.list_videos(sort="published") + assert [item["aid"] for item in published["items"]] == [103, 101, 102] + + added = q.list_videos(sort="added") + assert added["total"] == 3 + + ups = q.list_ups(sort="efficiency", year=2026) + assert ups["year"] == 2026 and ups["total"] == 2 + ups_none = q.list_ups(sort="views", year=2025) + assert ups_none["total"] == 0 + finally: + db.close() + + def test_about_payload(): with tempfile.TemporaryDirectory() as tmp: db, q = setup(tmp) @@ -454,6 +481,7 @@ def main_test(): test_up_queries() test_query_cache_invalidation() test_duration_efficiency_queries() + test_year_filter_and_published_sort() test_about_payload() test_fans_custom_range_modes() test_fans_candidates_system_and_export() diff --git a/tests/test_web_server.py b/tests/test_web_server.py index 8dba8e2..c56b4cc 100644 --- a/tests/test_web_server.py +++ b/tests/test_web_server.py @@ -111,6 +111,15 @@ def test_readonly_routes_and_static(): items = json.loads(payload)["data"]["items"] assert items and items[0]["aid"] == 102 + status, _, payload = running.json("/api/videos?year=2026") + assert status == 200 + assert payload["data"]["year"] == 2026 + status, _, payload = running.json("/api/videos?year=1800") + assert status == 400 + status, _, payload = running.json("/api/ups?year=2026") + assert status == 200 + assert payload["data"]["year"] == 2026 + status, _, payload = running.request("/api/ups") assert status == 200 assert json.loads(payload)["data"]["sort"] == "efficiency"