""" Database adapter for Paid Content feature Follows existing BaseDatabaseAdapter pattern """ import json import re from datetime import datetime from pathlib import Path from typing import Dict, List, Optional, Any from modules.base_module import BaseDatabaseAdapter class PaidContentDBAdapter(BaseDatabaseAdapter): """Database operations for paid content tables""" # Attachment status constants class AttachmentStatus: PENDING = 'pending' DOWNLOADING = 'downloading' COMPLETED = 'completed' FAILED = 'failed' SKIPPED = 'skipped' MISSING = 'missing' DUPLICATE = 'duplicate' # Embed status constants (same as attachment) class EmbedStatus: PENDING = 'pending' DOWNLOADING = 'downloading' COMPLETED = 'completed' FAILED = 'failed' SKIPPED = 'skipped' # Attachment columns for SELECT queries (excludes binary thumbnail_data) ATTACHMENT_COLUMNS = """a.id, a.post_id, a.message_id, a.attachment_index, a.name, a.file_type, a.extension, a.server_path, a.download_url, a.file_size, a.width, a.height, a.duration, a.status, a.local_path, a.local_filename, a.file_hash, a.perceptual_hash, a.error_message, a.download_attempts, a.last_attempt, a.created_at, a.downloaded_at, a.auto_requeue, a.needs_quality_recheck, a.last_quality_check, a.quality_recheck_count""" # Column whitelists for SQL injection prevention ALLOWED_CONFIG_COLUMNS = { 'base_download_path', 'organize_by_date', 'organize_by_post', 'check_interval_hours', 'max_concurrent_downloads', 'download_embeds', 'embed_quality', 'notifications_enabled', 'push_notifications_enabled', 'perceptual_duplicate_detection', 'perceptual_threshold', 'auto_retry_failed', 'retry_max_attempts', 'updated_at' } ALLOWED_SERVICE_COLUMNS = { 'name', 'base_url', 'enabled', 'session_cookie', 'session_updated_at', 'last_health_check', 'health_status', 'supported_services', 'rate_limit_requests', 'rate_limit_window_seconds', 'updated_at' } ALLOWED_CREATOR_COLUMNS = { 'username', 'display_name', 'profile_image_url', 'banner_image_url', 'bio', 'joined_date', 'location', 'external_links', 'identity_id', 'enabled', 'last_checked', 'last_post_date', 'post_count', 'downloaded_count', 'total_size_bytes', 'auto_download', 'download_embeds', 'updated_at', 'last_coomer_check', 'sync_posts', 'sync_stories', 'sync_highlights', 'filter_tagged_users', 'use_authenticated_api' } ALLOWED_IDENTITY_COLUMNS = { 'name', 'slug', 'profile_image_url', 'notes', 'updated_at' } ALLOWED_POST_COLUMNS = { 'title', 'content', 'published_at', 'added_at', 'edited_at', 'has_attachments', 'attachment_count', 'downloaded', 'download_date', 'embed_count', 'embed_downloaded', 'is_favorited', 'is_viewed', 'view_date', 'local_path', 'metadata', 'is_pinned', 'pinned_at' } ALLOWED_ATTACHMENT_COLUMNS = { 'attachment_index', 'name', 'file_type', 'extension', 'server_path', 'download_url', 'file_size', 'width', 'height', 'duration', 'status', 'local_path', 'local_filename', 'file_hash', 'perceptual_hash', 'error_message', 'download_attempts', 'last_attempt', 'downloaded_at', 'thumbnail_data', 'auto_requeue', 'needs_quality_recheck', 'last_quality_check', 'quality_recheck_count', 'message_id' } ALLOWED_EMBED_COLUMNS = { 'url', 'platform', 'video_id', 'title', 'status', 'local_path', 'local_filename', 'file_size', 'duration', 'error_message', 'download_attempts', 'downloaded_at' } ALLOWED_MESSAGE_COLUMNS = { 'message_id', 'text', 'sent_at', 'is_from_creator', 'is_tip', 'tip_amount', 'price', 'is_free', 'is_purchased', 'has_attachments', 'attachment_count', 'is_read', 'reply_to_message_id', 'metadata' } def __init__(self, unified_db): super().__init__(unified_db, platform='paid_content') def _validate_columns(self, columns: set, allowed: set, table_name: str) -> None: """Validate that all columns are in the allowed whitelist""" invalid = columns - allowed if invalid: raise ValueError(f"Invalid columns for {table_name}: {invalid}") # ============== CONFIG ============== # Default config values DEFAULT_CONFIG = { 'base_download_path': '/paid-content', 'organize_by_date': 1, 'organize_by_post': 1, 'check_interval_hours': 6, 'max_concurrent_downloads': 3, 'download_embeds': 1, 'embed_quality': 'best', 'notifications_enabled': 1, 'push_notifications_enabled': 1, 'perceptual_duplicate_detection': 1, 'perceptual_threshold': 12, 'auto_retry_failed': 1, 'retry_max_attempts': 3, } def get_config(self) -> Dict: """Get paid content configuration""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute("SELECT * FROM paid_content_config WHERE id = 1") row = cursor.fetchone() if row: return dict(row) # Return defaults if no row exists return {**self.DEFAULT_CONFIG, 'id': 1} def update_config(self, updates: Dict) -> bool: """Update configuration (creates row if not exists)""" # Validate columns against whitelist self._validate_columns(set(updates.keys()), self.ALLOWED_CONFIG_COLUMNS, 'paid_content_config') updates['updated_at'] = datetime.now().isoformat() with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() # First ensure the row exists cursor.execute("INSERT OR IGNORE INTO paid_content_config (id) VALUES (1)") # Now update set_clause = ', '.join(f"{k} = ?" for k in updates.keys()) cursor.execute( f"UPDATE paid_content_config SET {set_clause} WHERE id = 1", list(updates.values()) ) conn.commit() return True # ============== SERVICES ============== def get_services(self) -> List[Dict]: """Get all services""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute("SELECT * FROM paid_content_services ORDER BY name") return [dict(row) for row in cursor.fetchall()] def get_service(self, service_id: str) -> Optional[Dict]: """Get single service""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute("SELECT * FROM paid_content_services WHERE id = ?", (service_id,)) row = cursor.fetchone() return dict(row) if row else None def update_service(self, service_id: str, updates: Dict) -> bool: """Update service""" # Validate columns against whitelist self._validate_columns(set(updates.keys()), self.ALLOWED_SERVICE_COLUMNS, 'paid_content_services') updates['updated_at'] = datetime.now().isoformat() set_clause = ', '.join(f"{k} = ?" for k in updates.keys()) with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute( f"UPDATE paid_content_services SET {set_clause} WHERE id = ?", [*updates.values(), service_id] ) conn.commit() return cursor.rowcount > 0 # ============== CREATORS ============== def get_creators(self, service_id: str = None, platform: str = None, identity_id: int = None, enabled_only: bool = False, search: str = None, limit: int = 100, offset: int = 0) -> List[Dict]: """Get creators with filters""" query = """ SELECT c.*, i.name as identity_name, i.slug as identity_slug FROM paid_content_creators c LEFT JOIN paid_content_identities i ON c.identity_id = i.id WHERE 1=1 """ params = [] if service_id: query += " AND c.service_id = ?" params.append(service_id) if platform: query += " AND c.platform = ?" params.append(platform) if identity_id: query += " AND c.identity_id = ?" params.append(identity_id) if enabled_only: query += " AND c.enabled = 1" if search: query += " AND (c.username LIKE ? OR c.display_name LIKE ?)" params.extend([f"%{search}%", f"%{search}%"]) query += " ORDER BY c.username LIMIT ? OFFSET ?" params.extend([limit, offset]) with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute(query, params) return [dict(row) for row in cursor.fetchall()] def get_creator(self, creator_id: int) -> Optional[Dict]: """Get single creator""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" SELECT c.*, i.name as identity_name FROM paid_content_creators c LEFT JOIN paid_content_identities i ON c.identity_id = i.id WHERE c.id = ? """, (creator_id,)) row = cursor.fetchone() return dict(row) if row else None def get_creator_by_api_id(self, service_id: str, platform: str, creator_id: str) -> Optional[Dict]: """Get creator by API identifiers""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" SELECT * FROM paid_content_creators WHERE service_id = ? AND platform = ? AND creator_id = ? """, (service_id, platform, creator_id)) row = cursor.fetchone() return dict(row) if row else None def get_creator_by_username(self, service_id: str, platform: str, username: str) -> Optional[Dict]: """Get creator by service, platform, and username""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" SELECT * FROM paid_content_creators WHERE service_id = ? AND platform = ? AND username = ? """, (service_id, platform, username)) row = cursor.fetchone() return dict(row) if row else None def get_creator_tagged_usernames(self, creator_id: int) -> List[Dict]: """Get distinct tagged usernames for a creator's posts with counts""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" SELECT tu.username, COUNT(*) as post_count FROM paid_content_post_tagged_users tu JOIN paid_content_posts p ON tu.post_id = p.id WHERE p.creator_id = ? AND p.deleted_at IS NULL AND tu.username != '__no_tags__' GROUP BY tu.username ORDER BY tu.username ASC """, (creator_id,)) return [{'username': row[0], 'post_count': row[1]} for row in cursor.fetchall()] def add_creator(self, data: Dict) -> int: """Add new creator""" data['created_at'] = datetime.now().isoformat() data['updated_at'] = data['created_at'] columns = ', '.join(data.keys()) placeholders = ', '.join(['?'] * len(data)) with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute( f"INSERT INTO paid_content_creators ({columns}) VALUES ({placeholders})", list(data.values()) ) conn.commit() return cursor.lastrowid def update_creator(self, creator_id: int, updates: Dict) -> bool: """Update creator""" # Validate columns against whitelist self._validate_columns(set(updates.keys()), self.ALLOWED_CREATOR_COLUMNS, 'paid_content_creators') updates['updated_at'] = datetime.now().isoformat() set_clause = ', '.join(f"{k} = ?" for k in updates.keys()) with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute( f"UPDATE paid_content_creators SET {set_clause} WHERE id = ?", [*updates.values(), creator_id] ) conn.commit() return cursor.rowcount > 0 def delete_creator(self, creator_id: int) -> bool: """Delete creator (cascades to posts/attachments)""" with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute("DELETE FROM paid_content_creators WHERE id = ?", (creator_id,)) conn.commit() return cursor.rowcount > 0 def get_creator_post_count(self, creator_id: int) -> int: """Get post count for creator""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute( "SELECT COUNT(*) FROM paid_content_posts WHERE creator_id = ? AND deleted_at IS NULL", (creator_id,) ) return cursor.fetchone()[0] def get_creator_downloaded_count(self, creator_id: int) -> int: """Get downloaded posts count for creator""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute( "SELECT COUNT(*) FROM paid_content_posts WHERE creator_id = ? AND downloaded = 1 AND deleted_at IS NULL", (creator_id,) ) return cursor.fetchone()[0] def get_creator_stats(self, creator_id: int) -> Dict: """Get detailed stats for a creator""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() stats = {} # Post count cursor.execute( "SELECT COUNT(*) FROM paid_content_posts WHERE creator_id = ? AND deleted_at IS NULL", (creator_id,) ) stats['total_posts'] = cursor.fetchone()[0] # Downloaded posts cursor.execute( "SELECT COUNT(*) FROM paid_content_posts WHERE creator_id = ? AND downloaded = 1 AND deleted_at IS NULL", (creator_id,) ) stats['downloaded_posts'] = cursor.fetchone()[0] # Attachment stats cursor.execute(""" SELECT COUNT(*), SUM(CASE WHEN a.status = 'completed' THEN 1 ELSE 0 END), SUM(CASE WHEN a.status = 'completed' THEN a.file_size ELSE 0 END) FROM paid_content_attachments a JOIN paid_content_posts p ON a.post_id = p.id WHERE p.creator_id = ? AND p.deleted_at IS NULL """, (creator_id,)) row = cursor.fetchone() stats['total_attachments'] = row[0] or 0 stats['downloaded_attachments'] = row[1] or 0 stats['total_size_bytes'] = row[2] or 0 # Failed downloads cursor.execute(""" SELECT COUNT(*) FROM paid_content_attachments a JOIN paid_content_posts p ON a.post_id = p.id WHERE p.creator_id = ? AND a.status = 'failed' AND p.deleted_at IS NULL """, (creator_id,)) stats['failed_downloads'] = cursor.fetchone()[0] return stats def increment_creator_download_stats(self, creator_id: int, file_count: int = 1, file_size: int = 0) -> bool: """Increment creator download stats (for real-time updates during downloads)""" with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute(""" UPDATE paid_content_creators SET downloaded_count = downloaded_count + ?, total_size_bytes = total_size_bytes + ?, updated_at = ? WHERE id = ? """, (file_count, file_size, datetime.now().isoformat(), creator_id)) conn.commit() return cursor.rowcount > 0 # ============== IDENTITIES ============== def get_identities(self) -> List[Dict]: """Get all identities with creator counts""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" SELECT i.*, COUNT(c.id) as creator_count FROM paid_content_identities i LEFT JOIN paid_content_creators c ON i.id = c.identity_id GROUP BY i.id ORDER BY i.name """) return [dict(row) for row in cursor.fetchall()] def get_identity(self, identity_id: int) -> Optional[Dict]: """Get single identity""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute("SELECT * FROM paid_content_identities WHERE id = ?", (identity_id,)) row = cursor.fetchone() return dict(row) if row else None def create_identity(self, name: str, notes: str = None) -> int: """Create new identity""" slug = name.lower().replace(' ', '-') # Make slug unique base_slug = slug counter = 1 while True: with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute("SELECT id FROM paid_content_identities WHERE slug = ?", (slug,)) if not cursor.fetchone(): break slug = f"{base_slug}-{counter}" counter += 1 now = datetime.now().isoformat() with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute(""" INSERT INTO paid_content_identities (name, slug, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?) """, (name, slug, notes, now, now)) conn.commit() return cursor.lastrowid def update_identity(self, identity_id: int, updates: Dict) -> bool: """Update identity""" # Validate columns against whitelist self._validate_columns(set(updates.keys()), self.ALLOWED_IDENTITY_COLUMNS, 'paid_content_identities') updates['updated_at'] = datetime.now().isoformat() set_clause = ', '.join(f"{k} = ?" for k in updates.keys()) with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute( f"UPDATE paid_content_identities SET {set_clause} WHERE id = ?", [*updates.values(), identity_id] ) conn.commit() return cursor.rowcount > 0 def delete_identity(self, identity_id: int) -> bool: """Delete identity (unlinks creators but doesn't delete them)""" with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() # Unlink creators first cursor.execute( "UPDATE paid_content_creators SET identity_id = NULL WHERE identity_id = ?", (identity_id,) ) cursor.execute("DELETE FROM paid_content_identities WHERE id = ?", (identity_id,)) conn.commit() return cursor.rowcount > 0 def link_creator_to_identity(self, creator_id: int, identity_id: int) -> bool: """Link a creator to an identity""" return self.update_creator(creator_id, {'identity_id': identity_id}) def unlink_creator_from_identity(self, creator_id: int) -> bool: """Unlink a creator from its identity""" return self.update_creator(creator_id, {'identity_id': None}) # ============== POSTS ============== def upsert_post(self, creator_id: int, post_data: Dict) -> tuple: """Insert or update post, returns (post_id, is_new)""" with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() # Check if exists (including soft-deleted posts) cursor.execute(""" SELECT id, deleted_at FROM paid_content_posts WHERE creator_id = ? AND post_id = ? """, (creator_id, post_data['post_id'])) existing = cursor.fetchone() if existing: # If soft-deleted, do NOT re-add or update — preserve the deletion if existing[1] is not None: # deleted_at IS NOT NULL return (existing[0], False) # Update existing post — skip None values to avoid overwriting # data that was previously populated (e.g. published_at from yt-dlp) # Exception: 'title' can be set to None (e.g. OF direct client clears # corrupted titles inherited from Coomer imports) post_id = existing[0] clearable_fields = ('title',) update_data = {k: v for k, v in post_data.items() if k != 'post_id' and (v is not None or k in clearable_fields)} # Never overwrite these fields if they already have values in the DB # (e.g. published_at is set by yt-dlp during download and must not be # cleared by a subsequent flat-playlist resync that has no dates) protected_fields = ('published_at', 'downloaded', 'download_date') if update_data: # Check which protected fields already have values cursor.execute( f"SELECT {', '.join(protected_fields)} FROM paid_content_posts WHERE id = ?", (post_id,) ) existing_values = cursor.fetchone() if existing_values: for i, field in enumerate(protected_fields): if existing_values[i] is not None and field in update_data: del update_data[field] if update_data: # Validate columns against whitelist self._validate_columns(set(update_data.keys()), self.ALLOWED_POST_COLUMNS, 'paid_content_posts') set_clause = ', '.join(f"{k} = ?" for k in update_data.keys()) cursor.execute( f"UPDATE paid_content_posts SET {set_clause} WHERE id = ?", [*update_data.values(), post_id] ) conn.commit() return (post_id, False) # Not new else: # Insert new post post_data['creator_id'] = creator_id columns = ', '.join(post_data.keys()) placeholders = ', '.join(['?'] * len(post_data)) cursor.execute( f"INSERT INTO paid_content_posts ({columns}) VALUES ({placeholders})", list(post_data.values()) ) conn.commit() return (cursor.lastrowid, True) # Is new def get_post(self, post_id: int) -> Optional[Dict]: """Get single post with attachments""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" SELECT p.*, c.username, c.platform, c.service_id, c.display_name, c.profile_image_url, c.identity_id FROM paid_content_posts p JOIN paid_content_creators c ON p.creator_id = c.id WHERE p.id = ? AND p.deleted_at IS NULL """, (post_id,)) row = cursor.fetchone() if not row: return None post = dict(row) # Get attachments (exclude binary thumbnail_data to avoid JSON serialization issues) cursor.execute(""" SELECT id, post_id, attachment_index, name, file_type, extension, server_path, download_url, file_size, width, height, duration, status, local_path, local_filename, file_hash, perceptual_hash, error_message, download_attempts, last_attempt, created_at, downloaded_at FROM paid_content_attachments WHERE post_id = ? ORDER BY attachment_index """, (post_id,)) post['attachments'] = [dict(r) for r in cursor.fetchall()] # Get embeds cursor.execute(""" SELECT * FROM paid_content_embeds WHERE post_id = ? ORDER BY id """, (post_id,)) post['embeds'] = [dict(r) for r in cursor.fetchall()] # Get tags cursor.execute(""" SELECT t.id, t.name, t.slug, t.color FROM paid_content_tags t JOIN paid_content_post_tags pt ON t.id = pt.tag_id WHERE pt.post_id = ? ORDER BY t.name """, (post_id,)) post['tags'] = [dict(r) for r in cursor.fetchall()] return post def update_post(self, post_id: int, updates: Dict) -> bool: """Update post fields (title, content, etc.)""" if not updates: return False # Validate columns against whitelist self._validate_columns(set(updates.keys()), self.ALLOWED_POST_COLUMNS, 'paid_content_posts') set_clause = ', '.join(f"{k} = ?" for k in updates.keys()) values = list(updates.values()) + [post_id] with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute( f"UPDATE paid_content_posts SET {set_clause} WHERE id = ?", values ) conn.commit() return cursor.rowcount > 0 def _build_group_member_filter_clause(self, creator_group_id: int, conn) -> tuple: """Build per-member filter conditions for a creator group. Returns (sql_clause, params) to append to the main query. The clause replaces both the simple group subquery AND the global filter_tagged_users block. """ cursor = conn.cursor() cursor.execute(""" SELECT creator_id, filter_tagged_users, filter_tag_ids FROM paid_content_creator_group_members WHERE group_id = ? """, (creator_group_id,)) members = cursor.fetchall() if not members: return " AND 1=0", [] # No members = no posts unfiltered_ids = [] filtered_clauses = [] params = [] for row in members: cid = row[0] raw_tagged = row[1] raw_tags = row[2] # Parse the filter values tagged_users = [] tag_ids = [] try: if raw_tagged: tagged_users = json.loads(raw_tagged) except (json.JSONDecodeError, TypeError): pass try: if raw_tags: tag_ids = json.loads(raw_tags) except (json.JSONDecodeError, TypeError): pass has_member_filter = bool(tagged_users) or bool(tag_ids) if not has_member_filter: unfiltered_ids.append(cid) else: # Build per-member condition sub_conditions = [] sub_params = [] if tagged_users: tu_placeholders = ','.join(['?'] * len(tagged_users)) sub_conditions.append( f"EXISTS (SELECT 1 FROM paid_content_post_tagged_users tu WHERE tu.post_id = p.id AND tu.username IN ({tu_placeholders}))" ) sub_params.extend(tagged_users) if tag_ids: ti_placeholders = ','.join(['?'] * len(tag_ids)) sub_conditions.append( f"EXISTS (SELECT 1 FROM paid_content_post_tags pt WHERE pt.post_id = p.id AND pt.tag_id IN ({ti_placeholders}))" ) sub_params.extend(tag_ids) combined = ' OR '.join(sub_conditions) filtered_clauses.append(f"(p.creator_id = ? AND ({combined}))") params.append(cid) params.extend(sub_params) # Build the combined clause or_parts = [] if unfiltered_ids: # For unfiltered members, apply the global creator-level filter_tagged_users uf_placeholders = ','.join(['?'] * len(unfiltered_ids)) or_parts.append(f"""(p.creator_id IN ({uf_placeholders}) AND ( c.filter_tagged_users IS NULL OR TRIM(c.filter_tagged_users) = '' OR TRIM(c.filter_tagged_users) = '[]' OR EXISTS ( SELECT 1 FROM paid_content_post_tagged_users tu WHERE tu.post_id = p.id AND c.filter_tagged_users LIKE '%\"' || tu.username || '\"%' ) ))""") params = list(unfiltered_ids) + params or_parts.extend(filtered_clauses) if not or_parts: return " AND 1=0", [] clause = " AND (" + " OR ".join(or_parts) + ")" return clause, params def get_posts(self, creator_id: int = None, creator_ids: list = None, creator_group_id: int = None, identity_id: int = None, service: str = None, platform: str = None, content_type: str = None, min_resolution: str = None, tag_ids: List[int] = None, tagged_user: 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 = False, shuffle: bool = False, shuffle_seed: int = None, date_from: str = None, date_to: str = None, search: str = None, sort_by: str = 'published_at', sort_order: str = 'desc', limit: int = 50, offset: int = 0, pinned_first: bool = True, skip_pinned: bool = False) -> List[Dict]: """Get posts with filters (for feed)""" query = """ SELECT p.*, c.username, c.platform, c.service_id, c.display_name, c.profile_image_url, c.identity_id, i.name as identity_name FROM paid_content_posts p JOIN paid_content_creators c ON p.creator_id = c.id LEFT JOIN paid_content_identities i ON c.identity_id = i.id WHERE p.deleted_at IS NULL """ params = [] if skip_pinned: query += " AND (p.is_pinned = 0 OR p.is_pinned IS NULL)" if creator_id: query += " AND p.creator_id = ?" params.append(creator_id) if creator_ids: placeholders = ','.join(['?'] * len(creator_ids)) query += f" AND p.creator_id IN ({placeholders})" params.extend(creator_ids) _has_group_filter = False if creator_group_id: # Placeholder — actual clause built inside connection block _has_group_filter = True if identity_id: query += " AND c.identity_id = ?" params.append(identity_id) if service: query += " AND c.service_id = ?" params.append(service) if platform: query += " AND c.platform = ?" params.append(platform) if content_type: # Filter posts that have at least one attachment of the specified type query += " AND EXISTS (SELECT 1 FROM paid_content_attachments a WHERE a.post_id = p.id AND a.file_type = ?)" params.append(content_type) if min_resolution: # Filter posts that have at least one attachment at or above the resolution # Uses minimum dimension threshold to handle portrait/landscape and non-standard aspect ratios # e.g., 1080p includes 1920x1080, 1080x1920, 1080x1350, etc. resolution_thresholds = { '720p': 720, # Either dimension >= 720 '1080p': 1080, # Either dimension >= 1080 '1440p': 1440, # Either dimension >= 1440 '4k': 2160 # Either dimension >= 2160 } if min_resolution in resolution_thresholds: min_dim = resolution_thresholds[min_resolution] # Check if either dimension meets the threshold query += " AND EXISTS (SELECT 1 FROM paid_content_attachments a WHERE a.post_id = p.id AND a.width IS NOT NULL AND a.height IS NOT NULL AND (a.width >= ? OR a.height >= ?))" params.extend([min_dim, min_dim]) if favorites_only: query += " AND p.is_favorited = 1" if unviewed_only: query += " AND p.is_viewed = 0" if downloaded_only: query += " AND p.downloaded = 1" if has_missing: # Show posts that have at least one attachment with status 'failed' or 'pending' query += " AND EXISTS (SELECT 1 FROM paid_content_attachments a WHERE a.post_id = p.id AND a.status IN ('failed', 'pending', 'missing'))" if missing_description: # Show posts with missing or empty description/content (exclude posts marked as no_description) query += " AND (p.content IS NULL OR p.content = '' OR TRIM(p.content) = '') AND (p.no_description IS NULL OR p.no_description = 0)" if hide_empty: query += " AND EXISTS (SELECT 1 FROM paid_content_attachments a WHERE a.post_id = p.id)" if date_from: query += " AND p.published_at >= ?" params.append(date_from) if date_to: # Add time component to include the full day (published_at is stored as 2022-06-21T12:00:00) query += " AND p.published_at <= ?" params.append(f"{date_to}T23:59:59") if search: # Split into words so "sweater video" matches posts containing both words anywhere words = search.strip().split() if words: for word in words: w = f"%{word}%" query += """ AND (p.title ILIKE ? OR p.content ILIKE ? OR c.username ILIKE ? OR c.display_name ILIKE ? OR i.name ILIKE ? OR EXISTS (SELECT 1 FROM paid_content_attachments a2 WHERE a2.post_id = p.id AND a2.name ILIKE ?) OR EXISTS (SELECT 1 FROM paid_content_post_tags pt2 JOIN paid_content_tags t2 ON pt2.tag_id = t2.id WHERE pt2.post_id = p.id AND t2.name ILIKE ?))""" params.extend([w, w, w, w, w, w, w]) if tag_ids: # Filter posts that have ALL specified tags placeholders = ','.join(['?'] * len(tag_ids)) query += f" AND EXISTS (SELECT 1 FROM paid_content_post_tags pt WHERE pt.post_id = p.id AND pt.tag_id IN ({placeholders}))" params.extend(tag_ids) if tagged_user: if tagged_user == '__none__': query += " AND NOT EXISTS (SELECT 1 FROM paid_content_post_tagged_users tu WHERE tu.post_id = p.id)" else: query += " AND EXISTS (SELECT 1 FROM paid_content_post_tagged_users tu WHERE tu.post_id = p.id AND tu.username = ?)" params.append(tagged_user) # Per-creator tagged user filter (auto-filter based on creator settings) # When using group filter, this is handled by _build_group_member_filter_clause if not _has_group_filter: query += """ AND ( c.filter_tagged_users IS NULL OR TRIM(c.filter_tagged_users) = '' OR TRIM(c.filter_tagged_users) = '[]' OR EXISTS ( SELECT 1 FROM paid_content_post_tagged_users tu WHERE tu.post_id = p.id AND c.filter_tagged_users LIKE '%"' || tu.username || '"%' ) ) """ # Shuffle mode: fetch all matching IDs, shuffle with seed, paginate in Python if shuffle: # Add group filter if needed, then get all matching IDs with self.unified_db.get_connection() as conn: id_query = query.replace( "SELECT p.*, c.username, c.platform, c.service_id, c.display_name,\n" " c.profile_image_url, c.identity_id,\n" " i.name as identity_name", "SELECT p.id" ) id_params = list(params) if _has_group_filter: group_clause, group_params = self._build_group_member_filter_clause(creator_group_id, conn) id_query += group_clause id_params.extend(group_params) cursor = conn.cursor() cursor.execute(id_query, id_params) all_ids = [row[0] for row in cursor.fetchall()] # Deterministic shuffle with seed import random as _random rng = _random.Random(shuffle_seed if shuffle_seed is not None else 42) rng.shuffle(all_ids) # Paginate the shuffled IDs page_ids = all_ids[offset:offset + limit] if not page_ids: return [] # Fetch full post data for this page, maintaining shuffled order placeholders = ','.join(['?'] * len(page_ids)) full_query = f""" SELECT p.*, c.username, c.platform, c.service_id, c.display_name, c.profile_image_url, c.identity_id, i.name as identity_name FROM paid_content_posts p JOIN paid_content_creators c ON p.creator_id = c.id LEFT JOIN paid_content_identities i ON c.identity_id = i.id WHERE p.id IN ({placeholders}) """ cursor.execute(full_query, page_ids) rows_by_id = {dict(r)['id']: r for r in cursor.fetchall()} posts = [dict(rows_by_id[pid]) for pid in page_ids if pid in rows_by_id] else: # Normal sorting sort_col = sort_by if sort_by in ['published_at', 'added_at', 'title', 'download_date'] else 'published_at' sort_dir = 'DESC' if sort_order.lower() == 'desc' else 'ASC' if pinned_first: query += f" ORDER BY p.is_pinned DESC, p.{sort_col} {sort_dir}, p.id {sort_dir}" else: query += f" ORDER BY p.{sort_col} {sort_dir}, p.id {sort_dir}" query += " LIMIT ? OFFSET ?" params.extend([limit, offset]) with self.unified_db.get_connection() as conn: # Build group member filter clause (needs connection for member lookup) if _has_group_filter: group_clause, group_params = self._build_group_member_filter_clause(creator_group_id, conn) # Insert group clause before ORDER BY order_idx = query.index(' ORDER BY') query = query[:order_idx] + group_clause + query[order_idx:] # Insert group params before the LIMIT/OFFSET params (last 2) params = params[:-2] + group_params + params[-2:] cursor = conn.cursor() cursor.execute(query, params) posts = [dict(row) for row in cursor.fetchall()] # Fetch attachments, tags, tagged users for each post with self.unified_db.get_connection() as conn: cursor = conn.cursor() for post in posts: cursor.execute(""" SELECT id, post_id, attachment_index, name, file_type, extension, server_path, download_url, file_size, width, height, duration, status, local_path, local_filename, file_hash, perceptual_hash, error_message, download_attempts, last_attempt, created_at, downloaded_at FROM paid_content_attachments WHERE post_id = ? ORDER BY attachment_index """, (post['id'],)) post['attachments'] = [dict(row) for row in cursor.fetchall()] cursor.execute(""" SELECT t.id, t.name, t.slug, t.color FROM paid_content_tags t JOIN paid_content_post_tags pt ON t.id = pt.tag_id WHERE pt.post_id = ? ORDER BY t.name """, (post['id'],)) post['tags'] = [dict(row) for row in cursor.fetchall()] cursor.execute(""" SELECT username FROM paid_content_post_tagged_users WHERE post_id = ? ORDER BY username """, (post['id'],)) post['tagged_users'] = [row[0] for row in cursor.fetchall()] return posts def get_posts_count(self, **filters) -> int: """Get total count of posts matching filters""" query = """ SELECT COUNT(*) FROM paid_content_posts p JOIN paid_content_creators c ON p.creator_id = c.id LEFT JOIN paid_content_identities i ON c.identity_id = i.id WHERE p.deleted_at IS NULL """ params = [] if filters.get('creator_id'): query += " AND p.creator_id = ?" params.append(filters['creator_id']) if filters.get('creator_ids'): creator_ids = filters['creator_ids'] placeholders = ','.join(['?'] * len(creator_ids)) query += f" AND p.creator_id IN ({placeholders})" params.extend(creator_ids) _has_group_filter = False if filters.get('creator_group_id'): _has_group_filter = True if filters.get('identity_id'): query += " AND c.identity_id = ?" params.append(filters['identity_id']) if filters.get('service'): query += " AND c.service_id = ?" params.append(filters['service']) if filters.get('platform'): query += " AND c.platform = ?" params.append(filters['platform']) if filters.get('content_type'): query += " AND EXISTS (SELECT 1 FROM paid_content_attachments a WHERE a.post_id = p.id AND a.file_type = ?)" params.append(filters['content_type']) if filters.get('min_resolution'): resolution_thresholds = { '720p': 720, '1080p': 1080, '1440p': 1440, '4k': 2160 } min_resolution = filters['min_resolution'] if min_resolution in resolution_thresholds: min_dim = resolution_thresholds[min_resolution] query += " AND EXISTS (SELECT 1 FROM paid_content_attachments a WHERE a.post_id = p.id AND a.width IS NOT NULL AND a.height IS NOT NULL AND (a.width >= ? OR a.height >= ?))" params.extend([min_dim, min_dim]) if filters.get('favorites_only'): query += " AND p.is_favorited = 1" if filters.get('unviewed_only'): query += " AND p.is_viewed = 0" if filters.get('downloaded_only'): query += " AND p.downloaded = 1" if filters.get('has_missing'): query += " AND EXISTS (SELECT 1 FROM paid_content_attachments a WHERE a.post_id = p.id AND a.status IN ('failed', 'pending', 'missing'))" if filters.get('missing_description'): query += " AND (p.content IS NULL OR p.content = '' OR TRIM(p.content) = '') AND (p.no_description IS NULL OR p.no_description = 0)" if filters.get('hide_empty'): query += " AND EXISTS (SELECT 1 FROM paid_content_attachments a WHERE a.post_id = p.id)" if filters.get('search'): words = filters['search'].strip().split() if words: for word in words: w = f"%{word}%" query += """ AND (p.title ILIKE ? OR p.content ILIKE ? OR c.username ILIKE ? OR c.display_name ILIKE ? OR i.name ILIKE ? OR EXISTS (SELECT 1 FROM paid_content_attachments a2 WHERE a2.post_id = p.id AND a2.name ILIKE ?) OR EXISTS (SELECT 1 FROM paid_content_post_tags pt2 JOIN paid_content_tags t2 ON pt2.tag_id = t2.id WHERE pt2.post_id = p.id AND t2.name ILIKE ?))""" params.extend([w, w, w, w, w, w, w]) if filters.get('date_from'): query += " AND p.published_at >= ?" params.append(filters['date_from']) if filters.get('date_to'): query += " AND p.published_at <= ?" params.append(f"{filters['date_to']}T23:59:59") if filters.get('tag_ids'): tag_ids = filters['tag_ids'] placeholders = ','.join(['?'] * len(tag_ids)) query += f" AND EXISTS (SELECT 1 FROM paid_content_post_tags pt WHERE pt.post_id = p.id AND pt.tag_id IN ({placeholders}))" params.extend(tag_ids) if filters.get('tagged_user'): if filters['tagged_user'] == '__none__': query += " AND NOT EXISTS (SELECT 1 FROM paid_content_post_tagged_users tu WHERE tu.post_id = p.id)" else: query += " AND EXISTS (SELECT 1 FROM paid_content_post_tagged_users tu WHERE tu.post_id = p.id AND tu.username = ?)" params.append(filters['tagged_user']) if filters.get('skip_pinned'): query += " AND (p.is_pinned = 0 OR p.is_pinned IS NULL)" # Per-creator tagged user filter (auto-filter based on creator settings) if not _has_group_filter: query += """ AND ( c.filter_tagged_users IS NULL OR TRIM(c.filter_tagged_users) = '' OR TRIM(c.filter_tagged_users) = '[]' OR EXISTS ( SELECT 1 FROM paid_content_post_tagged_users tu WHERE tu.post_id = p.id AND c.filter_tagged_users LIKE '%"' || tu.username || '"%' ) ) """ with self.unified_db.get_connection() as conn: if _has_group_filter: group_clause, group_params = self._build_group_member_filter_clause(filters['creator_group_id'], conn) query += group_clause params.extend(group_params) cursor = conn.cursor() cursor.execute(query, params) return cursor.fetchone()[0] def get_media_count(self, **filters) -> int: """Get total count of completed media attachments matching filters""" query = """ SELECT COUNT(*) FROM paid_content_attachments a JOIN paid_content_posts p ON a.post_id = p.id JOIN paid_content_creators c ON p.creator_id = c.id LEFT JOIN paid_content_identities i ON c.identity_id = i.id WHERE a.status = 'completed' AND a.file_type IN ('image', 'video') AND p.deleted_at IS NULL """ params = [] if filters.get('creator_id'): query += " AND p.creator_id = ?" params.append(filters['creator_id']) if filters.get('creator_ids'): creator_ids = filters['creator_ids'] placeholders = ','.join(['?'] * len(creator_ids)) query += f" AND p.creator_id IN ({placeholders})" params.extend(creator_ids) _has_group_filter = False if filters.get('creator_group_id'): _has_group_filter = True if filters.get('identity_id'): query += " AND c.identity_id = ?" params.append(filters['identity_id']) if filters.get('service'): query += " AND c.service_id = ?" params.append(filters['service']) if filters.get('platform'): query += " AND c.platform = ?" params.append(filters['platform']) if filters.get('content_type'): query += " AND a.file_type = ?" params.append(filters['content_type']) if filters.get('min_resolution'): resolution_thresholds = { '720p': 720, '1080p': 1080, '1440p': 1440, '4k': 2160 } min_resolution = filters['min_resolution'] if min_resolution in resolution_thresholds: min_dim = resolution_thresholds[min_resolution] query += " AND a.width IS NOT NULL AND a.height IS NOT NULL AND (a.width >= ? OR a.height >= ?)" params.extend([min_dim, min_dim]) if filters.get('favorites_only'): query += " AND p.is_favorited = 1" if filters.get('unviewed_only'): query += " AND p.is_viewed = 0" if filters.get('downloaded_only'): query += " AND p.downloaded = 1" if filters.get('has_missing'): query += " AND EXISTS (SELECT 1 FROM paid_content_attachments a2 WHERE a2.post_id = p.id AND a2.status IN ('failed', 'pending', 'missing'))" if filters.get('missing_description'): query += " AND (p.content IS NULL OR p.content = '' OR TRIM(p.content) = '') AND (p.no_description IS NULL OR p.no_description = 0)" if filters.get('hide_empty'): # For media count, this is inherently true (only counting completed attachments) # but still filter the posts to be consistent pass if filters.get('search'): words = filters['search'].strip().split() if words: for word in words: w = f"%{word}%" query += """ AND (p.title ILIKE ? OR p.content ILIKE ? OR c.username ILIKE ? OR c.display_name ILIKE ? OR i.name ILIKE ? OR EXISTS (SELECT 1 FROM paid_content_attachments a2 WHERE a2.post_id = p.id AND a2.name ILIKE ?) OR EXISTS (SELECT 1 FROM paid_content_post_tags pt2 JOIN paid_content_tags t2 ON pt2.tag_id = t2.id WHERE pt2.post_id = p.id AND t2.name ILIKE ?))""" params.extend([w, w, w, w, w, w, w]) if filters.get('date_from'): query += " AND p.published_at >= ?" params.append(filters['date_from']) if filters.get('date_to'): query += " AND p.published_at <= ?" params.append(f"{filters['date_to']}T23:59:59") if filters.get('tag_ids'): tag_ids = filters['tag_ids'] placeholders = ','.join(['?'] * len(tag_ids)) query += f" AND EXISTS (SELECT 1 FROM paid_content_post_tags pt WHERE pt.post_id = p.id AND pt.tag_id IN ({placeholders}))" params.extend(tag_ids) if filters.get('tagged_user'): if filters['tagged_user'] == '__none__': query += " AND NOT EXISTS (SELECT 1 FROM paid_content_post_tagged_users tu WHERE tu.post_id = p.id)" else: query += " AND EXISTS (SELECT 1 FROM paid_content_post_tagged_users tu WHERE tu.post_id = p.id AND tu.username = ?)" params.append(filters['tagged_user']) if filters.get('skip_pinned'): query += " AND (p.is_pinned = 0 OR p.is_pinned IS NULL)" # Per-creator tagged user filter (auto-filter based on creator settings) if not _has_group_filter: query += """ AND ( c.filter_tagged_users IS NULL OR TRIM(c.filter_tagged_users) = '' OR TRIM(c.filter_tagged_users) = '[]' OR EXISTS ( SELECT 1 FROM paid_content_post_tagged_users tu WHERE tu.post_id = p.id AND c.filter_tagged_users LIKE '%"' || tu.username || '"%' ) ) """ with self.unified_db.get_connection() as conn: if _has_group_filter: group_clause, group_params = self._build_group_member_filter_clause(filters['creator_group_id'], conn) query += group_clause params.extend(group_params) cursor = conn.cursor() cursor.execute(query, params) return cursor.fetchone()[0] def mark_post_viewed(self, post_id: int) -> bool: """Mark post as viewed""" with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute(""" UPDATE paid_content_posts SET is_viewed = 1, view_date = ? WHERE id = ? AND is_viewed = 0 """, (datetime.now().isoformat(), post_id)) conn.commit() return cursor.rowcount > 0 def toggle_post_viewed(self, post_id: int) -> bool: """Toggle viewed status, returns new is_viewed state.""" with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute("SELECT is_viewed FROM paid_content_posts WHERE id = ? AND deleted_at IS NULL", (post_id,)) row = cursor.fetchone() if not row: return False new_val = 0 if row[0] else 1 view_date = datetime.now().isoformat() if new_val == 1 else None cursor.execute( "UPDATE paid_content_posts SET is_viewed = ?, view_date = ? WHERE id = ?", (new_val, view_date, post_id) ) conn.commit() return bool(new_val) def get_unviewed_posts_count(self) -> int: """Get count of unviewed posts (respects per-creator filter_tagged_users).""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" SELECT COUNT(*) as cnt FROM paid_content_posts p JOIN paid_content_creators c ON p.creator_id = c.id WHERE p.is_viewed = 0 AND p.deleted_at IS NULL AND EXISTS (SELECT 1 FROM paid_content_attachments a WHERE a.post_id = p.id) AND ( c.filter_tagged_users IS NULL OR TRIM(c.filter_tagged_users) = '' OR TRIM(c.filter_tagged_users) = '[]' OR EXISTS ( SELECT 1 FROM paid_content_post_tagged_users tu WHERE tu.post_id = p.id AND c.filter_tagged_users LIKE '%"' || tu.username || '"%' ) ) """) return cursor.fetchone()['cnt'] def mark_all_posts_viewed(self) -> int: """Mark all unviewed posts as viewed.""" with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute( 'UPDATE paid_content_posts SET is_viewed = 1, view_date = ? WHERE is_viewed = 0 AND deleted_at IS NULL', (datetime.now().isoformat(),) ) conn.commit() return cursor.rowcount def toggle_post_favorite(self, post_id: int) -> bool: """Toggle favorite status, returns new status""" with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute("SELECT is_favorited FROM paid_content_posts WHERE id = ? AND deleted_at IS NULL", (post_id,)) row = cursor.fetchone() if not row: return False new_status = 0 if row[0] else 1 cursor.execute( "UPDATE paid_content_posts SET is_favorited = ? WHERE id = ?", (new_status, post_id) ) conn.commit() return bool(new_status) def mark_post_downloaded(self, post_id: int) -> bool: """Mark post as downloaded""" with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute(""" UPDATE paid_content_posts SET downloaded = 1, download_date = ? WHERE id = ? AND deleted_at IS NULL """, (datetime.now().isoformat(), post_id)) conn.commit() return cursor.rowcount > 0 # ============== ATTACHMENTS ============== # Filename patterns for placeholder/missing-photo images to skip _PLACEHOLDER_PATTERNS = [ re.compile(r'^th_', re.IGNORECASE), # th_* forum thumbnails re.compile(r'\.th\.(jpe?g|png|gif)$', re.IGNORECASE), # *.th.jpg forum thumbnail suffix re.compile(r'^[a-zA-Z0-9_-]{6,12}_o\.jpe?g$', re.IGNORECASE), # gSFDFmSe_o.jpg imagevenue placeholders re.compile(r'^\d+_\d+_[a-f0-9]+_o\.jpg$', re.IGNORECASE), # 38565340_41955754042_ed011b4e87_o.jpg re.compile(r'^post-\d+-0-\d+-\d+\.gif$', re.IGNORECASE), # post-42358-0-1446015779-35437.gif forum icons re.compile(r'^158089130_', re.IGNORECASE), # duck-squad signature image re.compile(r'^fc50da521418916\.jpg$', re.IGNORECASE), # hqcelebcorner repeated tiny image ] def upsert_attachment(self, post_id: int, data: Dict) -> int: """Insert or update attachment""" # Skip placeholder/missing-photo images filename = data.get('name', '') if any(p.match(filename) for p in self._PLACEHOLDER_PATTERNS): return None with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute(""" SELECT id, status, width, height, duration FROM paid_content_attachments WHERE post_id = ? AND server_path = ? """, (post_id, data.get('server_path'))) existing = cursor.fetchone() if existing: att_id, status, existing_width, existing_height, existing_duration = existing updates = [] params = [] # Update download_url for pending/failed/skipped/unavailable attachments (Fansly URLs expire) if status in ('pending', 'failed', 'skipped', 'unavailable') and data.get('download_url'): updates.append('download_url = ?') params.append(data['download_url']) # Reset failed/skipped/unavailable attachments to pending so they retry with fresh URL # This handles PPV content that becomes available after purchase if status in ('failed', 'skipped', 'unavailable'): updates.append('status = ?') params.append('pending') updates.append('error_message = ?') params.append(None) updates.append('download_attempts = ?') params.append(0) # Mark failed/pending attachments as unavailable when no download URL (PPV/locked) elif status in ('pending', 'failed') and not data.get('download_url') and data.get('status') == 'unavailable': updates.append('status = ?') params.append('unavailable') # Update dimensions if not already set and provided in data if existing_width is None and data.get('width'): updates.append('width = ?') params.append(data['width']) if existing_height is None and data.get('height'): updates.append('height = ?') params.append(data['height']) if existing_duration is None and data.get('duration'): updates.append('duration = ?') params.append(data['duration']) # Set needs_quality_recheck flag if provided and not already set if data.get('needs_quality_recheck') and status == 'completed': updates.append('needs_quality_recheck = ?') params.append(1) if updates: params.append(att_id) cursor.execute(f""" UPDATE paid_content_attachments SET {', '.join(updates)} WHERE id = ? """, params) conn.commit() return att_id # Add internal fields data['post_id'] = post_id data['created_at'] = datetime.now().isoformat() # Validate and filter columns (allow internal columns: post_id, created_at) allowed_insert_columns = self.ALLOWED_ATTACHMENT_COLUMNS | {'post_id', 'created_at'} filtered_data = {k: v for k, v in data.items() if k in allowed_insert_columns} columns = ', '.join(filtered_data.keys()) placeholders = ', '.join(['?'] * len(filtered_data)) cursor.execute( f"INSERT INTO paid_content_attachments ({columns}) VALUES ({placeholders})", list(filtered_data.values()) ) conn.commit() return cursor.lastrowid def get_attachment(self, attachment_id: int) -> Optional[Dict]: """Get single attachment with creator info""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() # Try post attachment first (post_id > 0) cursor.execute(f""" SELECT {self.ATTACHMENT_COLUMNS}, p.post_id as post_api_id, p.title as post_title, c.id as creator_db_id, c.username, c.platform, c.service_id FROM paid_content_attachments a JOIN paid_content_posts p ON a.post_id = p.id JOIN paid_content_creators c ON p.creator_id = c.id WHERE a.id = ? """, (attachment_id,)) row = cursor.fetchone() if row: return dict(row) # Try message attachment (post_id = 0, message_id set) cursor.execute(f""" SELECT {self.ATTACHMENT_COLUMNS}, NULL as post_api_id, NULL as post_title, c.id as creator_db_id, c.username, c.platform, c.service_id FROM paid_content_attachments a JOIN paid_content_messages m ON a.message_id = m.id JOIN paid_content_creators c ON m.creator_id = c.id WHERE a.id = ? """, (attachment_id,)) row = cursor.fetchone() return dict(row) if row else None def find_attachment_by_hash(self, file_hash: str, exclude_id: int = None) -> Optional[Dict]: """Find a completed attachment by file hash (for duplicate resolution)""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() query = """ SELECT id, local_path, file_type, status, thumbnail_data IS NOT NULL as has_thumbnail FROM paid_content_attachments WHERE file_hash = ? AND status = 'completed' AND local_path IS NOT NULL """ params = [file_hash] if exclude_id: query += " AND id != ?" params.append(exclude_id) query += " LIMIT 1" cursor.execute(query, params) row = cursor.fetchone() return dict(row) if row else None def get_attachment_by_path(self, local_path: str) -> Optional[Dict]: """Get attachment by its local file path""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute(f""" SELECT {self.ATTACHMENT_COLUMNS}, p.post_id as post_api_id, p.title as post_title, p.content as post_content, p.published_at as post_date, c.id as creator_db_id, c.username, c.platform, c.service_id FROM paid_content_attachments a JOIN paid_content_posts p ON a.post_id = p.id JOIN paid_content_creators c ON p.creator_id = c.id WHERE a.local_path = ? """, (local_path,)) row = cursor.fetchone() return dict(row) if row else None def get_pending_attachments(self, creator_id: int = None, limit: int = None) -> List[Dict]: """Get attachments pending download Args: creator_id: Filter by creator (optional) limit: Maximum number to return (None = unlimited, fetches all) Ordering: - New items (no last_attempt) first, ordered by post date DESC - Re-queued items (have last_attempt) last, ordered by oldest attempt first """ query = f""" SELECT {self.ATTACHMENT_COLUMNS}, p.post_id as post_api_id, p.title as post_title, c.id as creator_db_id, c.username, c.platform, c.service_id FROM paid_content_attachments a JOIN paid_content_posts p ON a.post_id = p.id JOIN paid_content_creators c ON p.creator_id = c.id WHERE a.status = 'pending' """ params = [] if creator_id: query += " AND c.id = ?" params.append(creator_id) # New items first (NULL last_attempt), then re-queued by oldest attempt query += " ORDER BY (CASE WHEN a.last_attempt IS NULL THEN 0 ELSE 1 END), a.last_attempt ASC, p.published_at DESC" if limit: query += " LIMIT ?" params.append(limit) with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute(query, params) return [dict(row) for row in cursor.fetchall()] def get_pending_attachments_for_post(self, post_id: int) -> List[Dict]: """Get pending attachments for a specific post""" query = f""" SELECT {self.ATTACHMENT_COLUMNS}, p.post_id as post_api_id, p.title as post_title, c.id as creator_db_id, c.username, c.platform, c.service_id FROM paid_content_attachments a JOIN paid_content_posts p ON a.post_id = p.id JOIN paid_content_creators c ON p.creator_id = c.id WHERE a.status = 'pending' AND a.post_id = ? """ with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute(query, (post_id,)) return [dict(row) for row in cursor.fetchall()] def get_pending_attachment_count(self, creator_id: int = None) -> int: """Get count of attachments pending download""" query = """ SELECT COUNT(*) FROM paid_content_attachments a JOIN paid_content_posts p ON a.post_id = p.id WHERE a.status = 'pending' """ params = [] if creator_id: query += " AND p.creator_id = ?" params.append(creator_id) with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute(query, params) return cursor.fetchone()[0] def get_downloading_attachment_count(self, creator_id: int = None) -> int: """Get count of attachments currently downloading""" query = """ SELECT COUNT(*) FROM paid_content_attachments a JOIN paid_content_posts p ON a.post_id = p.id WHERE a.status = 'downloading' """ params = [] if creator_id: query += " AND p.creator_id = ?" params.append(creator_id) with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute(query, params) return cursor.fetchone()[0] def get_failed_attachment_count(self) -> int: """Get count of failed attachments""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute("SELECT COUNT(*) FROM paid_content_attachments WHERE status = 'failed'") return cursor.fetchone()[0] def update_attachment_status(self, attachment_id: int, status: str, **kwargs) -> bool: """Update attachment status and optional fields""" updates = {'status': status, **kwargs} # Validate columns against whitelist self._validate_columns(set(updates.keys()), self.ALLOWED_ATTACHMENT_COLUMNS, 'paid_content_attachments') set_clause = ', '.join(f"{k} = ?" for k in updates.keys()) with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute( f"UPDATE paid_content_attachments SET {set_clause} WHERE id = ?", [*updates.values(), attachment_id] ) conn.commit() return cursor.rowcount > 0 def update_attachment(self, attachment_id: int, updates: Dict) -> bool: """Update attachment fields""" if not updates: return False # Validate columns against whitelist self._validate_columns(set(updates.keys()), self.ALLOWED_ATTACHMENT_COLUMNS, 'paid_content_attachments') set_clause = ', '.join(f"{k} = ?" for k in updates.keys()) with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute( f"UPDATE paid_content_attachments SET {set_clause} WHERE id = ?", [*updates.values(), attachment_id] ) conn.commit() return cursor.rowcount > 0 def delete_attachment(self, attachment_id: int) -> bool: """Delete an attachment record from the database""" with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute("DELETE FROM paid_content_attachments WHERE id = ?", [attachment_id]) conn.commit() return cursor.rowcount > 0 def get_downloading_attachments(self, creator_id: int = None, limit: int = None) -> List[Dict]: """Get attachments currently being downloaded""" query = f""" SELECT {self.ATTACHMENT_COLUMNS}, p.post_id as post_api_id, p.title as post_title, c.id as creator_db_id, c.username, c.platform, c.service_id FROM paid_content_attachments a JOIN paid_content_posts p ON a.post_id = p.id JOIN paid_content_creators c ON p.creator_id = c.id WHERE a.status = 'downloading' """ params = [] if creator_id: query += " AND c.id = ?" params.append(creator_id) query += " ORDER BY a.last_attempt DESC" if limit: query += " LIMIT ?" params.append(limit) with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute(query, params) return [dict(row) for row in cursor.fetchall()] def reset_downloading_to_pending(self) -> int: """Reset all downloading items back to pending (for stop all)""" with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute(""" UPDATE paid_content_attachments SET status = 'pending' WHERE status = 'downloading' """) conn.commit() return cursor.rowcount def clear_failed_downloads(self) -> int: """Mark all failed downloads as skipped""" with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute(""" UPDATE paid_content_attachments SET status = 'skipped', error_message = 'Cleared by user' WHERE status = 'failed' """) conn.commit() return cursor.rowcount def get_attachment_thumbnail(self, attachment_id: int) -> Optional[bytes]: """Get thumbnail data for an attachment""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute( "SELECT thumbnail_data FROM paid_content_attachments WHERE id = ?", (attachment_id,) ) row = cursor.fetchone() if row and row['thumbnail_data']: return row['thumbnail_data'] return None def get_attachments_missing_thumbnails(self, limit: int = 100) -> List[Dict]: """Get completed attachments that don't have thumbnails yet""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" SELECT id, local_path, file_type, name FROM paid_content_attachments WHERE status = 'completed' AND local_path IS NOT NULL AND thumbnail_data IS NULL AND file_type IN ('image', 'video') LIMIT ? """, (limit,)) return [dict(row) for row in cursor.fetchall()] def count_attachments_missing_thumbnails(self) -> int: """Count how many completed attachments are missing thumbnails""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" SELECT COUNT(*) FROM paid_content_attachments WHERE status = 'completed' AND local_path IS NOT NULL AND thumbnail_data IS NULL AND file_type IN ('image', 'video') """) return cursor.fetchone()[0] def count_attachments_missing_dimensions(self) -> int: """Count how many completed attachments are missing width/height dimensions""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" SELECT COUNT(*) FROM paid_content_attachments WHERE status = 'completed' AND local_path IS NOT NULL AND (width IS NULL OR height IS NULL) AND file_type IN ('image', 'video') """) return cursor.fetchone()[0] def get_attachments_missing_dimensions(self, limit: int = 100) -> List[Dict]: """Get attachments that need dimension extraction""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" SELECT id, local_path, file_type, status FROM paid_content_attachments WHERE status = 'completed' AND local_path IS NOT NULL AND (width IS NULL OR height IS NULL) AND file_type IN ('image', 'video') LIMIT ? """, (limit,)) return [dict(row) for row in cursor.fetchall()] def get_failed_downloads(self, max_attempts: int = 3, hours: int = 72, limit: int = None) -> List[Dict]: """Get all failed downloads for dashboard display""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() query = f""" SELECT {self.ATTACHMENT_COLUMNS}, p.title as post_title, p.post_id as post_api_id, c.id as creator_db_id, c.username, c.platform, c.service_id, CASE WHEN a.download_attempts < ? THEN 1 ELSE 0 END as can_retry FROM paid_content_attachments a JOIN paid_content_posts p ON a.post_id = p.id JOIN paid_content_creators c ON p.creator_id = c.id WHERE a.status = 'failed' ORDER BY a.last_attempt DESC """ params: list = [max_attempts] if limit: query += " LIMIT ?" params.append(limit) cursor.execute(query, params) return [dict(row) for row in cursor.fetchall()] def get_quality_recheck_candidates(self, max_attempts: int = 24) -> List[Dict]: """Get attachments that need quality rechecking. Returns fansly_direct video attachments where needs_quality_recheck=1, quality_recheck_count < max_attempts, and last_quality_check is NULL or older than 1 hour. """ with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute(f""" SELECT {self.ATTACHMENT_COLUMNS}, p.post_id as post_api_id, p.title as post_title, c.id as creator_db_id, c.username, c.platform, c.service_id, c.creator_id as account_id FROM paid_content_attachments a JOIN paid_content_posts p ON a.post_id = p.id JOIN paid_content_creators c ON p.creator_id = c.id WHERE c.service_id = 'fansly_direct' AND a.file_type = 'video' AND a.needs_quality_recheck = 1 AND a.quality_recheck_count < ? AND (a.last_quality_check IS NULL OR CAST(a.last_quality_check AS TEXT) < CAST(datetime('now', '-1 hour') AS TEXT)) ORDER BY a.quality_recheck_count ASC, a.id ASC """, (max_attempts,)) return [dict(row) for row in cursor.fetchall()] def check_duplicate_hash(self, file_hash: str) -> Optional[Dict]: """Check if file hash already exists""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute(f""" SELECT {self.ATTACHMENT_COLUMNS}, p.title as post_title, c.username FROM paid_content_attachments a JOIN paid_content_posts p ON a.post_id = p.id JOIN paid_content_creators c ON p.creator_id = c.id WHERE a.file_hash = ? AND a.status = 'completed' LIMIT 1 """, (file_hash,)) row = cursor.fetchone() return dict(row) if row else None def get_attachments_with_phash(self) -> List[Dict]: """Get all completed attachments with perceptual hashes for duplicate detection""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" SELECT a.id, a.perceptual_hash, a.local_path, a.name, p.title as post_title, c.username FROM paid_content_attachments a JOIN paid_content_posts p ON a.post_id = p.id JOIN paid_content_creators c ON p.creator_id = c.id WHERE a.status = 'completed' AND a.perceptual_hash IS NOT NULL AND a.perceptual_hash != '' """) return [dict(row) for row in cursor.fetchall()] # ============== EMBEDS ============== def upsert_embed(self, post_id: int, data: Dict) -> int: """Insert or update embed""" with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute(""" SELECT id FROM paid_content_embeds WHERE post_id = ? AND url = ? """, (post_id, data.get('url'))) existing = cursor.fetchone() if existing: return existing[0] data['post_id'] = post_id data['created_at'] = datetime.now().isoformat() columns = ', '.join(data.keys()) placeholders = ', '.join(['?'] * len(data)) cursor.execute( f"INSERT INTO paid_content_embeds ({columns}) VALUES ({placeholders})", list(data.values()) ) conn.commit() return cursor.lastrowid def get_pending_embeds(self, creator_id: int = None, limit: int = 50) -> List[Dict]: """Get embeds pending download""" query = """ SELECT e.*, p.post_id as post_api_id, p.title as post_title, c.id as creator_db_id, c.username, c.platform FROM paid_content_embeds e JOIN paid_content_posts p ON e.post_id = p.id JOIN paid_content_creators c ON p.creator_id = c.id WHERE e.status = 'pending' """ params = [] if creator_id: query += " AND c.id = ?" params.append(creator_id) query += " ORDER BY p.published_at DESC LIMIT ?" params.append(limit) with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute(query, params) return [dict(row) for row in cursor.fetchall()] def update_embed_status(self, embed_id: int, status: str, **kwargs) -> bool: """Update embed status""" updates = {'status': status, **kwargs} # Validate columns against whitelist self._validate_columns(set(updates.keys()), self.ALLOWED_EMBED_COLUMNS, 'paid_content_embeds') set_clause = ', '.join(f"{k} = ?" for k in updates.keys()) with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute( f"UPDATE paid_content_embeds SET {set_clause} WHERE id = ?", [*updates.values(), embed_id] ) conn.commit() return cursor.rowcount > 0 # ============== NOTIFICATIONS ============== def create_notification(self, notification_type: str, title: str, message: str, creator_id: int = None, post_id: int = None, download_count: int = 0, file_count: int = 0, media_files: List[Dict] = None) -> int: """Create notification record with optional media file metadata""" import json metadata = None if media_files: metadata = json.dumps({'media_files': media_files}) with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute(""" INSERT INTO paid_content_notifications (notification_type, creator_id, post_id, title, message, download_count, file_count, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, (notification_type, creator_id, post_id, title, message, download_count, file_count, metadata)) conn.commit() return cursor.lastrowid def get_notifications(self, unread_only: bool = False, limit: int = 50, offset: int = 0) -> List[Dict]: """Get notifications with parsed metadata""" import json query = """ SELECT n.*, c.username, c.platform FROM paid_content_notifications n LEFT JOIN paid_content_creators c ON n.creator_id = c.id WHERE 1=1 """ params = [] if unread_only: query += " AND n.is_read = 0" query += " ORDER BY n.created_at DESC LIMIT ? OFFSET ?" params.extend([limit, offset]) with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute(query, params) notifications = [] for row in cursor.fetchall(): notif = dict(row) # Parse metadata JSON if present if notif.get('metadata'): try: notif['metadata'] = json.loads(notif['metadata']) except (json.JSONDecodeError, TypeError, ValueError): notif['metadata'] = {} else: notif['metadata'] = {} notifications.append(notif) return notifications def get_unread_notification_count(self) -> int: """Get count of unread notifications""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute("SELECT COUNT(*) FROM paid_content_notifications WHERE is_read = 0") return cursor.fetchone()[0] def mark_notifications_read(self, notification_ids: List[int]) -> int: """Mark specific notifications as read""" if not notification_ids: return 0 placeholders = ','.join(['?'] * len(notification_ids)) now = datetime.now().isoformat() with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute(f""" UPDATE paid_content_notifications SET is_read = 1, read_at = ? WHERE id IN ({placeholders}) """, [now] + notification_ids) conn.commit() return cursor.rowcount def mark_all_notifications_read(self) -> int: """Mark all notifications as read""" now = datetime.now().isoformat() with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute(""" UPDATE paid_content_notifications SET is_read = 1, read_at = ? WHERE is_read = 0 """, (now,)) conn.commit() return cursor.rowcount def delete_notification(self, notification_id: int) -> bool: """Delete a notification""" with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute( "DELETE FROM paid_content_notifications WHERE id = ?", (notification_id,) ) conn.commit() return cursor.rowcount > 0 # ============== RECYCLE BIN ============== def soft_delete_post(self, post_id: int, deleted_by: str = None) -> bool: """Soft delete a post by setting deleted_at timestamp""" with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() # Check post exists and is not already deleted cursor.execute("SELECT id FROM paid_content_posts WHERE id = ? AND deleted_at IS NULL", (post_id,)) if not cursor.fetchone(): return False # Set deleted_at timestamp (keeps record for sync dedup) cursor.execute( "UPDATE paid_content_posts SET deleted_at = ? WHERE id = ?", (datetime.now().isoformat(), post_id) ) # Also store in recycle bin for UI restore capability cursor.execute("SELECT * FROM paid_content_posts WHERE id = ?", (post_id,)) post = cursor.fetchone() if post: cursor.execute(""" INSERT INTO paid_content_recycle_bin (item_type, original_id, original_data, deleted_by) VALUES ('post', ?, ?, ?) """, (post_id, json.dumps(dict(post)), deleted_by)) conn.commit() return True def get_recycle_bin_items(self, item_type: str = None, search: str = None, platform: str = None, creator: str = None, content_type: str = None, limit: int = 50, offset: int = 0) -> List[Dict]: """Get items from recycle bin""" query = "SELECT * FROM paid_content_recycle_bin WHERE 1=1" params = [] if item_type: query += " AND item_type = ?" params.append(item_type) if search: query += " AND original_data LIKE ?" params.append(f"%{search}%") # Filter by platform (stored in original_data JSON) if platform: query += " AND json_extract(original_data, '$.platform') = ?" params.append(platform) # Filter by creator username (stored in original_data JSON) if creator: query += " AND json_extract(original_data, '$.creator_username') = ?" params.append(creator) # Filter by content/file type (for attachments) if content_type: query += " AND json_extract(original_data, '$.file_type') = ?" params.append(content_type) query += " ORDER BY deleted_at DESC LIMIT ? OFFSET ?" params.extend([limit, offset]) with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute(query, params) items = [] for row in cursor.fetchall(): item = dict(row) item['original_data'] = json.loads(item['original_data']) items.append(item) return items def restore_from_recycle_bin(self, recycle_id: int) -> bool: """Restore item from recycle bin""" with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() # Get recycled item cursor.execute("SELECT * FROM paid_content_recycle_bin WHERE id = ?", (recycle_id,)) item = cursor.fetchone() if not item: return False item = dict(item) original_data = json.loads(item['original_data']) item_type = item['item_type'] # Restore based on type if item_type == 'post': columns = ', '.join(original_data.keys()) placeholders = ', '.join(['?'] * len(original_data)) cursor.execute( f"INSERT OR REPLACE INTO paid_content_posts ({columns}) VALUES ({placeholders})", list(original_data.values()) ) # Remove from recycle bin cursor.execute("DELETE FROM paid_content_recycle_bin WHERE id = ?", (recycle_id,)) conn.commit() return True def empty_recycle_bin(self, item_type: str = None) -> int: """Permanently delete items from recycle bin""" with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() if item_type: cursor.execute("DELETE FROM paid_content_recycle_bin WHERE item_type = ?", (item_type,)) else: cursor.execute("DELETE FROM paid_content_recycle_bin") conn.commit() return cursor.rowcount def permanently_delete_recycle_item(self, recycle_id: int) -> bool: """Permanently delete a single item from recycle bin""" with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute("DELETE FROM paid_content_recycle_bin WHERE id = ?", (recycle_id,)) conn.commit() return cursor.rowcount > 0 # ============== STATS ============== def get_dashboard_stats(self) -> Dict: """Get dashboard statistics""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() stats = {} # Creator counts cursor.execute("SELECT COUNT(*) FROM paid_content_creators WHERE enabled = 1") stats['total_creators'] = cursor.fetchone()[0] cursor.execute("SELECT COUNT(*) FROM paid_content_creators") stats['all_creators'] = cursor.fetchone()[0] # Post counts cursor.execute("SELECT COUNT(*) FROM paid_content_posts WHERE deleted_at IS NULL") stats['total_posts'] = cursor.fetchone()[0] cursor.execute("SELECT COUNT(*) FROM paid_content_posts WHERE downloaded = 1 AND deleted_at IS NULL") stats['downloaded_posts'] = cursor.fetchone()[0] # Attachment counts cursor.execute("SELECT COUNT(*), SUM(file_size) FROM paid_content_attachments WHERE status = 'completed'") row = cursor.fetchone() stats['total_files'] = row[0] or 0 stats['total_size_bytes'] = row[1] or 0 # Failed downloads cursor.execute("SELECT COUNT(*) FROM paid_content_attachments WHERE status = 'failed'") stats['failed_downloads'] = cursor.fetchone()[0] # Pending downloads cursor.execute("SELECT COUNT(*) FROM paid_content_attachments WHERE status = 'pending'") stats['pending_downloads'] = cursor.fetchone()[0] # Unread notifications cursor.execute("SELECT COUNT(*) FROM paid_content_notifications WHERE is_read = 0") stats['unread_notifications'] = cursor.fetchone()[0] # Storage by creator (top 10) cursor.execute(""" SELECT c.id, c.username, c.platform, c.service_id, c.profile_image_url, SUM(a.file_size) as total_size, COUNT(a.id) as file_count FROM paid_content_creators c JOIN paid_content_posts p ON c.id = p.creator_id AND p.deleted_at IS NULL JOIN paid_content_attachments a ON p.id = a.post_id WHERE a.status = 'completed' GROUP BY c.id ORDER BY total_size DESC LIMIT 10 """) stats['storage_by_creator'] = [dict(row) for row in cursor.fetchall()] # Recent activity (last 7 days) cursor.execute(""" SELECT SUBSTR(downloaded_at, 1, 10) as date, COUNT(*) as count FROM paid_content_attachments WHERE status = 'completed' AND downloaded_at >= CAST(datetime('now', '-7 days') AS TEXT) GROUP BY SUBSTR(downloaded_at, 1, 10) ORDER BY date DESC """) stats['recent_activity'] = [dict(row) for row in cursor.fetchall()] # Service stats cursor.execute(""" SELECT s.id, s.name, s.health_status, s.last_health_check, COUNT(c.id) as creator_count FROM paid_content_services s LEFT JOIN paid_content_creators c ON s.id = c.service_id GROUP BY s.id """) stats['services'] = [dict(row) for row in cursor.fetchall()] return stats # ============== DOWNLOAD HISTORY ============== def record_download_attempt(self, attachment_id: int = None, embed_id: int = None, url: str = None, status: str = 'success', error_message: str = None, response_code: int = None, duration_seconds: float = None) -> int: """Record a download attempt in history""" try: with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute(""" INSERT INTO paid_content_download_history (attachment_id, embed_id, url, status, error_message, response_code, duration_seconds) VALUES (?, ?, ?, ?, ?, ?, ?) """, (attachment_id, embed_id, url, status, error_message, response_code, duration_seconds)) conn.commit() return cursor.lastrowid except Exception: return 0 def get_download_history(self, attachment_id: int = None, status: str = None, limit: int = 100) -> List[Dict]: """Get download history""" query = "SELECT * FROM paid_content_download_history WHERE 1=1" params = [] if attachment_id: query += " AND attachment_id = ?" params.append(attachment_id) if status: query += " AND status = ?" params.append(status) query += " ORDER BY attempt_date DESC LIMIT ?" params.append(limit) with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute(query, params) return [dict(row) for row in cursor.fetchall()] # ============== FILE INTEGRITY ============== def scan_missing_files(self) -> Dict: """ Scan all completed attachments and check if files exist on disk. Updates status to 'missing' for files that don't exist. Returns stats about what was found. """ stats = { 'scanned': 0, 'missing': 0, 'found': 0, 'missing_files': [] } with self.unified_db.get_connection() as conn: cursor = conn.cursor() # Get all completed attachments with local paths cursor.execute(""" SELECT a.id, a.name, a.local_path, p.title as post_title, c.username, c.platform FROM paid_content_attachments a JOIN paid_content_posts p ON a.post_id = p.id JOIN paid_content_creators c ON p.creator_id = c.id WHERE a.status = 'completed' AND a.local_path IS NOT NULL """) attachments = cursor.fetchall() missing_ids = [] for att in attachments: stats['scanned'] += 1 local_path = att['local_path'] if local_path and not Path(local_path).exists(): stats['missing'] += 1 missing_ids.append(att['id']) stats['missing_files'].append({ 'id': att['id'], 'name': att['name'], 'path': local_path, 'post_title': att['post_title'], 'creator': f"{att['username']} ({att['platform']})" }) else: stats['found'] += 1 # Update status for missing files if missing_ids: with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() placeholders = ','.join(['?'] * len(missing_ids)) cursor.execute(f""" UPDATE paid_content_attachments SET status = 'missing', error_message = 'File not found on disk during scan' WHERE id IN ({placeholders}) """, missing_ids) conn.commit() return stats def get_missing_attachments(self, limit: int = 100) -> List[Dict]: """Get attachments with missing files""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute(f""" SELECT {self.ATTACHMENT_COLUMNS}, p.title as post_title, p.post_id as api_post_id, c.username, c.platform, c.service_id FROM paid_content_attachments a JOIN paid_content_posts p ON a.post_id = p.id JOIN paid_content_creators c ON p.creator_id = c.id WHERE a.status = 'missing' ORDER BY a.id DESC LIMIT ? """, (limit,)) return [dict(row) for row in cursor.fetchall()] def reset_missing_to_pending(self, attachment_ids: List[int] = None) -> int: """Reset missing attachments to pending for re-download""" with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() if attachment_ids: placeholders = ','.join(['?'] * len(attachment_ids)) cursor.execute(f""" UPDATE paid_content_attachments SET status = 'pending', local_path = NULL, local_filename = NULL, file_hash = NULL, error_message = NULL, download_attempts = 0 WHERE id IN ({placeholders}) AND status = 'missing' """, attachment_ids) else: # Reset all missing cursor.execute(""" UPDATE paid_content_attachments SET status = 'pending', local_path = NULL, local_filename = NULL, file_hash = NULL, error_message = NULL, download_attempts = 0 WHERE status = 'missing' """) conn.commit() return cursor.rowcount # ============== TAGS ============== def get_tags(self, creator_ids: List[int] = None) -> List[Dict]: """Get all tags with post counts, optionally filtered by creator IDs""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() if creator_ids: placeholders = ','.join('?' for _ in creator_ids) cursor.execute(f""" SELECT t.*, COUNT(DISTINCT pt.post_id) as post_count FROM paid_content_tags t JOIN paid_content_post_tags pt ON t.id = pt.tag_id JOIN paid_content_posts p ON pt.post_id = p.id WHERE p.creator_id IN ({placeholders}) GROUP BY t.id ORDER BY t.name COLLATE NOCASE ASC """, creator_ids) else: cursor.execute(""" SELECT t.*, COUNT(pt.post_id) as post_count FROM paid_content_tags t LEFT JOIN paid_content_post_tags pt ON t.id = pt.tag_id GROUP BY t.id ORDER BY t.name COLLATE NOCASE ASC """) return [dict(row) for row in cursor.fetchall()] def get_tag(self, tag_id: int) -> Optional[Dict]: """Get single tag""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute("SELECT * FROM paid_content_tags WHERE id = ?", (tag_id,)) row = cursor.fetchone() return dict(row) if row else None def get_tag_by_slug(self, slug: str) -> Optional[Dict]: """Get tag by slug""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute("SELECT * FROM paid_content_tags WHERE slug = ?", (slug,)) row = cursor.fetchone() return dict(row) if row else None def create_tag(self, name: str, color: str = "#6b7280", description: str = None) -> Optional[int]: """Create a new tag""" slug = name.lower().replace(' ', '-').replace('/', '-') now = datetime.now().isoformat() with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() try: cursor.execute(""" INSERT INTO paid_content_tags (name, slug, color, description, created_at) VALUES (?, ?, ?, ?, ?) """, (name, slug, color, description, now)) conn.commit() return cursor.lastrowid except Exception: return None def update_tag(self, tag_id: int, updates: Dict) -> bool: """Update a tag""" allowed = {'name', 'color', 'description'} filtered = {k: v for k, v in updates.items() if k in allowed} if not filtered: return False # Update slug if name changed if 'name' in filtered: filtered['slug'] = filtered['name'].lower().replace(' ', '-').replace('/', '-') set_clause = ', '.join(f"{k} = ?" for k in filtered.keys()) with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute( f"UPDATE paid_content_tags SET {set_clause} WHERE id = ?", [*filtered.values(), tag_id] ) conn.commit() return cursor.rowcount > 0 def delete_tag(self, tag_id: int) -> bool: """Delete a tag""" with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute("DELETE FROM paid_content_tags WHERE id = ?", (tag_id,)) conn.commit() return cursor.rowcount > 0 def get_post_tags(self, post_id: int) -> List[Dict]: """Get tags for a specific post""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" SELECT t.* FROM paid_content_tags t JOIN paid_content_post_tags pt ON t.id = pt.tag_id WHERE pt.post_id = ? ORDER BY t.name """, (post_id,)) return [dict(row) for row in cursor.fetchall()] def add_tag_to_post(self, post_id: int, tag_id: int) -> bool: """Add a tag to a post""" with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() try: cursor.execute(""" INSERT OR IGNORE INTO paid_content_post_tags (post_id, tag_id, created_at) VALUES (?, ?, ?) """, (post_id, tag_id, datetime.now().isoformat())) conn.commit() return cursor.rowcount > 0 except Exception: return False def remove_tag_from_post(self, post_id: int, tag_id: int) -> bool: """Remove a tag from a post""" with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute(""" DELETE FROM paid_content_post_tags WHERE post_id = ? AND tag_id = ? """, (post_id, tag_id)) conn.commit() return cursor.rowcount > 0 def set_post_tags(self, post_id: int, tag_ids: List[int]) -> None: """Set tags for a post (replaces existing tags)""" with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() # Remove existing tags cursor.execute("DELETE FROM paid_content_post_tags WHERE post_id = ?", (post_id,)) # Add new tags now = datetime.now().isoformat() for tag_id in tag_ids: cursor.execute(""" INSERT OR IGNORE INTO paid_content_post_tags (post_id, tag_id, created_at) VALUES (?, ?, ?) """, (post_id, tag_id, now)) conn.commit() # ============== TAGGED USERS ============== def set_post_tagged_users(self, post_id: int, usernames: List[str]) -> None: """Set tagged users for a post (replaces existing)""" with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute("DELETE FROM paid_content_post_tagged_users WHERE post_id = ?", (post_id,)) now = datetime.now().isoformat() for username in usernames: cursor.execute(""" INSERT OR IGNORE INTO paid_content_post_tagged_users (post_id, username, created_at) VALUES (?, ?, ?) """, (post_id, username, now)) conn.commit() def add_tagged_user(self, post_id: int, username: str) -> None: """Add a single tagged user to a post (without removing existing ones)""" with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() now = datetime.now().isoformat() cursor.execute(""" INSERT OR IGNORE INTO paid_content_post_tagged_users (post_id, username, created_at) VALUES (?, ?, ?) """, (post_id, username, now)) conn.commit() def get_post_tagged_users(self, post_id: int) -> List[str]: """Get tagged usernames for a post""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" SELECT username FROM paid_content_post_tagged_users WHERE post_id = ? ORDER BY username """, (post_id,)) return [row[0] for row in cursor.fetchall()] def get_all_tagged_usernames(self, creator_ids: List[int] = None) -> List[Dict]: """Get all distinct tagged usernames with post counts, optionally filtered by creator IDs""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() if creator_ids: placeholders = ','.join('?' for _ in creator_ids) cursor.execute(f""" SELECT username, COUNT(*) as post_count FROM paid_content_post_tagged_users tu JOIN paid_content_posts p ON tu.post_id = p.id WHERE p.deleted_at IS NULL AND p.creator_id IN ({placeholders}) GROUP BY username ORDER BY username ASC """, creator_ids) else: cursor.execute(""" SELECT username, COUNT(*) as post_count FROM paid_content_post_tagged_users tu JOIN paid_content_posts p ON tu.post_id = p.id WHERE p.deleted_at IS NULL GROUP BY username ORDER BY username ASC """) return [{'username': row[0], 'post_count': row[1]} for row in cursor.fetchall()] def get_content_types(self, creator_ids: List[int] = None) -> List[str]: """Get distinct content types (from attachments), optionally filtered by creator IDs""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() if creator_ids: placeholders = ','.join('?' for _ in creator_ids) cursor.execute(f""" SELECT DISTINCT a.file_type FROM paid_content_attachments a JOIN paid_content_posts p ON a.post_id = p.id WHERE p.deleted_at IS NULL AND a.file_type IS NOT NULL AND p.creator_id IN ({placeholders}) ORDER BY a.file_type ASC """, creator_ids) else: cursor.execute(""" SELECT DISTINCT a.file_type FROM paid_content_attachments a JOIN paid_content_posts p ON a.post_id = p.id WHERE p.deleted_at IS NULL AND a.file_type IS NOT NULL ORDER BY a.file_type ASC """) return [row[0] for row in cursor.fetchall()] # ============== MESSAGES ============== def upsert_message(self, creator_id: int, msg_data: Dict) -> tuple: """Insert or update message, returns (message_db_id, is_new)""" with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute(""" SELECT id FROM paid_content_messages WHERE creator_id = ? AND message_id = ? """, (creator_id, msg_data['message_id'])) existing = cursor.fetchone() if existing: msg_db_id = existing[0] update_data = {k: v for k, v in msg_data.items() if k != 'message_id' and v is not None} if update_data: self._validate_columns(set(update_data.keys()), self.ALLOWED_MESSAGE_COLUMNS, 'paid_content_messages') set_clause = ', '.join(f"{k} = ?" for k in update_data.keys()) cursor.execute( f"UPDATE paid_content_messages SET {set_clause} WHERE id = ?", [*update_data.values(), msg_db_id] ) conn.commit() return (msg_db_id, False) else: msg_data['creator_id'] = creator_id msg_data['created_at'] = datetime.now().isoformat() # Filter to allowed columns + internal columns allowed_insert = self.ALLOWED_MESSAGE_COLUMNS | {'creator_id', 'created_at'} filtered = {k: v for k, v in msg_data.items() if k in allowed_insert} columns = ', '.join(filtered.keys()) placeholders = ', '.join(['?'] * len(filtered)) cursor.execute( f"INSERT INTO paid_content_messages ({columns}) VALUES ({placeholders})", list(filtered.values()) ) conn.commit() return (cursor.lastrowid, True) def upsert_message_attachment(self, message_id: int, data: Dict) -> int: """Insert or update attachment for a message (dedup on message_id + server_path). Message attachments have post_id = NULL (they don't belong to a post). """ with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute(""" SELECT id, status FROM paid_content_attachments WHERE message_id = ? AND server_path = ? """, (message_id, data.get('server_path'))) existing = cursor.fetchone() if existing: att_id, status = existing['id'], existing['status'] # Update download_url for pending/failed attachments (URLs expire) if status in ('pending', 'failed', 'skipped') and data.get('download_url'): updates = ['download_url = ?'] params = [data['download_url']] if status in ('failed', 'skipped'): updates.extend(['status = ?', 'error_message = ?', 'download_attempts = ?']) params.extend(['pending', None, 0]) params.append(att_id) cursor.execute(f""" UPDATE paid_content_attachments SET {', '.join(updates)} WHERE id = ? """, params) conn.commit() return att_id # Insert new — post_id is NULL for message attachments data['message_id'] = message_id data['created_at'] = datetime.now().isoformat() allowed_insert = self.ALLOWED_ATTACHMENT_COLUMNS | {'message_id', 'created_at'} filtered = {k: v for k, v in data.items() if k in allowed_insert and k != 'post_id'} columns = ', '.join(filtered.keys()) placeholders = ', '.join(['?'] * len(filtered)) try: cursor.execute( f"INSERT INTO paid_content_attachments ({columns}) VALUES ({placeholders})", list(filtered.values()) ) conn.commit() return cursor.lastrowid except Exception as e: logger.error(f"Error inserting message attachment: {e}") return 0 def get_conversations(self, creator_id: int = None, service_id: str = None, search: str = None, limit: int = 50, offset: int = 0) -> List[Dict]: """Get conversation list grouped by creator with message stats""" query = """ SELECT c.id as creator_id, c.username, c.display_name, c.platform, c.service_id, c.profile_image_url, COUNT(m.id) as message_count, SUM(CASE WHEN m.is_read = 0 THEN 1 ELSE 0 END) as unread_count, MAX(m.sent_at) as last_message_at, (SELECT m2.text FROM paid_content_messages m2 WHERE m2.creator_id = c.id ORDER BY m2.sent_at DESC LIMIT 1) as last_message_text, (SELECT m2.is_from_creator FROM paid_content_messages m2 WHERE m2.creator_id = c.id ORDER BY m2.sent_at DESC LIMIT 1) as last_message_from_creator FROM paid_content_creators c INNER JOIN paid_content_messages m ON c.id = m.creator_id WHERE 1=1 """ params = [] if creator_id: query += " AND c.id = ?" params.append(creator_id) if service_id: query += " AND c.service_id = ?" params.append(service_id) if search: query += " AND (c.username LIKE ? OR c.display_name LIKE ?)" params.extend([f"%{search}%", f"%{search}%"]) query += " GROUP BY c.id ORDER BY last_message_at DESC LIMIT ? OFFSET ?" params.extend([limit, offset]) with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute(query, params) return [dict(row) for row in cursor.fetchall()] def get_messages(self, creator_id: int, before: str = None, limit: int = 50) -> List[Dict]: """Get paginated messages for a conversation, newest first, with attachments""" query = """ SELECT m.* FROM paid_content_messages m WHERE m.creator_id = ? """ params = [creator_id] if before: query += " AND m.sent_at < ?" params.append(before) query += " ORDER BY m.sent_at DESC LIMIT ?" params.append(limit) with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute(query, params) messages = [dict(row) for row in cursor.fetchall()] # Fetch attachments for each message for msg in messages: cursor.execute(""" SELECT id, message_id, attachment_index, name, file_type, extension, server_path, download_url, file_size, width, height, duration, status, local_path, local_filename, file_hash, error_message, download_attempts, created_at, downloaded_at FROM paid_content_attachments WHERE message_id = ? ORDER BY attachment_index """, (msg['id'],)) atts = [dict(row) for row in cursor.fetchall()] # Resolve local_path for attachments that are copies of post media for att in atts: if not att.get('local_path') and att.get('file_hash') and att.get('status') in ('completed', 'duplicate'): cursor.execute(""" SELECT local_path FROM paid_content_attachments WHERE file_hash = ? AND local_path IS NOT NULL AND local_path != '' AND id != ? LIMIT 1 """, (att['file_hash'], att['id'])) orig = cursor.fetchone() if orig: att['local_path'] = orig['local_path'] msg['attachments'] = atts return messages def get_message_count(self, creator_id: int) -> int: """Get total message count for a creator""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute( "SELECT COUNT(*) FROM paid_content_messages WHERE creator_id = ?", (creator_id,) ) return cursor.fetchone()[0] def get_total_unread_messages_count(self) -> int: """Get total count of unread messages across all creators.""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute('SELECT COUNT(*) FROM paid_content_messages WHERE is_read = 0 AND is_from_creator = 1') return cursor.fetchone()[0] def mark_all_messages_read(self) -> int: """Mark all unread messages as read across all creators.""" with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute('UPDATE paid_content_messages SET is_read = 1 WHERE is_read = 0') conn.commit() return cursor.rowcount def mark_messages_read(self, creator_id: int, message_ids: list = None) -> int: """Mark messages as read. If message_ids provided, marks only those; otherwise marks all.""" with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() if message_ids: placeholders = ','.join('?' for _ in message_ids) cursor.execute( f"UPDATE paid_content_messages SET is_read = 1 WHERE creator_id = ? AND id IN ({placeholders}) AND is_read = 0", [creator_id] + list(message_ids) ) else: cursor.execute( "UPDATE paid_content_messages SET is_read = 1 WHERE creator_id = ? AND is_read = 0", (creator_id,) ) conn.commit() return cursor.rowcount def get_pending_message_attachments(self, creator_id: int = None) -> List[Dict]: """Get pending message attachments for download""" query = f""" SELECT {self.ATTACHMENT_COLUMNS}, m.message_id as msg_api_id, c.id as creator_db_id, c.username, c.platform, c.service_id FROM paid_content_attachments a JOIN paid_content_messages m ON a.message_id = m.id JOIN paid_content_creators c ON m.creator_id = c.id WHERE a.status = 'pending' AND a.message_id IS NOT NULL """ params = [] if creator_id: query += " AND c.id = ?" params.append(creator_id) query += " ORDER BY m.sent_at DESC" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute(query, params) return [dict(row) for row in cursor.fetchall()] # ======================================================================== # CREATOR GROUPS # ======================================================================== def get_creator_groups(self) -> List[Dict]: """List all creator groups with member count""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" SELECT g.*, COUNT(m.creator_id) as member_count FROM paid_content_creator_groups g LEFT JOIN paid_content_creator_group_members m ON g.id = m.group_id GROUP BY g.id ORDER BY g.name """) return [dict(row) for row in cursor.fetchall()] def get_creator_group(self, group_id: int) -> Optional[Dict]: """Get a single group with full member details""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute("SELECT * FROM paid_content_creator_groups WHERE id = ?", (group_id,)) group = cursor.fetchone() if not group: return None group = dict(group) cursor.execute(""" SELECT c.id, c.username, c.display_name, c.platform, c.service_id, c.profile_image_url, c.post_count, c.enabled, m.added_at, m.filter_tagged_users, m.filter_tag_ids FROM paid_content_creator_group_members m JOIN paid_content_creators c ON m.creator_id = c.id WHERE m.group_id = ? ORDER BY c.username """, (group_id,)) group['members'] = [dict(row) for row in cursor.fetchall()] group['member_count'] = len(group['members']) return group def create_creator_group(self, name: str, description: str = None) -> Dict: """Create a new creator group""" now = datetime.now().isoformat() with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute( "INSERT INTO paid_content_creator_groups (name, description, created_at, updated_at) VALUES (?, ?, ?, ?)", (name, description, now, now) ) conn.commit() group_id = cursor.lastrowid return {'id': group_id, 'name': name, 'description': description, 'member_count': 0, 'created_at': now, 'updated_at': now} def update_creator_group(self, group_id: int, name: str = None, description: str = None) -> bool: """Update a creator group""" updates = {} if name is not None: updates['name'] = name if description is not None: updates['description'] = description if not updates: return False updates['updated_at'] = datetime.now().isoformat() set_clause = ', '.join(f"{k} = ?" for k in updates.keys()) values = list(updates.values()) + [group_id] with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute( f"UPDATE paid_content_creator_groups SET {set_clause} WHERE id = ?", values ) conn.commit() return cursor.rowcount > 0 def delete_creator_group(self, group_id: int) -> bool: """Delete a creator group (CASCADE removes members)""" with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute("DELETE FROM paid_content_creator_groups WHERE id = ?", (group_id,)) conn.commit() return cursor.rowcount > 0 def add_creator_to_group(self, group_id: int, creator_id: int) -> bool: """Add a creator to a group""" with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute( "INSERT OR IGNORE INTO paid_content_creator_group_members (group_id, creator_id, added_at) VALUES (?, ?, ?)", (group_id, creator_id, datetime.now().isoformat()) ) conn.commit() return cursor.rowcount > 0 def update_pinned_posts(self, creator_id: int, pinned_post_ids: dict): """Update is_pinned and pinned_at for posts by their platform post_id. pinned_post_ids: dict mapping post_id (str) -> {'pinned_at': str or None} """ if not pinned_post_ids: return with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() # First, clear pinned status for any posts no longer pinned placeholders = ', '.join(['?'] * len(pinned_post_ids)) cursor.execute( f"UPDATE paid_content_posts SET is_pinned = 0, pinned_at = NULL " f"WHERE creator_id = ? AND is_pinned = 1 AND post_id NOT IN ({placeholders})", [creator_id] + list(pinned_post_ids.keys()) ) # Then set pinned status for current pinned posts for post_id_str, info in pinned_post_ids.items(): cursor.execute( "UPDATE paid_content_posts SET is_pinned = 1, pinned_at = ? " "WHERE creator_id = ? AND post_id = ? AND is_pinned = 0", (info.get('pinned_at'), creator_id, post_id_str) ) conn.commit() def remove_creator_from_group(self, group_id: int, creator_id: int) -> bool: """Remove a creator from a group""" with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute( "DELETE FROM paid_content_creator_group_members WHERE group_id = ? AND creator_id = ?", (group_id, creator_id) ) conn.commit() return cursor.rowcount > 0 def update_group_member_filters(self, group_id: int, creator_id: int, filter_tagged_users: str = None, filter_tag_ids: str = None) -> bool: """Update per-member filter overrides for a group member""" with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute( """UPDATE paid_content_creator_group_members SET filter_tagged_users = ?, filter_tag_ids = ? WHERE group_id = ? AND creator_id = ?""", (filter_tagged_users, filter_tag_ids, group_id, creator_id) ) conn.commit() return cursor.rowcount > 0 # ========================================================================= # AUTO-TAG RULES # ========================================================================= def get_auto_tag_rules(self) -> List[Dict]: """Get all auto-tag rules ordered by priority""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute( "SELECT * FROM paid_content_auto_tag_rules ORDER BY priority DESC, id ASC" ) return [dict(row) for row in cursor.fetchall()] def get_auto_tag_rule(self, rule_id: int) -> Optional[Dict]: """Get a single auto-tag rule by ID""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute("SELECT * FROM paid_content_auto_tag_rules WHERE id = ?", (rule_id,)) row = cursor.fetchone() return dict(row) if row else None def create_auto_tag_rule(self, name: str, conditions: Dict, tag_ids: List[int], priority: int = 0) -> int: """Create a new auto-tag rule""" with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() now = datetime.utcnow().isoformat() cursor.execute( """INSERT INTO paid_content_auto_tag_rules (name, conditions, tag_ids, priority, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)""", (name, json.dumps(conditions), json.dumps(tag_ids), priority, now, now) ) conn.commit() return cursor.lastrowid def update_auto_tag_rule(self, rule_id: int, updates: Dict) -> bool: """Update an auto-tag rule""" allowed = {'name', 'enabled', 'conditions', 'tag_ids', 'priority'} parts = [] values = [] for key, value in updates.items(): if key in allowed: if key == 'conditions': value = json.dumps(value) if isinstance(value, dict) else value elif key == 'tag_ids': value = json.dumps(value) if isinstance(value, list) else value parts.append(f"{key} = ?") values.append(value) if not parts: return False parts.append("updated_at = ?") values.append(datetime.utcnow().isoformat()) values.append(rule_id) with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute( f"UPDATE paid_content_auto_tag_rules SET {', '.join(parts)} WHERE id = ?", values ) conn.commit() return cursor.rowcount > 0 def delete_auto_tag_rule(self, rule_id: int) -> bool: """Delete an auto-tag rule""" with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute("DELETE FROM paid_content_auto_tag_rules WHERE id = ?", (rule_id,)) conn.commit() return cursor.rowcount > 0 def evaluate_rules_for_post(self, post_id: int) -> List[int]: """Evaluate all enabled auto-tag rules against a post, return matching tag IDs""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() # Get post data with creator info cursor.execute(""" SELECT p.id, p.title, p.content, p.creator_id, c.platform, c.username FROM paid_content_posts p JOIN paid_content_creators c ON p.creator_id = c.id WHERE p.id = ? """, (post_id,)) post = cursor.fetchone() if not post: return [] post = dict(post) # Get attachment info for content_type and resolution checks cursor.execute(""" SELECT file_type, width, height FROM paid_content_attachments WHERE post_id = ? AND status != 'duplicate' """, (post_id,)) attachments = [dict(r) for r in cursor.fetchall()] # Get enabled rules cursor.execute( "SELECT * FROM paid_content_auto_tag_rules WHERE enabled = 1 ORDER BY priority DESC" ) rules = [dict(r) for r in cursor.fetchall()] matching_tag_ids = set() for rule in rules: conditions = json.loads(rule['conditions']) if isinstance(rule['conditions'], str) else rule['conditions'] tag_ids = json.loads(rule['tag_ids']) if isinstance(rule['tag_ids'], str) else rule['tag_ids'] if self._rule_matches(conditions, post, attachments): matching_tag_ids.update(tag_ids) return list(matching_tag_ids) def _rule_matches(self, conditions: Dict, post: Dict, attachments: List[Dict]) -> bool: """Check if a rule's conditions match a post""" if not conditions: return False if 'creator_id' in conditions and conditions['creator_id']: if post['creator_id'] != conditions['creator_id']: return False if 'platform' in conditions and conditions['platform']: if post.get('platform', '').lower() != conditions['platform'].lower(): return False if 'content_type' in conditions and conditions['content_type']: target = conditions['content_type'].lower() has_type = any( (a.get('file_type') or '').lower() == target for a in attachments ) if not has_type: return False if 'min_resolution' in conditions and conditions['min_resolution']: res_map = {'720p': 720, '1080p': 1080, '1440p': 1440, '4k': 2160} min_h = res_map.get(conditions['min_resolution'], 0) has_res = any( (a.get('height') or 0) >= min_h for a in attachments ) if not has_res: return False if 'title_contains' in conditions and conditions['title_contains']: needle = conditions['title_contains'].lower() title = (post.get('title') or '').lower() if needle not in title: return False if 'description_contains' in conditions and conditions['description_contains']: needle = conditions['description_contains'].lower() content = (post.get('content') or '').lower() if needle not in content: return False return True def apply_auto_tags_to_post(self, post_id: int) -> int: """Evaluate rules and apply matching tags to a post. Returns count of tags applied.""" tag_ids = self.evaluate_rules_for_post(post_id) if not tag_ids: return 0 applied = 0 with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() for tag_id in tag_ids: try: cursor.execute( "INSERT OR IGNORE INTO paid_content_post_tags (post_id, tag_id) VALUES (?, ?)", (post_id, tag_id) ) if cursor.rowcount > 0: applied += 1 except Exception: pass # Increment match counts for matching rules if applied > 0: cursor.execute( "SELECT id, conditions, tag_ids FROM paid_content_auto_tag_rules WHERE enabled = 1" ) rules = [dict(r) for r in cursor.fetchall()] # Get post data for re-evaluation cursor.execute(""" SELECT p.id, p.title, p.content, p.creator_id, c.platform, c.username FROM paid_content_posts p JOIN paid_content_creators c ON p.creator_id = c.id WHERE p.id = ? """, (post_id,)) post = cursor.fetchone() if post: post = dict(post) cursor.execute( "SELECT file_type, width, height FROM paid_content_attachments WHERE post_id = ? AND status != 'duplicate'", (post_id,) ) atts = [dict(r) for r in cursor.fetchall()] for rule in rules: conds = json.loads(rule['conditions']) if isinstance(rule['conditions'], str) else rule['conditions'] if self._rule_matches(conds, post, atts): cursor.execute( "UPDATE paid_content_auto_tag_rules SET match_count = match_count + 1 WHERE id = ?", (rule['id'],) ) conn.commit() return applied def run_rules_on_existing_posts(self, rule_id: Optional[int] = None) -> Dict: """Run auto-tag rules on all existing posts. Returns stats.""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() if rule_id: cursor.execute("SELECT * FROM paid_content_auto_tag_rules WHERE id = ? AND enabled = 1", (rule_id,)) else: cursor.execute("SELECT * FROM paid_content_auto_tag_rules WHERE enabled = 1 ORDER BY priority DESC") rules = [dict(r) for r in cursor.fetchall()] if not rules: return {'posts_checked': 0, 'tags_applied': 0, 'rules_matched': 0} with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute("SELECT id FROM paid_content_posts WHERE deleted_at IS NULL") post_ids = [row['id'] for row in cursor.fetchall()] posts_checked = 0 tags_applied = 0 rules_matched = set() for pid in post_ids: posts_checked += 1 count = self.apply_auto_tags_to_post(pid) if count > 0: tags_applied += count return { 'posts_checked': posts_checked, 'tags_applied': tags_applied, 'rules_count': len(rules) } # ========================================================================= # ANALYTICS # ========================================================================= def get_storage_growth_over_time(self, days: int = 30) -> List[Dict]: """Get daily cumulative storage from downloaded_at""" days = int(days) # Sanitize with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute(f""" SELECT DATE(datetime(downloaded_at)) as date, SUM(file_size) as daily_bytes, COUNT(*) as daily_count FROM paid_content_attachments WHERE downloaded_at IS NOT NULL AND file_size IS NOT NULL AND datetime(downloaded_at) >= datetime('now', '-{days} days') GROUP BY DATE(datetime(downloaded_at)) ORDER BY date ASC """) rows = [dict(r) for r in cursor.fetchall()] # Calculate cumulative totals # First get total bytes before the window cursor.execute(f""" SELECT COALESCE(SUM(file_size), 0) as prior_bytes FROM paid_content_attachments WHERE downloaded_at IS NOT NULL AND file_size IS NOT NULL AND datetime(downloaded_at) < datetime('now', '-{days} days') """) prior = cursor.fetchone() cumulative = prior['prior_bytes'] if prior else 0 result = [] for row in rows: cumulative += row['daily_bytes'] or 0 result.append({ 'date': row['date'], 'daily_bytes': row['daily_bytes'] or 0, 'daily_count': row['daily_count'], 'cumulative_bytes': cumulative }) return result def get_downloads_per_period(self, period: str = 'day', days: int = 30) -> List[Dict]: """Get download counts per day or week""" days = int(days) # Sanitize if period == 'week': date_expr = "strftime('%Y-W%W', downloaded_at)" else: date_expr = "DATE(datetime(downloaded_at))" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute(f""" SELECT {date_expr} as period, COUNT(*) as count, COALESCE(SUM(file_size), 0) as bytes FROM paid_content_attachments WHERE downloaded_at IS NOT NULL AND datetime(downloaded_at) >= datetime('now', '-{days} days') GROUP BY {date_expr} ORDER BY period ASC """) return [dict(r) for r in cursor.fetchall()] def get_storage_by_creator(self, limit: int = 20) -> List[Dict]: """Get top creators by storage usage""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" SELECT c.id, c.username, c.display_name, c.platform, c.profile_image_url, COALESCE(SUM(a.file_size), 0) as total_bytes, COUNT(CASE WHEN a.status = 'completed' THEN 1 END) as file_count FROM paid_content_creators c LEFT JOIN paid_content_posts p ON p.creator_id = c.id AND p.deleted_at IS NULL LEFT JOIN paid_content_attachments a ON a.post_id = p.id AND a.file_size IS NOT NULL GROUP BY c.id, c.username, c.display_name, c.platform, c.profile_image_url HAVING COALESCE(SUM(a.file_size), 0) > 0 ORDER BY total_bytes DESC LIMIT ? """, (limit,)) return [dict(r) for r in cursor.fetchall()] def get_platform_distribution(self) -> List[Dict]: """Get content count and storage by platform""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" SELECT c.platform, COUNT(DISTINCT p.id) as post_count, COUNT(CASE WHEN a.status = 'completed' THEN 1 END) as file_count, COALESCE(SUM(a.file_size), 0) as total_bytes FROM paid_content_creators c JOIN paid_content_posts p ON p.creator_id = c.id AND p.deleted_at IS NULL LEFT JOIN paid_content_attachments a ON a.post_id = p.id AND a.file_size IS NOT NULL GROUP BY c.platform ORDER BY total_bytes DESC """) return [dict(r) for r in cursor.fetchall()] def get_content_type_distribution(self) -> List[Dict]: """Get content count and storage by file type""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" SELECT COALESCE(file_type, 'unknown') as content_type, COUNT(*) as count, COALESCE(SUM(file_size), 0) as total_bytes FROM paid_content_attachments WHERE status = 'completed' GROUP BY file_type ORDER BY total_bytes DESC """) return [dict(r) for r in cursor.fetchall()] def get_creator_scorecards(self, sort_by: str = 'total_bytes', limit: int = 50) -> List[Dict]: """Get per-creator stats table""" allowed_sorts = {'total_bytes', 'file_count', 'post_count', 'username', 'latest_download'} if sort_by not in allowed_sorts: sort_by = 'total_bytes' with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute(f""" SELECT c.id, c.username, c.display_name, c.platform, c.profile_image_url, COUNT(DISTINCT p.id) as post_count, COUNT(CASE WHEN a.status = 'completed' THEN 1 END) as file_count, COALESCE(SUM(a.file_size), 0) as total_bytes, MAX(a.downloaded_at) as latest_download, COUNT(CASE WHEN a.file_type = 'image' AND a.status = 'completed' THEN 1 END) as image_count, COUNT(CASE WHEN a.file_type = 'video' AND a.status = 'completed' THEN 1 END) as video_count FROM paid_content_creators c LEFT JOIN paid_content_posts p ON p.creator_id = c.id AND p.deleted_at IS NULL LEFT JOIN paid_content_attachments a ON a.post_id = p.id GROUP BY c.id, c.username, c.display_name, c.platform, c.profile_image_url HAVING COUNT(CASE WHEN a.status = 'completed' THEN 1 END) > 0 ORDER BY {sort_by} DESC LIMIT ? """, (limit,)) return [dict(r) for r in cursor.fetchall()] # ========================================================================= # WATCH LATER # ========================================================================= def get_watch_later(self) -> List[Dict]: """Get all watch later items with full metadata""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" SELECT wl.id, wl.attachment_id, wl.post_id, wl.creator_id, wl.position, wl.added_at, a.name as filename, a.file_type, a.extension, a.file_size, a.width, a.height, a.duration, a.status, a.local_path, a.local_filename, p.title as post_title, p.post_id as external_post_id, c.username, c.display_name, c.platform, c.profile_image_url FROM paid_content_watch_later wl JOIN paid_content_attachments a ON wl.attachment_id = a.id JOIN paid_content_posts p ON wl.post_id = p.id JOIN paid_content_creators c ON wl.creator_id = c.id ORDER BY wl.position ASC, wl.added_at ASC """) return [dict(r) for r in cursor.fetchall()] def add_to_watch_later(self, attachment_id: int) -> Optional[int]: """Add an attachment to watch later. Returns ID or None if already exists.""" with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() # Get post_id and creator_id from attachment cursor.execute(""" SELECT a.id, a.post_id, p.creator_id FROM paid_content_attachments a JOIN paid_content_posts p ON a.post_id = p.id WHERE a.id = ? """, (attachment_id,)) att = cursor.fetchone() if not att: return None # Get next position cursor.execute("SELECT COALESCE(MAX(position), -1) + 1 as next_pos FROM paid_content_watch_later") next_pos = cursor.fetchone()['next_pos'] try: cursor.execute( """INSERT INTO paid_content_watch_later (attachment_id, post_id, creator_id, position) VALUES (?, ?, ?, ?)""", (attachment_id, att['post_id'], att['creator_id'], next_pos) ) conn.commit() return cursor.lastrowid except Exception: # Already exists (UNIQUE constraint) return None def remove_from_watch_later(self, attachment_id: int) -> bool: """Remove an attachment from watch later""" with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute("DELETE FROM paid_content_watch_later WHERE attachment_id = ?", (attachment_id,)) conn.commit() return cursor.rowcount > 0 def clear_watch_later(self) -> int: """Clear all watch later items. Returns count removed.""" with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute("DELETE FROM paid_content_watch_later") conn.commit() return cursor.rowcount def reorder_watch_later(self, ordered_ids: List[int]) -> bool: """Reorder watch later items by setting positions based on list order. ordered_ids is a list of watch_later IDs in desired order.""" with self.unified_db.get_connection(for_write=True) as conn: cursor = conn.cursor() for pos, wl_id in enumerate(ordered_ids): cursor.execute( "UPDATE paid_content_watch_later SET position = ? WHERE id = ?", (pos, wl_id) ) conn.commit() return True def is_in_watch_later(self, attachment_id: int) -> bool: """Check if an attachment is in watch later""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute("SELECT 1 FROM paid_content_watch_later WHERE attachment_id = ?", (attachment_id,)) return cursor.fetchone() is not None def get_watch_later_attachment_ids(self) -> List[int]: """Get set of attachment IDs in watch later (for toggle state)""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute("SELECT attachment_id FROM paid_content_watch_later") return [row['attachment_id'] for row in cursor.fetchall()] def get_watch_later_count(self) -> int: """Get count of watch later items""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute("SELECT COUNT(*) as count FROM paid_content_watch_later") return cursor.fetchone()['count'] # ======================================================================== # GALLERY (flat media timeline) # ======================================================================== def _build_gallery_base_query(self, select_clause: str, creator_group_id: int = None, creator_id: int = None, content_type: str = None, min_resolution: str = None, date_from: str = None, date_to: str = None, search: str = None) -> tuple: """Build the shared FROM/WHERE for gallery queries. Returns (query_string, params, needs_group_filter). """ query = f""" {select_clause} FROM paid_content_attachments a JOIN paid_content_posts p ON a.post_id = p.id JOIN paid_content_creators c ON p.creator_id = c.id LEFT JOIN paid_content_identities i ON c.identity_id = i.id WHERE a.status = 'completed' AND a.file_type IN ('image', 'video') AND p.deleted_at IS NULL """ params = [] _has_group_filter = False if creator_id: query += " AND p.creator_id = ?" params.append(creator_id) if creator_group_id and creator_group_id > 0: _has_group_filter = True if content_type: query += " AND a.file_type = ?" params.append(content_type) if min_resolution: resolution_thresholds = { '720p': 720, '1080p': 1080, '1440p': 1440, '4k': 2160 } if min_resolution in resolution_thresholds: min_dim = resolution_thresholds[min_resolution] query += " AND a.width IS NOT NULL AND a.height IS NOT NULL AND (a.width >= ? OR a.height >= ?)" params.extend([min_dim, min_dim]) if date_from: query += " AND COALESCE(p.published_at, a.downloaded_at) >= ?" params.append(date_from) if date_to: query += " AND COALESCE(p.published_at, a.downloaded_at) <= ?" params.append(f"{date_to}T23:59:59") if search: words = search.strip().split() for word in words: w = f"%{word}%" query += """ AND (p.title ILIKE ? OR p.content ILIKE ? OR c.username ILIKE ? OR c.display_name ILIKE ? OR a.name ILIKE ?)""" params.extend([w, w, w, w, w]) # Per-creator tagged user filter (when not using group filter) if not _has_group_filter: query += """ AND ( c.filter_tagged_users IS NULL OR TRIM(c.filter_tagged_users) = '' OR TRIM(c.filter_tagged_users) = '[]' OR EXISTS ( SELECT 1 FROM paid_content_post_tagged_users tu WHERE tu.post_id = p.id AND c.filter_tagged_users LIKE '%"' || tu.username || '"%' ) ) """ return query, params, _has_group_filter def get_gallery_media(self, creator_group_id: int = None, creator_id: int = None, content_type: str = None, min_resolution: str = None, date_from: str = None, date_to: str = None, search: str = None, shuffle: bool = False, shuffle_seed: int = None, limit: int = 200, offset: int = 0) -> List[Dict]: """Get flat list of media items for the gallery timeline.""" select = """SELECT a.id, a.post_id, a.name, a.file_type, a.extension, a.width, a.height, a.duration, a.file_hash, a.local_path, a.local_filename, a.file_size, a.downloaded_at, COALESCE(p.published_at, a.downloaded_at) as media_date, p.title as post_title, p.is_favorited, c.username, c.display_name, c.platform, c.service_id, c.profile_image_url, c.id as creator_id, i.name as identity_name""" query, params, _has_group_filter = self._build_gallery_base_query( select, 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 ) with self.unified_db.get_connection() as conn: if _has_group_filter: group_clause, group_params = self._build_group_member_filter_clause(creator_group_id, conn) query += group_clause params.extend(group_params) if shuffle: # Deterministic shuffle using PostgreSQL md5 hash — no Python memory needed seed = shuffle_seed if shuffle_seed is not None else 42 query += " ORDER BY md5(a.id::text || ?::text), a.id" params.append(str(seed)) else: query += " ORDER BY COALESCE(p.published_at, a.downloaded_at) DESC, a.id DESC" query += " LIMIT ? OFFSET ?" params.extend([limit, offset]) cursor = conn.cursor() cursor.execute(query, params) return [dict(row) for row in cursor.fetchall()] def get_gallery_media_count(self, creator_group_id: int = None, creator_id: int = None, content_type: str = None, min_resolution: str = None, date_from: str = None, date_to: str = None, search: str = None) -> int: """Get total count of gallery media items.""" query, params, _has_group_filter = self._build_gallery_base_query( "SELECT 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 ) with self.unified_db.get_connection() as conn: if _has_group_filter: group_clause, group_params = self._build_group_member_filter_clause(creator_group_id, conn) query += group_clause params.extend(group_params) cursor = conn.cursor() cursor.execute(query, params) return cursor.fetchone()[0] def get_gallery_date_range(self, creator_group_id: int = None, creator_id: int = None, content_type: str = None) -> List[Dict]: """Get year/month distribution for timeline scrubber.""" select = """SELECT EXTRACT(YEAR FROM COALESCE(p.published_at::timestamp, a.downloaded_at::timestamp))::int as year, EXTRACT(MONTH FROM COALESCE(p.published_at::timestamp, a.downloaded_at::timestamp))::int as month, COUNT(*) as count""" query, params, _has_group_filter = self._build_gallery_base_query( select, creator_group_id=creator_group_id, creator_id=creator_id, content_type=content_type ) query += " GROUP BY year, month ORDER BY year DESC, month DESC" with self.unified_db.get_connection() as conn: if _has_group_filter: group_clause, group_params = self._build_group_member_filter_clause(creator_group_id, conn) # Insert before GROUP BY group_idx = query.index(' GROUP BY') query = query[:group_idx] + group_clause + query[group_idx:] params.extend(group_params) cursor = conn.cursor() cursor.execute(query, params) return [dict(row) for row in cursor.fetchall()] def get_gallery_group_stats(self) -> List[Dict]: """Get per-group media counts and representative thumbnail for gallery landing.""" with self.unified_db.get_connection() as conn: cursor = conn.cursor() # Get all groups with member counts cursor.execute(""" SELECT g.id, g.name, g.description, COUNT(DISTINCT gm.creator_id) as member_count FROM paid_content_creator_groups g LEFT JOIN paid_content_creator_group_members gm ON g.id = gm.group_id GROUP BY g.id, g.name, g.description ORDER BY g.name """) groups = [dict(row) for row in cursor.fetchall()] for group in groups: # Single query per group: count + latest attachment + latest date group_clause, group_params = self._build_group_member_filter_clause(group['id'], conn) stats_query = """ SELECT COUNT(*) as total_media, MAX(COALESCE(p.published_at, a.downloaded_at)) as latest_media_date FROM paid_content_attachments a JOIN paid_content_posts p ON a.post_id = p.id JOIN paid_content_creators c ON p.creator_id = c.id LEFT JOIN paid_content_identities i ON c.identity_id = i.id WHERE a.status = 'completed' AND a.file_type IN ('image', 'video') AND p.deleted_at IS NULL """ + group_clause cursor.execute(stats_query, group_params) row = cursor.fetchone() group['total_media'] = row['total_media'] if row else 0 group['latest_media_date'] = row['latest_media_date'] if row else None # Get representative thumbnail (latest image) if group['total_media'] > 0: thumb_query = """ SELECT a.id as attachment_id FROM paid_content_attachments a JOIN paid_content_posts p ON a.post_id = p.id JOIN paid_content_creators c ON p.creator_id = c.id LEFT JOIN paid_content_identities i ON c.identity_id = i.id WHERE a.status = 'completed' AND a.file_type IN ('image', 'video') AND p.deleted_at IS NULL """ + group_clause + """ ORDER BY COALESCE(p.published_at, a.downloaded_at) DESC LIMIT 1 """ cursor.execute(thumb_query, group_params) latest = cursor.fetchone() group['representative_attachment_id'] = latest['attachment_id'] if latest else None else: group['representative_attachment_id'] = None return groups