feat(db): add mime_type column and caching helpers

Add a new `mime_type` column to the media_file_ids table and provide
functions to get and set cached MIME types. The API now attempts to
retrieve the MIME type from the database before falling back to
python-magic, persisting the detected type for future requests. This
reduces I/O overhead and improves response performance. Also adjust
logging to debug level to reduce noise.
This commit is contained in:
vvzvlad
2026-04-06 23:46:22 +03:00
parent 0f297185e4
commit 57a96044bc
2 changed files with 59 additions and 17 deletions
+27 -16
View File
@@ -38,7 +38,8 @@ from rss_generator import generate_channel_rss, generate_channel_html
from post_parser import PostParser from post_parser import PostParser
from url_signer import verify_media_digest, generate_media_digest from url_signer import verify_media_digest, generate_media_digest
from file_io import (DB_PATH, init_db_sync, get_all_media_file_ids_sync, from file_io import (DB_PATH, init_db_sync, get_all_media_file_ids_sync,
update_media_file_access_sync, remove_media_file_ids_sync) update_media_file_access_sync, remove_media_file_ids_sync,
get_mime_type_sync, set_mime_type_sync)
# Global python-magic instance for MIME type detection # Global python-magic instance for MIME type detection
magic_mime = magic.Magic(mime=True) magic_mime = magic.Magic(mime=True)
@@ -49,15 +50,11 @@ class ZeroSizeFileError(Exception):
class RequestLoggingMiddleware(BaseHTTPMiddleware): class RequestLoggingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):
# Log request details # Log only method and URL at debug level to avoid flooding logs on active RSS polling
logger.info(f"Request: {request.method} {request.url}") logger.debug(f"Request: {request.method} {request.url}")
logger.info(f"Headers: {dict(request.headers)}")
logger.info(f"Query params: {dict(request.query_params)}")
try: try:
response = await call_next(request) response = await call_next(request)
# Log response details logger.debug(f"Response status: {response.status_code}")
logger.info(f"Response status: {response.status_code}")
return response return response
except Exception as e: except Exception as e:
logger.error(f"Request processing error: {str(e)}") logger.error(f"Request processing error: {str(e)}")
@@ -204,17 +201,30 @@ def delayed_delete_file(file_path: str, delay: int = 300) -> None:
logger.error(f"Failed to delete temporary file {file_path}: {str(e)}") logger.error(f"Failed to delete temporary file {file_path}: {str(e)}")
async def prepare_file_response(file_path: str, request: Request, delete_after: bool = False) -> StreamingResponse: async def prepare_file_response(file_path: str, request: Request, delete_after: bool = False,
media_key: tuple[str, int, str] | None = None) -> StreamingResponse:
"""Prepare a streaming file response with HTTP Range request support.""" """Prepare a streaming file response with HTTP Range request support."""
if not os.path.exists(file_path): if not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="File not found") raise HTTPException(status_code=404, detail="File not found")
try: media_type: str | None = None
# Determine MIME type using python-magic in a thread to avoid blocking the event loop
media_type = await asyncio.to_thread(magic_mime.from_file, file_path) if media_key is not None:
except Exception as e: # Try to load the cached MIME type from the database (avoids repeated python-magic I/O)
logger.warning(f"Failed to determine MIME type using python-magic: {str(e)}") channel_key, post_id_key, file_unique_id_key = media_key
media_type = None media_type = await asyncio.to_thread(get_mime_type_sync, DB_PATH, channel_key, post_id_key, file_unique_id_key)
if not media_type:
# Cache miss or no media_key — detect with python-magic in a thread to avoid blocking the event loop
try:
media_type = await asyncio.to_thread(magic_mime.from_file, file_path)
except Exception as e:
logger.warning(f"Failed to determine MIME type using python-magic: {str(e)}")
media_type = None
# Persist the detected MIME type so the next request can skip python-magic
if media_type and media_key is not None:
await asyncio.to_thread(set_mime_type_sync, DB_PATH, channel_key, post_id_key, file_unique_id_key, media_type)
if not media_type: media_type, _ = mimetypes.guess_type(file_path) # Fallback to mimetypes if python-magic failed if not media_type: media_type, _ = mimetypes.guess_type(file_path) # Fallback to mimetypes if python-magic failed
if not media_type: media_type = "application/octet-stream" # Final fallback to octet-stream if not media_type: media_type = "application/octet-stream" # Final fallback to octet-stream
@@ -853,7 +863,8 @@ async def get_media(channel: str, post_id: int, file_unique_id: str, request: Re
if not file_path: if not file_path:
raise HTTPException(status_code=404, detail="File not found") raise HTTPException(status_code=404, detail="File not found")
if file_path: if file_path:
return await prepare_file_response(file_path, request=request, delete_after=delete_after) return await prepare_file_response(file_path, request=request, delete_after=delete_after,
media_key=(str(channel), post_id, file_unique_id))
except ZeroSizeFileError as e: # Catch zero-size file errors except ZeroSizeFileError as e: # Catch zero-size file errors
logger.warning(f"zero_size_file_encountered: {str(e)}. Instructing client to retry.") logger.warning(f"zero_size_file_encountered: {str(e)}. Instructing client to retry.")
return Response( return Response(
+32 -1
View File
@@ -48,13 +48,22 @@ def init_db_sync(db_path: str) -> None:
) )
""" """
) )
# Add mime_type column if it does not exist yet (idempotent migration)
try:
conn.execute("ALTER TABLE media_file_ids ADD COLUMN mime_type TEXT")
except sqlite3.OperationalError as e:
if "duplicate column name" not in str(e):
raise # Re-raise any OperationalError that is not "duplicate column name"
def upsert_media_file_id_sync(db_path: str, channel: str, post_id: int, file_unique_id: str, added: float) -> None: def upsert_media_file_id_sync(db_path: str, channel: str, post_id: int, file_unique_id: str, added: float) -> None:
"""Insert or replace a single media file ID record.""" """Insert or replace a single media file ID record."""
with _db_connection(db_path) as conn: with _db_connection(db_path) as conn:
conn.execute( conn.execute(
"INSERT OR REPLACE INTO media_file_ids (channel, post_id, file_unique_id, added) VALUES (?, ?, ?, ?)", """INSERT INTO media_file_ids (channel, post_id, file_unique_id, added)
VALUES (?, ?, ?, ?)
ON CONFLICT(channel, post_id, file_unique_id)
DO UPDATE SET added = excluded.added""",
(channel, post_id, file_unique_id, added), (channel, post_id, file_unique_id, added),
) )
@@ -86,3 +95,25 @@ def remove_media_file_ids_sync(db_path: str, entries: List[tuple]) -> None:
) )
def get_mime_type_sync(db_path: str, channel: str, post_id: int, file_unique_id: str) -> str | None:
"""Return the cached MIME type for a given media key, or None if not stored yet."""
with _db_connection(db_path) as conn:
cursor = conn.execute(
"SELECT mime_type FROM media_file_ids WHERE channel = ? AND post_id = ? AND file_unique_id = ?",
(channel, post_id, file_unique_id),
)
row = cursor.fetchone()
if row is None:
return None
return row[0] # May be None if the column value was never set
def set_mime_type_sync(db_path: str, channel: str, post_id: int, file_unique_id: str, mime_type: str) -> None:
"""Persist a detected MIME type for an existing media file ID record."""
with _db_connection(db_path) as conn:
conn.execute(
"UPDATE media_file_ids SET mime_type = ? WHERE channel = ? AND post_id = ? AND file_unique_id = ?",
(mime_type, channel, post_id, file_unique_id),
)