Files
yunke/yunke-server/app/api/routers/media.py
T
浅念 7b49f94dcb feat: media downloader + serve api
M2 Task 2.6 (media downloader) + Task 2.8 (media serve API).

Downloader (app/scraper/media.py):
- download_url(client, url): fetches bytes via the YunkeClient's underlying
  httpx client (carries session cookies; respx-mockable; follows redirects).
- download_message_media(client, msg, store, session): URL-driven fetcher that
  picks the best http(s) URL (file_url -> file_hd_url -> file_th_url), dedups
  by md5 (skips the network when a Media row with local file already exists),
  falls back to sha256(bytes) when no md5, stores via ObjectStore.put_by_hash,
  and upserts a Media row. Skips type-10 system messages (non-media per
  phase0-findings §2) and type-3 opaque-id / text (no http url). Resilient:
  download errors are logged and return None (no raise).
- media_type_from_message / guess_mime_type helpers.

Repo (app/storage/repos.py): upsert_media — idempotent Core upsert into Media by
media_hash. Volatile download artifacts (local_path/size_bytes/mime_type) use
COALESCE(incoming, existing) on conflict so a re-scrape that did not re-download
does not clobber previously stored values. Exported from app.storage.

Serve API (app/api/routers/media.py): GET /api/media/{media_hash} streams the
local file via FileResponse (handles HTTP Range / 206). 404 when the row is
absent, has no local file, or the file is missing on disk. Content-type from the
row's mime_type, falling back to ext-derived guess.

Tests (+19, suite 130 -> 149, all green; ruff clean):
- test_media_downloader.py: image/video download + upsert; md5 dedup skips
  second network hit; sha256 fallback when no md5; type-3/text/system -> None;
  download failure -> None (no raise); upsert_media idempotency +
  null-local_path preservation + 3x-same-key-one-row.
- test_api_media.py: 200 + content-type + bytes; 404 unknown hash; 404 missing
  file on disk; Range -> 206 partial; ext-derived content-type when mime NULL.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 14:13:50 +08:00

55 lines
2.0 KiB
Python

"""GET /api/media/{media_hash} — stream a cached media blob (Task 2.8).
Looks up the :class:`app.db.models.Media` row keyed by ``media_hash`` and
streams the local file with the correct ``Content-Type``. HTTP Range requests
are honored (Starlette's :class:`fastapi.responses.FileResponse` handles
``Range`` / ``206 Partial Content`` natively). Returns ``404`` when the row is
absent, has no local file recorded, or the underlying file is missing on disk.
The endpoint reads the absolute ``local_path`` straight off the row (the
downloader stores it absolute), so it does not need to know the
:class:`ObjectStore` layout — only that the file exists where the row points.
"""
from __future__ import annotations
from pathlib import Path
from typing import Annotated
import sqlalchemy as sa
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_session
from app.db.models import Media
from app.scraper.media import guess_mime_type
router = APIRouter(prefix="/api", tags=["media"])
SessionDep = Annotated[AsyncSession, Depends(get_session)]
@router.get("/media/{media_hash}")
async def get_media(media_hash: str, session: SessionDep) -> FileResponse:
"""Stream the cached media blob for ``media_hash``."""
row = (
await session.execute(
sa.select(Media).where(Media.media_hash == media_hash)
)
).scalar_one_or_none()
if row is None or not row.local_path:
raise HTTPException(status_code=404, detail="media not found")
path = Path(row.local_path)
if not path.is_file():
# Row exists but the blob was removed out-of-band — treat as not-found.
raise HTTPException(status_code=404, detail="media file missing")
media_type = (
row.mime_type
or guess_mime_type(path.suffix)
or "application/octet-stream"
)
return FileResponse(path, media_type=media_type, filename=path.name)