"""The image bank: libraries, tags, and per-library access. Images are stored through `storage_service`, so the bytes live in MinIO when the backend is set to s3 and on the volume otherwise. A row stores the key, never a URL, because a URL embeds the backend and would break the moment it changed. """ import logging import uuid from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile from pydantic import BaseModel from sqlalchemy import func, or_, text as sa_text from sqlalchemy.orm import Session from app.database import get_db from app.models.media import MediaAsset, MediaLibrary, MediaLibraryGrant, MediaTagLink from app.models.user import User from app.services import embedding_service, storage_service from app.models.question_media import QuestionMedia from app.services.search_service import hybrid_ids from app.utils.auth import get_current_user, require_moderator router = APIRouter() log = logging.getLogger(__name__) MAX_IMAGE_BYTES = 12 * 1024 * 1024 # A murmur is thirty seconds of audio and a bedside clip is a few megabytes, so # they get their own ceiling rather than being squeezed under the image one. MAX_MEDIA_BYTES = 60 * 1024 * 1024 IMAGE_TYPES = {"image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml"} # Heart sounds are the reason this exists: a murmur cannot be shown as a picture. AUDIO_TYPES = {"audio/mpeg", "audio/mp3", "audio/wav", "audio/x-wav", "audio/ogg", "audio/webm", "audio/mp4", "audio/aac"} VIDEO_TYPES = {"video/mp4", "video/webm", "video/ogg", "video/quicktime"} ALLOWED_TYPES = IMAGE_TYPES | AUDIO_TYPES | VIDEO_TYPES def kind_for(content_type: str) -> str: """Which of the three a file is. The player differs for each.""" if content_type in AUDIO_TYPES: return "audio" if content_type in VIDEO_TYPES: return "video" return "image" def limit_for(kind: str) -> int: return MAX_IMAGE_BYTES if kind == "image" else MAX_MEDIA_BYTES def readable_libraries(db: Session, user: User) -> set[int] | None: """Library ids this user may use; None means every one of them.""" if user.is_moderator: return None return {row[0] for row in db.query(MediaLibraryGrant.library_id).filter( MediaLibraryGrant.user_id == user.id).all()} def assert_can_use(scope: set[int] | None, library_id: int | None) -> None: if scope is None: return if library_id is None or library_id not in scope: raise HTTPException(403, "You do not have access to that image library") def _asset_json(asset: MediaAsset, tags: list[str]) -> dict: return { "id": asset.id, "path": asset.path, "title": asset.title, "caption": asset.caption, "alt_text": asset.alt_text, "source": asset.source, "source_url": asset.source_url, "overlay": asset.overlay, "kind": asset.kind, "library_id": asset.library_id, "category_id": asset.category_id, "byte_size": asset.byte_size, "storage": asset.storage, "tags": tags, # The editor shows this on hover so an image can be referenced by id. "url": f"/uploads/{asset.path}", } def _tags_for(db: Session, asset_ids: list[int]) -> dict[int, list[str]]: if not asset_ids: return {} # Bound parameters rather than ANY(), which is Postgres-only. placeholders = ", ".join(f":id{i}" for i in range(len(asset_ids))) rows = db.execute(sa_text(f""" SELECT l.media_id, t.name FROM media_tag_links l JOIN question_tags t ON t.id = l.tag_id WHERE l.media_id IN ({placeholders}) """), {f"id{i}": value for i, value in enumerate(asset_ids)}).fetchall() out: dict[int, list[str]] = {} for media_id, name in rows: out.setdefault(media_id, []).append(name) return out @router.get("/libraries") def list_libraries(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): """Libraries this user may use, with how many images each holds.""" scope = readable_libraries(db, current_user) counts = dict(db.query(MediaAsset.library_id, func.count(MediaAsset.id)) .group_by(MediaAsset.library_id).all()) query = db.query(MediaLibrary).order_by(MediaLibrary.name) if scope is not None: query = query.filter(MediaLibrary.id.in_(scope or {0})) return [{"id": lib.id, "name": lib.name, "description": lib.description, "image_count": counts.get(lib.id, 0)} for lib in query.all()] class LibraryIn(BaseModel): name: str description: str | None = None @router.post("/libraries", status_code=201) def create_library(data: LibraryIn, db: Session = Depends(get_db), current_user: User = Depends(require_moderator)): if db.query(MediaLibrary.id).filter(func.lower(MediaLibrary.name) == data.name.strip().lower()).first(): raise HTTPException(409, "A library with that name already exists") library = MediaLibrary(name=data.name.strip(), description=data.description, user_id=None) db.add(library) db.commit() db.refresh(library) return {"id": library.id, "name": library.name} class LibraryGrantIn(BaseModel): user_id: int @router.post("/libraries/{library_id}/grants", status_code=201) def grant_library(library_id: int, data: LibraryGrantIn, db: Session = Depends(get_db), current_user: User = Depends(require_moderator)): if not db.get(MediaLibrary, library_id): raise HTTPException(404, "Library not found") if not db.get(User, data.user_id): raise HTTPException(404, "User not found") if db.query(MediaLibraryGrant.id).filter_by(library_id=library_id, user_id=data.user_id).first(): raise HTTPException(409, "That user already has access to this library") db.add(MediaLibraryGrant(library_id=library_id, user_id=data.user_id, granted_by=current_user.id)) db.commit() return {"library_id": library_id, "user_id": data.user_id} @router.delete("/libraries/{library_id}/grants/{user_id}", status_code=204) def revoke_library(library_id: int, user_id: int, db: Session = Depends(get_db), current_user: User = Depends(require_moderator)): grant = db.query(MediaLibraryGrant).filter_by(library_id=library_id, user_id=user_id).first() if not grant: raise HTTPException(404, "Grant not found") db.delete(grant) db.commit() @router.get("/") def list_media( q: str | None = Query(None), library_id: int | None = Query(None), limit: int = Query(60, le=200), offset: int = Query(0), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Browse the image bank. `q` searches captions, alt text and titles.""" scope = readable_libraries(db, current_user) query = db.query(MediaAsset) if scope is not None: query = query.filter(MediaAsset.library_id.in_(scope or {0})) if library_id is not None: assert_can_use(scope, library_id) query = query.filter(MediaAsset.library_id == library_id) if q and q.strip(): ranked, _ = hybrid_ids(db, q.strip(), "media", limit=200) if not ranked: return {"total": 0, "images": []} query = query.filter(MediaAsset.id.in_(ranked)) total = query.count() assets = query.order_by(MediaAsset.id.desc()).offset(offset).limit(limit).all() tags = _tags_for(db, [a.id for a in assets]) # How many questions point at each one. Renaming and moving are safe because # the link is the id; deleting is the one act that cannot be undone, so the # count travels with the row and is shown before anyone presses delete. used = dict(db.query(QuestionMedia.media_id, func.count(QuestionMedia.id)).filter( QuestionMedia.media_id.in_([a.id for a in assets] or [0]) ).group_by(QuestionMedia.media_id).all()) if assets else {} return {"total": total, "images": [ {**_asset_json(a, tags.get(a.id, [])), "used_by": used.get(a.id, 0)} for a in assets]} @router.get("/by-path") def media_by_path(path: str = Query(...), db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): """What is known about the image at this path: label, description, source. The reader's viewer asks for this when a figure is opened, not when the page is drawn — a page of prose with six figures in it should cost six requests only if somebody opens all six. Answers 404 rather than 403 for an image in a library this person cannot use: whether a private library holds a given filename is not a question this endpoint should answer. """ cleaned = (path or "").strip().lstrip("/") if cleaned.startswith("uploads/"): cleaned = cleaned[len("uploads/"):] asset = db.query(MediaAsset).filter(MediaAsset.path == cleaned).first() if not asset: raise HTTPException(404, "Image not found") scope = readable_libraries(db, current_user) if scope is not None and asset.library_id is not None and asset.library_id not in scope: raise HTTPException(404, "Image not found") return _asset_json(asset, _tags_for(db, [asset.id]).get(asset.id, [])) @router.post("/upload", status_code=201) def upload_media( file: UploadFile = File(...), library_id: int | None = Form(None), title: str | None = Form(None), caption: str | None = Form(None), alt_text: str | None = Form(None), source: str | None = Form(None), source_url: str | None = Form(None), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Store an image and index it so it can be found by what it shows.""" scope = readable_libraries(db, current_user) if library_id is not None: assert_can_use(scope, library_id) elif scope is not None: raise HTTPException(400, "Choose one of your libraries for this image") if file.content_type not in ALLOWED_TYPES: raise HTTPException(400, "Upload an image (PNG, JPEG, GIF, WebP, SVG), " "audio (MP3, WAV, OGG, M4A) or video (MP4, WebM, MOV)") kind = kind_for(file.content_type) ceiling = limit_for(kind) data = file.file.read(ceiling + 1) if len(data) > ceiling: raise HTTPException(413, f"Keep {kind} under {ceiling // (1024 * 1024)} MB") if not data: raise HTTPException(400, "That file is empty") suffix = (file.filename or "").rsplit(".", 1)[-1].lower()[:8] or "bin" key = f"media/{uuid.uuid4().hex}.{suffix}" storage_service.save(key, data, file.content_type) asset = MediaAsset( path=key, title=title or file.filename, caption=caption, alt_text=alt_text, kind=kind, library_id=library_id, user_id=None, storage="s3" if storage_service.using_s3() else "local", byte_size=len(data), ) db.add(asset) db.commit() db.refresh(asset) # Text today; a vision model can embed the image itself later. try: if embedding_service.embed_record(asset, "media"): db.commit() except Exception: db.rollback() log.warning("Could not embed media %s; the retry task will", asset.id, exc_info=True) return _asset_json(asset, []) class MediaUpdate(BaseModel): title: str | None = None caption: str | None = None alt_text: str | None = None source: str | None = None source_url: str | None = None #: Vector shapes in normalised coordinates; see docs/image-overlays.md. overlay: dict | None = None library_id: int | None = None category_id: int | None = None tags: list[str] | None = None @router.patch("/{media_id}") def update_media(media_id: int, data: MediaUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): """Edit an image's description and tags, and move it between libraries.""" scope = readable_libraries(db, current_user) asset = db.get(MediaAsset, media_id) if not asset: raise HTTPException(404, "Image not found") assert_can_use(scope, asset.library_id) values = data.model_dump(exclude_unset=True) tags = values.pop("tags", None) if "library_id" in values: assert_can_use(scope, values["library_id"]) for field, value in values.items(): setattr(asset, field, value) if tags is not None: db.query(MediaTagLink).filter(MediaTagLink.media_id == asset.id).delete(synchronize_session=False) for name in dict.fromkeys(t.strip() for t in tags if t.strip()): # Reuse the shared vocabulary rather than inventing a media-only one. row = db.execute(sa_text( "SELECT id FROM question_tags WHERE lower(name) = lower(:n) ORDER BY id LIMIT 1" ), {"n": name}).first() tag_id = row[0] if row else db.execute(sa_text( "INSERT INTO question_tags (name, type) VALUES (:n, 'keyword') RETURNING id" ), {"n": name}).scalar() db.add(MediaTagLink(media_id=asset.id, tag_id=tag_id)) db.commit() db.refresh(asset) try: if embedding_service.embed_record(asset, "media"): db.commit() except Exception: db.rollback() return _asset_json(asset, _tags_for(db, [asset.id]).get(asset.id, [])) @router.delete("/{media_id}", status_code=204) def delete_media(media_id: int, force: bool = False, db: Session = Depends(get_db), current_user: User = Depends(require_moderator)): """Delete an image. Refused while a question still uses it, unless forced. A question refers to a figure by id, so renaming it or moving it between libraries never breaks anything. Deleting does, and silently — the cascade would take the link with it and the question would simply stop having a picture. So the count has to be faced first. """ used = db.query(QuestionMedia).filter(QuestionMedia.media_id == media_id).count() if used and not force: raise HTTPException(409, f"{used} question{'s' if used > 1 else ''} still use this. " "Detach it there first, or delete it anyway.") asset = db.get(MediaAsset, media_id) if not asset: raise HTTPException(404, "Image not found") storage_service.delete(asset.path) db.query(MediaTagLink).filter(MediaTagLink.media_id == asset.id).delete(synchronize_session=False) db.delete(asset) db.commit()