942
web/backend/routers/discovery.py
Normal file
942
web/backend/routers/discovery.py
Normal file
@@ -0,0 +1,942 @@
|
||||
"""
|
||||
Discovery Router
|
||||
|
||||
Handles discovery, organization and browsing features:
|
||||
- Tags management (CRUD, file tagging, bulk operations)
|
||||
- Smart folders (filter-based virtual folders)
|
||||
- Collections (manual file groupings)
|
||||
- Timeline and activity views
|
||||
- Discovery queue management
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Query, Request
|
||||
from pydantic import BaseModel
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
from ..core.dependencies import get_current_user, get_app_state
|
||||
from ..core.exceptions import handle_exceptions, NotFoundError, ValidationError
|
||||
from ..core.responses import message_response, id_response, count_response, offset_paginated
|
||||
from modules.discovery_system import get_discovery_system
|
||||
from modules.universal_logger import get_logger
|
||||
|
||||
logger = get_logger('API')
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["Discovery"])
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PYDANTIC MODELS
|
||||
# ============================================================================
|
||||
|
||||
class TagCreate(BaseModel):
|
||||
name: str
|
||||
parent_id: Optional[int] = None
|
||||
color: str = '#6366f1'
|
||||
icon: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class TagUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
parent_id: Optional[int] = None
|
||||
color: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class BulkTagRequest(BaseModel):
|
||||
file_ids: List[int]
|
||||
tag_ids: List[int]
|
||||
|
||||
|
||||
class SmartFolderCreate(BaseModel):
|
||||
name: str
|
||||
filters: dict = {}
|
||||
icon: str = 'folder'
|
||||
color: str = '#6366f1'
|
||||
description: Optional[str] = None
|
||||
sort_by: str = 'post_date'
|
||||
sort_order: str = 'desc'
|
||||
|
||||
|
||||
class SmartFolderUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
filters: Optional[dict] = None
|
||||
icon: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
sort_by: Optional[str] = None
|
||||
sort_order: Optional[str] = None
|
||||
|
||||
|
||||
class CollectionCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
color: str = '#6366f1'
|
||||
|
||||
|
||||
class CollectionUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
cover_file_id: Optional[int] = None
|
||||
|
||||
|
||||
class BulkCollectionAdd(BaseModel):
|
||||
file_ids: List[int]
|
||||
|
||||
|
||||
class DiscoveryQueueAdd(BaseModel):
|
||||
file_ids: List[int]
|
||||
priority: int = 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TAGS ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/tags")
|
||||
@limiter.limit("60/minute")
|
||||
@handle_exceptions
|
||||
async def get_tags(
|
||||
request: Request,
|
||||
current_user: Dict = Depends(get_current_user),
|
||||
parent_id: Optional[int] = Query(None, description="Parent tag ID (null for root, -1 for all)"),
|
||||
include_counts: bool = Query(True, description="Include file counts")
|
||||
):
|
||||
"""Get all tags, optionally filtered by parent."""
|
||||
app_state = get_app_state()
|
||||
discovery = get_discovery_system(app_state.db)
|
||||
tags = discovery.get_tags(parent_id=parent_id, include_counts=include_counts)
|
||||
return {"tags": tags}
|
||||
|
||||
|
||||
@router.get("/tags/{tag_id}")
|
||||
@limiter.limit("60/minute")
|
||||
@handle_exceptions
|
||||
async def get_tag(
|
||||
request: Request,
|
||||
tag_id: int,
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get a single tag by ID."""
|
||||
app_state = get_app_state()
|
||||
discovery = get_discovery_system(app_state.db)
|
||||
tag = discovery.get_tag(tag_id)
|
||||
if not tag:
|
||||
raise NotFoundError("Tag not found")
|
||||
return tag
|
||||
|
||||
|
||||
@router.post("/tags")
|
||||
@limiter.limit("30/minute")
|
||||
@handle_exceptions
|
||||
async def create_tag(
|
||||
request: Request,
|
||||
tag_data: TagCreate,
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
):
|
||||
"""Create a new tag."""
|
||||
app_state = get_app_state()
|
||||
discovery = get_discovery_system(app_state.db)
|
||||
tag_id = discovery.create_tag(
|
||||
name=tag_data.name,
|
||||
parent_id=tag_data.parent_id,
|
||||
color=tag_data.color,
|
||||
icon=tag_data.icon,
|
||||
description=tag_data.description
|
||||
)
|
||||
if tag_id is None:
|
||||
raise ValidationError("Failed to create tag")
|
||||
return id_response(tag_id, "Tag created successfully")
|
||||
|
||||
|
||||
@router.put("/tags/{tag_id}")
|
||||
@limiter.limit("30/minute")
|
||||
@handle_exceptions
|
||||
async def update_tag(
|
||||
request: Request,
|
||||
tag_id: int,
|
||||
tag_data: TagUpdate,
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
):
|
||||
"""Update a tag."""
|
||||
app_state = get_app_state()
|
||||
discovery = get_discovery_system(app_state.db)
|
||||
success = discovery.update_tag(
|
||||
tag_id=tag_id,
|
||||
name=tag_data.name,
|
||||
color=tag_data.color,
|
||||
icon=tag_data.icon,
|
||||
description=tag_data.description,
|
||||
parent_id=tag_data.parent_id
|
||||
)
|
||||
if not success:
|
||||
raise NotFoundError("Tag not found or update failed")
|
||||
return message_response("Tag updated successfully")
|
||||
|
||||
|
||||
@router.delete("/tags/{tag_id}")
|
||||
@limiter.limit("30/minute")
|
||||
@handle_exceptions
|
||||
async def delete_tag(
|
||||
request: Request,
|
||||
tag_id: int,
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
):
|
||||
"""Delete a tag."""
|
||||
app_state = get_app_state()
|
||||
discovery = get_discovery_system(app_state.db)
|
||||
success = discovery.delete_tag(tag_id)
|
||||
if not success:
|
||||
raise NotFoundError("Tag not found")
|
||||
return message_response("Tag deleted successfully")
|
||||
|
||||
|
||||
@router.get("/files/{file_id}/tags")
|
||||
@limiter.limit("60/minute")
|
||||
@handle_exceptions
|
||||
async def get_file_tags(
|
||||
request: Request,
|
||||
file_id: int,
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get all tags for a file."""
|
||||
app_state = get_app_state()
|
||||
discovery = get_discovery_system(app_state.db)
|
||||
tags = discovery.get_file_tags(file_id)
|
||||
return {"tags": tags}
|
||||
|
||||
|
||||
@router.post("/files/{file_id}/tags/{tag_id}")
|
||||
@limiter.limit("60/minute")
|
||||
@handle_exceptions
|
||||
async def tag_file(
|
||||
request: Request,
|
||||
file_id: int,
|
||||
tag_id: int,
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
):
|
||||
"""Add a tag to a file."""
|
||||
app_state = get_app_state()
|
||||
discovery = get_discovery_system(app_state.db)
|
||||
success = discovery.tag_file(file_id, tag_id, created_by=current_user.get('sub'))
|
||||
if not success:
|
||||
raise ValidationError("Failed to tag file")
|
||||
return message_response("Tag added to file")
|
||||
|
||||
|
||||
@router.delete("/files/{file_id}/tags/{tag_id}")
|
||||
@limiter.limit("60/minute")
|
||||
@handle_exceptions
|
||||
async def untag_file(
|
||||
request: Request,
|
||||
file_id: int,
|
||||
tag_id: int,
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
):
|
||||
"""Remove a tag from a file."""
|
||||
app_state = get_app_state()
|
||||
discovery = get_discovery_system(app_state.db)
|
||||
success = discovery.untag_file(file_id, tag_id)
|
||||
if not success:
|
||||
raise NotFoundError("Tag not found on file")
|
||||
return message_response("Tag removed from file")
|
||||
|
||||
|
||||
@router.get("/tags/{tag_id}/files")
|
||||
@limiter.limit("60/minute")
|
||||
@handle_exceptions
|
||||
async def get_files_by_tag(
|
||||
request: Request,
|
||||
tag_id: int,
|
||||
current_user: Dict = Depends(get_current_user),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
offset: int = Query(0, ge=0)
|
||||
):
|
||||
"""Get files with a specific tag."""
|
||||
app_state = get_app_state()
|
||||
discovery = get_discovery_system(app_state.db)
|
||||
files, total = discovery.get_files_by_tag(tag_id, limit=limit, offset=offset)
|
||||
return offset_paginated(files, total, limit, offset, key="files")
|
||||
|
||||
|
||||
@router.post("/tags/bulk")
|
||||
@limiter.limit("30/minute")
|
||||
@handle_exceptions
|
||||
async def bulk_tag_files(
|
||||
request: Request,
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
):
|
||||
"""Tag multiple files with multiple tags."""
|
||||
data = await request.json()
|
||||
file_ids = data.get('file_ids', [])
|
||||
tag_ids = data.get('tag_ids', [])
|
||||
|
||||
if not file_ids or not tag_ids:
|
||||
raise ValidationError("file_ids and tag_ids required")
|
||||
|
||||
app_state = get_app_state()
|
||||
discovery = get_discovery_system(app_state.db)
|
||||
count = discovery.bulk_tag_files(file_ids, tag_ids, created_by=current_user.get('sub'))
|
||||
return count_response(f"Tagged {count} file-tag pairs", count)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SMART FOLDERS ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/smart-folders")
|
||||
@limiter.limit("60/minute")
|
||||
@handle_exceptions
|
||||
async def get_smart_folders(
|
||||
request: Request,
|
||||
current_user: Dict = Depends(get_current_user),
|
||||
include_system: bool = Query(True, description="Include system smart folders")
|
||||
):
|
||||
"""Get all smart folders."""
|
||||
app_state = get_app_state()
|
||||
discovery = get_discovery_system(app_state.db)
|
||||
folders = discovery.get_smart_folders(include_system=include_system)
|
||||
return {"smart_folders": folders}
|
||||
|
||||
|
||||
@router.get("/smart-folders/stats")
|
||||
@limiter.limit("30/minute")
|
||||
@handle_exceptions
|
||||
async def get_smart_folders_stats(
|
||||
request: Request,
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get file counts and preview thumbnails for all smart folders."""
|
||||
app_state = get_app_state()
|
||||
discovery = get_discovery_system(app_state.db)
|
||||
folders = discovery.get_smart_folders(include_system=True)
|
||||
|
||||
stats = {}
|
||||
with app_state.db.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
for folder in folders:
|
||||
filters = folder.get('filters', {})
|
||||
folder_id = folder['id']
|
||||
|
||||
query = '''
|
||||
SELECT COUNT(*) as count
|
||||
FROM file_inventory fi
|
||||
WHERE fi.location = 'final'
|
||||
'''
|
||||
params = []
|
||||
|
||||
if filters.get('platform'):
|
||||
query += ' AND fi.platform = ?'
|
||||
params.append(filters['platform'])
|
||||
|
||||
if filters.get('media_type'):
|
||||
query += ' AND fi.content_type = ?'
|
||||
params.append(filters['media_type'])
|
||||
|
||||
if filters.get('source'):
|
||||
query += ' AND fi.source = ?'
|
||||
params.append(filters['source'])
|
||||
|
||||
if filters.get('size_min'):
|
||||
query += ' AND fi.file_size >= ?'
|
||||
params.append(filters['size_min'])
|
||||
|
||||
cursor.execute(query, params)
|
||||
count = cursor.fetchone()[0]
|
||||
|
||||
preview_query = '''
|
||||
SELECT fi.file_path, fi.content_type
|
||||
FROM file_inventory fi
|
||||
WHERE fi.location = 'final'
|
||||
'''
|
||||
preview_params = []
|
||||
|
||||
if filters.get('platform'):
|
||||
preview_query += ' AND fi.platform = ?'
|
||||
preview_params.append(filters['platform'])
|
||||
|
||||
if filters.get('media_type'):
|
||||
preview_query += ' AND fi.content_type = ?'
|
||||
preview_params.append(filters['media_type'])
|
||||
|
||||
if filters.get('source'):
|
||||
preview_query += ' AND fi.source = ?'
|
||||
preview_params.append(filters['source'])
|
||||
|
||||
if filters.get('size_min'):
|
||||
preview_query += ' AND fi.file_size >= ?'
|
||||
preview_params.append(filters['size_min'])
|
||||
|
||||
preview_query += ' ORDER BY fi.created_date DESC LIMIT 4'
|
||||
cursor.execute(preview_query, preview_params)
|
||||
|
||||
previews = []
|
||||
for row in cursor.fetchall():
|
||||
previews.append({
|
||||
'file_path': row['file_path'],
|
||||
'content_type': row['content_type']
|
||||
})
|
||||
|
||||
stats[folder_id] = {
|
||||
'count': count,
|
||||
'previews': previews
|
||||
}
|
||||
|
||||
return {"stats": stats}
|
||||
|
||||
|
||||
@router.get("/smart-folders/{folder_id}")
|
||||
@limiter.limit("60/minute")
|
||||
@handle_exceptions
|
||||
async def get_smart_folder(
|
||||
request: Request,
|
||||
folder_id: int,
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get a single smart folder by ID."""
|
||||
app_state = get_app_state()
|
||||
discovery = get_discovery_system(app_state.db)
|
||||
folder = discovery.get_smart_folder(folder_id=folder_id)
|
||||
if not folder:
|
||||
raise NotFoundError("Smart folder not found")
|
||||
return folder
|
||||
|
||||
|
||||
@router.post("/smart-folders")
|
||||
@limiter.limit("30/minute")
|
||||
@handle_exceptions
|
||||
async def create_smart_folder(
|
||||
request: Request,
|
||||
folder_data: SmartFolderCreate,
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
):
|
||||
"""Create a new smart folder."""
|
||||
app_state = get_app_state()
|
||||
discovery = get_discovery_system(app_state.db)
|
||||
folder_id = discovery.create_smart_folder(
|
||||
name=folder_data.name,
|
||||
filters=folder_data.filters,
|
||||
icon=folder_data.icon,
|
||||
color=folder_data.color,
|
||||
description=folder_data.description,
|
||||
sort_by=folder_data.sort_by,
|
||||
sort_order=folder_data.sort_order
|
||||
)
|
||||
if folder_id is None:
|
||||
raise ValidationError("Failed to create smart folder")
|
||||
return {"id": folder_id, "message": "Smart folder created successfully"}
|
||||
|
||||
|
||||
@router.put("/smart-folders/{folder_id}")
|
||||
@limiter.limit("30/minute")
|
||||
@handle_exceptions
|
||||
async def update_smart_folder(
|
||||
request: Request,
|
||||
folder_id: int,
|
||||
folder_data: SmartFolderUpdate,
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
):
|
||||
"""Update a smart folder (cannot update system folders)."""
|
||||
app_state = get_app_state()
|
||||
discovery = get_discovery_system(app_state.db)
|
||||
success = discovery.update_smart_folder(
|
||||
folder_id=folder_id,
|
||||
name=folder_data.name,
|
||||
filters=folder_data.filters,
|
||||
icon=folder_data.icon,
|
||||
color=folder_data.color,
|
||||
description=folder_data.description,
|
||||
sort_by=folder_data.sort_by,
|
||||
sort_order=folder_data.sort_order
|
||||
)
|
||||
if not success:
|
||||
raise ValidationError("Failed to update smart folder (may be a system folder)")
|
||||
return {"message": "Smart folder updated successfully"}
|
||||
|
||||
|
||||
@router.delete("/smart-folders/{folder_id}")
|
||||
@limiter.limit("30/minute")
|
||||
@handle_exceptions
|
||||
async def delete_smart_folder(
|
||||
request: Request,
|
||||
folder_id: int,
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
):
|
||||
"""Delete a smart folder (cannot delete system folders)."""
|
||||
app_state = get_app_state()
|
||||
discovery = get_discovery_system(app_state.db)
|
||||
success = discovery.delete_smart_folder(folder_id)
|
||||
if not success:
|
||||
raise ValidationError("Failed to delete smart folder (may be a system folder)")
|
||||
return {"message": "Smart folder deleted successfully"}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# COLLECTIONS ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/collections")
|
||||
@limiter.limit("60/minute")
|
||||
@handle_exceptions
|
||||
async def get_collections(
|
||||
request: Request,
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get all collections."""
|
||||
app_state = get_app_state()
|
||||
discovery = get_discovery_system(app_state.db)
|
||||
collections = discovery.get_collections()
|
||||
return {"collections": collections}
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}")
|
||||
@limiter.limit("60/minute")
|
||||
@handle_exceptions
|
||||
async def get_collection(
|
||||
request: Request,
|
||||
collection_id: int,
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get a single collection by ID."""
|
||||
app_state = get_app_state()
|
||||
discovery = get_discovery_system(app_state.db)
|
||||
collection = discovery.get_collection(collection_id=collection_id)
|
||||
if not collection:
|
||||
raise NotFoundError("Collection not found")
|
||||
return collection
|
||||
|
||||
|
||||
@router.post("/collections")
|
||||
@limiter.limit("30/minute")
|
||||
@handle_exceptions
|
||||
async def create_collection(
|
||||
request: Request,
|
||||
collection_data: CollectionCreate,
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
):
|
||||
"""Create a new collection."""
|
||||
app_state = get_app_state()
|
||||
discovery = get_discovery_system(app_state.db)
|
||||
collection_id = discovery.create_collection(
|
||||
name=collection_data.name,
|
||||
description=collection_data.description,
|
||||
color=collection_data.color
|
||||
)
|
||||
if collection_id is None:
|
||||
raise ValidationError("Failed to create collection")
|
||||
return {"id": collection_id, "message": "Collection created successfully"}
|
||||
|
||||
|
||||
@router.put("/collections/{collection_id}")
|
||||
@limiter.limit("30/minute")
|
||||
@handle_exceptions
|
||||
async def update_collection(
|
||||
request: Request,
|
||||
collection_id: int,
|
||||
collection_data: CollectionUpdate,
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
):
|
||||
"""Update a collection."""
|
||||
app_state = get_app_state()
|
||||
discovery = get_discovery_system(app_state.db)
|
||||
success = discovery.update_collection(
|
||||
collection_id=collection_id,
|
||||
name=collection_data.name,
|
||||
description=collection_data.description,
|
||||
color=collection_data.color,
|
||||
cover_file_id=collection_data.cover_file_id
|
||||
)
|
||||
if not success:
|
||||
raise NotFoundError("Collection not found")
|
||||
return {"message": "Collection updated successfully"}
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}")
|
||||
@limiter.limit("30/minute")
|
||||
@handle_exceptions
|
||||
async def delete_collection(
|
||||
request: Request,
|
||||
collection_id: int,
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
):
|
||||
"""Delete a collection."""
|
||||
app_state = get_app_state()
|
||||
discovery = get_discovery_system(app_state.db)
|
||||
success = discovery.delete_collection(collection_id)
|
||||
if not success:
|
||||
raise NotFoundError("Collection not found")
|
||||
return {"message": "Collection deleted successfully"}
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/files")
|
||||
@limiter.limit("60/minute")
|
||||
@handle_exceptions
|
||||
async def get_collection_files(
|
||||
request: Request,
|
||||
collection_id: int,
|
||||
current_user: Dict = Depends(get_current_user),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
offset: int = Query(0, ge=0)
|
||||
):
|
||||
"""Get files in a collection."""
|
||||
app_state = get_app_state()
|
||||
discovery = get_discovery_system(app_state.db)
|
||||
files, total = discovery.get_collection_files(collection_id, limit=limit, offset=offset)
|
||||
return {"files": files, "total": total, "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/files/{file_id}")
|
||||
@limiter.limit("60/minute")
|
||||
@handle_exceptions
|
||||
async def add_to_collection(
|
||||
request: Request,
|
||||
collection_id: int,
|
||||
file_id: int,
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
):
|
||||
"""Add a file to a collection."""
|
||||
app_state = get_app_state()
|
||||
discovery = get_discovery_system(app_state.db)
|
||||
success = discovery.add_to_collection(collection_id, file_id, added_by=current_user.get('sub'))
|
||||
if not success:
|
||||
raise ValidationError("Failed to add file to collection")
|
||||
return {"message": "File added to collection"}
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}/files/{file_id}")
|
||||
@limiter.limit("60/minute")
|
||||
@handle_exceptions
|
||||
async def remove_from_collection(
|
||||
request: Request,
|
||||
collection_id: int,
|
||||
file_id: int,
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
):
|
||||
"""Remove a file from a collection."""
|
||||
app_state = get_app_state()
|
||||
discovery = get_discovery_system(app_state.db)
|
||||
success = discovery.remove_from_collection(collection_id, file_id)
|
||||
if not success:
|
||||
raise NotFoundError("File not found in collection")
|
||||
return {"message": "File removed from collection"}
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/files/bulk")
|
||||
@limiter.limit("30/minute")
|
||||
@handle_exceptions
|
||||
async def bulk_add_to_collection(
|
||||
request: Request,
|
||||
collection_id: int,
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
):
|
||||
"""Add multiple files to a collection."""
|
||||
data = await request.json()
|
||||
file_ids = data.get('file_ids', [])
|
||||
|
||||
if not file_ids:
|
||||
raise ValidationError("file_ids required")
|
||||
|
||||
app_state = get_app_state()
|
||||
discovery = get_discovery_system(app_state.db)
|
||||
count = discovery.bulk_add_to_collection(collection_id, file_ids, added_by=current_user.get('sub'))
|
||||
return {"message": f"Added {count} files to collection", "count": count}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TIMELINE ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/timeline")
|
||||
@limiter.limit("60/minute")
|
||||
@handle_exceptions
|
||||
async def get_timeline(
|
||||
request: Request,
|
||||
current_user: Dict = Depends(get_current_user),
|
||||
granularity: str = Query('day', pattern='^(day|week|month|year)$'),
|
||||
date_from: Optional[str] = Query(None, pattern=r'^\d{4}-\d{2}-\d{2}$'),
|
||||
date_to: Optional[str] = Query(None, pattern=r'^\d{4}-\d{2}-\d{2}$'),
|
||||
platform: Optional[str] = Query(None),
|
||||
source: Optional[str] = Query(None)
|
||||
):
|
||||
"""Get timeline aggregation data."""
|
||||
app_state = get_app_state()
|
||||
discovery = get_discovery_system(app_state.db)
|
||||
data = discovery.get_timeline_data(
|
||||
granularity=granularity,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
platform=platform,
|
||||
source=source
|
||||
)
|
||||
return {"timeline": data, "granularity": granularity}
|
||||
|
||||
|
||||
@router.get("/timeline/heatmap")
|
||||
@limiter.limit("60/minute")
|
||||
@handle_exceptions
|
||||
async def get_timeline_heatmap(
|
||||
request: Request,
|
||||
current_user: Dict = Depends(get_current_user),
|
||||
year: Optional[int] = Query(None, ge=2000, le=2100),
|
||||
platform: Optional[str] = Query(None)
|
||||
):
|
||||
"""Get activity heatmap data (file counts per day for a year)."""
|
||||
app_state = get_app_state()
|
||||
discovery = get_discovery_system(app_state.db)
|
||||
heatmap = discovery.get_activity_heatmap(year=year, platform=platform)
|
||||
return {"heatmap": heatmap, "year": year or datetime.now().year}
|
||||
|
||||
|
||||
@router.get("/timeline/on-this-day")
|
||||
@limiter.limit("60/minute")
|
||||
@handle_exceptions
|
||||
async def get_on_this_day(
|
||||
request: Request,
|
||||
current_user: Dict = Depends(get_current_user),
|
||||
month: Optional[int] = Query(None, ge=1, le=12),
|
||||
day: Optional[int] = Query(None, ge=1, le=31),
|
||||
limit: int = Query(50, ge=1, le=200)
|
||||
):
|
||||
"""Get content from the same day in previous years ('On This Day' feature)."""
|
||||
app_state = get_app_state()
|
||||
discovery = get_discovery_system(app_state.db)
|
||||
files = discovery.get_on_this_day(month=month, day=day, limit=limit)
|
||||
return {"files": files, "count": len(files)}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# RECENT ACTIVITY ENDPOINT
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/discovery/recent-activity")
|
||||
@limiter.limit("60/minute")
|
||||
@handle_exceptions
|
||||
async def get_recent_activity(
|
||||
request: Request,
|
||||
current_user: Dict = Depends(get_current_user),
|
||||
limit: int = Query(10, ge=1, le=50)
|
||||
):
|
||||
"""Get recent activity across downloads, deletions, and restores."""
|
||||
app_state = get_app_state()
|
||||
|
||||
activity = {
|
||||
'recent_downloads': [],
|
||||
'recent_deleted': [],
|
||||
'recent_restored': [],
|
||||
'recent_moved_to_review': [],
|
||||
'summary': {
|
||||
'downloads_24h': 0,
|
||||
'downloads_7d': 0,
|
||||
'deleted_24h': 0,
|
||||
'deleted_7d': 0
|
||||
}
|
||||
}
|
||||
|
||||
with app_state.db.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Recent downloads
|
||||
cursor.execute('''
|
||||
SELECT
|
||||
fi.id, fi.file_path, fi.filename, fi.platform, fi.source,
|
||||
fi.content_type, fi.file_size, fi.created_date,
|
||||
d.download_date, d.post_date
|
||||
FROM file_inventory fi
|
||||
LEFT JOIN downloads d ON d.file_path = fi.file_path
|
||||
WHERE fi.location = 'final'
|
||||
ORDER BY fi.created_date DESC
|
||||
LIMIT ?
|
||||
''', (limit,))
|
||||
|
||||
for row in cursor.fetchall():
|
||||
activity['recent_downloads'].append({
|
||||
'id': row['id'],
|
||||
'file_path': row['file_path'],
|
||||
'filename': row['filename'],
|
||||
'platform': row['platform'],
|
||||
'source': row['source'],
|
||||
'content_type': row['content_type'],
|
||||
'file_size': row['file_size'],
|
||||
'timestamp': row['download_date'] or row['created_date'],
|
||||
'action': 'download'
|
||||
})
|
||||
|
||||
# Recent deleted
|
||||
cursor.execute('''
|
||||
SELECT
|
||||
id, original_path, original_filename, recycle_path,
|
||||
file_size, deleted_at, deleted_from, metadata
|
||||
FROM recycle_bin
|
||||
ORDER BY deleted_at DESC
|
||||
LIMIT ?
|
||||
''', (limit,))
|
||||
|
||||
for row in cursor.fetchall():
|
||||
metadata = {}
|
||||
if row['metadata']:
|
||||
try:
|
||||
metadata = json.loads(row['metadata'])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
activity['recent_deleted'].append({
|
||||
'id': row['id'],
|
||||
'file_path': row['recycle_path'],
|
||||
'original_path': row['original_path'],
|
||||
'filename': row['original_filename'],
|
||||
'platform': metadata.get('platform', 'unknown'),
|
||||
'source': metadata.get('source', ''),
|
||||
'content_type': metadata.get('content_type', 'image'),
|
||||
'file_size': row['file_size'] or 0,
|
||||
'timestamp': row['deleted_at'],
|
||||
'deleted_from': row['deleted_from'],
|
||||
'action': 'delete'
|
||||
})
|
||||
|
||||
# Recent moved to review
|
||||
cursor.execute('''
|
||||
SELECT
|
||||
id, file_path, filename, platform, source,
|
||||
content_type, file_size, created_date
|
||||
FROM file_inventory
|
||||
WHERE location = 'review'
|
||||
ORDER BY created_date DESC
|
||||
LIMIT ?
|
||||
''', (limit,))
|
||||
|
||||
for row in cursor.fetchall():
|
||||
activity['recent_moved_to_review'].append({
|
||||
'id': row['id'],
|
||||
'file_path': row['file_path'],
|
||||
'filename': row['filename'],
|
||||
'platform': row['platform'],
|
||||
'source': row['source'],
|
||||
'content_type': row['content_type'],
|
||||
'file_size': row['file_size'],
|
||||
'timestamp': row['created_date'],
|
||||
'action': 'review'
|
||||
})
|
||||
|
||||
# Summary stats
|
||||
cursor.execute('''
|
||||
SELECT COUNT(*) FROM file_inventory
|
||||
WHERE location = 'final'
|
||||
AND created_date >= datetime('now', '-1 day')
|
||||
''')
|
||||
activity['summary']['downloads_24h'] = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute('''
|
||||
SELECT COUNT(*) FROM file_inventory
|
||||
WHERE location = 'final'
|
||||
AND created_date >= datetime('now', '-7 days')
|
||||
''')
|
||||
activity['summary']['downloads_7d'] = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute('''
|
||||
SELECT COUNT(*) FROM recycle_bin
|
||||
WHERE deleted_at >= datetime('now', '-1 day')
|
||||
''')
|
||||
activity['summary']['deleted_24h'] = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute('''
|
||||
SELECT COUNT(*) FROM recycle_bin
|
||||
WHERE deleted_at >= datetime('now', '-7 days')
|
||||
''')
|
||||
activity['summary']['deleted_7d'] = cursor.fetchone()[0]
|
||||
|
||||
return activity
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# DISCOVERY QUEUE ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/discovery/queue/stats")
|
||||
@limiter.limit("60/minute")
|
||||
@handle_exceptions
|
||||
async def get_queue_stats(
|
||||
request: Request,
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get discovery queue statistics."""
|
||||
app_state = get_app_state()
|
||||
discovery = get_discovery_system(app_state.db)
|
||||
stats = discovery.get_queue_stats()
|
||||
return stats
|
||||
|
||||
|
||||
@router.get("/discovery/queue/pending")
|
||||
@limiter.limit("60/minute")
|
||||
@handle_exceptions
|
||||
async def get_pending_queue(
|
||||
request: Request,
|
||||
current_user: Dict = Depends(get_current_user),
|
||||
limit: int = Query(100, ge=1, le=1000)
|
||||
):
|
||||
"""Get pending items in the discovery queue."""
|
||||
app_state = get_app_state()
|
||||
discovery = get_discovery_system(app_state.db)
|
||||
items = discovery.get_pending_queue(limit=limit)
|
||||
return {"items": items, "count": len(items)}
|
||||
|
||||
|
||||
@router.post("/discovery/queue/add")
|
||||
@limiter.limit("30/minute")
|
||||
@handle_exceptions
|
||||
async def add_to_queue(
|
||||
request: Request,
|
||||
current_user: Dict = Depends(get_current_user),
|
||||
file_id: int = Body(...),
|
||||
priority: int = Body(0)
|
||||
):
|
||||
"""Add a file to the discovery queue."""
|
||||
app_state = get_app_state()
|
||||
discovery = get_discovery_system(app_state.db)
|
||||
success = discovery.add_to_queue(file_id, priority=priority)
|
||||
if not success:
|
||||
raise ValidationError("Failed to add file to queue")
|
||||
return {"message": "File added to queue"}
|
||||
|
||||
|
||||
@router.post("/discovery/queue/bulk-add")
|
||||
@limiter.limit("10/minute")
|
||||
@handle_exceptions
|
||||
async def bulk_add_to_queue(
|
||||
request: Request,
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
):
|
||||
"""Add multiple files to the discovery queue."""
|
||||
data = await request.json()
|
||||
file_ids = data.get('file_ids', [])
|
||||
priority = data.get('priority', 0)
|
||||
|
||||
if not file_ids:
|
||||
raise ValidationError("file_ids required")
|
||||
|
||||
app_state = get_app_state()
|
||||
discovery = get_discovery_system(app_state.db)
|
||||
count = discovery.bulk_add_to_queue(file_ids, priority=priority)
|
||||
return {"message": f"Added {count} files to queue", "count": count}
|
||||
|
||||
|
||||
@router.delete("/discovery/queue/clear")
|
||||
@limiter.limit("10/minute")
|
||||
@handle_exceptions
|
||||
async def clear_queue(
|
||||
request: Request,
|
||||
current_user: Dict = Depends(get_current_user)
|
||||
):
|
||||
"""Clear the discovery queue."""
|
||||
app_state = get_app_state()
|
||||
discovery = get_discovery_system(app_state.db)
|
||||
count = discovery.clear_queue()
|
||||
return {"message": f"Cleared {count} items from queue", "count": count}
|
||||
Reference in New Issue
Block a user