funflix-api 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
funflix_api/app.py ADDED
@@ -0,0 +1,87 @@
1
+ """FastAPI 应用装配。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import logging
8
+ from collections.abc import AsyncIterator
9
+ from contextlib import asynccontextmanager
10
+
11
+ from fastapi import FastAPI
12
+ from funflix.base.config import get_settings
13
+ from funflix.base.db import dispose_engine, get_engine
14
+ from sqlalchemy import text
15
+ from starlette.middleware.sessions import SessionMiddleware
16
+
17
+ from funflix_api.v1 import api_router
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ @asynccontextmanager
23
+ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
24
+ settings = get_settings()
25
+ logging.basicConfig(
26
+ level=settings.log_level.upper(),
27
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
28
+ )
29
+ # 启动即探一次库,让配置错误在启动时暴露,而不是等第一个请求打进来才 500
30
+ async with get_engine().connect() as conn:
31
+ await conn.execute(text("SELECT 1"))
32
+ logger.info("funflix 启动完成,数据库=%s", settings.database_url.split("://", 1)[0])
33
+
34
+ task: asyncio.Task[None] | None = None
35
+ stop: asyncio.Event | None = None
36
+ if settings.worker_enabled:
37
+ from funflix.worker import spawn
38
+
39
+ task, stop = spawn(settings)
40
+ logger.info("进程内后台 worker 已启动")
41
+ else:
42
+ logger.info("进程内后台 worker 未启用(FUNFLIX_WORKER_ENABLED=true 开启)")
43
+
44
+ try:
45
+ yield
46
+ finally:
47
+ if task is not None and stop is not None:
48
+ stop.set()
49
+ # 给它一轮的时间收尾。超时就取消 —— 卡住的多半是某次外部调用,
50
+ # 等下去没有意义,任务本身有租约兜底,重启后会被重新领取。
51
+ try:
52
+ await asyncio.wait_for(task, timeout=10)
53
+ except (TimeoutError, asyncio.CancelledError):
54
+ task.cancel()
55
+ with contextlib.suppress(asyncio.CancelledError):
56
+ await task
57
+ await dispose_engine()
58
+
59
+
60
+ def create_app() -> FastAPI:
61
+ settings = get_settings()
62
+ app = FastAPI(
63
+ title=settings.app_name,
64
+ version="0.1.0",
65
+ description="影视资源分享文本的结构化采集、解析与网盘链接校验",
66
+ lifespan=lifespan,
67
+ debug=settings.debug,
68
+ )
69
+ app.add_middleware(
70
+ SessionMiddleware,
71
+ secret_key=settings.session_secret,
72
+ max_age=settings.session_max_age,
73
+ same_site="lax",
74
+ https_only=settings.session_cookie_secure,
75
+ )
76
+ app.include_router(api_router, prefix=settings.api_prefix)
77
+
78
+ @app.get("/healthz", tags=["ops"])
79
+ async def healthz() -> dict[str, str]:
80
+ async with get_engine().connect() as conn:
81
+ await conn.execute(text("SELECT 1"))
82
+ return {"status": "ok"}
83
+
84
+ return app
85
+
86
+
87
+ app = create_app()
funflix_api/cli.py ADDED
@@ -0,0 +1,279 @@
1
+ """funflix-api 命令行入口:API 服务的生命周期管理。
2
+
3
+ 领域逻辑(采集/解析/校验/worker/数据库迁移)由 `funflix` 提供,用 `funflix`
4
+ 自己的 CLI 管理;这里只管 HTTP 服务进程本身的 run/start/stop/restart/status。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import shutil
12
+ import signal
13
+ import subprocess
14
+ import sys
15
+ import time
16
+ from pathlib import Path
17
+ from typing import Annotated, Any, NoReturn
18
+
19
+ import typer
20
+ from funflix.base.config import get_settings
21
+
22
+ from funflix_api import __version__
23
+
24
+ app = typer.Typer(help="funflix-api 服务生命周期", no_args_is_help=True)
25
+
26
+ #: 命令行 / 配置文件都没给端口时的兜底默认值。
27
+ DEFAULT_SERVER_HOST = "127.0.0.1"
28
+ DEFAULT_SERVER_PORT = 18810
29
+ #: `stop`/`restart` 等多久优雅退出才放弃。
30
+ SERVER_STOP_TIMEOUT_SECONDS = 10
31
+
32
+
33
+ def _fail(message: str) -> NoReturn:
34
+ typer.secho(message, fg=typer.colors.RED, err=True)
35
+ raise typer.Exit(1)
36
+
37
+
38
+ def _ok(message: str) -> None:
39
+ typer.secho(message, fg=typer.colors.GREEN)
40
+
41
+
42
+ def _warn(message: str) -> None:
43
+ typer.secho(message, fg=typer.colors.YELLOW)
44
+
45
+
46
+ def _default_server_config_path() -> Path:
47
+ """XDG 约定的默认配置文件路径:
48
+ `${XDG_CONFIG_HOME:-~/.config}/farfarfun/funflix-api/config.toml`。
49
+
50
+ 生产环境直接把配置文件放在这个路径下即可,`funflix-api start` 不用
51
+ 带任何参数;`--config` 仍然可以显式覆盖,开发时常用来指向仓库内的文件。
52
+ """
53
+ xdg_config_home = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
54
+ return Path(xdg_config_home) / "farfarfun" / "funflix-api" / "config.toml"
55
+
56
+
57
+ def _server_state_dir() -> Path:
58
+ """PID/日志统一放在配置目录下,跟 `--config` 默认路径同一棵树管理,
59
+ 不依赖仓库或当前工作目录。"""
60
+ return _default_server_config_path().parent
61
+
62
+
63
+ def _server_pid_file() -> Path:
64
+ return _server_state_dir() / "server.pid"
65
+
66
+
67
+ def _server_log_file() -> Path:
68
+ return _server_state_dir() / "server.log"
69
+
70
+
71
+ def _load_server_config(config: Path | None) -> dict[str, Any]:
72
+ """按扩展名解析 `--config` 指向的文件:`.toml` / `.json` / `.env`。
73
+
74
+ 未显式传 `--config` 时落到 XDG 默认路径;那个路径不存在就是没有配置文件、
75
+ 直接用命令行默认值,不算错误。显式传了但文件不存在才报错。
76
+ """
77
+ path = config or _default_server_config_path()
78
+ if not path.exists():
79
+ if config is not None:
80
+ _fail(f"配置文件不存在:{path}")
81
+ return {}
82
+
83
+ suffix = path.suffix.lower()
84
+ if suffix == ".toml":
85
+ import tomllib
86
+
87
+ data: dict[str, Any] = tomllib.loads(path.read_text(encoding="utf-8"))
88
+ elif suffix == ".json":
89
+ data = json.loads(path.read_text(encoding="utf-8"))
90
+ elif suffix == ".env":
91
+ from dotenv import dotenv_values
92
+
93
+ data = dict(dotenv_values(path))
94
+ else:
95
+ _fail(f"不支持的配置文件格式:{path.suffix}(仅支持 .toml / .json / .env)")
96
+ return {str(key).lower(): value for key, value in data.items() if value is not None}
97
+
98
+
99
+ def _resolve_server_host_port(
100
+ host: str | None, port: int | None, config: Path | None
101
+ ) -> tuple[str, int]:
102
+ file_config = _load_server_config(config)
103
+ resolved_host = host or str(file_config.get("host") or DEFAULT_SERVER_HOST)
104
+ resolved_port = (
105
+ port if port is not None else int(file_config.get("port") or DEFAULT_SERVER_PORT)
106
+ )
107
+ return resolved_host, resolved_port
108
+
109
+
110
+ def _read_server_pid() -> int | None:
111
+ pid_file = _server_pid_file()
112
+ if not pid_file.exists():
113
+ return None
114
+ try:
115
+ pid = int(pid_file.read_text().strip())
116
+ except ValueError:
117
+ return None
118
+ return pid if pid > 1 else None
119
+
120
+
121
+ def _pid_is_live(pid: int) -> bool:
122
+ try:
123
+ os.kill(pid, 0)
124
+ except OSError:
125
+ return False
126
+ return True
127
+
128
+
129
+ def _resolve_cli_executable() -> str:
130
+ """后台启动时重新拉起自己:优先用 PATH 上装好的 `funflix-api`,找不到就退回
131
+ 当前进程的入口脚本——两者对应同一个已安装的包。"""
132
+ return shutil.which("funflix-api") or sys.argv[0]
133
+
134
+
135
+ def _version_callback(value: bool) -> None:
136
+ if value:
137
+ typer.echo(__version__)
138
+ raise typer.Exit()
139
+
140
+
141
+ @app.callback()
142
+ def _main(
143
+ version: Annotated[
144
+ bool,
145
+ typer.Option(
146
+ "--version", callback=_version_callback, is_eager=True, help="打印版本号后退出"
147
+ ),
148
+ ] = False,
149
+ ) -> None:
150
+ """funflix-api:funflix 的 HTTP API 服务进程。"""
151
+
152
+
153
+ @app.command("run")
154
+ def server_run(
155
+ host: Annotated[str | None, typer.Option(help="监听地址,覆盖配置文件")] = None,
156
+ port: Annotated[int | None, typer.Option(help="监听端口,覆盖配置文件")] = None,
157
+ config: Annotated[
158
+ Path | None,
159
+ typer.Option("--config", help="配置文件路径(.toml/.json/.env),缺省用 XDG 默认路径"),
160
+ ] = None,
161
+ reload: Annotated[
162
+ bool, typer.Option(help="代码变更自动重载(开发用,不代表托管生命周期)")
163
+ ] = False,
164
+ ) -> None:
165
+ """前台启动 API 服务,Ctrl-C 停止。
166
+
167
+ 调试、临时跑一下用这个;要后台常驻用 `funflix-api start`——它内部
168
+ 就是拉一个子进程跑这条命令,只是重定向了输出、写了 PID 文件。
169
+ """
170
+ import uvicorn
171
+
172
+ resolved_host, resolved_port = _resolve_server_host_port(host, port, config)
173
+ uvicorn.run(
174
+ "funflix_api.app:app",
175
+ host=resolved_host,
176
+ port=resolved_port,
177
+ reload=reload,
178
+ log_level=get_settings().log_level.lower(),
179
+ )
180
+
181
+
182
+ @app.command("start")
183
+ def server_start(
184
+ host: Annotated[str | None, typer.Option(help="监听地址,覆盖配置文件")] = None,
185
+ port: Annotated[int | None, typer.Option(help="监听端口,覆盖配置文件")] = None,
186
+ config: Annotated[
187
+ Path | None,
188
+ typer.Option("--config", help="配置文件路径(.toml/.json/.env),缺省用 XDG 默认路径"),
189
+ ] = None,
190
+ ) -> None:
191
+ """后台启动 API 服务。
192
+
193
+ 拉一个子进程跑 `run`,PID 写到配置目录下的 `server.pid`,输出
194
+ 重定向到同目录的 `server.log`。停止/重启见 `stop` / `restart`。
195
+ """
196
+ existing = _read_server_pid()
197
+ if existing is not None and _pid_is_live(existing):
198
+ _fail(f"funflix-api 已在运行(pid {existing})")
199
+
200
+ pid_file = _server_pid_file()
201
+ log_file = _server_log_file()
202
+ pid_file.parent.mkdir(parents=True, exist_ok=True)
203
+
204
+ command = [_resolve_cli_executable(), "run"]
205
+ if host is not None:
206
+ command += ["--host", host]
207
+ if port is not None:
208
+ command += ["--port", str(port)]
209
+ if config is not None:
210
+ command += ["--config", str(config)]
211
+
212
+ with log_file.open("ab") as log:
213
+ process = subprocess.Popen(
214
+ command,
215
+ stdin=subprocess.DEVNULL,
216
+ stdout=log,
217
+ stderr=subprocess.STDOUT,
218
+ start_new_session=True,
219
+ )
220
+ pid_file.write_text(f"{process.pid}\n")
221
+
222
+ time.sleep(1)
223
+ if process.poll() is not None:
224
+ pid_file.unlink(missing_ok=True)
225
+ _fail(f"funflix-api 启动失败,看日志:{log_file}")
226
+
227
+ _, resolved_port = _resolve_server_host_port(host, port, config)
228
+ _ok(f"funflix-api 已启动(pid {process.pid},端口 {resolved_port},日志 {log_file})")
229
+
230
+
231
+ @app.command("stop")
232
+ def server_stop() -> None:
233
+ """停止后台运行的 API 服务(发 SIGTERM,等它优雅退出)。"""
234
+ pid_file = _server_pid_file()
235
+ pid = _read_server_pid()
236
+ if pid is None:
237
+ pid_file.unlink(missing_ok=True)
238
+ _warn("funflix-api 未在运行")
239
+ return
240
+ if not _pid_is_live(pid):
241
+ pid_file.unlink(missing_ok=True)
242
+ _warn("PID 文件已失效,已清理")
243
+ return
244
+
245
+ os.kill(pid, signal.SIGTERM)
246
+ deadline = time.monotonic() + SERVER_STOP_TIMEOUT_SECONDS
247
+ while _pid_is_live(pid):
248
+ if time.monotonic() >= deadline:
249
+ _fail(f"funflix-api 在 {SERVER_STOP_TIMEOUT_SECONDS}s 内未退出(pid {pid})")
250
+ time.sleep(0.2)
251
+
252
+ pid_file.unlink(missing_ok=True)
253
+ _ok("funflix-api 已停止")
254
+
255
+
256
+ @app.command("restart")
257
+ def server_restart(
258
+ host: Annotated[str | None, typer.Option(help="监听地址,覆盖配置文件")] = None,
259
+ port: Annotated[int | None, typer.Option(help="监听端口,覆盖配置文件")] = None,
260
+ config: Annotated[
261
+ Path | None,
262
+ typer.Option("--config", help="配置文件路径(.toml/.json/.env),缺省用 XDG 默认路径"),
263
+ ] = None,
264
+ ) -> None:
265
+ """重启:先 `stop`(没在跑也不报错),再 `start`。"""
266
+ server_stop()
267
+ server_start(host=host, port=port, config=config)
268
+
269
+
270
+ @app.command("status")
271
+ def server_status() -> None:
272
+ """查看后台服务是否在跑,以及安装的版本号。"""
273
+ pid = _read_server_pid()
274
+ if pid is not None and _pid_is_live(pid):
275
+ _ok(f"运行中(pid {pid},版本 {__version__})")
276
+ elif _server_pid_file().exists():
277
+ _warn(f"PID 文件失效({_server_pid_file()})")
278
+ else:
279
+ typer.secho(f"已停止(版本 {__version__})", fg=typer.colors.BRIGHT_BLACK)
funflix_api/deps.py ADDED
@@ -0,0 +1,69 @@
1
+ """FastAPI 依赖。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from dataclasses import dataclass
7
+ from typing import Annotated, Any
8
+
9
+ from fastapi import Depends, HTTPException, Query, Request, status
10
+ from funflix.base.config import Settings, get_settings
11
+ from funflix.base.db import get_session
12
+ from funflix.models import User
13
+ from funflix.schemas.common import MAX_PAGE_NUMBER, MAX_PAGE_SIZE
14
+ from sqlalchemy.ext.asyncio import AsyncSession
15
+
16
+ SessionDep = Annotated[AsyncSession, Depends(get_session)]
17
+ SettingsDep = Annotated[Settings, Depends(get_settings)]
18
+
19
+
20
+ @dataclass(slots=True)
21
+ class PageParams:
22
+ """翻页入参。
23
+
24
+ 四个列表接口曾各写一遍 page/size 的声明与 `(page - 1) * size`,
25
+ 上限已经先漂了一次(两处 100、两处 200)。收敛到这里,
26
+ 翻页语义只有一个定义点。
27
+ """
28
+
29
+ page: int
30
+ size: int
31
+
32
+ @property
33
+ def offset(self) -> int:
34
+ return (self.page - 1) * self.size
35
+
36
+
37
+ def page_params(
38
+ page: Annotated[int, Query(ge=1, le=MAX_PAGE_NUMBER, description="页码,从 1 开始")] = 1,
39
+ size: Annotated[int, Query(ge=1, le=MAX_PAGE_SIZE, description="每页条数")] = 20,
40
+ ) -> PageParams:
41
+ return PageParams(page=page, size=size)
42
+
43
+
44
+ PageDep = Annotated[PageParams, Depends(page_params)]
45
+
46
+
47
+ async def get_or_404(session: AsyncSession, model: type[Any], pk: Any, detail: str) -> Any:
48
+ """按主键取,取不到就 404。"""
49
+ row = await session.get(model, pk)
50
+ if row is None:
51
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=detail)
52
+ return row
53
+
54
+
55
+ async def get_current_user(request: Request, session: SessionDep) -> User:
56
+ """「运维」区接口的鉴权:要求已登录会话。"""
57
+ user_id = request.session.get("user_id")
58
+ if not user_id:
59
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="请先登录")
60
+ user = await session.get(User, uuid.UUID(user_id))
61
+ if user is None or not user.is_active:
62
+ request.session.clear()
63
+ raise HTTPException(
64
+ status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已失效,请重新登录"
65
+ )
66
+ return user
67
+
68
+
69
+ CurrentUserDep = Annotated[User, Depends(get_current_user)]
@@ -0,0 +1,15 @@
1
+ """v1 路由聚合。"""
2
+
3
+ from fastapi import APIRouter
4
+
5
+ from funflix_api.v1 import auth, media, raw, resources, sources, stats
6
+
7
+ api_router = APIRouter()
8
+ api_router.include_router(auth.router)
9
+ api_router.include_router(sources.router)
10
+ api_router.include_router(raw.router)
11
+ api_router.include_router(media.router)
12
+ api_router.include_router(resources.router)
13
+ api_router.include_router(stats.router)
14
+
15
+ __all__ = ["api_router"]
funflix_api/v1/auth.py ADDED
@@ -0,0 +1,88 @@
1
+ """登录 / 登出 / 当前用户 / 注册。
2
+
3
+ 「运维」区其余路由都靠 `CurrentUserDep`(见 `api/deps.py`)挡在会话之后,
4
+ 这个模块只负责建立/销毁那个会话。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import uuid
10
+
11
+ from fastapi import APIRouter, HTTPException, Request, status
12
+ from funflix.models import User
13
+ from funflix.security import hash_password, verify_password
14
+ from pydantic import BaseModel, Field
15
+ from sqlalchemy import select
16
+
17
+ from funflix_api.deps import SessionDep, SettingsDep
18
+
19
+ router = APIRouter(prefix="/auth", tags=["auth"])
20
+
21
+
22
+ class LoginPayload(BaseModel):
23
+ username: str
24
+ password: str
25
+
26
+
27
+ class RegisterPayload(BaseModel):
28
+ username: str = Field(min_length=1, max_length=64)
29
+ password: str = Field(min_length=6, max_length=128)
30
+
31
+
32
+ class UserOut(BaseModel):
33
+ id: uuid.UUID
34
+ username: str
35
+
36
+ model_config = {"from_attributes": True}
37
+
38
+
39
+ class AuthConfigOut(BaseModel):
40
+ registration_enabled: bool
41
+
42
+
43
+ @router.get("/config", response_model=AuthConfigOut)
44
+ async def get_auth_config(settings: SettingsDep) -> AuthConfigOut:
45
+ return AuthConfigOut(registration_enabled=settings.registration_enabled)
46
+
47
+
48
+ @router.post("/login", response_model=UserOut)
49
+ async def login(payload: LoginPayload, request: Request, session: SessionDep) -> UserOut:
50
+ user = await session.scalar(select(User).where(User.username == payload.username))
51
+ if (
52
+ user is None
53
+ or not user.is_active
54
+ or not verify_password(payload.password, user.password_hash)
55
+ ):
56
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
57
+ request.session["user_id"] = str(user.id)
58
+ return UserOut.model_validate(user)
59
+
60
+
61
+ @router.post("/logout", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
62
+ async def logout(request: Request) -> None:
63
+ request.session.clear()
64
+
65
+
66
+ @router.get("/me", response_model=UserOut | None)
67
+ async def me(request: Request, session: SessionDep) -> UserOut | None:
68
+ user_id = request.session.get("user_id")
69
+ if not user_id:
70
+ return None
71
+ user = await session.get(User, uuid.UUID(user_id))
72
+ if user is None or not user.is_active:
73
+ request.session.clear()
74
+ return None
75
+ return UserOut.model_validate(user)
76
+
77
+
78
+ @router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
79
+ async def register(payload: RegisterPayload, settings: SettingsDep, session: SessionDep) -> UserOut:
80
+ if not settings.registration_enabled:
81
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="注册入口未开放")
82
+ existing = await session.scalar(select(User).where(User.username == payload.username))
83
+ if existing is not None:
84
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="用户名已被占用")
85
+ user = User(username=payload.username, password_hash=hash_password(payload.password))
86
+ session.add(user)
87
+ await session.commit()
88
+ return UserOut.model_validate(user)
@@ -0,0 +1,103 @@
1
+ """作品查询接口(DESIGN §7.2)。
2
+
3
+ 列表走 `services.search` 的后端抽象:PG 上是 pg_trgm 模糊匹配,
4
+ 其余方言回落 LIKE,调用方无感知。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import uuid
10
+ from typing import Annotated
11
+
12
+ from fastapi import APIRouter, HTTPException, Query, status
13
+ from funflix.base.enums import CheckStatus, MediaType, Provider
14
+ from funflix.models import Media, Resource, media_resource
15
+ from funflix.models.media import UNKNOWN_YEAR
16
+ from funflix.schemas.common import Page
17
+ from funflix.schemas.media import MediaDetail, MediaSummary
18
+ from funflix.services.search import SearchQuery, count_media, search_media
19
+ from sqlalchemy import case, select
20
+ from sqlalchemy.orm import selectinload
21
+ from sqlalchemy.orm.attributes import set_committed_value
22
+
23
+ from funflix_api.deps import PageDep, SessionDep
24
+
25
+ #: 详情页最多返回多少条资源。`resource_count` 仍是真实总数。
26
+ MAX_DETAIL_RESOURCES = 200
27
+
28
+ router = APIRouter(prefix="/media", tags=["media"])
29
+
30
+
31
+ @router.get("", response_model=Page[MediaSummary])
32
+ async def list_media(
33
+ session: SessionDep,
34
+ paging: PageDep,
35
+ keyword: str = Query(
36
+ default="", max_length=128, description="剧名关键词,留空则按入库时间倒序"
37
+ ),
38
+ media_type: MediaType | None = None,
39
+ year: int | None = Query(
40
+ default=None,
41
+ ge=UNKNOWN_YEAR,
42
+ le=2100,
43
+ description=f"年份;传 {UNKNOWN_YEAR} 查年份未知的作品(出参里这些作品的 year 是 null)",
44
+ ),
45
+ valid_only: bool = Query(default=False, description="只要至少有一条校验通过资源的作品"),
46
+ provider: Annotated[
47
+ Provider | None, Query(description="只要至少有一条该网盘资源的作品")
48
+ ] = None,
49
+ ) -> Page[MediaSummary]:
50
+ """搜索 / 浏览作品。"""
51
+ query = SearchQuery(
52
+ keyword=keyword.strip(),
53
+ media_type=media_type,
54
+ year=year,
55
+ valid_only=valid_only,
56
+ provider=provider,
57
+ limit=paging.size,
58
+ offset=paging.offset,
59
+ )
60
+ total = await count_media(session, query)
61
+ rows = await search_media(session, query)
62
+ return Page[MediaSummary](
63
+ items=[MediaSummary.model_validate(r) for r in rows],
64
+ total=total,
65
+ page=paging.page,
66
+ size=paging.size,
67
+ )
68
+
69
+
70
+ @router.get("/{media_id}", response_model=MediaDetail)
71
+ async def get_media(media_id: uuid.UUID, session: SessionDep) -> MediaDetail:
72
+ """作品详情,含网盘资源与标签。
73
+
74
+ 关联对象一律预加载 —— 异步会话下懒加载会在序列化时抛 MissingGreenlet,
75
+ 而不是悄悄多发几条查询。
76
+
77
+ 资源最多返回 `MAX_DETAIL_RESOURCES` 条,且可用的排在前面。热门剧集会被
78
+ 很多频道反复分享,`media_resource` 只增不删,全量返回能到几 MB ——
79
+ 而使用者要的只是「一条能用的链接」。总数看 `resource_count`。
80
+ """
81
+ media = await session.scalar(
82
+ select(Media).where(Media.id == media_id).options(selectinload(Media.tags))
83
+ )
84
+ if media is None:
85
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="作品不存在")
86
+
87
+ rows = list(
88
+ await session.scalars(
89
+ select(Resource)
90
+ .join(media_resource, media_resource.c.resource_id == Resource.id)
91
+ .where(media_resource.c.media_id == media_id)
92
+ # 可用的排前面,其余按入库倒序
93
+ .order_by(
94
+ case((Resource.check_status == CheckStatus.VALID, 0), else_=1),
95
+ Resource.id.desc(),
96
+ )
97
+ .limit(MAX_DETAIL_RESOURCES)
98
+ )
99
+ )
100
+ # 用 set_committed_value 而不是直接赋值:直接给关系属性赋值会被 ORM 当成
101
+ # 「这就是全部关联」,flush 时把没列进来的关联行删掉 —— 截断展示会变成截断数据。
102
+ set_committed_value(media, "resources", rows)
103
+ return MediaDetail.model_validate(media)