602
web/backend/routers/recycle.py
Normal file
602
web/backend/routers/recycle.py
Normal file
@@ -0,0 +1,602 @@
|
||||
"""
|
||||
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('thumbnails', 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('thumbnails') 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
|
||||
Reference in New Issue
Block a user