import threading import time import datetime import re from sqlalchemy import create_engine, or_, func, and_, text from sqlalchemy import Column, TEXT, Numeric, DateTime from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker, scoped_session from sqlalchemy.pool import QueuePool from sqlalchemy.exc import OperationalError, PendingRollbackError, NoResultFound from mfinder import DB_URL, LOGGER from mfinder.utils.helpers import unpack_new_file_id import asyncio BASE = declarative_base() class Files(BASE): __tablename__ = "files" file_name = Column(TEXT, primary_key=True) file_id = Column(TEXT) file_ref = Column(TEXT) file_size = Column(Numeric) file_type = Column(TEXT) mime_type = Column(TEXT) caption = Column(TEXT) created_at = Column(DateTime, server_default=func.now()) def __init__( self, file_name, file_id, file_ref, file_size, file_type, mime_type, caption, created_at=None ): self.file_name = file_name self.file_id = file_id self.file_ref = file_ref self.file_size = file_size self.file_type = file_type self.mime_type = mime_type self.caption = caption self.created_at = created_at or datetime.datetime.utcnow() class SearchSuggestionsCache(BASE): __tablename__ = "search_suggestions_cache" original_query = Column(TEXT, primary_key=True) corrected_query = Column(TEXT, nullable=True) keywords = Column(TEXT, nullable=True) # Comma separated suggestions = Column(TEXT, nullable=True) # Comma separated created_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) def __init__(self, original_query, corrected_query, keywords, suggestions): self.original_query = original_query.strip().lower() self.corrected_query = corrected_query self.keywords = keywords self.suggestions = suggestions def start() -> scoped_session: connect_args = {} if DB_URL and DB_URL.startswith(("postgres://", "postgresql://")): connect_args["sslmode"] = "require" engine = create_engine( DB_URL, connect_args=connect_args, poolclass=QueuePool, pool_size=10, max_overflow=20, pool_pre_ping=True, pool_recycle=1800 ) BASE.metadata.bind = engine BASE.metadata.create_all(engine) # DDL migrations: ensure created_at column on files table and pg_trgm indexes try: with engine.connect() as conn: conn.execute(text("ALTER TABLE files ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP")) conn.execute(text("CREATE EXTENSION IF NOT EXISTS pg_trgm")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_files_file_name_trgm ON files USING gin (file_name gin_trgm_ops)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_files_caption_trgm ON files USING gin (caption gin_trgm_ops)")) conn.commit() except Exception as e: LOGGER.warning(f"Failed to create GIN trigram indexes or extension: {e}") return scoped_session(sessionmaker(bind=engine, autoflush=False)) SESSION = start() INSERTION_LOCK = threading.RLock() import asyncio import functools from sqlalchemy.exc import OperationalError, PendingRollbackError def db_retry(func): if asyncio.iscoroutinefunction(func): @functools.wraps(func) async def wrapper(*args, **kwargs): for attempt in range(3): try: return await func(*args, **kwargs) except (OperationalError, PendingRollbackError) as e: LOGGER.warning("Database connection error in %s (attempt %s): %s. Reconnecting...", func.__name__, attempt + 1, str(e)) try: SESSION.rollback() SESSION.remove() except Exception: pass if attempt == 2: raise await asyncio.sleep(0.5) return wrapper else: @functools.wraps(func) def wrapper(*args, **kwargs): for attempt in range(3): try: return func(*args, **kwargs) except (OperationalError, PendingRollbackError) as e: LOGGER.warning("Database connection error in %s (attempt %s): %s. Reconnecting...", func.__name__, attempt + 1, str(e)) try: SESSION.rollback() SESSION.remove() except Exception: pass if attempt == 2: raise import time time.sleep(0.5) return wrapper def reconnect_session(max_retries=5, delay=5): """Attempt to reconnect to the database a specified number of times with a delay.""" for attempt in range(max_retries): try: global SESSION SESSION = start() return SESSION except OperationalError as e: LOGGER.warning(f"Database connection failed: {e}. Retrying in {delay} seconds...") time.sleep(delay) raise Exception("Failed to reconnect to the database after multiple attempts") @db_retry async def save_file(media): """Save a media file to the database.""" file_id, file_ref = unpack_new_file_id(media.file_id) with INSERTION_LOCK: try: # Check if file with same file_id exists file = SESSION.query(Files).filter_by(file_id=file_id).one() LOGGER.warning("%s is already saved in the database", media.file_name) except NoResultFound: try: # Check if file with same file_name and file_size exists file = SESSION.query(Files).filter_by(file_name=media.file_name, file_size=media.file_size).one() LOGGER.warning("%s with size %s is already saved in the database", media.file_name, media.file_size) except NoResultFound: # Create a new file record file = Files( file_name=media.caption if media.caption else media.file_name, file_id=file_id, file_ref=file_ref, file_size=media.file_size, file_type=media.file_type, mime_type=media.mime_type, caption=media.caption if media.caption else media.file_name, ) LOGGER.info("%s is saved in the database", media.file_name) SESSION.add(file) SESSION.commit() return True except Exception as e: LOGGER.warning("Error occurred while saving file in the database: %s", str(e)) SESSION.rollback() return False except Exception as e: LOGGER.warning("Error occurred while saving file in the database: %s", str(e)) SESSION.rollback() return False finally: try: SESSION.close() except Exception as close_error: LOGGER.error(f"Error closing session: {close_error}") async def get_filter_results(query, page=1, per_page=10): """Get filtered results from the database.""" retries = 3 while retries > 0: try: with INSERTION_LOCK: offset = (page - 1) * per_page search = query.split() conditions = [] for word in search: conditions.append( or_( Files.file_name.ilike(f"%{word}%"), Files.caption.ilike(f"%{word}%"), ) ) combined_condition = and_(*conditions) files_query = ( SESSION.query(Files) .filter(combined_condition) .order_by(Files.file_name) ) total_count = files_query.count() files = files_query.offset(offset).limit(per_page).all() return files, total_count except PendingRollbackError: SESSION.rollback() retries -= 1 continue except OperationalError as e: LOGGER.warning(f"OperationalError: {e}. Retrying...") reconnect_session() retries -= 1 except Exception as e: LOGGER.warning(f"Error occurred while retrieving filter results: {e}") return [], 0 finally: try: SESSION.close() except Exception as close_error: LOGGER.error(f"Error closing session: {close_error}") return [], 0 async def get_precise_filter_results(query, page=1, per_page=10): """Get precise filtered results from the database.""" retries = 3 while retries > 0: try: with INSERTION_LOCK: offset = (page - 1) * per_page search = query.split() conditions = [] for word in search: conditions.append( or_( func.concat(" ", Files.file_name, " ").ilike(f"% {word} %"), func.concat(" ", Files.caption, " ").ilike(f"% {word} %"), ) ) combined_condition = and_(*conditions) files_query = ( SESSION.query(Files) .filter(combined_condition) .order_by(Files.file_name) ) total_count = files_query.count() files = files_query.offset(offset).limit(per_page).all() return files, total_count except PendingRollbackError: SESSION.rollback() retries -= 1 continue except OperationalError as e: LOGGER.warning(f"OperationalError: {e}. Retrying...") reconnect_session() retries -= 1 except Exception as e: LOGGER.warning(f"Error occurred while retrieving filter results: {e}") return [], 0 finally: try: SESSION.close() except Exception as close_error: LOGGER.error(f"Error closing session: {close_error}") return [], 0 GENERIC_KEYWORDS_RE = re.compile( r'\b(1080p|720p|480p|4k|2k|hdrip|webdl|web-dl|bluray|blu-ray|hdtv|brrip|dvdrip|hevc|x264|x265|h264|h265|aac|dd5\.1|dd\+|ddp5\.1|ddp|dd|dual|multi|esub|esubs|sub|subs|tamil|telugu|hindi|malayalam|kannada|english|bengali|marathi|punjabi|movie|season|s\d+ep\d+|s\d+|ep\d+|combined)\b', re.IGNORECASE ) def clean_movie_title(text: str) -> str: if not text: return "" # Strip common channel tags (e.g. starting with @) title = re.sub(r'@[a-zA-Z0-9_]+', '', text) # Strip leading non-alphanumeric characters title = re.sub(r'^[^a-zA-Z0-9]+', '', title).strip() # Try to extract the title including the year in parentheses, e.g. "Movie Name (2024)" year_match = re.search(r'^(.*?)\s*\((\d{4})\)', title) if year_match: prefix = year_match.group(1) year = year_match.group(2) # Clean prefix from common separators like [, but NOT mid-word hyphens/apostrophes # Split by [ or ' - ' (with spaces) prefix = re.split(r'\[|\s+-\s+', prefix)[0] prefix = re.sub(r'\s+', ' ', prefix).strip() # Remove trailing non-alphanumeric except punctuation prefix = re.sub(r'[^a-zA-Z0-9\-\'\s]+$', '', prefix).strip() return f"{prefix} ({year})" # Otherwise, split at [ or ' - ' (with spaces) or ( title = re.split(r'\[|\s+-\s+|\(', title)[0] # Remove generic keywords title = GENERIC_KEYWORDS_RE.sub('', title) title = re.sub(r'\s+', ' ', title).strip() # Remove trailing non-alphanumeric except punctuation title = re.sub(r'[^a-zA-Z0-9\-\'\s]+$', '', title).strip() return title async def get_trigram_filter_results(query, page=1, per_page=10, threshold=0.3): """Get fuzzy filtered results from the database using trigram similarity and re-ranked in Python.""" import difflib retries = 3 while retries > 0: try: with INSERTION_LOCK: offset = (page - 1) * per_page # Set dynamic trigram word similarity threshold for matching SESSION.execute(text(f"SET LOCAL pg_trgm.word_similarity_threshold = {threshold}")) # Fetch up to 100 candidate fuzzy matches to re-rank sql_query = text(""" SELECT file_name, file_id, file_ref, file_size, file_type, mime_type, caption FROM files WHERE file_name %> :query OR caption %> :query LIMIT 100 """) params = {"query": query} result = SESSION.execute(sql_query, params).fetchall() # Clean the query q_clean = clean_movie_title(query).lower() if not q_clean: q_clean = query.lower() candidates = [] for row in result: title_name = clean_movie_title(row.file_name) ratio_name = difflib.SequenceMatcher(None, q_clean, title_name.lower()).ratio() ratio_caption = 0.0 if row.caption: title_caption = clean_movie_title(row.caption) ratio_caption = difflib.SequenceMatcher(None, q_clean, title_caption.lower()).ratio() best_ratio = max(ratio_name, ratio_caption) candidates.append((row, best_ratio)) # Re-rank candidates by ratio descending, then file_name candidates.sort(key=lambda x: (-x[1], x[0].file_name)) total_count = len(candidates) sliced_candidates = candidates[offset : offset + per_page] files = [] for row, ratio in sliced_candidates: files.append(Files( file_name=row.file_name, file_id=row.file_id, file_ref=row.file_ref, file_size=row.file_size, file_type=row.file_type, mime_type=row.mime_type, caption=row.caption )) return files, total_count except PendingRollbackError: SESSION.rollback() retries -= 1 continue except OperationalError as e: LOGGER.warning(f"OperationalError: {e}. Retrying...") reconnect_session() retries -= 1 except Exception as e: LOGGER.warning(f"Error occurred while retrieving trigram results: {e}") return [], 0 finally: try: SESSION.close() except Exception as close_error: LOGGER.error(f"Error closing session: {close_error}") return [], 0 async def get_file_details(file_id): """Get file details based on file_id.""" retries = 3 while retries > 0: try: with INSERTION_LOCK: file_details = SESSION.query(Files).filter_by(file_id=file_id).all() return file_details except PendingRollbackError: SESSION.rollback() retries -= 1 continue except OperationalError as e: LOGGER.warning(f"OperationalError: {e}. Retrying...") reconnect_session() retries -= 1 except Exception as e: LOGGER.warning(f"Error occurred while retrieving file details: {e}") return [] finally: try: SESSION.close() except Exception as close_error: LOGGER.error(f"Error closing session: {close_error}") return [] async def delete_file(media): """Delete a file record from the database.""" file_id, file_ref = unpack_new_file_id(media.file_id) retries = 3 while retries > 0: try: with INSERTION_LOCK: file = SESSION.query(Files).filter_by(file_id=file_id).first() if file: SESSION.delete(file) SESSION.commit() return True return "Not Found" LOGGER.warning("File to delete not found: %s", str(file_id)) except PendingRollbackError: SESSION.rollback() retries -= 1 continue except OperationalError as e: LOGGER.warning(f"OperationalError: {e}. Retrying...") reconnect_session() retries -= 1 except Exception as e: LOGGER.warning(f"Error occurred while deleting file: {e}") SESSION.rollback() return False finally: try: SESSION.close() except Exception as close_error: LOGGER.error(f"Error closing session: {close_error}") return False @db_retry async def get_keyword_db_suggestions(query: str, limit: int = 30) -> list: """ Extracts significant keywords (actor names, franchise words) from query and finds matching unique movie titles directly from uploaded files in DB. """ import re STOP_WORDS = { 'new', 'movie', 'movies', 'tamil', 'telugu', 'hindi', 'malayalam', 'kannada', 'english', 'full', 'download', 'hd', 'mp4', 'mkv', '1080p', '720p', '480p', 'best', 'latest', 'film', 'films', 'cinema', '2023', '2024', '2025', '2026', 'part', 'all', 'series', 'collection', 'link', 'file', 'files', 'video' } GENERIC_RE = re.compile( r'\b(1080p|720p|480p|4k|2k|hdrip|webdl|web-dl|bluray|blu-ray|hdtv|brrip|dvdrip|hevc|x264|x265|h264|h265|aac|dd5\.1|dd\+|ddp5\.1|ddp|dd|dual|multi|esub|esubs|sub|subs|tamil|telugu|hindi|malayalam|kannada|english|bengali|marathi|punjabi|movie|season|s\d+ep\d+|s\d+|ep\d+|combined)\b', re.IGNORECASE ) def _extract_clean_title(text: str) -> str: if not text: return "" t = text.replace("@FIREBOLTOFFICIAL", "") t = re.split(r'\(\d{4}\)', t)[0] t = t.split('[')[0] t = GENERIC_RE.sub('', t) t = re.sub(r'^[^a-zA-Z0-9]+|[^a-zA-Z0-9]+$', '', t) t = re.sub(r'\s+', ' ', t).strip() return t words = [w.strip().lower() for w in re.findall(r'\b\w+\b', query) if len(w) >= 3] keywords = [w for w in words if w not in STOP_WORDS] if not keywords: return [] retries = 3 while retries > 0: try: with INSERTION_LOCK: conditions = [Files.file_name.ilike(f"%{kw}%") for kw in keywords] files = ( SESSION.query(Files.file_name) .filter(or_(*conditions)) .order_by(Files.file_name.desc()) .limit(150) .all() ) extracted_titles = [] seen = set() for (f_name,) in files: title = _extract_clean_title(f_name) if title and len(title) >= 2: t_key = title.lower() if t_key not in seen: seen.add(t_key) extracted_titles.append(title) if len(extracted_titles) >= limit: break return extracted_titles except Exception as e: LOGGER.warning(f"Error extracting DB keyword suggestions: {e}") retries -= 1 await asyncio.sleep(0.2) finally: try: SESSION.close() except Exception: pass return [] async def count_files(): """Count the total number of files in the database.""" retries = 3 while retries > 0: try: with INSERTION_LOCK: total_count = SESSION.query(Files).count() return total_count except PendingRollbackError: SESSION.rollback() retries -= 1 continue except OperationalError as e: LOGGER.warning(f"OperationalError: {e}. Retrying...") reconnect_session() retries -= 1 except Exception as e: LOGGER.warning(f"Error occurred while counting files: {e}") return 0 finally: try: SESSION.close() except Exception as close_error: LOGGER.error(f"Error closing session: {close_error}") return 0 async def keep_alive(): """Keep the database connection alive.""" while True: try: with SESSION() as session: session.execute("SELECT 1") await asyncio.sleep(180) except Exception as e: LOGGER.warning(f"Keep-alive error: {e}") await asyncio.sleep(10) async def main(): asyncio.create_task(keep_alive()) if __name__ == "__main__": asyncio.run(main())