diff --git a/backend/src/app/api/v1/assets.py b/backend/src/app/api/v1/assets.py index aad8d3a..914093e 100644 --- a/backend/src/app/api/v1/assets.py +++ b/backend/src/app/api/v1/assets.py @@ -3,6 +3,7 @@ from typing import Optional from fastapi import APIRouter, Query, status +from fastapi.responses import StreamingResponse from app.api.dependencies import CurrentUser, DatabaseSession, S3ClientDep from app.api.schemas import AssetListResponse, AssetResponse, DownloadUrlResponse @@ -106,6 +107,44 @@ async def get_download_url( return DownloadUrlResponse(url=url, expires_in=settings.signed_url_ttl_seconds) +@router.get("/{asset_id}/media") +async def stream_media( + asset_id: str, + current_user: CurrentUser, + session: DatabaseSession, + s3_client: S3ClientDep, + kind: str = Query("original", pattern="^(original|thumb)$"), +): + """ + Stream media file content (proxy from S3). + + Args: + asset_id: Asset ID + current_user: Current authenticated user + session: Database session + s3_client: S3 client + kind: 'original' or 'thumb' + + Returns: + Streaming response with media content + """ + asset_service = AssetService(session, s3_client) + file_stream, content_type, content_length = await asset_service.stream_media( + user_id=current_user.id, + asset_id=asset_id, + kind=kind, + ) + + return StreamingResponse( + file_stream, + media_type=content_type, + headers={ + "Content-Length": str(content_length), + "Cache-Control": "public, max-age=31536000", # 1 year + }, + ) + + @router.delete("/{asset_id}", response_model=AssetResponse) async def delete_asset( asset_id: str, diff --git a/backend/src/app/infra/s3_client.py b/backend/src/app/infra/s3_client.py index e1b7ba1..f508b7a 100644 --- a/backend/src/app/infra/s3_client.py +++ b/backend/src/app/infra/s3_client.py @@ -1,7 +1,7 @@ """S3 client for file storage operations.""" from datetime import datetime, timezone -from typing import Optional +from typing import AsyncIterator, Optional, Tuple import boto3 from botocore.config import Config @@ -126,6 +126,35 @@ class S3Client: except ClientError: pass + async def stream_object( + self, storage_key: str, chunk_size: int = 8192 + ) -> Tuple[AsyncIterator[bytes], int]: + """ + Stream an object from S3. + + Args: + storage_key: S3 object key + chunk_size: Size of chunks to stream + + Returns: + Tuple of (async iterator of bytes, content_length) + """ + response = self.client.get_object(Bucket=self.bucket, Key=storage_key) + content_length = response["ContentLength"] + body = response["Body"] + + async def stream_generator(): + try: + while True: + chunk = body.read(chunk_size) + if not chunk: + break + yield chunk + finally: + body.close() + + return stream_generator(), content_length + def object_exists(self, storage_key: str) -> bool: """ Check if an object exists in S3. diff --git a/backend/src/app/services/asset_service.py b/backend/src/app/services/asset_service.py index 2e35182..8b9ec8a 100644 --- a/backend/src/app/services/asset_service.py +++ b/backend/src/app/services/asset_service.py @@ -1,7 +1,7 @@ """Asset management service.""" import os -from typing import Optional +from typing import AsyncIterator, Optional, Tuple from fastapi import HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession @@ -219,6 +219,42 @@ class AssetService: return self.s3_client.generate_presigned_url(storage_key) + async def stream_media( + self, user_id: str, asset_id: str, kind: str = "original" + ) -> Tuple[AsyncIterator[bytes], str, int]: + """ + Stream media file content from S3. + + Args: + user_id: User ID + asset_id: Asset ID + kind: 'original' or 'thumb' + + Returns: + Tuple of (file_stream, content_type, content_length) + + Raises: + HTTPException: If asset not found or not authorized + """ + asset = await self.get_asset(user_id, asset_id) + + if kind == "thumb": + storage_key = asset.storage_key_thumb + content_type = "image/jpeg" # thumbnails are always JPEG + if not storage_key: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Thumbnail not available", + ) + else: + storage_key = asset.storage_key_original + content_type = asset.content_type + + # Stream file from S3 + file_stream, content_length = await self.s3_client.stream_object(storage_key) + + return file_stream, content_type, content_length + async def delete_asset(self, user_id: str, asset_id: str) -> Asset: """ Soft delete an asset. diff --git a/frontend/src/components/MediaCard.tsx b/frontend/src/components/MediaCard.tsx index f979af9..3622d59 100644 --- a/frontend/src/components/MediaCard.tsx +++ b/frontend/src/components/MediaCard.tsx @@ -36,13 +36,13 @@ export default function MediaCard({ asset, selected, onSelect, onClick }: MediaC try { setLoading(true); setError(false); - // Try to get thumbnail first, fallback to original for photos - const url = asset.storage_key_thumb - ? await api.getDownloadUrl(asset.id, 'thumb') - : asset.type === 'photo' - ? await api.getDownloadUrl(asset.id, 'original') - : ''; - setThumbnailUrl(url); + // Load media through backend proxy with auth + const kind = asset.storage_key_thumb ? 'thumb' : 'original'; + if (asset.type === 'photo' || asset.storage_key_thumb) { + const blob = await api.getMediaBlob(asset.id, kind); + const url = URL.createObjectURL(blob); + setThumbnailUrl(url); + } } catch (err) { console.error('Failed to load thumbnail:', err); setError(true); diff --git a/frontend/src/components/ViewerModal.tsx b/frontend/src/components/ViewerModal.tsx index 3ffb1ff..7a74a7a 100644 --- a/frontend/src/components/ViewerModal.tsx +++ b/frontend/src/components/ViewerModal.tsx @@ -42,6 +42,13 @@ export default function ViewerModal({ setCurrentIndex(index); loadMedia(asset); } + + // Cleanup blob URL on unmount or asset change + return () => { + if (currentUrl) { + URL.revokeObjectURL(currentUrl); + } + }; }, [asset]); useEffect(() => { @@ -72,7 +79,13 @@ export default function ViewerModal({ const loadMedia = async (asset: Asset) => { try { setLoading(true); - const url = await api.getDownloadUrl(asset.id, 'original'); + // Revoke previous blob URL to prevent memory leaks + if (currentUrl) { + URL.revokeObjectURL(currentUrl); + } + // Load media through backend proxy with auth + const blob = await api.getMediaBlob(asset.id, 'original'); + const url = URL.createObjectURL(blob); setCurrentUrl(url); } catch (error) { console.error('Failed to load media:', error); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 83e3462..3acd30f 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -94,6 +94,14 @@ class ApiClient { return data.url; } + async getMediaBlob(assetId: string, kind: 'original' | 'thumb' = 'original'): Promise { + const response = await this.client.get(`/assets/${assetId}/media`, { + params: { kind }, + responseType: 'blob', + }); + return response.data; + } + async deleteAsset(assetId: string): Promise { const { data } = await this.client.delete(`/assets/${assetId}`); return data;