Files
media-downloader/web/backend/routers/paid_content.py
Todd 0d7b2b1aab Initial commit
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 22:42:55 -04:00

6257 lines
216 KiB
Python

"""
Paid Content Router
API endpoints for the Paid Content feature:
- Dashboard stats and failed downloads
- Services management (Coomer/Kemono)
- Creators CRUD and sync
- Identities (creator linking)
- Feed browsing with filters
- Notifications
- Settings
- Import from file hosts
- Recycle bin
"""
import asyncio
import json
from datetime import datetime
from threading import Lock
from typing import Dict, List, Optional
from pathlib import Path
from fastapi import APIRouter, BackgroundTasks, Depends, Query, Request, Response, UploadFile, File, Form
from fastapi.responses import FileResponse
from pydantic import BaseModel, ConfigDict
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, now_iso8601
from modules.universal_logger import get_logger
logger = get_logger('API')
router = APIRouter(prefix="/api/paid-content", tags=["Paid Content"])
limiter = Limiter(key_func=get_remote_address)
# Auto health check throttle - prevents repeated checks within cooldown period
_auto_health_check_running = False
_last_auto_health_check: Optional[float] = None
_AUTO_HEALTH_CHECK_INTERVAL = 300 # 5 minutes
# Face scan job tracking (transient, same pattern as manual_import.py)
_face_scan_status: Dict[str, Dict] = {}
_face_scan_lock = Lock()
# ============================================================================
# PYDANTIC MODELS
# ============================================================================
class AddCreatorRequest(BaseModel):
service_id: str # 'coomer' or 'kemono'
platform: str # 'onlyfans', 'patreon', etc.
creator_id: str # Platform-specific ID
auto_download: bool = True
download_embeds: bool = True
class UpdateCreatorRequest(BaseModel):
enabled: Optional[bool] = None
auto_download: Optional[bool] = None
download_embeds: Optional[bool] = None
identity_id: Optional[int] = None
sync_posts: Optional[bool] = None
sync_stories: Optional[bool] = None
sync_highlights: Optional[bool] = None
use_authenticated_api: Optional[bool] = None
filter_tagged_users: Optional[List[str]] = None
class UpdateSessionRequest(BaseModel):
session_cookie: str
class CreateIdentityRequest(BaseModel):
name: str
notes: Optional[str] = None
class UpdateIdentityRequest(BaseModel):
name: Optional[str] = None
notes: Optional[str] = None
profile_image_url: Optional[str] = None
class LinkCreatorRequest(BaseModel):
creator_id: int
class ImportUrlRequest(BaseModel):
url: str
creator_id: Optional[int] = None
class UpdatePostRequest(BaseModel):
title: Optional[str] = None
content: Optional[str] = None
published_at: Optional[str] = None # Date in ISO format or YYYY-MM-DD
class RetryDownloadsRequest(BaseModel):
attachment_ids: List[int]
class RemoveFromQueueRequest(BaseModel):
attachment_ids: List[int]
class UpdateQueueItemRequest(BaseModel):
auto_requeue: Optional[bool] = None
class MarkNotificationsReadRequest(BaseModel):
notification_ids: Optional[List[int]] = None
mark_all: bool = False
class ImportToAttachmentRequest(BaseModel):
"""Request to import a file from URL and link it to an existing attachment"""
url: str
class ParseFilenamesRequest(BaseModel):
"""Request to parse filenames and extract dates"""
filenames: List[str]
class CreateTagRequest(BaseModel):
"""Request to create a new tag"""
name: str
color: Optional[str] = "#6b7280"
description: Optional[str] = None
class UpdateTagRequest(BaseModel):
"""Request to update a tag"""
name: Optional[str] = None
color: Optional[str] = None
description: Optional[str] = None
class TagPostsRequest(BaseModel):
"""Request to add/remove tags from posts"""
post_ids: List[int]
tag_ids: List[int]
class SetPostTagsRequest(BaseModel):
"""Request to set tags for a single post"""
tag_ids: List[int]
class CreateAutoTagRuleRequest(BaseModel):
"""Request to create an auto-tag rule"""
name: str
conditions: Dict
tag_ids: List[int]
priority: int = 0
class UpdateAutoTagRuleRequest(BaseModel):
"""Request to update an auto-tag rule"""
name: Optional[str] = None
enabled: Optional[int] = None
conditions: Optional[Dict] = None
tag_ids: Optional[List[int]] = None
priority: Optional[int] = None
class BulkDeletePostsRequest(BaseModel):
"""Request to bulk soft-delete posts"""
post_ids: List[int]
class WatchLaterAddRequest(BaseModel):
"""Request to add attachment to watch later"""
attachment_id: int
class WatchLaterBulkAddRequest(BaseModel):
"""Request to add multiple attachments to watch later"""
attachment_ids: List[int]
class WatchLaterRemoveRequest(BaseModel):
"""Request to remove multiple attachments from watch later"""
attachment_ids: List[int]
class WatchLaterReorderRequest(BaseModel):
"""Request to reorder watch later items"""
ordered_ids: List[int]
class FaceAddReferenceRequest(BaseModel):
person_name: str
file_path: str # local_path of an existing attachment
class FaceScanCreatorRequest(BaseModel):
creator_id: int
person_name: str # e.g. "India Reynolds"
tolerance: float = 0.35
class PaidContentSettings(BaseModel):
"""Settings model that ignores extra fields like id, created_at, updated_at from the database"""
model_config = ConfigDict(extra='ignore')
base_download_path: Optional[str] = None
organize_by_date: Optional[bool] = None
organize_by_post: Optional[bool] = None
check_interval_hours: Optional[int] = None
max_concurrent_downloads: Optional[int] = None
download_embeds: Optional[bool] = None
embed_quality: Optional[str] = None
notifications_enabled: Optional[bool] = None
push_notifications_enabled: Optional[bool] = None
perceptual_duplicate_detection: Optional[bool] = None
perceptual_threshold: Optional[int] = None
auto_retry_failed: Optional[bool] = None
retry_max_attempts: Optional[int] = None
# ============================================================================
# HELPER FUNCTIONS
# ============================================================================
def _get_db_adapter():
"""Get PaidContentDBAdapter instance"""
from modules.paid_content import PaidContentDBAdapter
app_state = get_app_state()
return PaidContentDBAdapter(app_state.db)
def _get_scraper():
"""Get PaidContentScraper instance"""
from modules.paid_content import PaidContentScraper
app_state = get_app_state()
return PaidContentScraper(
unified_db=app_state.db,
notifier=getattr(app_state, 'notifier', None),
websocket_manager=getattr(app_state, 'websocket_manager', None),
app_state=app_state
)
# ============================================================================
# DASHBOARD ENDPOINTS
# ============================================================================
@router.get("/dashboard/stats")
@limiter.limit("60/minute")
@handle_exceptions
async def get_dashboard_stats(
request: Request,
current_user: Dict = Depends(get_current_user)
):
"""Get dashboard statistics"""
db = _get_db_adapter()
stats = db.get_dashboard_stats()
return {"stats": stats, "timestamp": now_iso8601()}
@router.get("/dashboard/active-syncs")
@limiter.limit("120/minute")
@handle_exceptions
async def get_active_syncs(
request: Request,
current_user: Dict = Depends(get_current_user)
):
"""Get currently active sync tasks (for polling-based real-time updates).
Uses activity_manager for database-backed tracking that works across processes
(scheduler, API, etc.)
"""
from modules.activity_status import get_activity_manager
app_state = get_app_state()
# Get active syncs from activity_manager (database-backed, works across processes)
activity_manager = get_activity_manager(app_state.db if app_state else None)
# Get all active background tasks of type 'paid_content_sync'
all_tasks = activity_manager.get_active_background_tasks()
paid_content_syncs = [
task for task in all_tasks
if task.get('task_type') == 'paid_content_sync'
]
# Convert to the format expected by the frontend
syncs = []
for task in paid_content_syncs:
extra_data = task.get('extra_data', {}) or {}
sync_data = {
'creator_id': extra_data.get('creator_id'),
'creator': extra_data.get('creator'),
'platform': extra_data.get('platform'),
'phase': extra_data.get('phase', 'syncing'),
'status': task.get('detailed_status') or task.get('status', 'Running'),
'started_at': task.get('start_time'),
'updated_at': task.get('updated_at'),
**extra_data
}
if task.get('progress'):
sync_data['progress'] = task['progress'].get('current')
sync_data['total_files'] = task['progress'].get('total')
syncs.append(sync_data)
return {
"syncs": syncs,
"count": len(syncs)
}
@router.get("/dashboard/failed-downloads")
@limiter.limit("60/minute")
@handle_exceptions
async def get_failed_downloads(
request: Request,
current_user: Dict = Depends(get_current_user)
):
"""Get failed downloads for retry"""
db = _get_db_adapter()
failed = db.get_failed_downloads()
return {"downloads": failed, "count": len(failed)}
@router.post("/dashboard/retry-failed")
@limiter.limit("10/minute")
@handle_exceptions
async def retry_failed_downloads(
request: Request,
background_tasks: BackgroundTasks,
body: RetryDownloadsRequest,
current_user: Dict = Depends(get_current_user)
):
"""Retry failed downloads"""
background_tasks.add_task(_retry_downloads_background, body.attachment_ids)
return {"status": "queued", "count": len(body.attachment_ids)}
@router.post("/dashboard/scan-missing")
@limiter.limit("5/minute")
@handle_exceptions
async def scan_missing_files(
request: Request,
current_user: Dict = Depends(get_current_user)
):
"""
Scan all completed attachments and check if files exist on disk.
Updates status to 'missing' for files that don't exist.
"""
db = _get_db_adapter()
result = db.scan_missing_files()
return {
"status": "completed",
"scanned": result['scanned'],
"missing": result['missing'],
"found": result['found'],
"missing_files": result['missing_files'][:50] # Limit response size
}
@router.get("/dashboard/missing-files")
@limiter.limit("60/minute")
@handle_exceptions
async def get_missing_files(
request: Request,
limit: int = Query(default=100, le=500),
current_user: Dict = Depends(get_current_user)
):
"""Get list of attachments with missing files"""
db = _get_db_adapter()
missing = db.get_missing_attachments(limit=limit)
return {"files": missing, "count": len(missing)}
class ResetMissingRequest(BaseModel):
attachment_ids: Optional[List[int]] = None
reset_all: bool = False
@router.post("/dashboard/reset-missing")
@limiter.limit("10/minute")
@handle_exceptions
async def reset_missing_files(
request: Request,
body: ResetMissingRequest,
current_user: Dict = Depends(get_current_user)
):
"""Reset missing attachments to pending for re-download"""
db = _get_db_adapter()
if body.reset_all:
count = db.reset_missing_to_pending()
elif body.attachment_ids:
count = db.reset_missing_to_pending(body.attachment_ids)
else:
return {"status": "error", "message": "Provide attachment_ids or set reset_all=true"}
return {"status": "success", "reset_count": count}
class QualityRecheckRequest(BaseModel):
attachment_ids: List[int]
@router.post("/attachments/recheck-quality")
@limiter.limit("10/minute")
@handle_exceptions
async def recheck_attachment_quality(
request: Request,
background_tasks: BackgroundTasks,
body: QualityRecheckRequest,
current_user: Dict = Depends(get_current_user)
):
"""Re-check Fansly attachments for higher quality variants"""
background_tasks.add_task(_recheck_quality_background, body.attachment_ids)
return {"status": "queued", "count": len(body.attachment_ids)}
async def _recheck_quality_background(attachment_ids: List[int]):
"""Background task to recheck quality for specified attachments"""
from modules.paid_content.fansly_direct_client import FanslyDirectClient
db = _get_db_adapter()
# Get Fansly auth token
service = db.get_service('fansly_direct')
if not service or not service.get('session_cookie'):
logger.error("No Fansly auth token configured for quality recheck", module="PaidContent")
return
auth_token = service['session_cookie']
results = []
upgraded_creator_ids = set()
async with FanslyDirectClient(auth_token) as client:
for att_id in attachment_ids:
try:
result = await client.recheck_attachment_quality(att_id, db)
results.append({'attachment_id': att_id, **result})
if result.get('upgraded') and result.get('creator_id'):
upgraded_creator_ids.add(result['creator_id'])
except Exception as e:
logger.error(f"Quality recheck failed for attachment {att_id}: {e}", module="PaidContent")
results.append({'attachment_id': att_id, 'upgraded': False, 'error': str(e)})
# Delete upgraded posts and trigger re-sync to get fresh 4K versions
if upgraded_creator_ids:
for att_id in attachment_ids:
att = db.get_attachment(att_id)
if not att or not att.get('post_id'):
continue
post = db.get_post(att['post_id'])
if not post or post.get('creator_id') not in upgraded_creator_ids:
continue
# Only delete if this attachment was actually upgraded
result_for_att = next((r for r in results if r.get('attachment_id') == att_id and r.get('upgraded')), None)
if not result_for_att:
continue
# Delete the old file
local_path = att.get('local_path')
if local_path:
import os
try:
if os.path.exists(local_path):
os.unlink(local_path)
logger.info(f"Deleted old file for quality upgrade: {local_path}", module="PaidContent")
except Exception as e:
logger.warning(f"Failed to delete old file {local_path}: {e}", module="PaidContent")
# Delete attachment and post from DB
post_id = att['post_id']
with db.get_connection(for_write=True) as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM paid_content_attachments WHERE id = ?", (att_id,))
remaining = cursor.execute("SELECT COUNT(*) FROM paid_content_attachments WHERE post_id = ?", (post_id,)).fetchone()[0]
if remaining == 0:
cursor.execute("DELETE FROM paid_content_posts WHERE id = ?", (post_id,))
conn.commit()
logger.info(f"Deleted attachment {att_id} and post {post_id} for quality upgrade re-sync", module="PaidContent")
# Trigger a full sync for each creator to re-discover posts at 4K
for creator_id in upgraded_creator_ids:
try:
scraper = _get_scraper()
await scraper.sync_creator(creator_id, download=True)
await scraper.close()
logger.info(f"Quality upgrade re-sync completed for creator {creator_id}", module="PaidContent")
except Exception as e:
logger.error(f"Quality upgrade re-sync failed for creator {creator_id}: {e}", module="PaidContent")
# Broadcast WebSocket event
try:
app_state = get_app_state()
ws = getattr(app_state, 'websocket_manager', None)
if ws:
ws.broadcast_sync({
'type': 'quality_recheck_complete',
'results': results,
'upgraded_count': sum(1 for r in results if r.get('upgraded')),
'total_checked': len(results),
})
except Exception as e:
logger.warning(f"WebSocket broadcast failed for quality recheck: {e}", module="PaidContent")
async def _auto_quality_recheck_background(unified_db=None):
"""Automatically recheck quality for flagged attachments after Fansly sync"""
from modules.paid_content.fansly_direct_client import FanslyDirectClient
from modules.paid_content import PaidContentDBAdapter
if unified_db:
db = PaidContentDBAdapter(unified_db)
else:
db = _get_db_adapter()
candidates = db.get_quality_recheck_candidates(max_attempts=24)
if not candidates:
return
logger.info(f"Auto quality recheck: {len(candidates)} candidates found", module="PaidContent")
# Get Fansly auth token
service = db.get_service('fansly_direct')
if not service or not service.get('session_cookie'):
return
auth_token = service['session_cookie']
upgraded_creator_ids = set()
upgraded_attachments = []
async with FanslyDirectClient(auth_token) as client:
for candidate in candidates:
try:
result = await client.recheck_attachment_quality(candidate['id'], db)
if result.get('upgraded') and result.get('creator_id'):
upgraded_creator_ids.add(result['creator_id'])
upgraded_attachments.append(candidate['id'])
logger.info(
f"Auto recheck upgraded attachment {candidate['id']}: "
f"{result.get('old_width')}x{result.get('old_height')} -> "
f"{result.get('new_width')}x{result.get('new_height')}",
module="PaidContent"
)
except Exception as e:
logger.error(f"Auto quality recheck failed for {candidate['id']}: {e}", module="PaidContent")
# Delete upgraded posts and trigger re-sync to get fresh 4K versions
if upgraded_creator_ids:
import os
for att_id in upgraded_attachments:
att = db.get_attachment(att_id)
if not att or not att.get('post_id'):
continue
# Delete the old file
local_path = att.get('local_path')
if local_path:
try:
if os.path.exists(local_path):
os.unlink(local_path)
logger.info(f"Deleted old file for quality upgrade: {local_path}", module="PaidContent")
except Exception as e:
logger.warning(f"Failed to delete old file {local_path}: {e}", module="PaidContent")
# Delete attachment and post from DB
post_id = att['post_id']
with db.get_connection(for_write=True) as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM paid_content_attachments WHERE id = ?", (att_id,))
remaining = cursor.execute("SELECT COUNT(*) FROM paid_content_attachments WHERE post_id = ?", (post_id,)).fetchone()[0]
if remaining == 0:
cursor.execute("DELETE FROM paid_content_posts WHERE id = ?", (post_id,))
conn.commit()
logger.info(f"Deleted attachment {att_id} and post {post_id} for quality upgrade re-sync", module="PaidContent")
# Trigger a full sync for each creator to re-discover posts at 4K
for creator_id in upgraded_creator_ids:
try:
scraper = _get_scraper()
await scraper.sync_creator(creator_id, download=True)
await scraper.close()
logger.info(f"Quality upgrade re-sync completed for creator {creator_id}", module="PaidContent")
except Exception as e:
logger.error(f"Quality upgrade re-sync failed for creator {creator_id}: {e}", module="PaidContent")
# ============================================================================
# DOWNLOAD QUEUE ENDPOINTS
# ============================================================================
@router.get("/queue")
@limiter.limit("60/minute")
@handle_exceptions
async def get_download_queue(
request: Request,
status: Optional[str] = Query(None, description="Filter by status: pending, downloading, failed"),
creator_id: Optional[int] = None,
limit: int = Query(100, description="Max items per status category (0 = all)"),
current_user: Dict = Depends(get_current_user)
):
"""Get download queue with pending, downloading, and failed items"""
from modules.activity_status import get_activity_manager
app_state = get_app_state()
db = _get_db_adapter()
activity_manager = get_activity_manager(app_state.db if app_state else None)
# Auto-cleanup: if no active syncs, reset any "downloading" items to "pending"
# This handles cases where the server was restarted mid-download
# Check both activity_manager (works across processes) and app_state (local)
all_tasks = activity_manager.get_active_background_tasks()
has_active_syncs = any(t.get('task_type') == 'paid_content_sync' for t in all_tasks)
if not has_active_syncs and hasattr(app_state, 'active_paid_content_syncs'):
has_active_syncs = len(app_state.active_paid_content_syncs) > 0
if not has_active_syncs:
reset_count = db.reset_downloading_to_pending()
if reset_count > 0:
logger.info(f"Auto-reset {reset_count} stale downloading items to pending")
item_limit = limit if limit > 0 else None
# Get counts efficiently (always), items only up to limit
result = {
'pending': [],
'downloading': [],
'failed': [],
'counts': {'pending': 0, 'downloading': 0, 'failed': 0}
}
if status is None or status == 'pending':
result['counts']['pending'] = db.get_pending_attachment_count(creator_id=creator_id)
result['pending'] = db.get_pending_attachments(creator_id=creator_id, limit=item_limit)
if status is None or status == 'downloading':
result['counts']['downloading'] = db.get_downloading_attachment_count(creator_id=creator_id)
result['downloading'] = db.get_downloading_attachments(creator_id=creator_id, limit=item_limit)
if status is None or status == 'failed':
result['counts']['failed'] = db.get_failed_attachment_count()
result['failed'] = db.get_failed_downloads(limit=item_limit)
return result
@router.delete("/queue/{attachment_id}")
@limiter.limit("30/minute")
@handle_exceptions
async def remove_from_queue(
request: Request,
attachment_id: int,
current_user: Dict = Depends(get_current_user)
):
"""Remove a single item from the download queue"""
db = _get_db_adapter()
success = db.update_attachment_status(attachment_id, 'skipped',
error_message='Removed from queue by user')
if not success:
raise NotFoundError(f"Attachment {attachment_id} not found")
return {"status": "removed", "id": attachment_id}
@router.post("/queue/remove")
@limiter.limit("10/minute")
@handle_exceptions
async def remove_multiple_from_queue(
request: Request,
body: RemoveFromQueueRequest,
current_user: Dict = Depends(get_current_user)
):
"""Remove multiple items from the download queue"""
db = _get_db_adapter()
removed = 0
for att_id in body.attachment_ids:
if db.update_attachment_status(att_id, 'skipped',
error_message='Removed from queue by user'):
removed += 1
return {"status": "removed", "count": removed}
@router.put("/queue/{attachment_id}")
@limiter.limit("30/minute")
@handle_exceptions
async def update_queue_item(
request: Request,
attachment_id: int,
body: UpdateQueueItemRequest,
current_user: Dict = Depends(get_current_user)
):
"""Update queue item settings (e.g., auto_requeue)"""
db = _get_db_adapter()
updates = {}
if body.auto_requeue is not None:
updates['auto_requeue'] = 1 if body.auto_requeue else 0
if updates:
success = db.update_attachment(attachment_id, updates)
if not success:
raise NotFoundError(f"Attachment {attachment_id} not found")
return {"status": "updated", "id": attachment_id}
@router.post("/queue/stop-all")
@limiter.limit("5/minute")
@handle_exceptions
async def stop_all_downloads(
request: Request,
current_user: Dict = Depends(get_current_user)
):
"""Stop all active downloads and clear the queue"""
from modules.activity_status import get_activity_manager
app_state = get_app_state()
db = _get_db_adapter()
activity_manager = get_activity_manager(app_state.db if app_state else None)
# Cancel active syncs via activity_manager (database-backed, works across processes)
stopped_syncs = 0
all_tasks = activity_manager.get_active_background_tasks()
for task in all_tasks:
if task.get('task_type') == 'paid_content_sync':
activity_manager.stop_background_task(task['task_id'])
stopped_syncs += 1
# Also clear from app_state for backwards compatibility
if hasattr(app_state, 'active_paid_content_syncs'):
sync_ids = list(app_state.active_paid_content_syncs.keys())
for sync_id in sync_ids:
app_state.active_paid_content_syncs.pop(sync_id, None)
# Mark downloading items as pending (so they can be resumed later)
downloading_reset = db.reset_downloading_to_pending()
return {
"status": "stopped",
"syncs_cancelled": stopped_syncs,
"downloads_paused": downloading_reset
}
@router.post("/queue/clear-failed")
@limiter.limit("10/minute")
@handle_exceptions
async def clear_failed_downloads(
request: Request,
current_user: Dict = Depends(get_current_user)
):
"""Clear all failed downloads (mark as skipped)"""
db = _get_db_adapter()
count = db.clear_failed_downloads()
return {"status": "cleared", "count": count}
# ============================================================================
# SERVICES ENDPOINTS
# ============================================================================
async def _check_single_service_health(service: dict, app_state) -> dict:
"""Check health for a single service. Returns health dict."""
service_id = service['id']
if service_id == 'youtube':
from modules.paid_content import YouTubeClient
youtube = YouTubeClient()
return {'status': 'healthy', 'message': 'yt-dlp is available'} if youtube.is_available() \
else {'status': 'down', 'message': 'yt-dlp not found'}
elif service_id == 'twitch':
from modules.paid_content import TwitchClient
twitch = TwitchClient()
return {'status': 'healthy', 'message': 'yt-dlp is available for Twitch clips'} if twitch.is_available() \
else {'status': 'down', 'message': 'yt-dlp not found'}
elif service_id == 'fansly_direct':
from modules.paid_content import FanslyDirectClient
auth_token = service.get('session_cookie')
if not auth_token:
return {'status': 'down', 'message': 'Auth token not configured'}
client = FanslyDirectClient(auth_token=auth_token)
# Skip rate limiting for health checks
client._init_rate_limiter(min_delay=0, max_delay=0, batch_delay_min=0, batch_delay_max=0)
try:
result = await client.check_auth()
if result.get('valid'):
return {'status': 'healthy', 'message': f"Connected as {result.get('username', 'unknown')}"}
return {'status': 'down', 'message': result.get('error', 'Auth failed')}
finally:
await client.close()
elif service_id == 'onlyfans_direct':
from modules.paid_content import OnlyFansClient
import json as _json
raw = service.get('session_cookie')
if not raw:
return {'status': 'down', 'message': 'Credentials not configured'}
try:
auth_config = _json.loads(raw)
except (_json.JSONDecodeError, TypeError):
auth_config = {}
if not auth_config.get('sess'):
return {'status': 'down', 'message': 'Credentials not configured'}
client = OnlyFansClient(auth_config=auth_config, signing_url=auth_config.get('signing_url'))
# Skip rate limiting for health checks
client._init_rate_limiter(min_delay=0, max_delay=0, batch_delay_min=0, batch_delay_max=0)
try:
result = await client.check_auth()
if result.get('valid'):
return {'status': 'healthy', 'message': f"Connected as {result.get('username', 'unknown')}"}
return {'status': 'down', 'message': result.get('error', 'Auth failed')}
finally:
await client.close()
elif service_id == 'pornhub':
from modules.paid_content.pornhub_client import PornhubClient
pornhub = PornhubClient(unified_db=app_state.db)
if not pornhub.is_available():
return {'status': 'down', 'message': 'yt-dlp not found'}
# Check cookie health from scrapers table
try:
scraper = app_state.db.get_scraper('pornhub')
if scraper:
if scraper.get('last_test_status') == 'failed':
msg = scraper.get('last_test_message') or 'Cookies expired'
return {'status': 'degraded', 'message': f'yt-dlp available but cookies bad: {msg}'}
if scraper.get('cookies_json'):
return {'status': 'healthy', 'message': 'yt-dlp available with cookies'}
except Exception:
pass
return {'status': 'healthy', 'message': 'yt-dlp available (no cookies — some content may be restricted)'}
elif service_id == 'xhamster':
from modules.paid_content.xhamster_client import XHamsterClient
xhamster = XHamsterClient(unified_db=app_state.db)
if xhamster.is_available():
return {'status': 'healthy', 'message': 'yt-dlp is available for XHamster'}
return {'status': 'down', 'message': 'yt-dlp not found'}
elif service_id == 'tiktok':
from modules.paid_content.tiktok_client import TikTokClient
tiktok = TikTokClient(unified_db=app_state.db)
if not tiktok.ytdlp_path:
return {'status': 'down', 'message': 'yt-dlp not found'}
if not tiktok.is_available():
return {'status': 'degraded', 'message': 'gallery-dl not found (downloads may fail)'}
# Check cookie health from scrapers table
try:
scraper = app_state.db.get_scraper('tiktok')
if scraper:
if scraper.get('last_test_status') == 'failed':
msg = scraper.get('last_test_message') or 'Cookies expired or invalid'
return {'status': 'degraded', 'message': f'Tools available but cookies bad: {msg}'}
cookies_json = scraper.get('cookies_json')
if not cookies_json:
return {'status': 'degraded', 'message': 'Tools available but no cookies configured'}
except Exception:
pass
return {'status': 'healthy', 'message': 'yt-dlp and gallery-dl available with cookies'}
elif service_id == 'instagram':
# Instagram on paid content uses ImgInn API (no login cookies required)
# Just verify imginn.com is reachable and FlareSolverr handles Cloudflare
import httpx
try:
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
resp = await client.get('https://imginn.com', headers={
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
})
# 403 from Cloudflare is normal — FlareSolverr handles bypass at runtime
if resp.status_code in (200, 403):
return {'status': 'healthy', 'message': 'ImgInn API available (via FlareSolverr)'}
return {'status': 'degraded', 'message': f'ImgInn returned HTTP {resp.status_code}'}
except Exception as e:
return {'status': 'down', 'message': f'Cannot reach ImgInn: {str(e)}'}
elif service_id == 'soundgasm':
# Soundgasm requires no cookies — just check the site is reachable
import httpx
try:
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
resp = await client.get('https://soundgasm.net', headers={
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
})
if resp.status_code == 200:
return {'status': 'healthy', 'message': 'Soundgasm reachable (no cookies required)'}
return {'status': 'degraded', 'message': f'HTTP {resp.status_code}'}
except Exception as e:
return {'status': 'down', 'message': f'Cannot reach Soundgasm: {str(e)}'}
elif service_id == 'bellazon':
# Bellazon works with or without cookies — cookies unlock restricted content
import httpx
try:
session_cookie = service.get('session_cookie')
cookie_dict = {}
if session_cookie:
import json as _json
try:
parsed = _json.loads(session_cookie)
if isinstance(parsed, dict):
cookie_dict = parsed
elif isinstance(parsed, list):
cookie_dict = {c['name']: c['value'] for c in parsed if c.get('name') and c.get('value')}
except (_json.JSONDecodeError, TypeError):
cookie_dict = {}
base_url = service.get('base_url', 'https://www.bellazon.com/main')
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
}
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
resp = await client.get(base_url, headers=headers, cookies=cookie_dict)
if resp.status_code != 200:
return {'status': 'degraded', 'message': f'HTTP {resp.status_code}'}
body = resp.text
# Check if logged in by looking for sign-out link or member menu
logged_in = 'sign-out' in body.lower() or 'signout' in body.lower() or 'ipsUserPhoto' in body
if cookie_dict:
if logged_in:
return {'status': 'healthy', 'message': 'Logged in with cookies'}
else:
return {'status': 'degraded', 'message': 'Cookies set but not logged in — session may be expired'}
else:
return {'status': 'healthy', 'message': 'Reachable (no cookies — limited access)'}
except Exception as e:
return {'status': 'down', 'message': f'Cannot reach Bellazon: {str(e)}'}
elif service_id in ('hqcelebcorner', 'picturepub'):
# XenForo forums — check if the site is reachable (via FlareSolverr if needed)
import httpx
base_url = service.get('base_url', '')
try:
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
resp = await client.get(base_url, headers={
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
})
if resp.status_code in (200, 403):
# 403 is expected from Cloudflare — FlareSolverr handles bypass at runtime
return {'status': 'healthy', 'message': f'Forum reachable (HTTP {resp.status_code}, FlareSolverr used at runtime)'}
return {'status': 'degraded', 'message': f'HTTP {resp.status_code}'}
except Exception as e:
return {'status': 'down', 'message': f'Cannot reach forum: {str(e)}'}
elif service_id == 'coppermine':
# Coppermine is a pure HTTP scraper — no base_url needed (per-creator gallery URLs)
import httpx
try:
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
# Test reachability with a known Coppermine gallery host
resp = await client.get('https://www.google.com', headers={
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
})
return {'status': 'healthy', 'message': 'HTTP client available (per-creator gallery URLs)'}
except Exception as e:
return {'status': 'down', 'message': f'HTTP client error: {str(e)}'}
elif service_id == 'besteyecandy':
# BestEyeCandy — check if the site is reachable
import httpx
base_url = service.get('base_url', 'https://besteyecandy.com')
try:
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
resp = await client.get(base_url, headers={
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
})
if resp.status_code == 200:
return {'status': 'healthy', 'message': 'BestEyeCandy reachable'}
elif resp.status_code == 403:
return {'status': 'healthy', 'message': 'BestEyeCandy reachable (Cloudflare, FlareSolverr used at runtime)'}
return {'status': 'degraded', 'message': f'HTTP {resp.status_code}'}
except Exception as e:
return {'status': 'down', 'message': f'Cannot reach BestEyeCandy: {str(e)}'}
elif service_id == 'reddit':
# Reddit uses gallery-dl — check that gallery-dl is available
import shutil as _shutil
import os as _os
gdl = _shutil.which('gallery-dl') or '/opt/media-downloader/venv/bin/gallery-dl'
if _os.path.isfile(gdl):
# Also check cookie health from scrapers table
try:
scraper = app_state.db.get_scraper('gallerydl')
if scraper and scraper.get('last_test_status') == 'failed':
msg = scraper.get('last_test_message') or 'gallery-dl test failed'
return {'status': 'degraded', 'message': f'gallery-dl available but: {msg}'}
except Exception:
pass
return {'status': 'healthy', 'message': 'gallery-dl available for Reddit'}
return {'status': 'down', 'message': 'gallery-dl not found'}
elif service_id == 'snapchat':
# Snapchat paid content uses HTTP client (no cookies needed for public profiles)
import httpx
try:
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
resp = await client.get('https://story.snapchat.com', headers={
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
})
if resp.status_code in (200, 301, 302):
return {'status': 'healthy', 'message': 'Snapchat story endpoint reachable'}
return {'status': 'degraded', 'message': f'HTTP {resp.status_code}'}
except Exception as e:
return {'status': 'down', 'message': f'Cannot reach Snapchat: {str(e)}'}
else:
from modules.paid_content import PaidContentAPIClient
client = PaidContentAPIClient(
service_id,
session_cookie=service.get('session_cookie'),
base_url=service.get('base_url')
)
try:
return await client.check_health()
finally:
await client.close()
async def _run_auto_health_checks():
"""Run health checks for all services in the background with per-service timeout."""
global _auto_health_check_running, _last_auto_health_check
import time
try:
_auto_health_check_running = True
db = _get_db_adapter()
app_state = get_app_state()
services = db.get_services()
for service in services:
service_id = service['id']
try:
# 30s timeout per service to prevent blocking
health = await asyncio.wait_for(
_check_single_service_health(service, app_state),
timeout=30.0
)
db.update_service(service_id, {
'health_status': health.get('status', 'unknown'),
'last_health_check': datetime.now().isoformat()
})
except asyncio.TimeoutError:
logger.warning(f"Auto health check timed out for {service_id}", module="PaidContent")
db.update_service(service_id, {
'health_status': 'degraded',
'last_health_check': datetime.now().isoformat()
})
except Exception as e:
logger.error(f"Auto health check failed for {service_id}: {e}", module="PaidContent")
db.update_service(service_id, {
'health_status': 'down',
'last_health_check': datetime.now().isoformat()
})
_last_auto_health_check = time.time()
logger.info("Auto health checks completed", module="PaidContent")
except Exception as e:
logger.error(f"Auto health check error: {e}", module="PaidContent")
finally:
_auto_health_check_running = False
@router.get("/services")
@limiter.limit("60/minute")
@handle_exceptions
async def get_services(
request: Request,
current_user: Dict = Depends(get_current_user)
):
"""Get all services with health status"""
global _last_auto_health_check
import time
db = _get_db_adapter()
services = db.get_services()
# Auto-trigger health checks if stale (not checked in last 5 minutes)
if not _auto_health_check_running:
now = time.time()
needs_check = _last_auto_health_check is None or (now - _last_auto_health_check) > _AUTO_HEALTH_CHECK_INTERVAL
if needs_check:
_last_auto_health_check = now # Set immediately to prevent duplicate triggers
if any(not s.get('last_health_check') or s.get('health_status') in ('unknown', None, '') for s in services):
# First check or stale data — run synchronously so response has fresh data
await _run_auto_health_checks()
services = db.get_services()
else:
asyncio.create_task(_run_auto_health_checks())
return {"services": services}
@router.get("/services/{service_id}")
@limiter.limit("60/minute")
@handle_exceptions
async def get_service(
request: Request,
service_id: str,
current_user: Dict = Depends(get_current_user)
):
"""Get single service details"""
db = _get_db_adapter()
service = db.get_service(service_id)
if not service:
raise NotFoundError(f"Service '{service_id}' not found")
return service
@router.get("/services/{service_id}/health")
@limiter.limit("30/minute")
@handle_exceptions
async def check_service_health(
request: Request,
service_id: str,
current_user: Dict = Depends(get_current_user)
):
"""Check service health"""
db = _get_db_adapter()
app_state = get_app_state()
service = db.get_service(service_id)
if not service:
raise NotFoundError(f"Service '{service_id}' not found")
try:
health = await asyncio.wait_for(
_check_single_service_health(service, app_state),
timeout=30.0
)
except asyncio.TimeoutError:
health = {'status': 'degraded', 'message': 'Health check timed out'}
except Exception as e:
health = {'status': 'down', 'message': str(e)}
db.update_service(service_id, {
'health_status': health.get('status', 'unknown'),
'last_health_check': datetime.now().isoformat()
})
return health
@router.post("/services/check-all-health")
@limiter.limit("6/minute")
@handle_exceptions
async def check_all_services_health(
request: Request,
current_user: Dict = Depends(get_current_user)
):
"""Check health of all services"""
db = _get_db_adapter()
app_state = get_app_state()
services = db.get_services()
results = {}
for service in services:
service_id = service['id']
try:
health = await asyncio.wait_for(
_check_single_service_health(service, app_state),
timeout=30.0
)
db.update_service(service_id, {
'health_status': health.get('status', 'unknown'),
'last_health_check': datetime.now().isoformat()
})
results[service_id] = health
except asyncio.TimeoutError:
logger.warning(f"Health check timed out for {service_id}", module="PaidContent")
db.update_service(service_id, {
'health_status': 'degraded',
'last_health_check': datetime.now().isoformat()
})
results[service_id] = {'status': 'degraded', 'message': 'Health check timed out'}
except Exception as e:
logger.error(f"Health check failed for {service_id}: {e}", module="PaidContent")
db.update_service(service_id, {
'health_status': 'down',
'last_health_check': datetime.now().isoformat()
})
results[service_id] = {'status': 'down', 'message': str(e)}
return {'services': results, 'checked_at': datetime.now().isoformat()}
@router.post("/services/{service_id}/session")
@limiter.limit("10/minute")
@handle_exceptions
async def update_session(
request: Request,
service_id: str,
body: UpdateSessionRequest,
current_user: Dict = Depends(get_current_user)
):
"""Update session cookie for service"""
db = _get_db_adapter()
success = db.update_service(service_id, {
'session_cookie': body.session_cookie,
'session_updated_at': datetime.now().isoformat()
})
if not success:
raise NotFoundError(f"Service '{service_id}' not found")
return message_response("Session updated successfully")
class UpdateServiceUrlRequest(BaseModel):
base_url: str
@router.put("/services/{service_id}/url")
@limiter.limit("10/minute")
@handle_exceptions
async def update_service_url(
request: Request,
service_id: str,
body: UpdateServiceUrlRequest,
current_user: Dict = Depends(get_current_user)
):
"""Update base URL for service (when domains change)"""
db = _get_db_adapter()
# Validate URL format
url = body.base_url.strip().rstrip('/')
if not url.startswith('https://') and not url.startswith('http://'):
raise ValidationError("URL must start with http:// or https://")
# Remove /api/v1 suffix if present (we add it automatically)
if '/api/v1' in url:
url = url.replace('/api/v1', '').rstrip('/')
success = db.update_service(service_id, {
'base_url': url,
'updated_at': datetime.now().isoformat(),
'health_status': 'unknown' # Reset health status since URL changed
})
if not success:
raise NotFoundError(f"Service '{service_id}' not found")
logger.info(f"Updated {service_id} base URL to: {url}", module="PaidContent")
return message_response("Service URL updated successfully")
# ============================================================================
# FANSLY DIRECT ENDPOINTS
# ============================================================================
class FanslyVerifyAuthRequest(BaseModel):
auth_token: str
class FanslyDirectSyncRequest(BaseModel):
username: str
date_from: Optional[str] = None
date_to: Optional[str] = None
days_back: Optional[int] = None
download: bool = True
@router.post("/fansly-direct/verify-auth")
@limiter.limit("10/minute")
@handle_exceptions
async def verify_fansly_auth(
request: Request,
body: FanslyVerifyAuthRequest,
current_user: Dict = Depends(get_current_user)
):
"""Verify Fansly auth token and return account info"""
from modules.paid_content import FanslyDirectClient
client = FanslyDirectClient(auth_token=body.auth_token)
try:
result = await client.check_auth()
return result
finally:
await client.close()
@router.post("/fansly-direct/sync")
@limiter.limit("10/minute")
@handle_exceptions
async def sync_fansly_direct(
request: Request,
body: FanslyDirectSyncRequest,
background_tasks: BackgroundTasks,
current_user: Dict = Depends(get_current_user)
):
"""
Sync a Fansly creator via direct API.
Auto-adds the creator if not already tracked.
"""
db = _get_db_adapter()
# Check if auth token is configured
service = db.get_service('fansly_direct')
if not service or not service.get('session_cookie'):
raise ValidationError("Fansly auth token not configured. Please set it in Settings.")
# Check if creator exists, if not add them
# Try by creator_id first, then by username (creator_id may be numeric Fansly ID)
existing = db.get_creator_by_api_id('fansly_direct', 'fansly', body.username)
if not existing:
existing = db.get_creator_by_username('fansly_direct', 'fansly', body.username)
if existing:
creator_id = existing['id']
else:
# Add new creator
scraper = _get_scraper()
try:
result = await scraper.add_creator(
service_id='fansly_direct',
platform='fansly',
creator_id=body.username,
auto_download=body.download
)
finally:
await scraper.close()
if not result.get('success'):
raise ValidationError(result.get('error', 'Failed to add creator'))
creator_id = result['creator']['id']
# Build date filter info for response
date_filter = None
if body.date_from or body.date_to:
date_filter = {'from': body.date_from, 'to': body.date_to}
elif body.days_back:
date_filter = {'days_back': body.days_back}
# Queue background sync with date filters
background_tasks.add_task(
_sync_fansly_direct_background,
creator_id,
body.download,
body.date_from,
body.date_to,
body.days_back
)
return {
"status": "queued",
"creator_id": creator_id,
"username": body.username,
"date_filter": date_filter
}
async def _sync_fansly_direct_background(
creator_id: int,
download: bool,
date_from: str = None,
date_to: str = None,
days_back: int = None
):
"""Background task for Fansly Direct sync with date filters"""
scraper = _get_scraper()
try:
creator = scraper.db.get_creator(creator_id)
if creator and creator.get('service_id') == 'fansly_direct':
await scraper._sync_fansly_direct_creator(
creator,
download=download,
date_from=date_from,
date_to=date_to,
days_back=days_back
)
finally:
await scraper.close()
# Auto-recheck quality for flagged attachments after sync
try:
await _auto_quality_recheck_background()
except Exception as e:
logger.error(f"Auto quality recheck after sync failed: {e}", module="PaidContent")
# ============================================================================
# ONLYFANS DIRECT ENDPOINTS
# ============================================================================
class OnlyFansVerifyAuthRequest(BaseModel):
sess: str
auth_id: str
auth_uid: Optional[str] = None
x_bc: Optional[str] = None
user_agent: Optional[str] = None
signing_url: Optional[str] = None
class OnlyFansDirectSyncRequest(BaseModel):
username: str
date_from: Optional[str] = None
date_to: Optional[str] = None
days_back: Optional[int] = None
download: bool = True
@router.post("/onlyfans-direct/verify-auth")
@limiter.limit("10/minute")
@handle_exceptions
async def verify_onlyfans_auth(
request: Request,
body: OnlyFansVerifyAuthRequest,
current_user: Dict = Depends(get_current_user)
):
"""Verify OnlyFans credentials and return account info.
Also saves/updates the credentials in the database on success."""
from modules.paid_content import OnlyFansClient
import json as _json
# Default user-agent and x-bc if not provided (same approach as Coomer importers)
default_ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
auth_config = {
'sess': body.sess,
'auth_id': body.auth_id,
'auth_uid': body.auth_uid or '',
'x_bc': body.x_bc or '',
'user_agent': body.user_agent or default_ua,
}
client = OnlyFansClient(
auth_config=auth_config,
signing_url=body.signing_url,
)
try:
result = await client.check_auth()
# If valid, save credentials to database
if result.get('valid'):
db = _get_db_adapter()
cred_blob = _json.dumps({
**auth_config,
'signing_url': body.signing_url or '',
})
db.update_service('onlyfans_direct', {'session_cookie': cred_blob})
return result
finally:
await client.close()
@router.post("/onlyfans-direct/sync")
@limiter.limit("10/minute")
@handle_exceptions
async def sync_onlyfans_direct(
request: Request,
body: OnlyFansDirectSyncRequest,
background_tasks: BackgroundTasks,
current_user: Dict = Depends(get_current_user)
):
"""
Sync an OnlyFans creator via direct API.
Auto-adds the creator if not already tracked.
"""
db = _get_db_adapter()
# Check if credentials are configured
service = db.get_service('onlyfans_direct')
if not service or not service.get('session_cookie'):
raise ValidationError("OnlyFans credentials not configured. Please set them in Settings.")
# Check if creator exists, if not add them
existing = db.get_creator_by_api_id('onlyfans_direct', 'onlyfans', body.username)
if existing:
creator_id = existing['id']
else:
scraper = _get_scraper()
try:
result = await scraper.add_creator(
service_id='onlyfans_direct',
platform='onlyfans',
creator_id=body.username,
auto_download=body.download,
)
finally:
await scraper.close()
if not result.get('success'):
raise ValidationError(result.get('error', 'Failed to add creator'))
creator_id = result['creator']['id']
# Build date filter info for response
date_filter = None
if body.date_from or body.date_to:
date_filter = {'from': body.date_from, 'to': body.date_to}
elif body.days_back:
date_filter = {'days_back': body.days_back}
# Queue background sync
background_tasks.add_task(
_sync_onlyfans_direct_background,
creator_id,
body.download,
body.date_from,
body.date_to,
body.days_back,
)
return {
"status": "queued",
"creator_id": creator_id,
"username": body.username,
"date_filter": date_filter,
}
async def _sync_onlyfans_direct_background(
creator_id: int,
download: bool,
date_from: str = None,
date_to: str = None,
days_back: int = None,
):
"""Background task for OnlyFans Direct sync with date filters"""
scraper = _get_scraper()
try:
creator = scraper.db.get_creator(creator_id)
if creator and creator.get('service_id') == 'onlyfans_direct':
await scraper._sync_onlyfans_direct_creator(
creator,
download=download,
date_from=date_from,
date_to=date_to,
days_back=days_back,
)
finally:
await scraper.close()
# ============================================================================
# ATTACHMENT MANAGEMENT ENDPOINTS
# ============================================================================
@router.post("/posts/{post_id}/attachments/upload")
@limiter.limit("30/minute")
@handle_exceptions
async def upload_attachment(
request: Request,
post_id: int,
file: UploadFile = File(...),
current_user: Dict = Depends(get_current_user)
):
"""
Upload a new attachment to a post.
Saves file to post's directory and creates attachment record.
"""
import shutil
db = _get_db_adapter()
# Get post and creator info
post = db.get_post(post_id)
if not post:
raise NotFoundError(f"Post {post_id} not found")
creator = db.get_creator(post['creator_id'])
if not creator:
raise NotFoundError("Creator not found")
# Build output path
config = db.get_config()
base_path = Path(config.get('base_download_path', '/paid-content'))
# Organize by platform/username/date
published_at = post.get('published_at') or ''
post_date = published_at[:10] if published_at else 'unknown-date'
output_dir = base_path / creator['platform'] / creator['username'] / post_date
# Create directory if needed
output_dir.mkdir(parents=True, exist_ok=True)
# Generate unique filename if file exists
original_name = file.filename or 'uploaded_file'
output_path = output_dir / original_name
counter = 1
while output_path.exists():
name_parts = original_name.rsplit('.', 1)
if len(name_parts) == 2:
output_path = output_dir / f"{name_parts[0]}_{counter}.{name_parts[1]}"
else:
output_path = output_dir / f"{original_name}_{counter}"
counter += 1
# Save file
try:
with open(output_path, 'wb') as f:
shutil.copyfileobj(file.file, f)
except Exception as e:
raise ValidationError(f"Failed to save file: {str(e)}")
# Get file info
file_size = output_path.stat().st_size
file_type = 'unknown'
ext = output_path.suffix.lower().lstrip('.')
image_exts = {'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'tiff', 'heic', 'heif', 'avif'}
video_exts = {'mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v', 'wmv', 'flv'}
audio_exts = {'mp3', 'wav', 'flac', 'aac', 'm4a', 'ogg'}
if ext in image_exts:
file_type = 'image'
elif ext in video_exts:
file_type = 'video'
elif ext in audio_exts:
file_type = 'audio'
# Get next attachment index
existing_attachments = db.get_post_attachments(post_id)
next_index = max((a.get('attachment_index', 0) for a in existing_attachments), default=-1) + 1
# Extract dimensions/duration for images and videos
width, height, duration = None, None, None
if file_type == 'image':
try:
from PIL import Image
with Image.open(output_path) as img:
width, height = img.size
except Exception:
pass
elif file_type == 'video':
try:
import subprocess as _sp
result = _sp.run(
['ffprobe', '-v', 'quiet', '-print_format', 'json',
'-show_streams', '-select_streams', 'v:0', str(output_path)],
capture_output=True, text=True, timeout=10
)
if result.returncode == 0:
probe = json.loads(result.stdout)
if probe.get('streams'):
stream = probe['streams'][0]
width = stream.get('width')
height = stream.get('height')
duration_str = stream.get('duration')
if duration_str:
duration = int(float(duration_str))
except Exception:
pass
# Create attachment record
attachment_data = {
'name': output_path.name,
'file_type': file_type,
'extension': ext,
'server_path': f'uploaded/{output_path.name}',
'file_size': file_size,
'width': width,
'height': height,
'duration': duration,
'status': 'completed',
'local_path': str(output_path),
'local_filename': output_path.name,
'attachment_index': next_index,
'downloaded_at': datetime.now().isoformat()
}
attachment_id = db.upsert_attachment(post_id, attachment_data)
# Invalidate stale thumbnail caches so they regenerate from the new file
db.update_attachment(attachment_id, {'thumbnail_data': None})
for thumb_size in ('small', 'medium', 'large', 'native'):
stale_thumb = Path(f"/opt/media-downloader/cache/thumbnails/{thumb_size}/{attachment_id}.jpg")
if stale_thumb.exists():
stale_thumb.unlink()
return {
"attachment_id": attachment_id,
"filename": output_path.name,
"file_type": file_type,
"file_size": file_size,
"width": width,
"height": height,
"duration": duration,
"local_path": str(output_path)
}
@router.delete("/posts/{post_id}/attachments/{attachment_id}")
@limiter.limit("30/minute")
@handle_exceptions
async def delete_attachment(
request: Request,
post_id: int,
attachment_id: int,
permanent: bool = False,
current_user: Dict = Depends(get_current_user)
):
"""
Delete an attachment from a post.
If permanent=True, deletes file and DB record.
If permanent=False, moves file to recycle bin.
"""
db = _get_db_adapter()
# Get attachment
attachment = db.get_attachment(attachment_id)
if not attachment:
raise NotFoundError(f"Attachment {attachment_id} not found")
if attachment['post_id'] != post_id:
raise ValidationError("Attachment does not belong to this post")
local_path = attachment.get('local_path')
if permanent:
# Delete file from disk
if local_path and Path(local_path).exists():
try:
Path(local_path).unlink()
except Exception as e:
logger.warning(f"Failed to delete file {local_path}: {e}", module="PaidContent")
# Delete from database
db.delete_attachment(attachment_id)
return {"success": True, "action": "deleted"}
else:
# Move to recycle bin
if local_path and Path(local_path).exists():
config = db.get_config()
recycle_path = Path(config.get('base_download_path', '/paid-content')) / '.recycle'
recycle_path.mkdir(parents=True, exist_ok=True)
dest_path = recycle_path / Path(local_path).name
counter = 1
while dest_path.exists():
name_parts = Path(local_path).name.rsplit('.', 1)
if len(name_parts) == 2:
dest_path = recycle_path / f"{name_parts[0]}_{counter}.{name_parts[1]}"
else:
dest_path = recycle_path / f"{Path(local_path).name}_{counter}"
counter += 1
try:
Path(local_path).rename(dest_path)
# Update attachment with recycle location
db.update_attachment(attachment_id, {
'status': 'deleted',
'error_message': f'Moved to recycle: {dest_path}'
})
except Exception as e:
logger.warning(f"Failed to move file to recycle: {e}", module="PaidContent")
raise ValidationError(f"Failed to move file: {str(e)}")
return {"success": True, "action": "recycled"}
@router.delete("/feed/{post_id}")
@limiter.limit("30/minute")
@handle_exceptions
async def delete_post(
request: Request,
post_id: int,
current_user: Dict = Depends(get_current_user)
):
"""Delete a post and all its attachments (files + DB records)."""
db = _get_db_adapter()
post = db.get_post(post_id)
if not post:
raise NotFoundError(f"Post {post_id} not found")
# Delete attachment files from disk
with db.get_connection() as conn:
cursor = conn.cursor()
cursor.execute('SELECT * FROM paid_content_attachments WHERE post_id = ?', (post_id,))
attachments = [dict(row) for row in cursor.fetchall()]
for att in attachments:
local_path = att.get('local_path')
if local_path:
try:
Path(local_path).unlink()
except FileNotFoundError:
pass
except OSError as e:
logger.warning(f"Could not delete {local_path}: {e}", module="PaidContent")
# Soft delete: set deleted_at timestamp (keeps record for sync dedup)
# Attachment DB records are kept so upsert_attachment finds them → no re-download
with db.get_connection(for_write=True) as conn:
cursor = conn.cursor()
cursor.execute("UPDATE paid_content_posts SET deleted_at = datetime('now') WHERE id = ?", (post_id,))
conn.commit()
return {"success": True, "message": "Post deleted"}
@router.post("/feed/bulk-delete")
@limiter.limit("10/minute")
@handle_exceptions
async def bulk_delete_posts(
request: Request,
body: BulkDeletePostsRequest,
current_user: Dict = Depends(get_current_user)
):
"""Bulk soft-delete posts and their attachment files."""
db = _get_db_adapter()
deleted_count = 0
for post_id in body.post_ids:
post = db.get_post(post_id)
if not post:
continue
# Delete attachment files from disk
with db.get_connection() as conn:
cursor = conn.cursor()
cursor.execute('SELECT * FROM paid_content_attachments WHERE post_id = ?', (post_id,))
attachments = [dict(row) for row in cursor.fetchall()]
for att in attachments:
local_path = att.get('local_path')
if local_path:
try:
Path(local_path).unlink()
except FileNotFoundError:
pass
except OSError as e:
logger.warning(f"Could not delete {local_path}: {e}", module="PaidContent")
# Soft delete: set deleted_at timestamp
with db.get_connection(for_write=True) as conn:
cursor = conn.cursor()
cursor.execute("UPDATE paid_content_posts SET deleted_at = datetime('now') WHERE id = ?", (post_id,))
conn.commit()
deleted_count += 1
return {"success": True, "deleted_count": deleted_count}
# ============================================================================
# CREATORS ENDPOINTS
# ============================================================================
@router.get("/creators")
@limiter.limit("60/minute")
@handle_exceptions
async def get_creators(
request: Request,
service: Optional[str] = None,
platform: Optional[str] = None,
identity_id: Optional[int] = None,
search: Optional[str] = None,
enabled_only: bool = False,
limit: int = Query(default=100, le=500),
offset: int = 0,
current_user: Dict = Depends(get_current_user)
):
"""Get tracked creators"""
db = _get_db_adapter()
creators = db.get_creators(
service_id=service,
platform=platform,
identity_id=identity_id,
search=search,
enabled_only=enabled_only,
limit=limit,
offset=offset
)
return {"creators": creators, "count": len(creators)}
@router.get("/creators/search")
@limiter.limit("30/minute")
@handle_exceptions
async def search_creators(
request: Request,
service_id: str,
query: str,
platform: Optional[str] = None,
current_user: Dict = Depends(get_current_user)
):
"""Search for creators on a service"""
scraper = _get_scraper()
try:
results = await scraper.search_creators(service_id, query, platform)
finally:
await scraper.close()
return {"results": results, "count": len(results)}
@router.get("/creators/{creator_id}")
@limiter.limit("60/minute")
@handle_exceptions
async def get_creator(
request: Request,
creator_id: int,
current_user: Dict = Depends(get_current_user)
):
"""Get single creator details"""
db = _get_db_adapter()
creator = db.get_creator(creator_id)
if not creator:
raise NotFoundError(f"Creator {creator_id} not found")
# Add stats
creator['stats'] = db.get_creator_stats(creator_id)
return creator
@router.post("/creators")
@limiter.limit("30/minute")
@handle_exceptions
async def add_creator(
request: Request,
body: AddCreatorRequest,
current_user: Dict = Depends(get_current_user)
):
"""Add new creator to track"""
scraper = _get_scraper()
try:
result = await scraper.add_creator(
service_id=body.service_id,
platform=body.platform,
creator_id=body.creator_id,
auto_download=body.auto_download,
download_embeds=body.download_embeds
)
finally:
await scraper.close()
if not result.get('success'):
raise ValidationError(result.get('error', 'Failed to add creator'))
return {"creator": result['creator'], "message": "Creator added successfully"}
class AddCreatorByUrlRequest(BaseModel):
url: str
auto_download: bool = True
download_embeds: bool = True
@router.post("/creators/by-url")
@limiter.limit("30/minute")
@handle_exceptions
async def add_creator_by_url(
request: Request,
body: AddCreatorByUrlRequest,
current_user: Dict = Depends(get_current_user)
):
"""Add new creator by parsing a Coomer/Kemono URL"""
from modules.paid_content.utils import parse_creator_url
parsed = parse_creator_url(body.url)
if not parsed:
raise ValidationError("Invalid URL. Expected format: https://onlyfans.com/username, https://coomer.party/onlyfans/user/creatorid, https://www.youtube.com/@channelhandle, https://www.pornhub.com/pornstar/name, https://xhamster.com/creators/name, https://www.tiktok.com/@username, https://www.instagram.com/username, https://besteyecandy.com/section/celeb-photogallery/cid-XXX/.../Name.html, or https://example.com/gallery/index.php (Coppermine gallery)")
service_id, platform, creator_id = parsed
scraper = _get_scraper()
try:
result = await scraper.add_creator(
service_id=service_id,
platform=platform,
creator_id=creator_id,
auto_download=body.auto_download,
download_embeds=body.download_embeds
)
finally:
await scraper.close()
if not result.get('success'):
raise ValidationError(result.get('error', 'Failed to add creator'))
return {"creator": result['creator'], "message": "Creator added successfully"}
@router.put("/creators/{creator_id}")
@limiter.limit("30/minute")
@handle_exceptions
async def update_creator(
request: Request,
creator_id: int,
body: UpdateCreatorRequest,
current_user: Dict = Depends(get_current_user)
):
"""Update creator settings"""
db = _get_db_adapter()
updates = body.dict(exclude_none=True)
if not updates:
raise ValidationError("No updates provided")
# Convert booleans to integers for database storage
for key in ['enabled', 'auto_download', 'download_embeds', 'sync_posts', 'sync_stories', 'sync_highlights', 'use_authenticated_api']:
if key in updates:
updates[key] = 1 if updates[key] else 0
# Convert filter_tagged_users list to JSON string
if 'filter_tagged_users' in updates:
updates['filter_tagged_users'] = json.dumps(updates['filter_tagged_users'])
success = db.update_creator(creator_id, updates)
if not success:
raise NotFoundError(f"Creator {creator_id} not found")
return message_response("Creator updated successfully")
@router.get("/creators/{creator_id}/tagged-users")
@limiter.limit("30/minute")
@handle_exceptions
async def get_creator_tagged_users(
request: Request,
creator_id: int,
current_user: Dict = Depends(get_current_user)
):
"""Get distinct tagged usernames for a creator's posts"""
db = _get_db_adapter()
return db.get_creator_tagged_usernames(creator_id)
@router.delete("/creators/{creator_id}")
@limiter.limit("10/minute")
@handle_exceptions
async def delete_creator(
request: Request,
creator_id: int,
delete_files: bool = False,
current_user: Dict = Depends(get_current_user)
):
"""Remove creator and optionally delete their files"""
db = _get_db_adapter()
# Get creator info before deletion
creator = db.get_creator(creator_id)
if not creator:
raise NotFoundError(f"Creator {creator_id} not found")
files_deleted = 0
# Optionally delete files from filesystem
if delete_files:
import shutil
config = db.get_config()
base_path = Path(config.get('base_download_path', '/paid-content'))
creator_path = base_path / creator['platform'] / creator['username']
if creator_path.exists() and creator_path.is_dir():
try:
# Count files before deletion
files_deleted = sum(1 for _ in creator_path.rglob('*') if _.is_file())
shutil.rmtree(creator_path)
except Exception as e:
# Log but don't fail - still delete from DB
logger.warning(f"Could not delete creator files: {e}", module="PaidContent")
success = db.delete_creator(creator_id)
if not success:
raise NotFoundError(f"Creator {creator_id} not found")
if delete_files and files_deleted > 0:
return message_response(f"Creator deleted successfully. Removed {files_deleted} files.")
return message_response("Creator deleted successfully")
@router.post("/creators/{creator_id}/sync")
@limiter.limit("10/minute")
@handle_exceptions
async def sync_creator(
request: Request,
creator_id: int,
background_tasks: BackgroundTasks,
download: bool = True,
force_backfill: bool = False,
current_user: Dict = Depends(get_current_user)
):
"""Manually trigger sync for creator"""
db = _get_db_adapter()
creator = db.get_creator(creator_id)
if not creator:
raise NotFoundError(f"Creator {creator_id} not found")
background_tasks.add_task(_sync_creator_background, creator_id, download, force_backfill)
return {"status": "queued", "creator": creator['username']}
@router.post("/creators/sync-service/{service_id}")
@limiter.limit("5/minute")
@handle_exceptions
async def sync_service_creators(
request: Request,
service_id: str,
background_tasks: BackgroundTasks,
current_user: Dict = Depends(get_current_user)
):
"""Sync all enabled creators for a specific service"""
db = _get_db_adapter()
creators = db.get_creators(service_id=service_id, enabled_only=True, limit=1000)
if not creators:
raise NotFoundError(f"No enabled creators found for service '{service_id}'")
usernames = [c['username'] for c in creators]
creator_ids = [c['id'] for c in creators]
background_tasks.add_task(_sync_service_background, creator_ids)
return {"status": "queued", "count": len(creators), "creators": usernames}
@router.post("/creators/sync-all")
@limiter.limit("5/minute")
@handle_exceptions
async def sync_all_creators(
request: Request,
background_tasks: BackgroundTasks,
current_user: Dict = Depends(get_current_user)
):
"""Sync all enabled creators"""
background_tasks.add_task(_sync_all_creators_background)
return {"status": "queued"}
@router.post("/download-queue/start")
@limiter.limit("5/minute")
@handle_exceptions
async def start_download_queue(
request: Request,
background_tasks: BackgroundTasks,
current_user: Dict = Depends(get_current_user)
):
"""Download all pending attachments without syncing metadata"""
async def _download_all_background():
try:
scraper = _get_scraper()
scraper.app_state = None # Disable app_state cancellation check for download-only
await scraper.download_all_pending()
await scraper.close()
except Exception as e:
import traceback
logger.error(f"Background download all failed: {e}\n{traceback.format_exc()}", module="PaidContent")
background_tasks.add_task(_download_all_background)
return {"status": "queued", "message": "Downloading pending files"}
# ============================================================================
# IDENTITIES ENDPOINTS
# ============================================================================
@router.get("/identities")
@limiter.limit("60/minute")
@handle_exceptions
async def get_identities(
request: Request,
current_user: Dict = Depends(get_current_user)
):
"""Get all identities"""
db = _get_db_adapter()
identities = db.get_identities()
return {"identities": identities}
@router.get("/identities/{identity_id}")
@limiter.limit("60/minute")
@handle_exceptions
async def get_identity(
request: Request,
identity_id: int,
current_user: Dict = Depends(get_current_user)
):
"""Get single identity with linked creators"""
db = _get_db_adapter()
identity = db.get_identity(identity_id)
if not identity:
raise NotFoundError(f"Identity {identity_id} not found")
# Get linked creators
identity['creators'] = db.get_creators(identity_id=identity_id)
return identity
@router.post("/identities")
@limiter.limit("30/minute")
@handle_exceptions
async def create_identity(
request: Request,
body: CreateIdentityRequest,
current_user: Dict = Depends(get_current_user)
):
"""Create new identity"""
db = _get_db_adapter()
identity_id = db.create_identity(body.name, body.notes)
return {"id": identity_id, "name": body.name, "message": "Identity created"}
@router.put("/identities/{identity_id}")
@limiter.limit("30/minute")
@handle_exceptions
async def update_identity(
request: Request,
identity_id: int,
body: UpdateIdentityRequest,
current_user: Dict = Depends(get_current_user)
):
"""Update identity"""
db = _get_db_adapter()
updates = body.dict(exclude_none=True)
if not updates:
raise ValidationError("No updates provided")
success = db.update_identity(identity_id, updates)
if not success:
raise NotFoundError(f"Identity {identity_id} not found")
return message_response("Identity updated successfully")
@router.delete("/identities/{identity_id}")
@limiter.limit("10/minute")
@handle_exceptions
async def delete_identity(
request: Request,
identity_id: int,
current_user: Dict = Depends(get_current_user)
):
"""Delete identity (unlinks creators but doesn't delete them)"""
db = _get_db_adapter()
success = db.delete_identity(identity_id)
if not success:
raise NotFoundError(f"Identity {identity_id} not found")
return message_response("Identity deleted successfully")
@router.post("/identities/{identity_id}/link")
@limiter.limit("30/minute")
@handle_exceptions
async def link_creator(
request: Request,
identity_id: int,
body: LinkCreatorRequest,
current_user: Dict = Depends(get_current_user)
):
"""Link creator to identity"""
db = _get_db_adapter()
success = db.link_creator_to_identity(body.creator_id, identity_id)
if not success:
raise NotFoundError("Creator or identity not found")
return message_response("Creator linked to identity")
@router.post("/identities/{identity_id}/unlink/{creator_id}")
@limiter.limit("30/minute")
@handle_exceptions
async def unlink_creator(
request: Request,
identity_id: int,
creator_id: int,
current_user: Dict = Depends(get_current_user)
):
"""Unlink creator from identity"""
db = _get_db_adapter()
success = db.unlink_creator_from_identity(creator_id)
return message_response("Creator unlinked from identity")
# ============================================================================
# TAG ENDPOINTS
# ============================================================================
@router.get("/tags")
@limiter.limit("60/minute")
@handle_exceptions
async def get_tags(
request: Request,
creator_ids: Optional[str] = Query(None, description="Comma-separated creator IDs to scope tags"),
current_user: Dict = Depends(get_current_user)
):
"""Get all tags, optionally scoped to specific creators"""
db = _get_db_adapter()
parsed_ids = [int(x) for x in creator_ids.split(',') if x.strip().isdigit()] if creator_ids else None
tags = db.get_tags(creator_ids=parsed_ids if parsed_ids else None)
return {"tags": tags}
@router.post("/tags")
@limiter.limit("30/minute")
@handle_exceptions
async def create_tag(
request: Request,
body: CreateTagRequest,
current_user: Dict = Depends(get_current_user)
):
"""Create a new tag"""
db = _get_db_adapter()
tag_id = db.create_tag(body.name, body.color, body.description)
if not tag_id:
raise ValidationError("Failed to create tag - name may already exist")
tag = db.get_tag(tag_id)
return {"tag": tag}
@router.put("/tags/{tag_id}")
@limiter.limit("30/minute")
@handle_exceptions
async def update_tag(
request: Request,
tag_id: int,
body: UpdateTagRequest,
current_user: Dict = Depends(get_current_user)
):
"""Update a tag"""
db = _get_db_adapter()
updates = body.model_dump(exclude_none=True)
if not updates:
raise ValidationError("No updates provided")
success = db.update_tag(tag_id, updates)
if not success:
raise NotFoundError(f"Tag {tag_id} not found")
tag = db.get_tag(tag_id)
return {"tag": tag}
@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"""
db = _get_db_adapter()
success = db.delete_tag(tag_id)
if not success:
raise NotFoundError(f"Tag {tag_id} not found")
return message_response("Tag deleted")
@router.post("/tags/add-to-posts")
@limiter.limit("60/minute")
@handle_exceptions
async def add_tags_to_posts(
request: Request,
body: TagPostsRequest,
current_user: Dict = Depends(get_current_user)
):
"""Add tags to multiple posts"""
db = _get_db_adapter()
added = 0
for post_id in body.post_ids:
for tag_id in body.tag_ids:
if db.add_tag_to_post(post_id, tag_id):
added += 1
return {"added": added}
@router.post("/tags/remove-from-posts")
@limiter.limit("60/minute")
@handle_exceptions
async def remove_tags_from_posts(
request: Request,
body: TagPostsRequest,
current_user: Dict = Depends(get_current_user)
):
"""Remove tags from multiple posts"""
db = _get_db_adapter()
removed = 0
for post_id in body.post_ids:
for tag_id in body.tag_ids:
if db.remove_tag_from_post(post_id, tag_id):
removed += 1
return {"removed": removed}
@router.get("/posts/{post_id}/tags")
@limiter.limit("60/minute")
@handle_exceptions
async def get_post_tags(
request: Request,
post_id: int,
current_user: Dict = Depends(get_current_user)
):
"""Get tags for a specific post"""
db = _get_db_adapter()
tags = db.get_post_tags(post_id)
return {"tags": tags}
@router.put("/posts/{post_id}/tags")
@limiter.limit("60/minute")
@handle_exceptions
async def set_post_tags(
request: Request,
post_id: int,
body: SetPostTagsRequest,
current_user: Dict = Depends(get_current_user)
):
"""Set tags for a post (replaces existing tags)"""
db = _get_db_adapter()
db.set_post_tags(post_id, body.tag_ids)
tags = db.get_post_tags(post_id)
return {"tags": tags}
class SetPostTaggedUsersRequest(BaseModel):
usernames: List[str]
@router.put("/posts/{post_id}/tagged-users")
@limiter.limit("60/minute")
@handle_exceptions
async def set_post_tagged_users(
request: Request,
post_id: int,
body: SetPostTaggedUsersRequest,
current_user: Dict = Depends(get_current_user)
):
"""Set tagged users for a post (replaces existing tagged users)"""
db = _get_db_adapter()
db.set_post_tagged_users(post_id, body.usernames)
tagged_users = db.get_post_tagged_users(post_id)
return {"tagged_users": tagged_users}
# ============================================================================
# CREATOR GROUPS
# ============================================================================
class CreatorGroupCreate(BaseModel):
name: str
description: Optional[str] = None
class CreatorGroupUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
class CreatorGroupMemberAdd(BaseModel):
creator_id: int
class GroupMemberFilterUpdate(BaseModel):
filter_tagged_users: Optional[List[str]] = None
filter_tag_ids: Optional[List[int]] = None
@router.get("/creator-groups")
@limiter.limit("60/minute")
@handle_exceptions
async def list_creator_groups(
request: Request,
current_user: Dict = Depends(get_current_user)
):
"""List all creator groups"""
db = _get_db_adapter()
groups = db.get_creator_groups()
return {"groups": groups}
@router.get("/creator-groups/{group_id}")
@limiter.limit("60/minute")
@handle_exceptions
async def get_creator_group(
request: Request,
group_id: int,
current_user: Dict = Depends(get_current_user)
):
"""Get a single creator group with members"""
db = _get_db_adapter()
group = db.get_creator_group(group_id)
if not group:
raise NotFoundError(f"Creator group {group_id} not found")
return group
@router.post("/creator-groups")
@limiter.limit("30/minute")
@handle_exceptions
async def create_creator_group(
request: Request,
body: CreatorGroupCreate,
current_user: Dict = Depends(get_current_user)
):
"""Create a new creator group"""
db = _get_db_adapter()
group = db.create_creator_group(body.name, body.description)
return {"group": group, "message": "Group created"}
@router.put("/creator-groups/{group_id}")
@limiter.limit("30/minute")
@handle_exceptions
async def update_creator_group(
request: Request,
group_id: int,
body: CreatorGroupUpdate,
current_user: Dict = Depends(get_current_user)
):
"""Update a creator group"""
db = _get_db_adapter()
success = db.update_creator_group(group_id, name=body.name, description=body.description)
if not success:
raise NotFoundError(f"Creator group {group_id} not found")
return {"success": True, "message": "Group updated"}
@router.delete("/creator-groups/{group_id}")
@limiter.limit("30/minute")
@handle_exceptions
async def delete_creator_group(
request: Request,
group_id: int,
current_user: Dict = Depends(get_current_user)
):
"""Delete a creator group"""
db = _get_db_adapter()
success = db.delete_creator_group(group_id)
if not success:
raise NotFoundError(f"Creator group {group_id} not found")
return {"success": True, "message": "Group deleted"}
@router.post("/creator-groups/{group_id}/members")
@limiter.limit("60/minute")
@handle_exceptions
async def add_creator_to_group(
request: Request,
group_id: int,
body: CreatorGroupMemberAdd,
current_user: Dict = Depends(get_current_user)
):
"""Add a creator to a group"""
db = _get_db_adapter()
# Verify group exists
group = db.get_creator_group(group_id)
if not group:
raise NotFoundError(f"Creator group {group_id} not found")
db.add_creator_to_group(group_id, body.creator_id)
return {"success": True, "message": "Creator added to group"}
@router.delete("/creator-groups/{group_id}/members/{creator_id}")
@limiter.limit("60/minute")
@handle_exceptions
async def remove_creator_from_group(
request: Request,
group_id: int,
creator_id: int,
current_user: Dict = Depends(get_current_user)
):
"""Remove a creator from a group"""
db = _get_db_adapter()
db.remove_creator_from_group(group_id, creator_id)
return {"success": True, "message": "Creator removed from group"}
@router.put("/creator-groups/{group_id}/members/{creator_id}/filters")
@limiter.limit("60/minute")
@handle_exceptions
async def update_group_member_filters(
request: Request,
group_id: int,
creator_id: int,
body: GroupMemberFilterUpdate,
current_user: Dict = Depends(get_current_user)
):
"""Update per-member filter overrides for a group member"""
db = _get_db_adapter()
filter_tagged_users = json.dumps(body.filter_tagged_users) if body.filter_tagged_users else None
filter_tag_ids = json.dumps(body.filter_tag_ids) if body.filter_tag_ids else None
success = db.update_group_member_filters(group_id, creator_id, filter_tagged_users, filter_tag_ids)
if not success:
raise NotFoundError(f"Member not found in group")
return {"success": True, "message": "Member filters updated"}
# ============================================================================
# FEED ENDPOINTS
# ============================================================================
@router.get("/unviewed-count")
@limiter.limit("120/minute")
@handle_exceptions
async def get_unviewed_count(request: Request, current_user: Dict = Depends(get_current_user)):
"""Get count of unviewed posts."""
db = _get_db_adapter()
count = db.get_unviewed_posts_count()
return {"count": count}
@router.post("/mark-all-viewed")
@limiter.limit("30/minute")
@handle_exceptions
async def mark_all_viewed(request: Request, current_user: Dict = Depends(get_current_user)):
"""Mark all unviewed posts as viewed."""
db = _get_db_adapter()
updated = db.mark_all_posts_viewed()
return {"updated": updated}
@router.get("/tagged-users")
@limiter.limit("60/minute")
@handle_exceptions
async def get_tagged_users(
request: Request,
creator_ids: Optional[str] = Query(None, description="Comma-separated creator IDs to scope tagged users"),
current_user: Dict = Depends(get_current_user)
):
"""Get all distinct tagged usernames with post counts, optionally scoped to specific creators."""
db = _get_db_adapter()
parsed_ids = [int(x) for x in creator_ids.split(',') if x.strip().isdigit()] if creator_ids else None
return db.get_all_tagged_usernames(creator_ids=parsed_ids if parsed_ids else None)
@router.get("/content-types")
@limiter.limit("60/minute")
@handle_exceptions
async def get_content_types(
request: Request,
creator_ids: Optional[str] = Query(None, description="Comma-separated creator IDs to scope content types"),
current_user: Dict = Depends(get_current_user)
):
"""Get distinct content types, optionally scoped to specific creators."""
db = _get_db_adapter()
parsed_ids = [int(x) for x in creator_ids.split(',') if x.strip().isdigit()] if creator_ids else None
return db.get_content_types(creator_ids=parsed_ids if parsed_ids else None)
@router.get("/feed")
@limiter.limit("300/minute")
@handle_exceptions
async def get_feed(
request: Request,
creator_id: Optional[int] = None,
creator_ids: Optional[str] = None, # Comma-separated creator IDs
creator_group_id: Optional[int] = None,
identity_id: Optional[int] = None,
service: Optional[str] = None,
platform: Optional[str] = None,
content_type: Optional[str] = None,
min_resolution: Optional[str] = None, # 720p, 1080p, 1440p, 4k
date_from: Optional[str] = None,
date_to: Optional[str] = None,
search: Optional[str] = None,
tag_ids: Optional[str] = None, # Comma-separated tag IDs
tagged_user: Optional[str] = None,
favorites_only: bool = False,
unviewed_only: bool = False,
downloaded_only: bool = False,
has_missing: bool = False,
missing_description: bool = False,
hide_empty: bool = True,
sort_by: str = "published_at",
sort_order: str = "desc",
shuffle: bool = False,
shuffle_seed: Optional[int] = None,
limit: int = Query(default=50, le=500),
offset: int = 0,
pinned_first: bool = True,
skip_pinned: bool = False,
current_user: Dict = Depends(get_current_user)
):
"""Get feed with filters"""
db = _get_db_adapter()
# Parse tag_ids if provided
parsed_tag_ids = None
if tag_ids:
try:
parsed_tag_ids = [int(t.strip()) for t in tag_ids.split(',') if t.strip()]
except ValueError:
pass
# Parse creator_ids if provided
parsed_creator_ids = None
if creator_ids:
try:
parsed_creator_ids = [int(c.strip()) for c in creator_ids.split(',') if c.strip()]
except ValueError:
pass
posts = db.get_posts(
creator_id=creator_id,
creator_ids=parsed_creator_ids,
creator_group_id=creator_group_id,
identity_id=identity_id,
service=service,
platform=platform,
content_type=content_type,
min_resolution=min_resolution,
date_from=date_from,
date_to=date_to,
search=search,
tag_ids=parsed_tag_ids,
tagged_user=tagged_user,
favorites_only=favorites_only,
unviewed_only=unviewed_only,
downloaded_only=downloaded_only,
has_missing=has_missing,
missing_description=missing_description,
hide_empty=hide_empty,
sort_by=sort_by,
sort_order=sort_order,
shuffle=shuffle,
shuffle_seed=shuffle_seed,
limit=limit,
offset=offset,
pinned_first=pinned_first,
skip_pinned=skip_pinned
)
# Get total count
filter_kwargs = dict(
creator_id=creator_id,
creator_ids=parsed_creator_ids,
creator_group_id=creator_group_id,
identity_id=identity_id,
service=service,
platform=platform,
content_type=content_type,
min_resolution=min_resolution,
date_from=date_from,
date_to=date_to,
search=search,
tag_ids=parsed_tag_ids,
tagged_user=tagged_user,
favorites_only=favorites_only,
unviewed_only=unviewed_only,
downloaded_only=downloaded_only,
has_missing=has_missing,
missing_description=missing_description,
hide_empty=hide_empty,
skip_pinned=skip_pinned
)
total = db.get_posts_count(**filter_kwargs)
total_media = db.get_media_count(**filter_kwargs)
return {
"posts": posts,
"count": len(posts),
"total": total,
"total_media": total_media,
"limit": limit,
"offset": offset
}
@router.get("/feed/{post_id}")
@limiter.limit("60/minute")
@handle_exceptions
async def get_post(
request: Request,
post_id: int,
current_user: Dict = Depends(get_current_user)
):
"""Get single post with attachments"""
db = _get_db_adapter()
post = db.get_post(post_id)
if not post:
raise NotFoundError(f"Post {post_id} not found")
# Mark as viewed
db.mark_post_viewed(post_id)
return post
@router.post("/feed/{post_id}/viewed")
@limiter.limit("120/minute")
@handle_exceptions
async def toggle_viewed(
request: Request,
post_id: int,
current_user: Dict = Depends(get_current_user)
):
"""Toggle viewed status"""
db = _get_db_adapter()
is_viewed = db.toggle_post_viewed(post_id)
return {"is_viewed": is_viewed}
@router.post("/feed/{post_id}/favorite")
@limiter.limit("60/minute")
@handle_exceptions
async def toggle_favorite(
request: Request,
post_id: int,
current_user: Dict = Depends(get_current_user)
):
"""Toggle favorite status"""
db = _get_db_adapter()
is_favorited = db.toggle_post_favorite(post_id)
return {"is_favorited": is_favorited}
@router.post("/feed/{post_id}/download")
@limiter.limit("30/minute")
@handle_exceptions
async def download_post(
request: Request,
post_id: int,
background_tasks: BackgroundTasks,
current_user: Dict = Depends(get_current_user)
):
"""Download all attachments for a post"""
db = _get_db_adapter()
post = db.get_post(post_id)
if not post:
raise NotFoundError(f"Post {post_id} not found")
background_tasks.add_task(_download_post_background, post_id)
return {"status": "queued", "post_id": post_id}
@router.put("/feed/{post_id}")
@limiter.limit("30/minute")
@handle_exceptions
async def update_post(
request: Request,
post_id: int,
body: UpdatePostRequest,
current_user: Dict = Depends(get_current_user)
):
"""Update post title, description, and/or date (with file relocation)"""
import os
import shutil
from pathlib import Path
db = _get_db_adapter()
post = db.get_post(post_id)
if not post:
raise NotFoundError(f"Post {post_id} not found")
updates = body.model_dump(exclude_none=True)
if not updates:
raise ValidationError("No updates provided")
files_moved = False
new_local_path = None
# If date is changing and we have a local_path, move the files
if 'published_at' in updates and post.get('local_path'):
old_date = post.get('published_at', '')[:10] if post.get('published_at') else None
new_date = updates['published_at'][:10] if updates['published_at'] else None
if old_date and new_date and old_date != new_date:
old_path = Path(post['local_path'])
if old_path.exists():
# Build new path by replacing the date directory
# Path structure: /base/platform/creator/DATE/post_id/
path_parts = list(old_path.parts)
# Find and replace the date part (YYYY-MM-DD format)
for i, part in enumerate(path_parts):
if len(part) == 10 and part[4] == '-' and part[7] == '-':
try:
# Verify it's a date
int(part[:4]) # year
int(part[5:7]) # month
int(part[8:10]) # day
path_parts[i] = new_date
break
except ValueError:
pass
new_path = Path(*path_parts)
if new_path != old_path:
# Create parent directory and move
new_path.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(old_path), str(new_path))
# Remove old empty parent directories
try:
old_path.parent.rmdir()
except OSError:
pass # Not empty, that's fine
new_local_path = str(new_path)
files_moved = True
# Update attachment paths
for att in post.get('attachments', []):
if att.get('local_path'):
old_att_path = att['local_path']
new_att_path = old_att_path.replace(str(old_path), str(new_path))
# Also rename attachment if name contains old date
if att.get('name') and old_date in att['name']:
new_name = att['name'].replace(old_date, new_date)
db.update_attachment(att['id'], {'name': new_name, 'local_path': new_att_path})
else:
db.update_attachment(att['id'], {'local_path': new_att_path})
# Add local_path update if files were moved
if new_local_path:
updates['local_path'] = new_local_path
success = db.update_post(post_id, updates)
if success:
result = {"status": "success", "updated": list(updates.keys())}
if files_moved:
result["files_moved"] = True
result["new_path"] = new_local_path
return result
return {"status": "failed"}
async def _refresh_fansly_post(db, post, post_id, creator):
"""Refresh a Fansly post by fetching fresh data from the API"""
from modules.paid_content.fansly_direct_client import FanslyDirectClient
service = db.get_service('fansly_direct')
if not service or not service.get('session_cookie'):
raise ValidationError("Fansly auth token not configured")
client = FanslyDirectClient(
auth_token=service['session_cookie'],
log_callback=lambda msg, level='info': logger.log(msg, module='FanslyDirect', level=level)
)
try:
fresh_post = await client.get_single_post(post['post_id'], creator['creator_id'])
if not fresh_post:
raise NotFoundError("Post not found on Fansly")
# Update post metadata
post_updates = {}
if fresh_post.content and fresh_post.content != post.get('content'):
post_updates['content'] = fresh_post.content
if post_updates:
db.update_post(post_id, post_updates)
# Upsert attachments (creates missing ones, updates URLs for existing)
new_attachments = 0
for idx, attachment in enumerate(fresh_post.attachments):
att_data = attachment.to_dict()
att_data['attachment_index'] = idx
if attachment.download_url is None:
att_data['status'] = 'unavailable'
if db.upsert_attachment(post_id, att_data):
new_attachments += 1
# Remove Preview tag and add Full Length tag if content is now unlocked (keep PPV tag)
has_ppv = any(att.download_url is None for att in fresh_post.attachments)
if not has_ppv:
preview_tag = db.get_tag_by_slug('preview')
if preview_tag:
db.remove_tag_from_post(post_id, preview_tag['id'])
full_length_tag = db.get_tag_by_slug('full-length')
if full_length_tag:
db.add_tag_to_post(post_id, full_length_tag['id'])
# Download any new pending attachments in background
pending = db.get_pending_attachments_for_post(post_id)
if pending:
import asyncio
creator_id_val = creator['id']
async def _download_pending():
scraper = _get_scraper()
scraper.app_state = None # Disable app_state cancellation check
# Register a temporary active sync so activity_manager doesn't cancel
scraper._register_active_sync(creator_id_val, {
'username': creator['username'],
'platform': 'fansly',
'service': 'fansly_direct',
'status': 'Downloading refreshed post...',
'phase': 'downloading',
})
try:
await scraper.download_pending_for_creator(creator_id_val)
finally:
scraper._unregister_active_sync(creator_id_val)
await scraper.close()
asyncio.create_task(_download_pending())
return {
"status": "success",
"new_attachments": new_attachments,
"total_attachments": len(fresh_post.attachments),
"pending_download": len(pending) if pending else 0,
"has_ppv": has_ppv,
"updated": list(post_updates.keys()) if post_updates else []
}
finally:
await client.close()
async def _refresh_onlyfans_post(db, post, post_id, creator):
"""Refresh an OnlyFans post by fetching fresh data from the API"""
import json as _json
from modules.paid_content.onlyfans_client import OnlyFansClient
service = db.get_service('onlyfans_direct')
if not service or not service.get('session_cookie'):
raise ValidationError("OnlyFans credentials not configured")
try:
auth_config = _json.loads(service['session_cookie'])
except (_json.JSONDecodeError, TypeError):
raise ValidationError("OnlyFans credentials: invalid JSON")
client = OnlyFansClient(
auth_config=auth_config,
signing_url=auth_config.get('signing_url'),
log_callback=lambda msg, level='info': logger.log(msg, module='OnlyFansDirect', level=level)
)
try:
fresh_post = await client.get_single_post(post['post_id'])
if not fresh_post:
raise NotFoundError("Post not found on OnlyFans")
post_updates = {}
if fresh_post.content and fresh_post.content != post.get('content'):
post_updates['content'] = fresh_post.content
if post_updates:
db.update_post(post_id, post_updates)
new_attachments = 0
for idx, attachment in enumerate(fresh_post.attachments):
att_data = attachment.to_dict()
att_data['attachment_index'] = idx
if attachment.download_url is None:
att_data['status'] = 'unavailable'
if db.upsert_attachment(post_id, att_data):
new_attachments += 1
# Remove Preview tag and add Full Length tag if content is now unlocked (keep PPV tag)
has_ppv = any(att.download_url is None for att in fresh_post.attachments)
if not has_ppv:
preview_tag = db.get_tag_by_slug('preview')
if preview_tag:
db.remove_tag_from_post(post_id, preview_tag['id'])
full_length_tag = db.get_tag_by_slug('full-length')
if full_length_tag:
db.add_tag_to_post(post_id, full_length_tag['id'])
pending = db.get_pending_attachments_for_post(post_id)
if pending:
import asyncio
creator_id_val = creator['id']
async def _download_pending():
scraper = _get_scraper()
scraper.app_state = None
scraper._register_active_sync(creator_id_val, {
'username': creator['username'],
'platform': 'onlyfans',
'service': 'onlyfans_direct',
'status': 'Downloading refreshed post...',
'phase': 'downloading',
})
try:
await scraper.download_pending_for_creator(creator_id_val)
finally:
scraper._unregister_active_sync(creator_id_val)
await scraper.close()
asyncio.create_task(_download_pending())
return {
"status": "success",
"new_attachments": new_attachments,
"total_attachments": len(fresh_post.attachments),
"pending_download": len(pending) if pending else 0,
"has_ppv": has_ppv,
"updated": list(post_updates.keys()) if post_updates else []
}
finally:
await client.close()
@router.post("/feed/{post_id}/refresh")
@limiter.limit("10/minute")
@handle_exceptions
async def refresh_post_metadata(
request: Request,
post_id: int,
current_user: Dict = Depends(get_current_user)
):
"""Refresh post metadata and attachments from source"""
db = _get_db_adapter()
post = db.get_post(post_id)
if not post:
raise NotFoundError(f"Post {post_id} not found")
creator = db.get_creator(post['creator_id'])
if not creator:
raise NotFoundError(f"Creator not found for post {post_id}")
# Handle Fansly Direct posts
if creator['service_id'] == 'fansly_direct':
return await _refresh_fansly_post(db, post, post_id, creator)
# Handle OnlyFans Direct posts
elif creator['service_id'] == 'onlyfans_direct':
return await _refresh_onlyfans_post(db, post, post_id, creator)
# Handle YouTube posts
elif creator['service_id'] == 'youtube':
from modules.paid_content import YouTubeClient
app_state = get_app_state()
client = YouTubeClient(unified_db=app_state.db)
video_id = post.get('post_id')
if not video_id:
raise ValidationError("No video ID found for post")
video_url = f"https://www.youtube.com/watch?v={video_id}"
# Fetch fresh metadata
try:
cmd = client._get_base_cmd() + [
'--skip-download',
'--no-warnings',
'-j',
video_url
]
import asyncio
result = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await result.communicate()
if result.returncode == 0:
import json
data = json.loads(stdout.decode('utf-8', errors='replace'))
updates = {
'title': data.get('title'),
'content': data.get('description'),
}
# Remove None values
updates = {k: v for k, v in updates.items() if v is not None}
if updates:
db.update_post(post_id, updates)
client.cleanup()
return {"status": "success", "updated": list(updates.keys())}
else:
client.cleanup()
return {"status": "no_changes"}
else:
client.cleanup()
error = stderr.decode('utf-8', errors='replace')[:200]
raise ValidationError(f"Failed to fetch metadata: {error}")
except Exception as e:
client.cleanup()
raise ValidationError(f"Failed to refresh: {str(e)}")
# Handle Coomer/Kemono posts
else:
from modules.paid_content import PaidContentAPIClient
app_state = get_app_state()
service = db.get_service(creator['service_id'])
client = PaidContentAPIClient(
creator['service_id'],
session_cookie=service.get('session_cookie') if service else None
)
try:
post_data = await client.get_post(
creator['platform'],
creator['creator_id'],
post['post_id']
)
await client.close()
if post_data:
updates = {
'title': post_data.get('title'),
'content': post_data.get('content'),
}
updates = {k: v for k, v in updates.items() if v is not None}
if updates:
db.update_post(post_id, updates)
return {"status": "success", "updated": list(updates.keys())}
else:
return {"status": "no_changes"}
else:
raise NotFoundError("Post not found on source")
except Exception as e:
await client.close()
raise ValidationError(f"Failed to refresh: {str(e)}")
# ============================================================================
# NOTIFICATIONS ENDPOINTS
# ============================================================================
@router.get("/notifications")
@limiter.limit("60/minute")
@handle_exceptions
async def get_notifications(
request: Request,
unread_only: bool = False,
limit: int = Query(default=50, le=100),
offset: int = 0,
current_user: Dict = Depends(get_current_user)
):
"""Get notification history"""
db = _get_db_adapter()
notifications = db.get_notifications(unread_only=unread_only, limit=limit, offset=offset)
unread_count = db.get_unread_notification_count()
return {"notifications": notifications, "count": len(notifications), "unread_count": unread_count}
@router.post("/notifications/mark-read")
@limiter.limit("30/minute")
@handle_exceptions
async def mark_notifications_read(
request: Request,
body: MarkNotificationsReadRequest,
current_user: Dict = Depends(get_current_user)
):
"""Mark notifications as read"""
db = _get_db_adapter()
if body.mark_all:
count = db.mark_all_notifications_read()
elif body.notification_ids:
count = db.mark_notifications_read(body.notification_ids)
else:
count = 0
return {"marked_count": count}
@router.delete("/notifications/{notification_id}")
@limiter.limit("30/minute")
@handle_exceptions
async def delete_notification(
request: Request,
notification_id: int,
current_user: Dict = Depends(get_current_user)
):
"""Delete a notification"""
db = _get_db_adapter()
success = db.delete_notification(notification_id)
if not success:
raise NotFoundError(f"Notification {notification_id} not found")
return {"status": "success"}
# ============================================================================
# SETTINGS ENDPOINTS
# ============================================================================
@router.get("/settings")
@limiter.limit("60/minute")
@handle_exceptions
async def get_settings(
request: Request,
current_user: Dict = Depends(get_current_user)
):
"""Get settings"""
db = _get_db_adapter()
config = db.get_config()
return config
@router.put("/settings")
@limiter.limit("10/minute")
@handle_exceptions
async def update_settings(
request: Request,
body: PaidContentSettings,
current_user: Dict = Depends(get_current_user)
):
"""Update settings"""
db = _get_db_adapter()
updates = body.dict(exclude_none=True)
if updates:
# Convert booleans to integers for database storage
for key in ['organize_by_date', 'organize_by_post', 'download_embeds',
'notifications_enabled', 'push_notifications_enabled',
'perceptual_duplicate_detection', 'auto_retry_failed']:
if key in updates:
updates[key] = 1 if updates[key] else 0
db.update_config(updates)
return message_response("Settings updated successfully")
# ============================================================================
# IMPORT ENDPOINTS
# ============================================================================
@router.post("/import/url")
@limiter.limit("10/minute")
@handle_exceptions
async def import_from_url(
request: Request,
background_tasks: BackgroundTasks,
body: ImportUrlRequest,
current_user: Dict = Depends(get_current_user)
):
"""Import content from external file host URL"""
from modules.paid_content import FileHostDownloader
downloader = FileHostDownloader()
if not downloader.is_supported_url(body.url):
raise ValidationError(f"Unsupported URL. Supported hosts: {', '.join(downloader.get_supported_domains())}")
background_tasks.add_task(_import_url_background, body.url, body.creator_id)
return {"status": "queued", "url": body.url}
@router.post("/parse-filenames")
@limiter.limit("60/minute")
@handle_exceptions
async def parse_filenames(
request: Request,
body: ParseFilenamesRequest,
current_user: Dict = Depends(get_current_user)
):
"""
Parse filenames to extract dates and metadata.
Supports Fansly snowflake IDs and embedded date formats.
"""
from modules.paid_content.filename_parser import parse_filenames as do_parse
analysis = do_parse(body.filenames)
# Convert datetimes to ISO strings for JSON
def dt_to_str(dt):
return dt.isoformat() if dt else None
return {
"files": [
{
**f,
"detected_date": dt_to_str(f["detected_date"]),
}
for f in analysis["files"]
],
"earliest_date": dt_to_str(analysis["earliest_date"]),
"latest_date": dt_to_str(analysis["latest_date"]),
"suggested_date": dt_to_str(analysis["suggested_date"]),
"has_dates": analysis["has_dates"],
}
@router.post("/upload")
@limiter.limit("30/minute")
@handle_exceptions
async def upload_manual_content(
request: Request,
files: List[UploadFile] = File(...),
creator_id: int = Form(...),
title: str = Form(""),
description: str = Form(""),
post_date: str = Form(None),
tags: str = Form(""), # Comma-separated tag IDs
current_user: Dict = Depends(get_current_user)
):
"""
Upload files manually and add them to the feed.
Creates a new post for the specified creator with the uploaded files.
"""
import hashlib
import mimetypes
import uuid
db = _get_db_adapter()
# Validate creator exists
creator = db.get_creator(creator_id)
if not creator:
raise NotFoundError(f"Creator {creator_id} not found")
# Get config for base path
config = db.get_config()
base_path = Path(config.get('base_download_path', '/opt/immich/paid'))
# Parse post date or use current date
if post_date:
try:
# Validate date format (YYYY-MM-DD)
datetime.strptime(post_date, '%Y-%m-%d')
date_str = post_date
except ValueError:
date_str = datetime.now().strftime('%Y-%m-%d')
else:
date_str = datetime.now().strftime('%Y-%m-%d')
# Generate unique post ID
post_id_str = f"manual_{uuid.uuid4().hex[:12]}"
# Build destination directory
# Format: /base/platform/username/date/post_id/
dest_dir = base_path / creator['platform'] / creator['username'] / date_str / post_id_str
dest_dir.mkdir(parents=True, exist_ok=True)
# First pass: determine file types for title generation
image_exts = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.heic']
video_exts = ['.mp4', '.webm', '.mov', '.avi', '.mkv', '.m4v', '.ts']
image_count = 0
video_count = 0
for upload_file in files:
ext = Path(upload_file.filename).suffix.lower() if upload_file.filename else ''
if not ext and upload_file.content_type:
ext = mimetypes.guess_extension(upload_file.content_type) or ''
if ext in image_exts:
image_count += 1
elif ext in video_exts:
video_count += 1
# Generate default title if not provided: "Jan 23, 2026 — 3 images, 1 video"
if not title.strip():
# Parse date for formatting
try:
date_obj = datetime.strptime(date_str, '%Y-%m-%d')
formatted_date = date_obj.strftime('%b %d, %Y') # "Jan 23, 2026"
except ValueError:
formatted_date = date_str
# Build content description
parts = []
if image_count > 0:
parts.append(f"{image_count} {'image' if image_count == 1 else 'images'}")
if video_count > 0:
parts.append(f"{video_count} {'video' if video_count == 1 else 'videos'}")
if not parts:
parts.append(f"{len(files)} {'file' if len(files) == 1 else 'files'}")
default_title = f"{formatted_date}{', '.join(parts)}"
else:
default_title = title.strip()
# Create the post record
post_data = {
'post_id': post_id_str,
'title': default_title,
'content': description.strip(),
'published_at': f"{date_str}T12:00:00",
'added_at': datetime.now().isoformat(),
'has_attachments': 1 if files else 0,
'attachment_count': len(files),
'downloaded': 1, # Already downloaded since we're uploading
'download_date': datetime.now().isoformat(),
'local_path': str(dest_dir),
'metadata': '{"source": "manual_upload"}'
}
post_db_id = db.upsert_post(creator_id, post_data)
if not post_db_id:
raise ValidationError("Failed to create post record")
# Process each uploaded file
saved_files = []
for idx, upload_file in enumerate(files):
try:
# Generate filename: 001.ext, 002.ext, etc.
original_ext = Path(upload_file.filename).suffix.lower() if upload_file.filename else ''
if not original_ext:
# Try to guess from content type
content_type = upload_file.content_type or 'application/octet-stream'
original_ext = mimetypes.guess_extension(content_type) or '.bin'
filename = f"{(idx + 1):03d}{original_ext}"
file_path = dest_dir / filename
# Reset file position since we may have read it in first pass
await upload_file.seek(0)
# Read and save file
content = await upload_file.read()
with open(file_path, 'wb') as f:
f.write(content)
# Compute hash
file_hash = hashlib.sha256(content).hexdigest()
# Determine file type
file_type = 'other'
if original_ext in image_exts:
file_type = 'image'
elif original_ext in video_exts:
file_type = 'video'
# Get dimensions for images/videos if possible
width, height, duration = None, None, None
if file_type == 'image':
try:
from PIL import Image
with Image.open(file_path) as img:
width, height = img.size
except Exception:
pass
elif file_type == 'video':
try:
import subprocess
result = subprocess.run(
['ffprobe', '-v', 'quiet', '-print_format', 'json',
'-show_streams', '-select_streams', 'v:0', str(file_path)],
capture_output=True, text=True, timeout=10
)
if result.returncode == 0:
import json
data = json.loads(result.stdout)
if data.get('streams'):
stream = data['streams'][0]
width = stream.get('width')
height = stream.get('height')
duration_str = stream.get('duration')
if duration_str:
duration = int(float(duration_str))
except Exception:
pass
# Create attachment record
attachment_data = {
'attachment_index': idx,
'name': upload_file.filename or filename,
'file_type': file_type,
'extension': original_ext.lstrip('.'),
'server_path': f'manual://{filename}',
'download_url': '',
'file_size': len(content),
'width': width,
'height': height,
'duration': duration,
'status': 'completed',
'local_path': str(file_path),
'local_filename': filename,
'file_hash': file_hash,
'downloaded_at': datetime.now().isoformat()
}
att_id = db.upsert_attachment(post_db_id, attachment_data)
saved_files.append({
'id': att_id,
'filename': filename,
'original_name': upload_file.filename,
'size': len(content),
'file_type': file_type,
'path': str(file_path)
})
except Exception as e:
logger.error(f"Failed to save uploaded file {upload_file.filename}: {e}", module="PaidContent")
# Continue with other files
# Update creator stats
db.update_creator(creator_id, {
'post_count': db.get_creator_post_count(creator_id),
'downloaded_count': db.get_creator_downloaded_count(creator_id)
})
# Apply tags if provided
applied_tags = []
if tags.strip():
try:
tag_ids = [int(t.strip()) for t in tags.split(',') if t.strip()]
for tag_id in tag_ids:
if db.add_tag_to_post(post_db_id, tag_id):
applied_tags.append(tag_id)
except ValueError:
pass # Invalid tag IDs, skip
return {
"status": "success",
"post_id": post_db_id,
"post_api_id": post_id_str,
"creator": creator['username'],
"files_saved": len(saved_files),
"files": saved_files,
"local_path": str(dest_dir),
"tags_applied": applied_tags
}
@router.get("/attachments/{attachment_id}")
@limiter.limit("120/minute")
@handle_exceptions
async def get_attachment(
request: Request,
attachment_id: int,
current_user: Dict = Depends(get_current_user)
):
"""Get a single attachment by ID"""
db = _get_db_adapter()
attachment = db.get_attachment(attachment_id)
if not attachment:
raise NotFoundError(f"Attachment {attachment_id} not found")
# Remove binary fields that can't be JSON serialized
result = {k: v for k, v in attachment.items() if k != 'thumbnail_data'}
return result
@router.post("/attachments/{attachment_id}/import-url")
@limiter.limit("30/minute")
@handle_exceptions
async def import_url_to_attachment(
request: Request,
attachment_id: int,
body: ImportToAttachmentRequest,
background_tasks: BackgroundTasks,
current_user: Dict = Depends(get_current_user)
):
"""
Import a file from a supported URL and link it to an existing attachment.
Downloads the file in background, renames it appropriately, and updates the database.
Returns immediately - poll GET /attachments/{id} for status.
"""
db = _get_db_adapter()
# Get the attachment details
attachment = db.get_attachment(attachment_id)
if not attachment:
raise NotFoundError(f"Attachment {attachment_id} not found")
# Get post and creator info for building the path
post = db.get_post(attachment['post_id'])
if not post:
raise NotFoundError(f"Post for attachment {attachment_id} not found")
creator = db.get_creator(post['creator_id'])
if not creator:
raise NotFoundError(f"Creator for attachment {attachment_id} not found")
# Mark as downloading immediately
db.update_attachment_status(attachment_id, 'downloading')
# Run download in background (ws_manager will be fetched inside the task)
background_tasks.add_task(
_import_url_to_attachment_background,
attachment_id, body.url, attachment, post, creator
)
return {
"status": "downloading",
"attachment_id": attachment_id,
"message": "Download started in background. Poll GET /attachments/{id} for status."
}
async def _import_url_to_attachment_background(
attachment_id: int,
url: str,
attachment: Dict,
post: Dict,
creator: Dict
):
"""Background task for importing URL to attachment with progress tracking"""
import aiohttp
import hashlib
from modules.paid_content import FileHostDownloader
from core.dependencies import get_app_state
db = _get_db_adapter()
# Helper to get ws_manager (fetched fresh each time to ensure it's initialized)
def get_ws_manager():
app_state = get_app_state()
return getattr(app_state, 'websocket_manager', None)
# Build the destination path
config = db.get_config()
base_path = Path(config.get('base_download_path', '/opt/immich/paid'))
# Get post date for path
post_date = (post.get('published_at') or '')[:10] or 'unknown-date'
post_id_str = post.get('post_id', str(post['id']))
# Build path: /base/platform/username/date/post_id/
dest_dir = base_path / creator['platform'] / creator['username'] / post_date / post_id_str
dest_dir.mkdir(parents=True, exist_ok=True)
# Determine filename - use attachment index + extension
ext = attachment.get('extension') or Path(attachment.get('name', '')).suffix or '.bin'
if not ext.startswith('.'):
ext = '.' + ext
filename = f"{(attachment.get('attachment_index', 0) + 1):03d}{ext}"
dest_path = dest_dir / filename
try:
ws_manager = get_ws_manager()
logger.info(f"Background download started for attachment {attachment_id}, ws_manager={'present' if ws_manager else 'MISSING'}")
# Progress callback to update database AND broadcast via WebSocket
def update_progress(downloaded: int, total: int, filename: str):
pct = int(downloaded * 100 / total) if total > 0 else 0
progress_msg = f"Downloading: {pct}% ({downloaded // (1024*1024)}MB / {total // (1024*1024)}MB)"
# Update database
db.update_attachment(attachment_id, {
'file_size': downloaded,
'error_message': progress_msg
})
# Broadcast via WebSocket for real-time UI updates
ws = get_ws_manager()
if ws:
try:
ws.broadcast_sync({
'type': 'attachment_download_progress',
'data': {
'attachment_id': attachment_id,
'status': 'downloading',
'progress': pct,
'downloaded': downloaded,
'total': total,
'message': progress_msg
}
})
except Exception as e:
logger.warning(f"WebSocket broadcast failed: {e}")
downloader = FileHostDownloader(progress_callback=update_progress)
# Download the file
if downloader.is_supported_url(url):
# Use file host downloader for supported hosts (handles CDN fallback, etc.)
result = await downloader.download_url(url, dest_dir)
if not result['success'] or not result['files']:
db.update_attachment_status(attachment_id, 'failed', error_message=result.get('error', 'Unknown error'))
logger.error(f"Import failed for attachment {attachment_id}: {result.get('error')}")
return
# Rename the downloaded file to the correct name
downloaded_path = Path(result['files'][0])
if downloaded_path != dest_path:
if dest_path.exists():
dest_path.unlink()
downloaded_path.rename(dest_path)
else:
# Direct download for other URLs with progress
timeout = aiohttp.ClientTimeout(total=3600) # 1 hour for large files
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
async with aiohttp.ClientSession(timeout=timeout, headers=headers) as session:
async with session.get(url) as resp:
if resp.status != 200:
db.update_attachment_status(attachment_id, 'failed', error_message=f"HTTP {resp.status}")
logger.error(f"Import failed for attachment {attachment_id}: HTTP {resp.status}")
return
total_size = int(resp.headers.get('content-length', 0))
downloaded = 0
last_update_pct = 0
with open(dest_path, 'wb') as f:
async for chunk in resp.content.iter_chunked(65536): # 64KB chunks
f.write(chunk)
downloaded += len(chunk)
# Update progress in DB every 5%
if total_size > 0:
pct = int(downloaded * 100 / total_size)
if pct >= last_update_pct + 5:
db.update_attachment(attachment_id, {
'file_size': downloaded,
'error_message': f"Downloading: {pct}% ({downloaded // (1024*1024)}MB / {total_size // (1024*1024)}MB)"
})
last_update_pct = pct
# Compute file hash
sha256 = hashlib.sha256()
with open(dest_path, 'rb') as f:
for chunk in iter(lambda: f.read(65536), b''):
sha256.update(chunk)
file_hash = sha256.hexdigest()
# Get file size
file_size = dest_path.stat().st_size
# Update database with success
db.update_attachment_status(
attachment_id,
'completed',
local_path=str(dest_path),
local_filename=filename,
file_hash=file_hash,
file_size=file_size,
downloaded_at=now_iso8601(),
error_message=None
)
logger.info(f"Import completed for attachment {attachment_id}: {file_size // (1024*1024)}MB")
# Broadcast completion via WebSocket
ws = get_ws_manager()
if ws:
ws.broadcast_sync({
'type': 'attachment_download_progress',
'data': {
'attachment_id': attachment_id,
'status': 'completed',
'progress': 100,
'file_size': file_size,
'message': 'Download complete'
}
})
except Exception as e:
logger.error(f"Failed to import URL to attachment {attachment_id}: {e}")
db.update_attachment_status(attachment_id, 'failed', error_message=str(e))
@router.post("/attachments/{attachment_id}/upload-file")
@limiter.limit("30/minute")
@handle_exceptions
async def upload_file_to_attachment(
request: Request,
attachment_id: int,
file: UploadFile = File(...),
current_user: Dict = Depends(get_current_user)
):
"""
Upload a file directly to an existing attachment.
Saves the file to the post's directory and updates the attachment record.
"""
import shutil
import hashlib
db = _get_db_adapter()
# Get the attachment details
attachment = db.get_attachment(attachment_id)
if not attachment:
raise NotFoundError(f"Attachment {attachment_id} not found")
# Get post and creator info for building the path
post = db.get_post(attachment['post_id'])
if not post:
raise NotFoundError(f"Post for attachment {attachment_id} not found")
creator = db.get_creator(post['creator_id'])
if not creator:
raise NotFoundError(f"Creator for attachment {attachment_id} not found")
# Build the destination path
config = db.get_config()
base_path = Path(config.get('base_download_path', '/opt/immich/paid'))
# Get post date for path
post_date = (post.get('published_at') or '')[:10] or 'unknown-date'
post_id_str = post.get('post_id', str(post['id']))
# Build path: /base/platform/username/date/post_id/
dest_dir = base_path / creator['platform'] / creator['username'] / post_date / post_id_str
dest_dir.mkdir(parents=True, exist_ok=True)
# Determine filename - use attachment index + extension from uploaded file
original_name = file.filename or 'uploaded_file'
ext = Path(original_name).suffix or '.bin'
if not ext.startswith('.'):
ext = '.' + ext
filename = f"{(attachment.get('attachment_index', 0) + 1):03d}{ext}"
dest_path = dest_dir / filename
try:
# Save file
with open(dest_path, 'wb') as f:
shutil.copyfileobj(file.file, f)
# Get file info
file_size = dest_path.stat().st_size
# Calculate file hash
hash_md5 = hashlib.md5()
with open(dest_path, 'rb') as f:
for chunk in iter(lambda: f.read(8192), b''):
hash_md5.update(chunk)
file_hash = hash_md5.hexdigest()
# Determine file type
file_type = 'unknown'
ext_lower = ext.lower().lstrip('.')
image_exts = {'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'tiff', 'heic', 'heif', 'avif'}
video_exts = {'mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v', 'wmv', 'flv'}
audio_exts = {'mp3', 'wav', 'flac', 'aac', 'm4a', 'ogg'}
if ext_lower in image_exts:
file_type = 'image'
elif ext_lower in video_exts:
file_type = 'video'
elif ext_lower in audio_exts:
file_type = 'audio'
# Extract dimensions/duration for images and videos
width, height, duration = None, None, None
if file_type == 'image':
try:
from PIL import Image
with Image.open(dest_path) as img:
width, height = img.size
except Exception:
pass
elif file_type == 'video':
try:
import subprocess as _sp
result = _sp.run(
['ffprobe', '-v', 'quiet', '-print_format', 'json',
'-show_streams', '-select_streams', 'v:0', str(dest_path)],
capture_output=True, text=True, timeout=30
)
if result.returncode == 0:
probe = json.loads(result.stdout)
if probe.get('streams'):
stream = probe['streams'][0]
width = stream.get('width')
height = stream.get('height')
duration_str = stream.get('duration')
if duration_str:
duration = int(float(duration_str))
except Exception:
pass
# Update attachment record
db.update_attachment_status(
attachment_id,
'completed',
local_path=str(dest_path),
local_filename=filename,
file_hash=file_hash,
file_size=file_size,
downloaded_at=now_iso8601(),
error_message=None
)
# Also update file_type and dimensions if we detected them
update_fields = {}
if file_type != 'unknown':
update_fields['file_type'] = file_type
update_fields['extension'] = ext_lower
if width is not None:
update_fields['width'] = width
if height is not None:
update_fields['height'] = height
if duration is not None:
update_fields['duration'] = duration
if update_fields:
db.update_attachment(attachment_id, update_fields)
# Invalidate stale thumbnail caches so they regenerate from the new file
db.update_attachment(attachment_id, {'thumbnail_data': None})
for thumb_size in ('small', 'medium', 'large', 'native'):
stale_thumb = Path(f"/opt/media-downloader/cache/thumbnails/{thumb_size}/{attachment_id}.jpg")
if stale_thumb.exists():
stale_thumb.unlink()
logger.info(f"File uploaded to attachment {attachment_id}: {filename} ({file_size // (1024*1024)}MB)")
return {
"status": "completed",
"attachment_id": attachment_id,
"filename": filename,
"file_type": file_type,
"file_size": file_size,
"width": width,
"height": height,
"duration": duration,
"local_path": str(dest_path)
}
except Exception as e:
logger.error(f"Failed to upload file to attachment {attachment_id}: {e}")
db.update_attachment_status(attachment_id, 'failed', error_message=str(e))
raise ValidationError(f"Upload failed: {str(e)}")
# ============================================================================
# RECYCLE BIN ENDPOINTS
# ============================================================================
@router.get("/recycle-bin")
@limiter.limit("60/minute")
@handle_exceptions
async def get_recycle_bin(
request: Request,
item_type: Optional[str] = None,
search: Optional[str] = None,
platform: Optional[str] = None,
creator: Optional[str] = None,
content_type: Optional[str] = None,
limit: int = Query(default=50, le=100),
offset: int = 0,
current_user: Dict = Depends(get_current_user)
):
"""Get items in recycle bin"""
db = _get_db_adapter()
items = db.get_recycle_bin_items(
item_type=item_type,
search=search,
platform=platform,
creator=creator,
content_type=content_type,
limit=limit,
offset=offset
)
return {"items": items, "count": len(items)}
@router.post("/recycle-bin/{item_id}/restore")
@limiter.limit("30/minute")
@handle_exceptions
async def restore_from_recycle_bin(
request: Request,
item_id: int,
current_user: Dict = Depends(get_current_user)
):
"""Restore item from recycle bin"""
db = _get_db_adapter()
success = db.restore_from_recycle_bin(item_id)
if not success:
raise NotFoundError(f"Item {item_id} not found in recycle bin")
return message_response("Item restored successfully")
@router.delete("/recycle-bin")
@limiter.limit("5/minute")
@handle_exceptions
async def empty_recycle_bin(
request: Request,
item_type: Optional[str] = None,
current_user: Dict = Depends(get_current_user)
):
"""Empty recycle bin"""
db = _get_db_adapter()
count = db.empty_recycle_bin(item_type=item_type)
return {"deleted_count": count}
class RecycleBinBatchRequest(BaseModel):
item_ids: List[int]
@router.post("/recycle-bin/restore-batch")
@limiter.limit("30/minute")
@handle_exceptions
async def restore_from_recycle_bin_batch(
request: Request,
body: RecycleBinBatchRequest,
current_user: Dict = Depends(get_current_user)
):
"""Restore multiple items from recycle bin"""
db = _get_db_adapter()
restored_count = 0
for item_id in body.item_ids:
if db.restore_from_recycle_bin(item_id):
restored_count += 1
return {"restored_count": restored_count}
@router.post("/recycle-bin/delete-permanently")
@limiter.limit("10/minute")
@handle_exceptions
async def delete_permanently(
request: Request,
body: RecycleBinBatchRequest,
current_user: Dict = Depends(get_current_user)
):
"""Permanently delete items from recycle bin"""
db = _get_db_adapter()
deleted_count = 0
for item_id in body.item_ids:
if db.permanently_delete_recycle_item(item_id):
deleted_count += 1
return {"deleted_count": deleted_count}
# ============================================================================
# BACKGROUND TASKS
# ============================================================================
async def _sync_creator_background(creator_id: int, download: bool = True, force_backfill: bool = False):
"""Background task for creator sync"""
logger.info(f"Starting background sync for creator {creator_id}", module="PaidContent")
try:
scraper = _get_scraper()
logger.info(f"Scraper created, app_state={scraper.app_state is not None}", module="PaidContent")
await scraper.sync_creator(creator_id, download=download, force_backfill=force_backfill)
await scraper.close()
logger.info(f"Background sync completed for creator {creator_id}", module="PaidContent")
except Exception as e:
import traceback
logger.error(f"Background sync failed for creator {creator_id}: {e}\n{traceback.format_exc()}", module="PaidContent")
async def _sync_all_creators_background():
"""Background task for syncing all creators"""
logger.info("Starting background sync for all creators", module="PaidContent")
try:
scraper = _get_scraper()
logger.info(f"Scraper created for sync_all, app_state={scraper.app_state is not None}", module="PaidContent")
await scraper.sync_all_creators()
await scraper.close()
logger.info("Background sync all completed", module="PaidContent")
except Exception as e:
import traceback
logger.error(f"Background sync all failed: {e}\n{traceback.format_exc()}", module="PaidContent")
async def _sync_service_background(creator_ids: List[int]):
"""Background task for syncing all creators in a service"""
logger.info(f"Starting background sync for {len(creator_ids)} creators in service", module="PaidContent")
try:
scraper = _get_scraper()
for creator_id in creator_ids:
try:
await scraper.sync_creator(creator_id)
except Exception as e:
import traceback
logger.error(f"Sync failed for creator {creator_id} in service sync: {e}\n{traceback.format_exc()}", module="PaidContent")
await scraper.close()
logger.info(f"Background service sync completed for {len(creator_ids)} creators", module="PaidContent")
except Exception as e:
import traceback
logger.error(f"Background service sync failed: {e}\n{traceback.format_exc()}", module="PaidContent")
async def sync_paid_content_all(from_scheduler: bool = False, download: bool = True):
"""
Standalone sync function for all paid content creators.
This function creates its own database connection and doesn't rely on app_state,
making it callable from the scheduler (which runs in a separate process).
Args:
from_scheduler: If True, send push notifications for new content.
Only the scheduler should set this to True.
download: If True, download attachments after syncing posts.
"""
from modules.unified_database import UnifiedDatabase
from modules.paid_content import PaidContentScraper
from modules.activity_status import get_activity_manager
logger.info(f"Starting paid content sync (from_scheduler={from_scheduler}, download={download})", module="PaidContent")
# Create standalone database connection (works across processes)
db = UnifiedDatabase()
activity_manager = get_activity_manager(db)
# Initialize notifier if this is a scheduled sync
notifier = None
if from_scheduler:
try:
from modules.settings_manager import SettingsManager
from modules.pushover_notifier import create_notifier_from_config
settings_manager = SettingsManager(str(db.db_path))
config = settings_manager.get_all()
notifier = create_notifier_from_config(config, unified_db=db)
except Exception as e:
logger.warning(f"Failed to initialize notifier: {e}", module="PaidContent")
try:
# Create scraper without app_state (works across processes)
scraper = PaidContentScraper(
unified_db=db,
notifier=notifier,
websocket_manager=None, # No websocket in scheduler process
app_state=None
)
# Run sync
results = await scraper.sync_all_creators(scheduled=from_scheduler, download=download)
# Count results
total_posts = sum(r.new_posts for r in results.values() if r.success)
total_files = sum(r.new_attachments for r in results.values() if r.success)
failed_count = sum(1 for r in results.values() if not r.success)
logger.info(f"Paid content sync complete: {total_posts} new posts, {total_files} new files, {failed_count} failures", module="PaidContent")
await scraper.close()
# Auto-recheck quality for flagged Fansly attachments
try:
await _auto_quality_recheck_background(unified_db=db)
except Exception as e:
logger.error(f"Auto quality recheck after scheduled sync failed: {e}", module="PaidContent")
return {
'success': True,
'new_posts': total_posts,
'new_files': total_files,
'failed_creators': failed_count
}
except Exception as e:
import traceback
logger.error(f"Paid content sync failed: {e}\n{traceback.format_exc()}", module="PaidContent")
return {
'success': False,
'error': str(e)
}
async def _retry_downloads_background(attachment_ids: List[int]):
"""Background task for retrying downloads"""
try:
scraper = _get_scraper()
await scraper.retry_failed_downloads(attachment_ids)
await scraper.close()
except Exception as e:
logger.error(f"Background retry failed: {e}", module="PaidContent")
async def _download_post_background(post_id: int):
"""Background task for downloading a post"""
try:
db = _get_db_adapter()
post = db.get_post(post_id)
if not post:
return
scraper = _get_scraper()
await scraper.download_pending_for_creator(post['creator_id'])
await scraper.close()
except Exception as e:
logger.error(f"Background download failed for post {post_id}: {e}", module="PaidContent")
# ============================================================================
# FILE SERVING
# ============================================================================
@router.get("/files/serve")
@limiter.limit("1000/minute")
@handle_exceptions
async def serve_file(
request: Request,
path: str,
current_user: Dict = Depends(get_current_user)
):
"""Serve a downloaded file with byte-range support for video streaming"""
file_path = Path(path)
# Security: ensure path is within allowed directories
db = _get_db_adapter()
config = db.get_config()
base_path = Path(config.get('base_download_path', '/paid-content'))
# Resolve to absolute path and check it's within base_path
try:
resolved_path = file_path.resolve()
resolved_base = base_path.resolve()
if not str(resolved_path).startswith(str(resolved_base)):
raise ValidationError("Access denied: path outside allowed directory")
except Exception:
raise ValidationError("Invalid path")
if not file_path.exists():
raise NotFoundError(f"File not found")
# Determine media type
suffix = file_path.suffix.lower()
media_types = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.mov': 'video/quicktime',
'.avi': 'video/x-msvideo',
'.mkv': 'video/x-matroska',
}
media_type = media_types.get(suffix, 'application/octet-stream')
# Get file size
file_size = file_path.stat().st_size
# Check for Range header (for video streaming)
range_header = request.headers.get('range')
if range_header and media_type.startswith('video/'):
# Parse range header: "bytes=start-end"
try:
range_spec = range_header.replace('bytes=', '')
if '-' in range_spec:
parts = range_spec.split('-')
start = int(parts[0]) if parts[0] else 0
end = int(parts[1]) if parts[1] else file_size - 1
else:
start = int(range_spec)
end = file_size - 1
# Clamp values
start = max(0, start)
end = min(end, file_size - 1)
content_length = end - start + 1
# Stream the requested range (sync generator runs in threadpool,
# keeping the async event loop free for other requests)
def stream_range():
with open(file_path, 'rb') as f:
f.seek(start)
remaining = content_length
chunk_size = 2 * 1024 * 1024 # 2MB chunks
while remaining > 0:
read_size = min(chunk_size, remaining)
data = f.read(read_size)
if not data:
break
remaining -= len(data)
yield data
from starlette.responses import StreamingResponse
return StreamingResponse(
stream_range(),
status_code=206,
media_type=media_type,
headers={
'Content-Range': f'bytes {start}-{end}/{file_size}',
'Accept-Ranges': 'bytes',
'Content-Length': str(content_length),
}
)
except (ValueError, IndexError):
pass # Fall through to regular file response
# Regular file response for non-range requests
return FileResponse(
path=str(file_path),
media_type=media_type,
filename=file_path.name,
headers={
'Accept-Ranges': 'bytes',
'Cache-Control': 'private, max-age=86400', # Cache for 24 hours
}
)
# HLS streaming cache directory
import subprocess
import hashlib
import tempfile
import os
import aiohttp
from io import BytesIO
from PIL import Image
async def _download_youtube_poster(video_id: str, max_size: tuple = None, crop_portrait: bool = False) -> Optional[bytes]:
"""Download YouTube poster image at the best available resolution.
If max_size is provided, resize to fit. If None (native), return full resolution.
If crop_portrait is True, center-crop the 16:9 image to 9:16 for Shorts."""
thumbnail_urls = [
f"https://i.ytimg.com/vi/{video_id}/maxresdefault.jpg", # 1280x720
f"https://i.ytimg.com/vi/{video_id}/sddefault.jpg", # 640x480
f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg", # 480x360
]
try:
async with aiohttp.ClientSession() as session:
for url in thumbnail_urls:
try:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
if resp.status == 200:
image_data = await resp.read()
if len(image_data) < 1000:
continue # Placeholder image
with Image.open(BytesIO(image_data)) as img:
if img.mode != 'RGB':
img = img.convert('RGB')
if crop_portrait:
# Center-crop 16:9 to 9:16
w, h = img.size
target_w = int(h * 9 / 16)
left = (w - target_w) // 2
img = img.crop((left, 0, left + target_w, h))
if max_size:
img.thumbnail(max_size, Image.Resampling.LANCZOS)
buffer = BytesIO()
img.save(buffer, format='JPEG', quality=90, optimize=True)
return buffer.getvalue()
except Exception:
continue
except Exception as e:
logger.warning(f"Failed to download YouTube poster for {video_id}: {e}")
return None
HLS_CACHE_DIR = Path(tempfile.gettempdir()) / "paid_content_hls_cache"
HLS_CACHE_DIR.mkdir(exist_ok=True)
def _get_hls_cache_path(file_path: Path) -> Path:
"""Get cache directory for HLS segments of a video file."""
# Use hash of file path + mtime for cache key
cache_key = hashlib.md5(f"{file_path}:{file_path.stat().st_mtime}".encode()).hexdigest()
return HLS_CACHE_DIR / cache_key
def _generate_hls_playlist(file_path: Path, cache_path: Path) -> bool:
"""Generate HLS playlist and segments using FFmpeg."""
cache_path.mkdir(parents=True, exist_ok=True)
playlist_path = cache_path / "playlist.m3u8"
# If playlist already exists, skip generation
if playlist_path.exists():
return True
try:
# Probe video codec to pick segment type
probe_cmd = ['ffprobe', '-v', 'error', '-select_streams', 'v:0',
'-show_entries', 'stream=codec_name', '-of', 'csv=p=0',
str(file_path)]
probe = subprocess.run(probe_cmd, capture_output=True, timeout=30)
video_codec = probe.stdout.decode().strip()
# MPEG-TS only supports H.264/H.265. For AV1/VP9, use fMP4 segments
# which browsers can play natively via HLS.
if video_codec in ('h264', 'hevc'):
seg_type = 'mpegts'
seg_ext = 'ts'
else:
seg_type = 'fmp4'
seg_ext = 'mp4'
cmd = [
'ffmpeg',
'-fflags', '+genpts+igndts',
'-avoid_negative_ts', 'make_zero',
'-i', str(file_path),
'-c:v', 'copy',
'-c:a', 'aac', '-b:a', '128k',
'-async', '1',
'-hls_time', '4',
'-hls_list_size', '0',
'-hls_segment_type', seg_type,
'-hls_segment_filename', str(cache_path / f'segment_%03d.{seg_ext}'),
'-f', 'hls',
str(playlist_path),
'-y',
'-loglevel', 'error'
]
result = subprocess.run(cmd, capture_output=True, timeout=300)
if result.returncode != 0:
logger.error(f"FFmpeg HLS error: {result.stderr.decode()}")
return False
return playlist_path.exists()
except subprocess.TimeoutExpired:
logger.error("FFmpeg HLS generation timed out")
return False
except Exception as e:
logger.error(f"HLS generation error: {e}")
return False
@router.get("/files/hls/{attachment_id}/playlist.m3u8")
@limiter.limit("100/minute")
@handle_exceptions
async def get_hls_playlist(
request: Request,
attachment_id: int,
current_user: Dict = Depends(get_current_user)
):
"""Get HLS playlist for video streaming. Generates on first request."""
db = _get_db_adapter()
# Get attachment
attachment = db.get_attachment(attachment_id)
if not attachment:
raise NotFoundError("Attachment not found")
if not attachment.get('local_path'):
raise NotFoundError("File not downloaded")
file_path = Path(attachment['local_path'])
if not file_path.exists():
raise NotFoundError("File not found on disk")
# Get cache path and generate HLS if needed
cache_path = _get_hls_cache_path(file_path)
# Run generation in thread pool to not block
import asyncio
loop = asyncio.get_event_loop()
success = await loop.run_in_executor(None, _generate_hls_playlist, file_path, cache_path)
if not success:
raise ValidationError("Failed to generate HLS stream")
playlist_path = cache_path / "playlist.m3u8"
# Read and modify playlist to use our segment endpoint
playlist_content = playlist_path.read_text()
# Replace segment filenames with our API endpoint (handles both .ts and .mp4)
import re
playlist_content = re.sub(
r'segment_(\d+)\.(ts|mp4)',
f'/api/paid-content/files/hls/{attachment_id}/segment_\\1.\\2',
playlist_content
)
# Also rewrite init segment for fMP4
playlist_content = playlist_content.replace(
'init.mp4',
f'/api/paid-content/files/hls/{attachment_id}/init.mp4'
)
from starlette.responses import Response
return Response(
content=playlist_content,
media_type='application/vnd.apple.mpegurl',
headers={
'Cache-Control': 'no-cache', # Don't cache playlist (segments can change)
}
)
@router.get("/files/hls/{attachment_id}/init.mp4")
@limiter.limit("1000/minute")
@handle_exceptions
async def get_hls_init_segment(
request: Request,
attachment_id: int,
current_user: Dict = Depends(get_current_user)
):
"""Get fMP4 init segment."""
db = _get_db_adapter()
attachment = db.get_attachment(attachment_id)
if not attachment or not attachment.get('local_path'):
raise NotFoundError("Attachment not found")
file_path = Path(attachment['local_path'])
if not file_path.exists():
raise NotFoundError("File not found on disk")
cache_path = _get_hls_cache_path(file_path)
init_path = cache_path / "init.mp4"
if not init_path.exists():
raise NotFoundError("Init segment not found")
return FileResponse(
path=str(init_path),
media_type='video/mp4',
headers={'Cache-Control': 'public, max-age=86400'},
)
@router.get("/files/hls/{attachment_id}/segment_{segment_num}.{ext}")
@limiter.limit("1000/minute")
@handle_exceptions
async def get_hls_segment(
request: Request,
attachment_id: int,
segment_num: str,
ext: str,
current_user: Dict = Depends(get_current_user)
):
"""Get HLS segment file (.ts or .mp4)."""
if ext not in ('ts', 'mp4'):
raise NotFoundError("Invalid segment type")
db = _get_db_adapter()
attachment = db.get_attachment(attachment_id)
if not attachment:
raise NotFoundError("Attachment not found")
if not attachment.get('local_path'):
raise NotFoundError("File not downloaded")
file_path = Path(attachment['local_path'])
if not file_path.exists():
raise NotFoundError("File not found on disk")
cache_path = _get_hls_cache_path(file_path)
segment_path = cache_path / f"segment_{segment_num}.{ext}"
if not segment_path.exists():
raise NotFoundError("Segment not found - playlist may need regeneration")
media_type = 'video/mp4' if ext == 'mp4' else 'video/mp2t'
return FileResponse(
path=str(segment_path),
media_type=media_type,
headers={
'Cache-Control': 'public, max-age=86400',
}
)
@router.get("/files/thumbnail/{attachment_id}")
@limiter.limit("1000/minute")
@handle_exceptions
async def get_attachment_thumbnail(
request: Request,
attachment_id: int,
size: str = "small",
current_user: Dict = Depends(get_current_user)
):
"""Get cached thumbnail for an attachment, generating if needed.
Size options:
- small: 300x300 (default, cached in DB)
- medium: 600x600 (file cached)
- large: 800x800 (file cached)
- native: original video resolution (file cached, for video posters)
"""
# Size presets (native=None tells ffmpeg to skip scaling)
SIZE_PRESETS = {
"small": (300, 300),
"medium": (600, 600),
"large": (800, 800),
"native": None,
}
max_size = SIZE_PRESETS.get(size, SIZE_PRESETS["small"])
use_db_cache = (size == "small") # Only cache small thumbnails in DB
db = _get_db_adapter()
thumbnail_data = None
# File cache directory for larger thumbnails
cache_dir = Path("/opt/media-downloader/cache/thumbnails") / size
cache_file = cache_dir / f"{attachment_id}.jpg"
# Try to get cached thumbnail
if use_db_cache:
thumbnail_data = db.get_attachment_thumbnail(attachment_id)
elif cache_file.exists():
# Read from file cache for medium/large
thumbnail_data = cache_file.read_bytes()
# If no cached thumbnail, generate it
if not thumbnail_data:
attachment = db.get_attachment(attachment_id)
if not attachment:
raise NotFoundError("Attachment not found")
# For attachments without a local_path (duplicates, message copies), find the original by file_hash
if not attachment.get('local_path') and attachment.get('file_hash'):
original = db.find_attachment_by_hash(attachment['file_hash'], exclude_id=attachment_id)
if original and original.get('local_path'):
attachment = original
if not attachment.get('local_path'):
raise NotFoundError("Attachment not found")
file_path = Path(attachment['local_path'])
if not file_path.exists():
raise NotFoundError("File not found")
file_type = attachment.get('file_type', '')
if file_type not in ('image', 'video'):
raise NotFoundError("Thumbnail not available for this file type")
# For YouTube videos, download the official poster image instead of extracting a frame
# For portrait/Shorts, center-crop the 16:9 poster to 9:16
is_youtube = 'youtube' in (attachment.get('local_path') or '')
is_portrait = (attachment.get('height') or 0) > (attachment.get('width') or 1)
video_id = attachment.get('post_api_id') if is_youtube else None
if video_id and file_type == 'video':
thumbnail_data = await _download_youtube_poster(video_id, max_size, crop_portrait=is_portrait)
# Fall back to generating from the video/image file
if not thumbnail_data:
from modules.paid_content.scraper import PaidContentScraper
app_state = get_app_state()
scraper = PaidContentScraper(app_state.db)
thumbnail_data = scraper._generate_thumbnail(file_path, file_type, max_size=max_size)
# Cache the thumbnail
if thumbnail_data:
if use_db_cache:
db.update_attachment_status(attachment_id, attachment['status'],
thumbnail_data=thumbnail_data)
else:
# File cache for medium/large
cache_dir.mkdir(parents=True, exist_ok=True)
cache_file.write_bytes(thumbnail_data)
if not thumbnail_data:
raise NotFoundError("Failed to generate thumbnail")
# Longer cache for larger sizes since they're more expensive to generate
cache_duration = 604800 if size in ("medium", "large") else 86400 # 7 days vs 1 day
# Generate ETag from cache file mtime for cache busting
etag = None
if cache_file.exists():
etag = f'"{attachment_id}-{int(cache_file.stat().st_mtime)}"'
headers = {
"Cache-Control": f"public, max-age={cache_duration}",
}
if etag:
headers["ETag"] = etag
return Response(
content=thumbnail_data,
media_type="image/jpeg",
headers=headers
)
@router.post("/files/thumbnails/backfill")
@limiter.limit("5/minute")
@handle_exceptions
async def backfill_thumbnails(
request: Request,
background_tasks: BackgroundTasks,
batch_size: int = Query(default=50, le=200),
current_user: Dict = Depends(get_current_user)
):
"""Start thumbnail backfill for existing attachments"""
db = _get_db_adapter()
missing_count = db.count_attachments_missing_thumbnails()
if missing_count == 0:
return {"status": "complete", "message": "All attachments have thumbnails"}
# Start background task
background_tasks.add_task(_backfill_thumbnails_background, batch_size)
return {
"status": "started",
"missing_thumbnails": missing_count,
"batch_size": batch_size
}
@router.get("/thumbnail")
@limiter.limit("120/minute")
@handle_exceptions
async def get_thumbnail_by_path(
request: Request,
file_path: str = Query(..., description="Full path to the file"),
size: str = Query(default="small", regex="^(small|medium|large)$"),
current_user: Dict = Depends(get_current_user)
):
"""Get thumbnail for a file by its path (for notifications page)"""
from pathlib import Path
path = Path(file_path)
if not path.exists():
raise HTTPException(status_code=404, detail="File not found")
# Determine file type from extension
ext = path.suffix.lower()
if ext in ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']:
file_type = 'image'
elif ext in ['.mp4', '.mov', '.webm', '.avi', '.mkv', '.m4v']:
file_type = 'video'
else:
raise HTTPException(status_code=400, detail="Unsupported file type")
# Size mapping
size_map = {"small": (200, 200), "medium": (400, 400), "large": (800, 800)}
max_size = size_map.get(size, (200, 200))
# Generate thumbnail
scraper = _get_scraper()
thumbnail_data = scraper._generate_thumbnail(path, file_type, max_size=max_size)
await scraper.close()
if not thumbnail_data:
raise HTTPException(status_code=500, detail="Failed to generate thumbnail")
return Response(
content=thumbnail_data,
media_type="image/jpeg",
headers={"Cache-Control": "public, max-age=86400"}
)
@router.get("/preview")
@limiter.limit("60/minute")
@handle_exceptions
async def get_preview_by_path(
request: Request,
file_path: str = Query(..., description="Full path to the file"),
current_user: Dict = Depends(get_current_user)
):
"""Serve a file for preview (for notifications lightbox)"""
from pathlib import Path
path = Path(file_path)
if not path.exists():
raise HTTPException(status_code=404, detail="File not found")
# Determine media type from extension
ext = path.suffix.lower()
media_types = {
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
'.gif': 'image/gif', '.webp': 'image/webp', '.bmp': 'image/bmp',
'.mp4': 'video/mp4', '.mov': 'video/quicktime', '.webm': 'video/webm',
'.avi': 'video/x-msvideo', '.mkv': 'video/x-matroska', '.m4v': 'video/mp4'
}
media_type = media_types.get(ext, 'application/octet-stream')
return FileResponse(
path=str(path),
media_type=media_type,
headers={"Cache-Control": "public, max-age=3600"}
)
@router.get("/files/thumbnails/status")
@limiter.limit("60/minute")
@handle_exceptions
async def get_thumbnail_backfill_status(
request: Request,
current_user: Dict = Depends(get_current_user)
):
"""Get thumbnail backfill status"""
db = _get_db_adapter()
missing_count = db.count_attachments_missing_thumbnails()
return {
"missing_thumbnails": missing_count,
"status": "complete" if missing_count == 0 else "pending"
}
@router.post("/files/thumbnails/backfill-large")
@limiter.limit("5/minute")
@handle_exceptions
async def backfill_large_thumbnails(
request: Request,
background_tasks: BackgroundTasks,
batch_size: int = Query(default=50, le=200),
current_user: Dict = Depends(get_current_user)
):
"""Start large thumbnail backfill for feed view optimization"""
db = _get_db_adapter()
# Count attachments that don't have large thumbnails cached
cache_dir = Path("/opt/media-downloader/cache/thumbnails/large")
cache_dir.mkdir(parents=True, exist_ok=True)
existing_cache = set(int(f.stem) for f in cache_dir.glob("*.jpg"))
# Get all completed image/video attachments
with db.db.get_connection() as conn:
cursor = conn.execute(
"SELECT id FROM paid_content_attachments WHERE status='completed' AND file_type IN ('image', 'video') AND local_path IS NOT NULL"
)
all_ids = set(row[0] for row in cursor.fetchall())
missing_ids = all_ids - existing_cache
missing_count = len(missing_ids)
if missing_count == 0:
return {"status": "complete", "message": "All large thumbnails cached", "total": len(all_ids)}
# Start background task
background_tasks.add_task(_backfill_large_thumbnails_background, list(missing_ids), batch_size)
return {
"status": "started",
"missing_thumbnails": missing_count,
"total_attachments": len(all_ids),
"batch_size": batch_size
}
async def _backfill_large_thumbnails_background(attachment_ids: list, batch_size: int = 50):
"""Background task to generate large thumbnails for feed view"""
from modules.paid_content.scraper import PaidContentScraper
app_state = get_app_state()
db = _get_db_adapter()
scraper = PaidContentScraper(app_state.db)
cache_dir = Path("/opt/media-downloader/cache/thumbnails/large")
cache_dir.mkdir(parents=True, exist_ok=True)
processed = 0
for att_id in attachment_ids:
try:
cache_file = cache_dir / f"{att_id}.jpg"
if cache_file.exists():
continue
attachment = db.get_attachment(att_id)
if not attachment or not attachment.get('local_path'):
continue
file_path = Path(attachment['local_path'])
if not file_path.exists():
continue
file_type = attachment.get('file_type', '')
if file_type not in ('image', 'video'):
continue
thumbnail_data = scraper._generate_thumbnail(file_path, file_type, max_size=(800, 800))
if thumbnail_data:
cache_file.write_bytes(thumbnail_data)
processed += 1
except Exception as e:
logger.error(f"Error generating large thumbnail for {att_id}: {e}", module="Thumbnail")
logger.info(f"Large thumbnail backfill complete: generated {processed} thumbnails", module="Thumbnail")
# ============================================================================
# DIMENSION BACKFILL ENDPOINTS
# ============================================================================
@router.post("/files/dimensions/backfill")
@limiter.limit("5/minute")
@handle_exceptions
async def backfill_dimensions(
request: Request,
background_tasks: BackgroundTasks,
batch_size: int = Query(default=100, le=500),
current_user: Dict = Depends(get_current_user)
):
"""Start dimension backfill for existing attachments that have files but no resolution info"""
db = _get_db_adapter()
missing_count = db.count_attachments_missing_dimensions()
if missing_count == 0:
return {"status": "complete", "message": "All attachments have dimensions"}
# Start background task
background_tasks.add_task(_backfill_dimensions_background, batch_size)
return {
"status": "started",
"missing_dimensions": missing_count,
"batch_size": batch_size
}
@router.get("/files/dimensions/status")
@limiter.limit("60/minute")
@handle_exceptions
async def get_dimension_backfill_status(
request: Request,
current_user: Dict = Depends(get_current_user)
):
"""Get dimension backfill status"""
db = _get_db_adapter()
missing_count = db.count_attachments_missing_dimensions()
return {
"missing_dimensions": missing_count,
"status": "complete" if missing_count == 0 else "pending"
}
# ============================================================================
# CONTENT BACKFILL ENDPOINTS
# ============================================================================
@router.post("/posts/content/backfill")
@limiter.limit("5/minute")
@handle_exceptions
async def backfill_truncated_content(
request: Request,
background_tasks: BackgroundTasks,
creator_id: Optional[int] = Query(default=None, description="Limit to specific creator"),
current_user: Dict = Depends(get_current_user)
):
"""
Re-fetch full content for posts with truncated descriptions from Coomer/Kemono.
The list endpoint returns a truncated 'substring' field. This backfill fetches
the full content from the individual post endpoint.
"""
db = _get_db_adapter()
# Count posts with missing/truncated content
with db.unified_db.get_connection() as conn:
cursor = conn.cursor()
query = """
SELECT COUNT(*) FROM paid_content_posts p
JOIN paid_content_creators c ON p.creator_id = c.id
WHERE c.service_id IN ('coomer', 'kemono')
AND (
(p.content IS NULL OR p.content = '') AND p.title IS NOT NULL AND p.title != ''
OR p.content LIKE '%..'
OR p.title LIKE '%..'
)
"""
params = []
if creator_id:
query += " AND p.creator_id = ?"
params.append(creator_id)
cursor.execute(query, params)
truncated_count = cursor.fetchone()[0]
if truncated_count == 0:
return {"status": "complete", "message": "No truncated posts found"}
background_tasks.add_task(_backfill_content_background, creator_id)
return {
"status": "started",
"truncated_posts": truncated_count,
"creator_id": creator_id,
}
@router.get("/posts/content/backfill/status")
@limiter.limit("60/minute")
@handle_exceptions
async def get_content_backfill_status(
request: Request,
current_user: Dict = Depends(get_current_user)
):
"""Get count of posts with truncated content"""
db = _get_db_adapter()
with db.unified_db.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT COUNT(*) FROM paid_content_posts p
JOIN paid_content_creators c ON p.creator_id = c.id
WHERE c.service_id IN ('coomer', 'kemono')
AND (
(p.content IS NULL OR p.content = '') AND p.title IS NOT NULL AND p.title != ''
OR p.content LIKE '%..'
OR p.title LIKE '%..'
)
""")
truncated_count = cursor.fetchone()[0]
return {
"truncated_posts": truncated_count,
"status": "complete" if truncated_count == 0 else "pending",
}
async def _backfill_content_background(creator_id: int = None):
"""Background task to re-fetch full content for truncated posts"""
scraper = _get_scraper()
try:
result = await scraper.backfill_truncated_content(creator_id=creator_id)
logger.info(
f"Content backfill complete: {result['updated']} updated, "
f"{result['failed']} failed, {result['total_truncated']} total",
module="PaidContent"
)
except Exception as e:
logger.error(f"Content backfill failed: {e}", module="PaidContent")
finally:
await scraper.close()
# ============================================================================
# BACKGROUND TASKS
# ============================================================================
async def _backfill_thumbnails_background(batch_size: int = 50):
"""Background task to generate thumbnails for existing attachments"""
from modules.paid_content.scraper import PaidContentScraper
db = _get_db_adapter()
app_state = get_app_state()
scraper = PaidContentScraper(app_state.db)
processed = 0
while True:
attachments = db.get_attachments_missing_thumbnails(limit=batch_size)
if not attachments:
break
for att in attachments:
try:
file_path = Path(att['local_path'])
if not file_path.exists():
continue
thumbnail_data = scraper._generate_thumbnail(file_path, att['file_type'])
if thumbnail_data:
db.update_attachment_status(att['id'], 'completed',
thumbnail_data=thumbnail_data)
processed += 1
except Exception as e:
logger.error(f"Error generating thumbnail for {att['id']}: {e}", module="Thumbnail")
# Small delay between batches to not overload the system
await asyncio.sleep(0.5)
logger.info(f"Thumbnail backfill complete: generated {processed} thumbnails", module="Thumbnail")
async def _backfill_dimensions_background(batch_size: int = 100):
"""Background task to extract dimensions for existing attachments"""
from modules.paid_content.scraper import PaidContentScraper
db = _get_db_adapter()
app_state = get_app_state()
scraper = PaidContentScraper(app_state.db)
processed = 0
errors = 0
while True:
attachments = db.get_attachments_missing_dimensions(limit=batch_size)
if not attachments:
break
for att in attachments:
try:
local_path = att.get('local_path')
if not local_path:
continue
file_path = Path(local_path)
if not file_path.exists():
continue
file_type = att.get('file_type', '')
if file_type not in ('image', 'video'):
continue
width, height, duration = scraper._extract_dimensions(file_path, file_type)
if width and height:
db.update_attachment_status(att['id'], att['status'],
width=width,
height=height,
duration=duration)
processed += 1
if processed % 50 == 0:
logger.info(f"Dimension backfill progress: {processed} attachments processed", module="Dimensions")
except Exception as e:
logger.error(f"Error extracting dimensions for {att['id']}: {e}", module="Dimensions")
errors += 1
# Small delay between batches to not overload the system
await asyncio.sleep(0.2)
logger.info(f"Dimension backfill complete: processed {processed} attachments, {errors} errors", module="Dimensions")
async def _import_url_background(url: str, creator_id: Optional[int]):
"""Background task for importing from URL"""
try:
from modules.paid_content import FileHostDownloader
from pathlib import Path
db = _get_db_adapter()
config = db.get_config()
base_path = Path(config.get('base_download_path', '/paid-content'))
# Determine save directory
if creator_id:
creator = db.get_creator(creator_id)
if creator:
save_dir = base_path / creator['platform'] / creator['username'] / 'imports'
else:
save_dir = base_path / 'imports'
else:
save_dir = base_path / 'imports'
downloader = FileHostDownloader()
result = await downloader.download_url(url, save_dir)
# Create notification
if result['success']:
db.create_notification(
notification_type='download_complete',
title="Import Complete",
message=f"Downloaded {len(result['files'])} files from {url[:50]}...",
file_count=len(result['files'])
)
else:
db.create_notification(
notification_type='error',
title="Import Failed",
message=f"Failed to import from {url[:50]}...: {result.get('error')}"
)
except Exception as e:
logger.error(f"Background import failed for {url}: {e}", module="PaidContent")
# Allowed domain suffixes for image proxy (security measure)
# Uses suffix matching so CDN subdomain variations are automatically covered
ALLOWED_IMAGE_DOMAIN_SUFFIXES = [
'.coomer.st',
'.coomer.party',
'.coomer.su',
'.kemono.st',
'.kemono.party',
'.kemono.su',
'.onlyfans.com',
'.fansly.com',
'.tiktokcdn-us.com',
'.tiktokcdn.com',
'.xhcdn.com',
'.imginn.com',
'.cdninstagram.com',
]
# Exact-match domains (for bare domains without subdomain)
ALLOWED_IMAGE_DOMAINS_EXACT = [
'public.onlyfans.com',
]
def _is_domain_allowed(domain: str) -> bool:
"""Check if a domain is allowed for image proxying."""
domain = domain.lower()
if domain in ALLOWED_IMAGE_DOMAINS_EXACT:
return True
return any(domain.endswith(suffix) for suffix in ALLOWED_IMAGE_DOMAIN_SUFFIXES)
@router.get("/proxy/image")
@handle_exceptions
async def proxy_image(url: str, current_user: Dict = Depends(get_current_user)):
"""
Proxy external images to bypass CORS restrictions.
Only allows images from approved domains for security.
"""
from urllib.parse import urlparse
import aiohttp
# Validate URL
try:
parsed = urlparse(url)
if parsed.scheme not in ('http', 'https'):
return Response(content="Invalid URL scheme", status_code=400)
# Check if domain is allowed
domain = parsed.netloc.lower()
if not _is_domain_allowed(domain):
return Response(content=f"Domain not allowed: {domain}", status_code=403)
except Exception:
return Response(content="Invalid URL", status_code=400)
# Fetch the image
try:
async with aiohttp.ClientSession() as session:
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'image/webp,image/apng,image/*,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5',
}
async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=30)) as resp:
if resp.status != 200:
return Response(content=f"Failed to fetch image: {resp.status}", status_code=resp.status)
content_type = resp.headers.get('Content-Type', 'image/jpeg')
image_data = await resp.read()
return Response(
content=image_data,
media_type=content_type,
headers={
'Cache-Control': 'public, max-age=86400', # Cache for 24 hours
}
)
except asyncio.TimeoutError:
return Response(content="Image fetch timeout", status_code=504)
except Exception as e:
logger.error(f"Image proxy error for {url}: {e}", module="PaidContent")
return Response(content="Failed to fetch image", status_code=500)
@router.get("/cache/profile-image/{filename}")
@handle_exceptions
async def serve_cached_profile_image(filename: str, current_user: Dict = Depends(get_current_user)):
"""Serve a locally cached profile image (avatar or banner)."""
import re as _re
# Sanitize filename — only allow alphanumeric, underscore, hyphen, dot
if not _re.match(r'^[\w.-]+$', filename):
return Response(content="Invalid filename", status_code=400)
from ..core.config import Settings
cache_dir = Settings.DATA_DIR / 'cache' / 'profile_images'
filepath = cache_dir / filename
if not filepath.exists() or not filepath.is_file():
return Response(content="Not found", status_code=404)
# Determine content type from extension
ext = filepath.suffix.lower()
content_types = {
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
'.webp': 'image/webp', '.gif': 'image/gif',
}
content_type = content_types.get(ext, 'image/jpeg')
return Response(
content=filepath.read_bytes(),
media_type=content_type,
headers={'Cache-Control': 'public, max-age=604800'},
)
# ==================== MESSAGES ====================
@router.get("/messages/conversations")
@handle_exceptions
async def get_conversations(
request: Request,
creator_id: Optional[int] = Query(None),
service_id: Optional[str] = Query(None),
search: Optional[str] = Query(None),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
current_user: Dict = Depends(get_current_user)
):
"""Get list of conversations with message stats"""
db = _get_db_adapter()
conversations = db.get_conversations(
creator_id=creator_id,
service_id=service_id,
search=search,
limit=limit,
offset=offset
)
return {"conversations": conversations}
@router.get("/messages/unread-count")
@limiter.limit("120/minute")
@handle_exceptions
async def get_unread_messages_count(request: Request, current_user: Dict = Depends(get_current_user)):
"""Get total count of unread messages."""
db = _get_db_adapter()
count = db.get_total_unread_messages_count()
return {"count": count}
@router.post("/messages/mark-all-read")
@limiter.limit("30/minute")
@handle_exceptions
async def mark_all_messages_read_endpoint(request: Request, current_user: Dict = Depends(get_current_user)):
"""Mark all messages as read."""
db = _get_db_adapter()
updated = db.mark_all_messages_read()
return {"updated": updated}
@router.get("/messages/{creator_id}")
@handle_exceptions
async def get_messages(
request: Request,
creator_id: int,
before: Optional[str] = Query(None),
limit: int = Query(50, ge=1, le=200),
current_user: Dict = Depends(get_current_user)
):
"""Get messages for a conversation"""
db = _get_db_adapter()
messages = db.get_messages(creator_id, before=before, limit=limit)
total = db.get_message_count(creator_id)
return {"messages": messages, "total": total}
@router.post("/messages/{creator_id}/sync")
@limiter.limit("10/minute")
@handle_exceptions
async def sync_messages(
request: Request,
creator_id: int,
background_tasks: BackgroundTasks,
current_user: Dict = Depends(get_current_user)
):
"""Trigger manual message sync for a creator"""
db = _get_db_adapter()
creator = db.get_creator(creator_id)
if not creator:
raise NotFoundError("Creator not found")
background_tasks.add_task(_sync_messages_background, creator_id)
return {
"status": "queued",
"creator_id": creator_id,
"username": creator['username']
}
async def _sync_messages_background(creator_id: int):
"""Background task for message sync and attachment download"""
scraper = _get_scraper()
try:
creator = scraper.db.get_creator(creator_id)
if not creator:
return
service_id = creator.get('service_id', '')
if service_id == 'onlyfans_direct':
client = scraper._get_onlyfans_direct_client()
if client:
await scraper._sync_messages_for_creator(creator, client, 'onlyfans')
elif service_id == 'fansly_direct':
client = scraper._get_fansly_direct_client()
if client:
await scraper._sync_messages_for_creator(creator, client, 'fansly')
# Download any pending message attachments
pending = scraper.db.get_pending_message_attachments(creator_id)
if pending:
base_path = Path(scraper.config.get('base_download_path', '/paid-content'))
logger.info(f"Downloading {len(pending)} message attachments for {creator['username']}", module="PaidContent")
await scraper._download_message_attachments(pending, base_path, creator)
finally:
await scraper.close()
@router.post("/messages/{creator_id}/mark-read")
@handle_exceptions
async def mark_messages_read(
request: Request,
creator_id: int,
body: Dict = None,
current_user: Dict = Depends(get_current_user)
):
"""Mark messages as read. If message_ids provided, marks only those; otherwise marks all."""
db = _get_db_adapter()
message_ids = body.get('message_ids') if body else None
count = db.mark_messages_read(creator_id, message_ids=message_ids)
return {"status": "ok", "marked_read": count}
# ============================================================================
# AUTO-TAG RULES ENDPOINTS
# ============================================================================
@router.get("/auto-tag-rules")
@limiter.limit("60/minute")
@handle_exceptions
async def get_auto_tag_rules(
request: Request,
current_user: Dict = Depends(get_current_user)
):
"""Get all auto-tag rules"""
db = _get_db_adapter()
rules = db.get_auto_tag_rules()
# Parse JSON fields
for rule in rules:
if isinstance(rule.get('conditions'), str):
rule['conditions'] = json.loads(rule['conditions'])
if isinstance(rule.get('tag_ids'), str):
rule['tag_ids'] = json.loads(rule['tag_ids'])
return {"rules": rules}
@router.get("/auto-tag-rules/{rule_id}")
@limiter.limit("60/minute")
@handle_exceptions
async def get_auto_tag_rule(
request: Request,
rule_id: int,
current_user: Dict = Depends(get_current_user)
):
"""Get a single auto-tag rule"""
db = _get_db_adapter()
rule = db.get_auto_tag_rule(rule_id)
if not rule:
raise NotFoundError(f"Auto-tag rule {rule_id} not found")
if isinstance(rule.get('conditions'), str):
rule['conditions'] = json.loads(rule['conditions'])
if isinstance(rule.get('tag_ids'), str):
rule['tag_ids'] = json.loads(rule['tag_ids'])
return {"rule": rule}
@router.post("/auto-tag-rules")
@limiter.limit("30/minute")
@handle_exceptions
async def create_auto_tag_rule(
request: Request,
body: CreateAutoTagRuleRequest,
current_user: Dict = Depends(get_current_user)
):
"""Create a new auto-tag rule"""
db = _get_db_adapter()
rule_id = db.create_auto_tag_rule(
name=body.name,
conditions=body.conditions,
tag_ids=body.tag_ids,
priority=body.priority
)
rule = db.get_auto_tag_rule(rule_id)
if isinstance(rule.get('conditions'), str):
rule['conditions'] = json.loads(rule['conditions'])
if isinstance(rule.get('tag_ids'), str):
rule['tag_ids'] = json.loads(rule['tag_ids'])
return {"rule": rule}
@router.put("/auto-tag-rules/{rule_id}")
@limiter.limit("30/minute")
@handle_exceptions
async def update_auto_tag_rule(
request: Request,
rule_id: int,
body: UpdateAutoTagRuleRequest,
current_user: Dict = Depends(get_current_user)
):
"""Update an auto-tag rule"""
db = _get_db_adapter()
existing = db.get_auto_tag_rule(rule_id)
if not existing:
raise NotFoundError(f"Auto-tag rule {rule_id} not found")
updates = {k: v for k, v in body.model_dump().items() if v is not None}
db.update_auto_tag_rule(rule_id, updates)
rule = db.get_auto_tag_rule(rule_id)
if isinstance(rule.get('conditions'), str):
rule['conditions'] = json.loads(rule['conditions'])
if isinstance(rule.get('tag_ids'), str):
rule['tag_ids'] = json.loads(rule['tag_ids'])
return {"rule": rule}
@router.delete("/auto-tag-rules/{rule_id}")
@limiter.limit("30/minute")
@handle_exceptions
async def delete_auto_tag_rule(
request: Request,
rule_id: int,
current_user: Dict = Depends(get_current_user)
):
"""Delete an auto-tag rule"""
db = _get_db_adapter()
if not db.delete_auto_tag_rule(rule_id):
raise NotFoundError(f"Auto-tag rule {rule_id} not found")
return message_response("Rule deleted")
_auto_tag_run_status = {}
@router.post("/auto-tag-rules/run")
@limiter.limit("5/minute")
@handle_exceptions
async def run_all_auto_tag_rules(
request: Request,
background_tasks: BackgroundTasks,
current_user: Dict = Depends(get_current_user)
):
"""Run all enabled auto-tag rules on existing posts (background)"""
import uuid
job_id = str(uuid.uuid4())[:8]
_auto_tag_run_status[job_id] = {'status': 'running', 'started_at': now_iso8601()}
def _run():
try:
db = _get_db_adapter()
result = db.run_rules_on_existing_posts()
_auto_tag_run_status[job_id] = {**result, 'status': 'completed', 'completed_at': now_iso8601()}
except Exception as e:
_auto_tag_run_status[job_id] = {'status': 'error', 'error': str(e)}
background_tasks.add_task(_run)
return {"status": "started", "job_id": job_id}
@router.post("/auto-tag-rules/{rule_id}/run")
@limiter.limit("5/minute")
@handle_exceptions
async def run_single_auto_tag_rule(
request: Request,
rule_id: int,
background_tasks: BackgroundTasks,
current_user: Dict = Depends(get_current_user)
):
"""Run a single auto-tag rule on existing posts (background)"""
db = _get_db_adapter()
rule = db.get_auto_tag_rule(rule_id)
if not rule:
raise NotFoundError(f"Auto-tag rule {rule_id} not found")
import uuid
job_id = str(uuid.uuid4())[:8]
_auto_tag_run_status[job_id] = {'status': 'running', 'started_at': now_iso8601()}
def _run():
try:
db2 = _get_db_adapter()
result = db2.run_rules_on_existing_posts(rule_id=rule_id)
_auto_tag_run_status[job_id] = {**result, 'status': 'completed', 'completed_at': now_iso8601()}
except Exception as e:
_auto_tag_run_status[job_id] = {'status': 'error', 'error': str(e)}
background_tasks.add_task(_run)
return {"status": "started", "job_id": job_id}
# ============================================================================
# ANALYTICS ENDPOINTS
# ============================================================================
@router.get("/analytics/storage-growth")
@limiter.limit("30/minute")
@handle_exceptions
async def get_storage_growth(
request: Request,
days: int = Query(default=30, ge=1, le=365),
current_user: Dict = Depends(get_current_user)
):
"""Get storage growth over time"""
db = _get_db_adapter()
data = db.get_storage_growth_over_time(days)
return {"data": data}
@router.get("/analytics/downloads-per-period")
@limiter.limit("30/minute")
@handle_exceptions
async def get_downloads_per_period(
request: Request,
period: str = Query(default='day'),
days: int = Query(default=30, ge=1, le=365),
current_user: Dict = Depends(get_current_user)
):
"""Get download counts per day or week"""
db = _get_db_adapter()
data = db.get_downloads_per_period(period, days)
return {"data": data}
@router.get("/analytics/storage-by-creator")
@limiter.limit("30/minute")
@handle_exceptions
async def get_storage_by_creator(
request: Request,
limit: int = Query(default=20, ge=1, le=100),
current_user: Dict = Depends(get_current_user)
):
"""Get top creators by storage"""
db = _get_db_adapter()
data = db.get_storage_by_creator(limit)
return {"data": data}
@router.get("/analytics/platform-distribution")
@limiter.limit("30/minute")
@handle_exceptions
async def get_platform_distribution(
request: Request,
current_user: Dict = Depends(get_current_user)
):
"""Get platform distribution"""
db = _get_db_adapter()
data = db.get_platform_distribution()
return {"data": data}
@router.get("/analytics/content-type-distribution")
@limiter.limit("30/minute")
@handle_exceptions
async def get_content_type_distribution(
request: Request,
current_user: Dict = Depends(get_current_user)
):
"""Get content type distribution"""
db = _get_db_adapter()
data = db.get_content_type_distribution()
return {"data": data}
@router.get("/analytics/creator-scorecards")
@limiter.limit("30/minute")
@handle_exceptions
async def get_creator_scorecards(
request: Request,
sort_by: str = Query(default='total_bytes'),
limit: int = Query(default=50, ge=1, le=200),
current_user: Dict = Depends(get_current_user)
):
"""Get creator stats table"""
db = _get_db_adapter()
data = db.get_creator_scorecards(sort_by, limit)
return {"data": data}
@router.get("/analytics/summary")
@limiter.limit("30/minute")
@handle_exceptions
async def get_analytics_summary(
request: Request,
days: int = Query(default=30, ge=1, le=365),
current_user: Dict = Depends(get_current_user)
):
"""Get all analytics data in one call"""
db = _get_db_adapter()
return {
"storage_growth": db.get_storage_growth_over_time(days),
"downloads_per_period": db.get_downloads_per_period('day', days),
"storage_by_creator": db.get_storage_by_creator(20),
"platform_distribution": db.get_platform_distribution(),
"content_type_distribution": db.get_content_type_distribution(),
"creator_scorecards": db.get_creator_scorecards('total_bytes', 50),
}
# ============================================================================
# WATCH LATER ENDPOINTS
# ============================================================================
@router.get("/watch-later")
@limiter.limit("60/minute")
@handle_exceptions
async def get_watch_later(
request: Request,
current_user: Dict = Depends(get_current_user)
):
"""Get all watch later items with metadata"""
db = _get_db_adapter()
items = db.get_watch_later()
return {"items": items}
@router.get("/watch-later/count")
@limiter.limit("120/minute")
@handle_exceptions
async def get_watch_later_count(
request: Request,
current_user: Dict = Depends(get_current_user)
):
"""Get watch later count"""
db = _get_db_adapter()
count = db.get_watch_later_count()
return {"count": count}
@router.get("/watch-later/attachment-ids")
@limiter.limit("60/minute")
@handle_exceptions
async def get_watch_later_attachment_ids(
request: Request,
current_user: Dict = Depends(get_current_user)
):
"""Get set of attachment IDs in watch later (for toggle state)"""
db = _get_db_adapter()
ids = db.get_watch_later_attachment_ids()
return {"attachment_ids": ids}
@router.post("/watch-later")
@limiter.limit("60/minute")
@handle_exceptions
async def add_to_watch_later(
request: Request,
body: WatchLaterAddRequest,
current_user: Dict = Depends(get_current_user)
):
"""Add an attachment to watch later"""
db = _get_db_adapter()
wl_id = db.add_to_watch_later(body.attachment_id)
if wl_id is None:
return {"status": "already_exists"}
return {"status": "added", "id": wl_id}
@router.post("/watch-later/bulk")
@limiter.limit("30/minute")
@handle_exceptions
async def add_bulk_to_watch_later(
request: Request,
body: WatchLaterBulkAddRequest,
current_user: Dict = Depends(get_current_user)
):
"""Add multiple attachments to watch later"""
db = _get_db_adapter()
added = 0
for aid in body.attachment_ids:
if db.add_to_watch_later(aid) is not None:
added += 1
return {"status": "ok", "added": added}
@router.delete("/watch-later/{attachment_id}")
@limiter.limit("60/minute")
@handle_exceptions
async def remove_from_watch_later(
request: Request,
attachment_id: int,
current_user: Dict = Depends(get_current_user)
):
"""Remove an attachment from watch later"""
db = _get_db_adapter()
db.remove_from_watch_later(attachment_id)
return message_response("Removed from watch later")
@router.post("/watch-later/remove")
@limiter.limit("60/minute")
@handle_exceptions
async def remove_multiple_from_watch_later(
request: Request,
body: WatchLaterRemoveRequest,
current_user: Dict = Depends(get_current_user)
):
"""Remove multiple attachments from watch later"""
db = _get_db_adapter()
removed = 0
for aid in body.attachment_ids:
if db.remove_from_watch_later(aid):
removed += 1
return {"status": "ok", "removed": removed}
@router.post("/watch-later/reorder")
@limiter.limit("30/minute")
@handle_exceptions
async def reorder_watch_later(
request: Request,
body: WatchLaterReorderRequest,
current_user: Dict = Depends(get_current_user)
):
"""Reorder watch later items"""
db = _get_db_adapter()
db.reorder_watch_later(body.ordered_ids)
return message_response("Reordered")
@router.delete("/watch-later")
@limiter.limit("10/minute")
@handle_exceptions
async def clear_watch_later(
request: Request,
current_user: Dict = Depends(get_current_user)
):
"""Clear all watch later items"""
db = _get_db_adapter()
count = db.clear_watch_later()
return {"status": "ok", "removed": count}
# ============================================================================
# FACE RECOGNITION FOR BULK DELETE
# ============================================================================
def _ensure_face_table():
"""Create paid_content_face_references table if it doesn't exist."""
app_state = get_app_state()
with app_state.db.get_connection(for_write=True) as conn:
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS paid_content_face_references (
id INTEGER PRIMARY KEY AUTOINCREMENT,
person_name TEXT NOT NULL,
encoding_data TEXT NOT NULL,
thumbnail_data TEXT,
is_active INTEGER DEFAULT 1,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)
""")
conn.commit()
def _face_scan_background(creator_id: int, person_name: str, tolerance: float):
"""Background task: scan all posts for a creator and tag by face match."""
import pickle
import base64
import gc
from modules.face_recognition_module import FaceRecognitionModule
app_state = get_app_state()
db = _get_db_adapter()
with _face_scan_lock:
_face_scan_status['current'] = {
'status': 'initializing',
'processed': 0,
'total': 0,
'matched': 0,
'unmatched': 0,
'errors': 0,
'current_post': None,
}
try:
# 1. Create FaceRecognitionModule, load ONLY paid_content references
fr = FaceRecognitionModule(unified_db=app_state.db)
fr.reference_encodings = {} # Clear default references
fr.reference_encodings_fr = {}
_ensure_face_table()
with app_state.db.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT person_name, encoding_data
FROM paid_content_face_references
WHERE is_active = 1
""")
rows = cursor.fetchall()
for pname, enc_data in rows:
enc = pickle.loads(base64.b64decode(enc_data))
if pname not in fr.reference_encodings:
fr.reference_encodings[pname] = []
fr.reference_encodings[pname].append(enc)
if not fr.reference_encodings:
with _face_scan_lock:
_face_scan_status['current'] = {
'status': 'error',
'processed': 0, 'total': 0, 'matched': 0, 'unmatched': 0, 'errors': 0,
'error_message': 'No face references loaded. Add reference photos first.',
}
return
logger.info(f"Loaded {sum(len(v) for v in fr.reference_encodings.values())} paid-content face references", module="FaceScan")
# 2. (Tags no longer created - matches add tagged user 'lovefromreyn' instead)
# 3. Get all non-deleted, non-pinned posts for creator with completed image attachments
# Skip posts that already have tagged users (they were tagged via Instagram backfill)
with app_state.db.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT DISTINCT p.id
FROM paid_content_posts p
JOIN paid_content_attachments a ON a.post_id = p.id
WHERE p.creator_id = ?
AND p.deleted_at IS NULL
AND p.is_pinned = 0
AND a.status = 'completed'
AND a.local_path IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM paid_content_post_tagged_users tu
WHERE tu.post_id = p.id
)
ORDER BY p.id
""", (creator_id,))
post_ids = [row[0] for row in cursor.fetchall()]
total = len(post_ids)
with _face_scan_lock:
_face_scan_status['current']['total'] = total
_face_scan_status['current']['status'] = 'scanning'
logger.info(f"Face scan: {total} posts to scan for creator {creator_id}", module="FaceScan")
matched = 0
unmatched = 0
errors = 0
match_confidences = [] # track confidence of each match
for idx, post_id in enumerate(post_ids):
# Get attachments for this post
with app_state.db.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT local_path, file_type
FROM paid_content_attachments
WHERE post_id = ?
AND status = 'completed'
AND local_path IS NOT NULL
""", (post_id,))
attachments = cursor.fetchall()
post_has_match = False
best_confidence = 0.0
for local_path, file_type in attachments:
if not Path(local_path).exists():
continue
try:
is_video = (file_type == 'video')
# Directly detect + match, bypassing Immich shortcut
# which would use the wrong reference table
video_tolerance = tolerance + 0.10 # looser for video frames
if is_video:
# Sample 3 frames for better coverage
temp_frames = fr._extract_video_frames_at_positions(
local_path, [0.15, 0.5, 0.85]
)
for temp_frame in temp_frames:
try:
face_encodings = fr.detect_faces_insightface(temp_frame)
for enc in face_encodings:
matched_person, confidence, _ = fr.match_face_insightface(enc, video_tolerance)
if confidence > best_confidence:
best_confidence = confidence
if matched_person:
post_has_match = True
break
finally:
import os as _os
try:
_os.unlink(temp_frame)
except Exception:
pass
if post_has_match:
break
else:
face_encodings = fr.detect_faces_insightface(local_path)
for enc in face_encodings:
matched_person, confidence, _ = fr.match_face_insightface(enc, tolerance)
if confidence > best_confidence:
best_confidence = confidence
if matched_person:
post_has_match = True
break
if post_has_match:
break # One match is enough for the post
except Exception as e:
logger.debug(f"Face scan error on {local_path}: {e}", module="FaceScan")
errors += 1
# Apply result: match adds tagged user 'lovefromreyn', no match does nothing
if post_has_match:
db.add_tagged_user(post_id, 'lovefromreyn')
matched += 1
match_confidences.append(round(float(best_confidence) * 100, 1))
else:
unmatched += 1
avg_conf = float(round(sum(match_confidences) / len(match_confidences), 1)) if match_confidences else 0.0
min_conf = float(min(match_confidences)) if match_confidences else 0.0
max_conf = float(max(match_confidences)) if match_confidences else 0.0
with _face_scan_lock:
_face_scan_status['current'].update({
'processed': idx + 1,
'matched': matched,
'unmatched': unmatched,
'avg_confidence': avg_conf,
'min_confidence': min_conf,
'max_confidence': max_conf,
'errors': errors,
})
# Periodic GC every 50 posts
if (idx + 1) % 50 == 0:
gc.collect()
# Done
fr.release_model()
gc.collect()
with _face_scan_lock:
_face_scan_status['current'].update({
'status': 'completed',
'processed': total,
'matched': matched,
'unmatched': unmatched,
'errors': errors,
})
logger.info(f"Face scan complete: {matched} matched, {unmatched} unmatched, {errors} errors", module="FaceScan")
except Exception as e:
logger.error(f"Face scan failed: {e}", module="FaceScan")
with _face_scan_lock:
_face_scan_status['current'] = {
'status': 'error',
'processed': 0, 'total': 0, 'matched': 0, 'unmatched': 0, 'errors': 0,
'error_message': str(e),
}
@router.post("/face/add-reference")
@limiter.limit("30/minute")
@handle_exceptions
async def face_add_reference(
request: Request,
body: FaceAddReferenceRequest,
current_user: Dict = Depends(get_current_user)
):
"""Add a face reference from an existing attachment's file path."""
import pickle
import base64
from modules.face_recognition_module import FaceRecognitionModule
file_path = body.file_path
if not Path(file_path).exists():
raise ValidationError(f"File not found: {file_path}")
app_state = get_app_state()
_ensure_face_table()
# Detect face and extract encoding
fr = FaceRecognitionModule(unified_db=app_state.db)
fr.reference_encodings = {}
fr.reference_encodings_fr = {}
encodings = fr.detect_faces_insightface(file_path)
if not encodings:
fr.release_model()
raise ValidationError("No face detected in image")
# Use the first (largest/most prominent) face
encoding = encodings[0]
encoding_b64 = base64.b64encode(pickle.dumps(encoding)).decode('utf-8')
# Generate thumbnail
thumbnail_b64 = fr._generate_thumbnail(file_path)
fr.release_model()
# Store in paid_content_face_references
with app_state.db.get_connection(for_write=True) as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO paid_content_face_references (person_name, encoding_data, thumbnail_data, is_active, created_at)
VALUES (?, ?, ?, 1, ?)
""", (body.person_name, encoding_b64, thumbnail_b64, datetime.now().isoformat()))
conn.commit()
ref_id = cursor.lastrowid
return {"id": ref_id, "person_name": body.person_name, "thumbnail": thumbnail_b64}
@router.get("/face/references")
@limiter.limit("60/minute")
@handle_exceptions
async def face_get_references(
request: Request,
current_user: Dict = Depends(get_current_user)
):
"""Get all active face references from paid_content_face_references."""
app_state = get_app_state()
_ensure_face_table()
with app_state.db.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT id, person_name, thumbnail_data, created_at
FROM paid_content_face_references
WHERE is_active = 1
ORDER BY created_at DESC
""")
refs = [
{"id": row[0], "person_name": row[1], "thumbnail": row[2], "created_at": row[3]}
for row in cursor.fetchall()
]
return {"references": refs}
@router.delete("/face/references/{ref_id}")
@limiter.limit("30/minute")
@handle_exceptions
async def face_delete_reference(
request: Request,
ref_id: int,
current_user: Dict = Depends(get_current_user)
):
"""Soft-delete a face reference."""
app_state = get_app_state()
_ensure_face_table()
with app_state.db.get_connection(for_write=True) as conn:
cursor = conn.cursor()
cursor.execute("UPDATE paid_content_face_references SET is_active = 0 WHERE id = ?", (ref_id,))
conn.commit()
if cursor.rowcount == 0:
raise NotFoundError(f"Reference {ref_id} not found")
return message_response("Reference deleted")
@router.post("/face/scan-creator")
@limiter.limit("5/minute")
@handle_exceptions
async def face_scan_creator(
request: Request,
background_tasks: BackgroundTasks,
body: FaceScanCreatorRequest,
current_user: Dict = Depends(get_current_user)
):
"""Start a background face scan for a creator's posts."""
# Clear any stale status (e.g. from a previous run killed by restart)
with _face_scan_lock:
_face_scan_status.pop('current', None)
background_tasks.add_task(
_face_scan_background,
body.creator_id,
body.person_name,
body.tolerance,
)
return {"status": "started", "creator_id": body.creator_id, "person_name": body.person_name}
@router.get("/face/scan-status")
@limiter.limit("120/minute")
@handle_exceptions
async def face_scan_status(
request: Request,
current_user: Dict = Depends(get_current_user)
):
"""Get current face scan status."""
with _face_scan_lock:
status = _face_scan_status.get('current', {'status': 'idle'})
return status
# ============================================================================
# GALLERY (flat media timeline)
# ============================================================================
@router.get("/gallery/groups")
@limiter.limit("60/minute")
@handle_exceptions
async def get_gallery_groups(
request: Request,
current_user: Dict = Depends(get_current_user)
):
"""Get creator groups enriched with media counts for gallery landing."""
db = _get_db_adapter()
groups = db.get_gallery_group_stats()
return {"groups": groups}
@router.get("/gallery/media")
@limiter.limit("300/minute")
@handle_exceptions
async def get_gallery_media(
request: Request,
creator_group_id: Optional[int] = None,
creator_id: Optional[int] = None,
content_type: Optional[str] = None,
min_resolution: Optional[str] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
search: Optional[str] = None,
shuffle: bool = False,
shuffle_seed: Optional[int] = None,
limit: int = Query(default=200, le=500),
offset: int = 0,
current_user: Dict = Depends(get_current_user)
):
"""Get flat media items for gallery timeline."""
db = _get_db_adapter()
items = db.get_gallery_media(
creator_group_id=creator_group_id,
creator_id=creator_id,
content_type=content_type,
min_resolution=min_resolution,
date_from=date_from,
date_to=date_to,
search=search,
shuffle=shuffle,
shuffle_seed=shuffle_seed,
limit=limit,
offset=offset
)
# Only run COUNT on first page — subsequent pages don't need it
total = None
if offset == 0:
total = db.get_gallery_media_count(
creator_group_id=creator_group_id,
creator_id=creator_id,
content_type=content_type,
min_resolution=min_resolution,
date_from=date_from,
date_to=date_to,
search=search
)
return {"items": items, "total": total, "limit": limit, "offset": offset}
@router.get("/gallery/date-range")
@limiter.limit("60/minute")
@handle_exceptions
async def get_gallery_date_range(
request: Request,
creator_group_id: Optional[int] = None,
creator_id: Optional[int] = None,
content_type: Optional[str] = None,
current_user: Dict = Depends(get_current_user)
):
"""Get year/month distribution for timeline scrubber."""
db = _get_db_adapter()
ranges = db.get_gallery_date_range(
creator_group_id=creator_group_id,
creator_id=creator_id,
content_type=content_type
)
return {"ranges": ranges}