"""Media service. Bytes go to the ObjectStore; a metadata row goes to the DB. Writes require editor rights; reads go through the privacy engine.""" import hashlib import uuid from datetime import UTC, datetime from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.integrations.objectstore.base import ObjectStore from app.models.media import Media from app.models.tree import Tree from app.models.user import User from app.services import privacy from app.services.audit import record_audit from app.services.exceptions import Forbidden, NotFound async def upload_media( session: AsyncSession, store: ObjectStore, *, actor: User, tree: Tree, data: bytes, filename: str, content_type: str, title: str | None = None, person_id: uuid.UUID | None = None, event_id: uuid.UUID | None = None, source_id: uuid.UUID | None = None, ) -> Media: if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree): raise Forbidden("not an editor of this tree") media_id = uuid.uuid4() key = f"{tree.id}/{media_id}/{filename}" await store.ensure_bucket() await store.put_object(key=key, data=data, content_type=content_type) media = Media( id=media_id, tree_id=tree.id, uploader_id=actor.id, storage_key=key, original_filename=filename, content_type=content_type, byte_size=len(data), checksum_sha256=hashlib.sha256(data).hexdigest(), title=title, person_id=person_id, event_id=event_id, source_id=source_id, ) session.add(media) await session.flush() record_audit( session, action="create", entity_type="Media", entity_id=media.id, tree_id=tree.id, actor_user_id=actor.id, after={"filename": filename, "bytes": len(data)}, ) await session.commit() await session.refresh(media) return media async def list_media(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree) -> list[Media]: if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree): raise Forbidden("not permitted to view this tree") stmt = ( select(Media) .where(Media.tree_id == tree.id, Media.deleted_at.is_(None)) .order_by(Media.created_at.desc()) ) return list((await session.execute(stmt)).scalars().all()) async def delete_media( session: AsyncSession, *, actor: User, tree: Tree, media_id: uuid.UUID ) -> None: if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree): raise Forbidden("not an editor of this tree") media = ( await session.execute( select(Media).where( Media.id == media_id, Media.tree_id == tree.id, Media.deleted_at.is_(None) ) ) ).scalar_one_or_none() if media is None: raise NotFound("media not found") # Soft delete the row; the object is removed by the worker's purge job. media.deleted_at = datetime.now(UTC) record_audit( session, action="delete", entity_type="Media", entity_id=media.id, tree_id=tree.id, actor_user_id=actor.id, ) await session.commit()