itcloud/backend/src/app/services/asset_service.py

370 lines
12 KiB
Python
Raw Normal View History

2025-12-30 13:35:19 +01:00
"""Asset management service."""
import os
2025-12-30 15:53:16 +01:00
from typing import AsyncIterator, Optional, Tuple
2025-12-30 13:35:19 +01:00
2025-12-30 22:19:33 +01:00
import redis
2025-12-30 16:02:50 +01:00
from botocore.exceptions import ClientError
from fastapi import HTTPException, UploadFile, status
2025-12-30 22:19:33 +01:00
from loguru import logger
from rq import Queue, Retry
2025-12-30 13:35:19 +01:00
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.models import Asset, AssetStatus, AssetType
2025-12-30 22:19:33 +01:00
from app.infra.config import get_settings
2025-12-30 13:35:19 +01:00
from app.infra.s3_client import S3Client
from app.repositories.asset_repository import AssetRepository
2025-12-30 22:19:33 +01:00
settings = get_settings()
2025-12-30 13:35:19 +01:00
class AssetService:
"""Service for asset management operations."""
def __init__(self, session: AsyncSession, s3_client: S3Client):
"""
Initialize asset service.
Args:
session: Database session
s3_client: S3 client instance
"""
self.asset_repo = AssetRepository(session)
self.s3_client = s3_client
def _get_asset_type(self, content_type: str) -> AssetType:
"""Determine asset type from content type."""
if content_type.startswith("image/"):
return AssetType.PHOTO
elif content_type.startswith("video/"):
return AssetType.VIDEO
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Unsupported content type",
)
async def create_upload(
self,
user_id: str,
original_filename: str,
content_type: str,
size_bytes: int,
2025-12-31 01:53:37 +01:00
folder_id: Optional[str] = None,
2025-12-30 13:35:19 +01:00
) -> tuple[Asset, dict]:
"""
Create an asset and generate pre-signed upload URL.
Args:
user_id: Owner user ID
original_filename: Original filename
content_type: MIME type
size_bytes: File size in bytes
2025-12-31 01:53:37 +01:00
folder_id: Optional folder ID to upload to
2025-12-30 13:35:19 +01:00
Returns:
Tuple of (asset, presigned_post_data)
"""
asset_type = self._get_asset_type(content_type)
_, ext = os.path.splitext(original_filename)
# Create asset record
asset = await self.asset_repo.create(
user_id=user_id,
asset_type=asset_type,
original_filename=original_filename,
content_type=content_type,
size_bytes=size_bytes,
storage_key_original="", # Will be set after upload
2025-12-31 01:53:37 +01:00
folder_id=folder_id,
2025-12-30 13:35:19 +01:00
)
# Generate storage key
storage_key = self.s3_client.generate_storage_key(
user_id=user_id,
asset_id=asset.id,
prefix="o",
extension=ext,
)
# Update asset with storage key
asset.storage_key_original = storage_key
await self.asset_repo.update(asset)
# Generate pre-signed POST
presigned_post = self.s3_client.generate_presigned_post(
storage_key=storage_key,
content_type=content_type,
max_size=size_bytes,
)
return asset, presigned_post
2025-12-30 16:02:50 +01:00
async def upload_file_to_s3(
self,
user_id: str,
asset_id: str,
file: UploadFile,
) -> None:
"""
Upload file content to S3 through backend.
Args:
user_id: User ID
asset_id: Asset ID
file: File to upload
Raises:
HTTPException: If asset not found or not authorized
"""
# Get asset and verify ownership
asset = await self.asset_repo.get_by_id(asset_id)
if not asset or asset.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Asset not found",
)
if not asset.storage_key_original:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Asset has no storage key",
)
# Upload file to S3
try:
content = await file.read()
self.s3_client.put_object(
storage_key=asset.storage_key_original,
file_data=content,
content_type=asset.content_type,
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to upload file: {str(e)}",
)
2025-12-30 13:35:19 +01:00
async def finalize_upload(
self,
user_id: str,
asset_id: str,
etag: Optional[str] = None,
sha256: Optional[str] = None,
) -> Asset:
"""
Finalize upload and mark asset as ready.
2025-12-30 22:19:33 +01:00
Enqueues background task for thumbnail generation.
2025-12-30 13:35:19 +01:00
Args:
user_id: User ID
asset_id: Asset ID
etag: Optional S3 ETag
sha256: Optional file SHA256 hash
Returns:
Updated asset
Raises:
HTTPException: If asset not found or not authorized
"""
asset = await self.asset_repo.get_by_id(asset_id)
if not asset or asset.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Asset not found",
)
# Verify file was uploaded
if not self.s3_client.object_exists(asset.storage_key_original):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File not found in storage",
)
asset.status = AssetStatus.READY
if sha256:
asset.sha256 = sha256
await self.asset_repo.update(asset)
2025-12-30 22:19:33 +01:00
# Enqueue thumbnail generation task (background processing with retry)
try:
redis_conn = redis.from_url(settings.redis_url)
thumbnail_queue = Queue("thumbnails", connection=redis_conn)
job = thumbnail_queue.enqueue(
"app.tasks.thumbnail_tasks.generate_thumbnail_task",
asset_id,
job_timeout="5m", # 5 minutes timeout per attempt
retry=Retry(max=3, interval=[30, 120, 300]), # 3 retries: 30s, 2m, 5m
failure_ttl=86400, # Keep failed jobs for 24 hours
result_ttl=3600, # Keep results for 1 hour
)
logger.info(
f"Enqueued thumbnail job {job.id} for asset {asset_id} "
f"(max 3 retries with backoff)"
)
except Exception as e:
# Log error but don't fail the request - thumbnail is not critical
logger.error(f"Failed to enqueue thumbnail task for asset {asset_id}: {e}")
2025-12-30 13:35:19 +01:00
return asset
async def list_assets(
self,
user_id: str,
limit: int = 50,
cursor: Optional[str] = None,
asset_type: Optional[AssetType] = None,
2025-12-30 23:18:13 +01:00
folder_id: Optional[str] = None,
2025-12-30 13:35:19 +01:00
) -> tuple[list[Asset], Optional[str], bool]:
"""
List user's assets.
Args:
user_id: User ID
limit: Maximum number of results
cursor: Pagination cursor
asset_type: Filter by asset type
2025-12-30 23:18:13 +01:00
folder_id: Filter by folder (None for root)
2025-12-30 13:35:19 +01:00
Returns:
Tuple of (assets, next_cursor, has_more)
"""
assets = await self.asset_repo.list_by_user(
user_id=user_id,
limit=limit + 1, # Fetch one more to check if there are more
cursor=cursor,
asset_type=asset_type,
2025-12-30 23:18:13 +01:00
folder_id=folder_id,
2025-12-30 13:35:19 +01:00
)
has_more = len(assets) > limit
if has_more:
assets = assets[:limit]
next_cursor = assets[-1].id if has_more and assets else None
return assets, next_cursor, has_more
async def get_asset(self, user_id: str, asset_id: str) -> Asset:
"""
Get asset by ID.
Args:
user_id: User ID
asset_id: Asset ID
Returns:
Asset instance
Raises:
HTTPException: If asset not found or not authorized
"""
asset = await self.asset_repo.get_by_id(asset_id)
if not asset or asset.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Asset not found",
)
return asset
async def get_download_url(
self, user_id: str, asset_id: str, kind: str = "original"
) -> str:
"""
Get pre-signed download URL for an asset.
Args:
user_id: User ID
asset_id: Asset ID
kind: 'original' or 'thumb'
Returns:
Pre-signed download URL
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
if not storage_key:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Thumbnail not available",
)
else:
storage_key = asset.storage_key_original
return self.s3_client.generate_presigned_url(storage_key)
2025-12-30 15:53:16 +01:00
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
2025-12-30 16:02:50 +01:00
try:
file_stream, content_length = await self.s3_client.stream_object(storage_key)
except ClientError as e:
error_code = e.response.get("Error", {}).get("Code", "")
if error_code == "NoSuchKey":
2025-12-30 16:03:49 +01:00
# File not found in S3 - delete asset from database
await self.asset_repo.delete(asset)
2025-12-30 16:02:50 +01:00
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Media file not found in storage",
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to retrieve media from storage: {error_code}",
)
2025-12-30 15:53:16 +01:00
return file_stream, content_type, content_length
2025-12-30 20:26:52 +01:00
async def delete_asset(self, user_id: str, asset_id: str) -> None:
2025-12-30 13:35:19 +01:00
"""
2025-12-30 20:26:52 +01:00
Delete an asset permanently (move files to trash bucket, delete from DB).
2025-12-30 13:35:19 +01:00
Args:
user_id: User ID
asset_id: Asset ID
"""
asset = await self.get_asset(user_id, asset_id)
2025-12-30 20:26:52 +01:00
# Move files to trash bucket in S3
self.s3_client.move_to_trash(asset.storage_key_original)
2025-12-30 13:35:19 +01:00
if asset.storage_key_thumb:
2025-12-30 20:26:52 +01:00
self.s3_client.move_to_trash(asset.storage_key_thumb)
2025-12-30 13:35:19 +01:00
# Delete from database
await self.asset_repo.delete(asset)