7b49f94dcb
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>
55 lines
2.0 KiB
Python
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)
|