""" Recycle Bin Router Handles all recycle bin operations: - List deleted files - Recycle bin statistics - Restore files - Permanently delete files - Empty recycle bin - Serve files for preview - Get file metadata """ import hashlib import json import mimetypes import sqlite3 from typing import Dict, Optional from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, Body, Query, Request from fastapi.responses import FileResponse, Response from slowapi import Limiter from slowapi.util import get_remote_address from ..core.dependencies import get_current_user, get_current_user_media, require_admin, get_app_state from ..core.config import settings from ..core.exceptions import ( handle_exceptions, DatabaseError, RecordNotFoundError, MediaFileNotFoundError as CustomFileNotFoundError, FileOperationError ) from ..core.responses import now_iso8601 from ..core.utils import ThumbnailLRUCache from modules.universal_logger import get_logger logger = get_logger('API') router = APIRouter(prefix="/api/recycle", tags=["Recycle Bin"]) limiter = Limiter(key_func=get_remote_address) # Global thumbnail memory cache for recycle bin (500 items or 100MB max) # Using shared ThumbnailLRUCache from core/utils.py _thumbnail_cache = ThumbnailLRUCache(max_size=500, max_memory_mb=100) @router.get("/list") @limiter.limit("100/minute") @handle_exceptions async def list_recycle_bin( request: Request, current_user: Dict = Depends(get_current_user), deleted_from: Optional[str] = None, platform: Optional[str] = None, source: Optional[str] = None, search: Optional[str] = None, media_type: Optional[str] = None, date_from: Optional[str] = None, date_to: Optional[str] = None, size_min: Optional[int] = None, size_max: Optional[int] = None, sort_by: str = Query('download_date', pattern='^(deleted_at|file_size|filename|deleted_from|download_date|post_date|confidence)$'), sort_order: str = Query('desc', pattern='^(asc|desc)$'), limit: int = Query(100, ge=1, le=1000), offset: int = Query(0, ge=0) ): """ List files in recycle bin. Args: deleted_from: Filter by source (downloads, media, review) platform: Filter by platform (instagram, tiktok, etc.) source: Filter by source/username search: Search in filename media_type: Filter by type (image, video) date_from: Filter by deletion date (YYYY-MM-DD) date_to: Filter by deletion date (YYYY-MM-DD) size_min: Minimum file size in bytes size_max: Maximum file size in bytes sort_by: Column to sort by sort_order: Sort direction (asc, desc) limit: Maximum items to return offset: Number of items to skip """ app_state = get_app_state() db = app_state.db if not db: raise DatabaseError("Database not initialized") result = db.list_recycle_bin( deleted_from=deleted_from, platform=platform, source=source, search=search, media_type=media_type, date_from=date_from, date_to=date_to, size_min=size_min, size_max=size_max, sort_by=sort_by, sort_order=sort_order, limit=limit, offset=offset ) return { "success": True, "items": result['items'], "total": result['total'] } @router.get("/filters") @limiter.limit("100/minute") @handle_exceptions async def get_recycle_filters( request: Request, current_user: Dict = Depends(get_current_user), platform: Optional[str] = None ): """ Get available filter options for recycle bin. Args: platform: If provided, only return sources for this platform """ app_state = get_app_state() db = app_state.db if not db: raise DatabaseError("Database not initialized") filters = db.get_recycle_bin_filters(platform=platform) return { "success": True, "platforms": filters['platforms'], "sources": filters['sources'] } @router.get("/stats") @limiter.limit("100/minute") @handle_exceptions async def get_recycle_bin_stats(request: Request, current_user: Dict = Depends(get_current_user)): """ Get recycle bin statistics. Returns total count, total size, and breakdown by deleted_from source. """ app_state = get_app_state() db = app_state.db if not db: raise DatabaseError("Database not initialized") stats = db.get_recycle_bin_stats() return { "success": True, "stats": stats, "timestamp": now_iso8601() } @router.post("/restore") @limiter.limit("20/minute") @handle_exceptions async def restore_from_recycle( request: Request, current_user: Dict = Depends(get_current_user), recycle_id: str = Body(..., embed=True) ): """ Restore a file from recycle bin to its original location. The file will be moved back to its original path and re-registered in the file_inventory table. """ app_state = get_app_state() db = app_state.db if not db: raise DatabaseError("Database not initialized") success = db.restore_from_recycle_bin(recycle_id) if success: # Broadcast update to connected clients try: # app_state already retrieved above, use it for websocket broadcast if hasattr(app_state, 'websocket_manager') and app_state.websocket_manager: await app_state.websocket_manager.broadcast({ "type": "recycle_restore_completed", "recycle_id": recycle_id, "timestamp": now_iso8601() }) except Exception: pass # Broadcasting is optional logger.info(f"Restored file from recycle bin: {recycle_id}", module="Recycle") return { "success": True, "message": "File restored successfully", "recycle_id": recycle_id } else: raise FileOperationError( "Failed to restore file", {"recycle_id": recycle_id} ) @router.delete("/delete/{recycle_id}") @limiter.limit("20/minute") @handle_exceptions async def permanently_delete_from_recycle( request: Request, recycle_id: str, current_user: Dict = Depends(require_admin) ): """ Permanently delete a file from recycle bin. **Admin only** - This action cannot be undone. The file will be removed from disk permanently. """ app_state = get_app_state() db = app_state.db if not db: raise DatabaseError("Database not initialized") success = db.permanently_delete_from_recycle_bin(recycle_id) if success: # Broadcast update try: if hasattr(app_state, 'websocket_manager') and app_state.websocket_manager: await app_state.websocket_manager.broadcast({ "type": "recycle_delete_completed", "recycle_id": recycle_id, "timestamp": now_iso8601() }) except Exception: pass logger.info(f"Permanently deleted file from recycle: {recycle_id}", module="Recycle") return { "success": True, "message": "File permanently deleted", "recycle_id": recycle_id } else: raise FileOperationError( "Failed to delete file", {"recycle_id": recycle_id} ) @router.post("/empty") @limiter.limit("5/minute") @handle_exceptions async def empty_recycle_bin( request: Request, current_user: Dict = Depends(require_admin), # Require admin for destructive operation older_than_days: Optional[int] = Body(None, embed=True) ): """ Empty recycle bin. Args: older_than_days: Only delete files older than X days. If not specified, all files are deleted. """ app_state = get_app_state() db = app_state.db if not db: raise DatabaseError("Database not initialized") deleted_count = db.empty_recycle_bin(older_than_days=older_than_days) # Broadcast update try: if hasattr(app_state, 'websocket_manager') and app_state.websocket_manager: await app_state.websocket_manager.broadcast({ "type": "recycle_emptied", "deleted_count": deleted_count, "timestamp": now_iso8601() }) except Exception: pass logger.info(f"Emptied recycle bin: {deleted_count} files deleted", module="Recycle") return { "success": True, "deleted_count": deleted_count, "older_than_days": older_than_days } @router.get("/file/{recycle_id}") @limiter.limit("5000/minute") @handle_exceptions async def get_recycle_file( request: Request, recycle_id: str, thumbnail: bool = False, type: Optional[str] = None, token: Optional[str] = None, current_user: Dict = Depends(get_current_user_media) ): """ Serve a file from recycle bin for preview. Args: recycle_id: ID of the recycle bin record thumbnail: If True, return a thumbnail instead of the full file type: Media type hint (image/video) """ app_state = get_app_state() db = app_state.db if not db: raise DatabaseError("Database not initialized") # Get recycle bin record with db.get_connection() as conn: cursor = conn.cursor() cursor.execute( 'SELECT recycle_path, original_path, original_filename, file_hash FROM recycle_bin WHERE id = ?', (recycle_id,) ) row = cursor.fetchone() if not row: raise RecordNotFoundError( "File not found in recycle bin", {"recycle_id": recycle_id} ) file_path = Path(row['recycle_path']) original_path = row['original_path'] # Path where thumbnail was originally cached if not file_path.exists(): raise CustomFileNotFoundError( "Physical file not found", {"path": str(file_path)} ) # If thumbnail requested, use 3-tier caching # Use content hash as cache key so thumbnails survive file moves if thumbnail: content_hash = row['file_hash'] cache_key = content_hash if content_hash else str(file_path) # 1. Check in-memory LRU cache first (fastest) thumbnail_data = _thumbnail_cache.get(cache_key) if thumbnail_data: return Response( content=thumbnail_data, media_type="image/jpeg", headers={ "Cache-Control": "public, max-age=86400, immutable", "Vary": "Accept-Encoding" } ) # 2. Get from database cache or generate on-demand # Pass content hash and original_path for fallback lookup thumbnail_data = _get_or_create_thumbnail(file_path, type or 'image', content_hash, original_path) if not thumbnail_data: raise FileOperationError("Failed to generate thumbnail") # 3. Add to in-memory cache for faster subsequent requests _thumbnail_cache.put(cache_key, thumbnail_data) return Response( content=thumbnail_data, media_type="image/jpeg", headers={ "Cache-Control": "public, max-age=86400, immutable", "Vary": "Accept-Encoding" } ) # Otherwise serve full file mime_type, _ = mimetypes.guess_type(str(file_path)) if not mime_type: mime_type = "application/octet-stream" return FileResponse( path=str(file_path), media_type=mime_type, filename=row['original_filename'] ) @router.get("/metadata/{recycle_id}") @limiter.limit("5000/minute") @handle_exceptions async def get_recycle_metadata( request: Request, recycle_id: str, current_user: Dict = Depends(get_current_user) ): """ Get metadata for a recycle bin file. Returns dimensions, size, platform, source, and other metadata. This is fetched on-demand for performance. """ app_state = get_app_state() db = app_state.db if not db: raise DatabaseError("Database not initialized") # Get recycle bin record with db.get_connection() as conn: cursor = conn.cursor() cursor.execute(''' SELECT recycle_path, original_filename, file_size, original_path, metadata FROM recycle_bin WHERE id = ? ''', (recycle_id,)) row = cursor.fetchone() if not row: raise RecordNotFoundError( "File not found in recycle bin", {"recycle_id": recycle_id} ) recycle_path = Path(row['recycle_path']) if not recycle_path.exists(): raise CustomFileNotFoundError( "Physical file not found", {"path": str(recycle_path)} ) # Parse metadata for platform/source info platform, source = None, None try: metadata = json.loads(row['metadata']) if row['metadata'] else {} platform = metadata.get('platform') source = metadata.get('source') except Exception: pass # Get dimensions dynamically width, height, duration = _extract_dimensions(recycle_path) return { "success": True, "recycle_id": recycle_id, "filename": row['original_filename'], "file_size": row['file_size'], "platform": platform, "source": source, "width": width, "height": height, "duration": duration } # ============================================================================ # HELPER FUNCTIONS # ============================================================================ def _get_or_create_thumbnail(file_path: Path, media_type: str, content_hash: str = None, original_path: str = None) -> Optional[bytes]: """ Get or create a thumbnail for a file. Uses the same caching system as media.py for consistency. Uses a 2-step lookup for backwards compatibility: 1. Try content hash (new method - survives file moves) 2. Fall back to original_path lookup (legacy thumbnails cached before move) Args: file_path: Path to the file (current location in recycle bin) media_type: 'image' or 'video' content_hash: Optional content hash (SHA256 of file content) to use for cache lookup. original_path: Optional original file path before moving to recycle bin. """ from PIL import Image import io from datetime import datetime try: with sqlite3.connect(str(settings.PROJECT_ROOT / 'database' / 'thumbnails.db'), timeout=30.0) as conn: cursor = conn.cursor() # 1. Try content hash first (new method - survives file moves) if content_hash: cursor.execute("SELECT thumbnail_data FROM thumbnails WHERE file_hash = ?", (content_hash,)) result = cursor.fetchone() if result: return result[0] # 2. Fall back to original_path lookup (legacy thumbnails cached before move) if original_path: cursor.execute("SELECT thumbnail_data FROM thumbnails WHERE file_path = ?", (original_path,)) result = cursor.fetchone() if result: return result[0] except Exception: pass # Generate thumbnail thumbnail_data = None try: if media_type == 'video': # For videos, try to extract a frame import subprocess result = subprocess.run([ 'ffmpeg', '-i', str(file_path), '-ss', '00:00:01', '-vframes', '1', '-f', 'image2pipe', '-vcodec', 'mjpeg', '-' ], capture_output=True, timeout=10) if result.returncode == 0: img = Image.open(io.BytesIO(result.stdout)) else: return None else: img = Image.open(file_path) # Convert to RGB if necessary if img.mode in ('RGBA', 'P'): img = img.convert('RGB') # Create thumbnail img.thumbnail((300, 300), Image.Resampling.LANCZOS) # Save to bytes output = io.BytesIO() img.save(output, format='JPEG', quality=85) thumbnail_data = output.getvalue() # Cache the generated thumbnail if thumbnail_data: try: file_mtime = file_path.stat().st_mtime if file_path.exists() else None # Compute file_hash if not provided thumb_file_hash = content_hash if content_hash else hashlib.sha256(str(file_path).encode()).hexdigest() with sqlite3.connect(str(settings.PROJECT_ROOT / 'database' / 'thumbnails.db')) as conn: conn.execute(""" INSERT OR REPLACE INTO thumbnails (file_hash, file_path, thumbnail_data, created_at, file_mtime) VALUES (?, ?, ?, ?, ?) """, (thumb_file_hash, str(file_path), thumbnail_data, datetime.now().isoformat(), file_mtime)) conn.commit() except Exception: pass # Caching is optional, don't fail if it doesn't work return thumbnail_data except Exception as e: logger.warning(f"Failed to generate thumbnail: {e}", module="Recycle") return None def _extract_dimensions(file_path: Path) -> tuple: """ Extract dimensions from a media file. Returns: (width, height, duration) """ width, height, duration = None, None, None file_ext = file_path.suffix.lower() try: if file_ext in ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.heic', '.heif']: from PIL import Image with Image.open(file_path) as img: width, height = img.size elif file_ext in ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v']: import subprocess result = subprocess.run([ 'ffprobe', '-v', 'quiet', '-print_format', 'json', '-show_streams', str(file_path) ], capture_output=True, text=True, timeout=10) if result.returncode == 0: data = json.loads(result.stdout) for stream in data.get('streams', []): if stream.get('codec_type') == 'video': width = stream.get('width') height = stream.get('height') duration_str = stream.get('duration') if duration_str: duration = float(duration_str) break except Exception as e: logger.warning(f"Failed to extract dimensions: {e}", module="Recycle") return width, height, duration