fix: wrap AsyncSession in context manager across all API blueprints

Replace all bare `session = AsyncSession(bind=conn)` assignments with
`async with AsyncSession(bind=conn) as session:` in downloads.py,
credentials.py, settings.py, sources.py, and subscriptions.py to
ensure sessions are properly closed on all code paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-18 23:38:58 -04:00
parent dc146f4a16
commit 080a1254c7
5 changed files with 771 additions and 800 deletions
+5 -9
View File
@@ -19,8 +19,7 @@ VALID_PLATFORMS = ["patreon", "subscribestar", "hentaifoundry", "discord", "pixi
async def list_credentials(): async def list_credentials():
"""List all credential status (without sensitive data).""" """List all credential status (without sensitive data)."""
async with current_app.db_engine.connect() as conn: async with current_app.db_engine.connect() as conn:
session = AsyncSession(bind=conn) async with AsyncSession(bind=conn) as session:
result = await session.execute(select(Credential)) result = await session.execute(select(Credential))
credentials = result.scalars().all() credentials = result.scalars().all()
@@ -48,7 +47,7 @@ async def upload_credentials():
if extension_key: if extension_key:
# Look up API key from database # Look up API key from database
async with current_app.db_engine.connect() as conn: async with current_app.db_engine.connect() as conn:
session = AsyncSession(bind=conn) async with AsyncSession(bind=conn) as session:
result = await session.execute( result = await session.execute(
select(Setting).where(Setting.key == EXTENSION_API_KEY_SETTING) select(Setting).where(Setting.key == EXTENSION_API_KEY_SETTING)
) )
@@ -81,8 +80,7 @@ async def upload_credentials():
return jsonify({"error": "Invalid credential type. Must be 'cookies' or 'token'"}), 400 return jsonify({"error": "Invalid credential type. Must be 'cookies' or 'token'"}), 400
async with current_app.db_engine.connect() as conn: async with current_app.db_engine.connect() as conn:
session = AsyncSession(bind=conn) async with AsyncSession(bind=conn) as session:
# Check for existing credential # Check for existing credential
result = await session.execute( result = await session.execute(
select(Credential).where(Credential.platform == platform) select(Credential).where(Credential.platform == platform)
@@ -127,8 +125,7 @@ async def delete_credentials(platform: str):
return jsonify({"error": f"Invalid platform. Must be one of: {', '.join(VALID_PLATFORMS)}"}), 400 return jsonify({"error": f"Invalid platform. Must be one of: {', '.join(VALID_PLATFORMS)}"}), 400
async with current_app.db_engine.connect() as conn: async with current_app.db_engine.connect() as conn:
session = AsyncSession(bind=conn) async with AsyncSession(bind=conn) as session:
result = await session.execute( result = await session.execute(
select(Credential).where(Credential.platform == platform) select(Credential).where(Credential.platform == platform)
) )
@@ -160,8 +157,7 @@ async def verify_credentials():
return jsonify({"error": f"Invalid platform. Must be one of: {', '.join(VALID_PLATFORMS)}"}), 400 return jsonify({"error": f"Invalid platform. Must be one of: {', '.join(VALID_PLATFORMS)}"}), 400
async with current_app.db_engine.connect() as conn: async with current_app.db_engine.connect() as conn:
session = AsyncSession(bind=conn) async with AsyncSession(bind=conn) as session:
result = await session.execute( result = await session.execute(
select(Credential).where(Credential.platform == platform) select(Credential).where(Credential.platform == platform)
) )
+6 -12
View File
@@ -28,8 +28,7 @@ async def list_downloads():
per_page = min(int(request.args.get("per_page", 50)), 100) per_page = min(int(request.args.get("per_page", 50)), 100)
async with current_app.db_engine.connect() as conn: async with current_app.db_engine.connect() as conn:
session = AsyncSession(bind=conn) async with AsyncSession(bind=conn) as session:
# Build query - eager load source and subscription for labeling # Build query - eager load source and subscription for labeling
query = select(Download).options( query = select(Download).options(
selectinload(Download.source).selectinload(Source.subscription) selectinload(Download.source).selectinload(Source.subscription)
@@ -89,8 +88,7 @@ async def list_downloads():
async def get_download(download_id: int): async def get_download(download_id: int):
"""Get download details.""" """Get download details."""
async with current_app.db_engine.connect() as conn: async with current_app.db_engine.connect() as conn:
session = AsyncSession(bind=conn) async with AsyncSession(bind=conn) as session:
result = await session.execute(select(Download).where(Download.id == download_id)) result = await session.execute(select(Download).where(Download.id == download_id))
download = result.scalar() download = result.scalar()
@@ -120,8 +118,7 @@ async def get_download(download_id: int):
async def retry_download(download_id: int): async def retry_download(download_id: int):
"""Retry a failed download.""" """Retry a failed download."""
async with current_app.db_engine.connect() as conn: async with current_app.db_engine.connect() as conn:
session = AsyncSession(bind=conn) async with AsyncSession(bind=conn) as session:
result = await session.execute(select(Download).where(Download.id == download_id)) result = await session.execute(select(Download).where(Download.id == download_id))
download = result.scalar() download = result.scalar()
@@ -162,8 +159,7 @@ async def recent_activity():
cutoff = utcnow() - timedelta(days=days) cutoff = utcnow() - timedelta(days=days)
async with current_app.db_engine.connect() as conn: async with current_app.db_engine.connect() as conn:
session = AsyncSession(bind=conn) async with AsyncSession(bind=conn) as session:
# Get failed downloads OR completed with files (noteworthy activity) # Get failed downloads OR completed with files (noteworthy activity)
# Exclude failed downloads that have been superseded by a later success # Exclude failed downloads that have been superseded by a later success
# for the same source - those are stale failures the user doesn't need to see # for the same source - those are stale failures the user doesn't need to see
@@ -286,8 +282,7 @@ async def get_stats():
period = request.args.get("period", "week") # day, week, month period = request.args.get("period", "week") # day, week, month
async with current_app.db_engine.connect() as conn: async with current_app.db_engine.connect() as conn:
session = AsyncSession(bind=conn) async with AsyncSession(bind=conn) as session:
# Total counts by status # Total counts by status
status_query = select( status_query = select(
Download.status, Download.status,
@@ -356,8 +351,7 @@ async def export_failed_logs():
cutoff_date = utcnow() - timedelta(days=days) cutoff_date = utcnow() - timedelta(days=days)
async with current_app.db_engine.connect() as conn: async with current_app.db_engine.connect() as conn:
session = AsyncSession(bind=conn) async with AsyncSession(bind=conn) as session:
# Build query for failed downloads (eager load source for platform) # Build query for failed downloads (eager load source for platform)
query = select(Download).options(selectinload(Download.source)).where( query = select(Download).options(selectinload(Download.source)).where(
and_( and_(
+6 -11
View File
@@ -81,8 +81,7 @@ async def get_setting_value(session: AsyncSession, key: str, default=None):
async def get_all_settings(): async def get_all_settings():
"""Get all application settings.""" """Get all application settings."""
async with current_app.db_engine.connect() as conn: async with current_app.db_engine.connect() as conn:
session = AsyncSession(bind=conn) async with AsyncSession(bind=conn) as session:
result = await session.execute(select(Setting)) result = await session.execute(select(Setting))
settings = result.scalars().all() settings = result.scalars().all()
@@ -110,8 +109,7 @@ async def update_settings():
return jsonify({"error": "No settings provided"}), 400 return jsonify({"error": "No settings provided"}), 400
async with current_app.db_engine.connect() as conn: async with current_app.db_engine.connect() as conn:
session = AsyncSession(bind=conn) async with AsyncSession(bind=conn) as session:
for key, value in data.items(): for key, value in data.items():
# Check if setting exists # Check if setting exists
result = await session.execute(select(Setting).where(Setting.key == key)) result = await session.execute(select(Setting).where(Setting.key == key))
@@ -142,7 +140,7 @@ async def update_settings():
async def get_extension_api_key(): async def get_extension_api_key():
"""Get the extension API key.""" """Get the extension API key."""
async with current_app.db_engine.connect() as conn: async with current_app.db_engine.connect() as conn:
session = AsyncSession(bind=conn) async with AsyncSession(bind=conn) as session:
api_key = await get_or_create_extension_api_key(session) api_key = await get_or_create_extension_api_key(session)
return jsonify({"api_key": api_key}) return jsonify({"api_key": api_key})
@@ -151,8 +149,7 @@ async def get_extension_api_key():
async def regenerate_extension_api_key(): async def regenerate_extension_api_key():
"""Regenerate the extension API key.""" """Regenerate the extension API key."""
async with current_app.db_engine.connect() as conn: async with current_app.db_engine.connect() as conn:
session = AsyncSession(bind=conn) async with AsyncSession(bind=conn) as session:
# Find existing key # Find existing key
result = await session.execute( result = await session.execute(
select(Setting).where(Setting.key == EXTENSION_API_KEY_SETTING) select(Setting).where(Setting.key == EXTENSION_API_KEY_SETTING)
@@ -246,8 +243,7 @@ async def get_logs():
limit = min(int(request.args.get("limit", 50)), 200) limit = min(int(request.args.get("limit", 50)), 200)
async with current_app.db_engine.connect() as conn: async with current_app.db_engine.connect() as conn:
session = AsyncSession(bind=conn) async with AsyncSession(bind=conn) as session:
# Get recent downloads with output logs # Get recent downloads with output logs
query = select(Download).order_by(desc(Download.created_at)).limit(limit) query = select(Download).order_by(desc(Download.created_at)).limit(limit)
result = await session.execute(query) result = await session.execute(query)
@@ -333,8 +329,7 @@ async def debug_schedule_interval():
the worker would read. the worker would read.
""" """
async with current_app.db_engine.connect() as conn: async with current_app.db_engine.connect() as conn:
session = AsyncSession(bind=conn) async with AsyncSession(bind=conn) as session:
# Query the raw setting from database # Query the raw setting from database
result = await session.execute( result = await session.execute(
select(Setting).where(Setting.key == "download.schedule_interval") select(Setting).where(Setting.key == "download.schedule_interval")
+6 -12
View File
@@ -26,8 +26,7 @@ async def list_sources():
per_page = min(int(request.args.get("per_page", 50)), 100) per_page = min(int(request.args.get("per_page", 50)), 100)
async with current_app.db_engine.connect() as conn: async with current_app.db_engine.connect() as conn:
session = AsyncSession(bind=conn) async with AsyncSession(bind=conn) as session:
# Build query with subscription eager loading # Build query with subscription eager loading
query = select(Source).options(selectinload(Source.subscription)) query = select(Source).options(selectinload(Source.subscription))
@@ -83,8 +82,7 @@ async def create_source():
return jsonify({"error": f"Invalid platform. Must be one of: {', '.join(valid_platforms)}"}), 400 return jsonify({"error": f"Invalid platform. Must be one of: {', '.join(valid_platforms)}"}), 400
async with current_app.db_engine.connect() as conn: async with current_app.db_engine.connect() as conn:
session = AsyncSession(bind=conn) async with AsyncSession(bind=conn) as session:
# Check subscription exists # Check subscription exists
sub_result = await session.execute( sub_result = await session.execute(
select(Subscription).where(Subscription.id == data["subscription_id"]) select(Subscription).where(Subscription.id == data["subscription_id"])
@@ -129,8 +127,7 @@ async def create_source():
async def get_source(source_id: int): async def get_source(source_id: int):
"""Get a single source by ID.""" """Get a single source by ID."""
async with current_app.db_engine.connect() as conn: async with current_app.db_engine.connect() as conn:
session = AsyncSession(bind=conn) async with AsyncSession(bind=conn) as session:
result = await session.execute( result = await session.execute(
select(Source) select(Source)
.options(selectinload(Source.subscription)) .options(selectinload(Source.subscription))
@@ -150,8 +147,7 @@ async def update_source(source_id: int):
data = await request.get_json() data = await request.get_json()
async with current_app.db_engine.connect() as conn: async with current_app.db_engine.connect() as conn:
session = AsyncSession(bind=conn) async with AsyncSession(bind=conn) as session:
result = await session.execute( result = await session.execute(
select(Source) select(Source)
.options(selectinload(Source.subscription)) .options(selectinload(Source.subscription))
@@ -182,8 +178,7 @@ async def update_source(source_id: int):
async def delete_source(source_id: int): async def delete_source(source_id: int):
"""Delete a source.""" """Delete a source."""
async with current_app.db_engine.connect() as conn: async with current_app.db_engine.connect() as conn:
session = AsyncSession(bind=conn) async with AsyncSession(bind=conn) as session:
result = await session.execute( result = await session.execute(
select(Source) select(Source)
.options(selectinload(Source.subscription)) .options(selectinload(Source.subscription))
@@ -206,8 +201,7 @@ async def delete_source(source_id: int):
async def trigger_check(source_id: int): async def trigger_check(source_id: int):
"""Trigger an immediate download check for a source.""" """Trigger an immediate download check for a source."""
async with current_app.db_engine.connect() as conn: async with current_app.db_engine.connect() as conn:
session = AsyncSession(bind=conn) async with AsyncSession(bind=conn) as session:
result = await session.execute( result = await session.execute(
select(Source) select(Source)
.options(selectinload(Source.subscription)) .options(selectinload(Source.subscription))
+8 -16
View File
@@ -24,8 +24,7 @@ async def list_subscriptions():
per_page = min(int(request.args.get("per_page", 50)), 100) per_page = min(int(request.args.get("per_page", 50)), 100)
async with current_app.db_engine.connect() as conn: async with current_app.db_engine.connect() as conn:
session = AsyncSession(bind=conn) async with AsyncSession(bind=conn) as session:
# Build query with eager loading of sources # Build query with eager loading of sources
query = select(Subscription).options(selectinload(Subscription.sources)) query = select(Subscription).options(selectinload(Subscription.sources))
@@ -70,8 +69,7 @@ async def create_subscription():
return jsonify({"error": "Name is required"}), 400 return jsonify({"error": "Name is required"}), 400
async with current_app.db_engine.connect() as conn: async with current_app.db_engine.connect() as conn:
session = AsyncSession(bind=conn) async with AsyncSession(bind=conn) as session:
# Check for duplicate name # Check for duplicate name
existing = await session.execute( existing = await session.execute(
select(Subscription).where(Subscription.name == data["name"]) select(Subscription).where(Subscription.name == data["name"])
@@ -118,8 +116,7 @@ async def create_subscription():
async def get_subscription(subscription_id: int): async def get_subscription(subscription_id: int):
"""Get a single subscription by ID with its sources.""" """Get a single subscription by ID with its sources."""
async with current_app.db_engine.connect() as conn: async with current_app.db_engine.connect() as conn:
session = AsyncSession(bind=conn) async with AsyncSession(bind=conn) as session:
result = await session.execute( result = await session.execute(
select(Subscription) select(Subscription)
.options(selectinload(Subscription.sources)) .options(selectinload(Subscription.sources))
@@ -139,8 +136,7 @@ async def update_subscription(subscription_id: int):
data = await request.get_json() data = await request.get_json()
async with current_app.db_engine.connect() as conn: async with current_app.db_engine.connect() as conn:
session = AsyncSession(bind=conn) async with AsyncSession(bind=conn) as session:
result = await session.execute( result = await session.execute(
select(Subscription) select(Subscription)
.options(selectinload(Subscription.sources)) .options(selectinload(Subscription.sources))
@@ -179,8 +175,7 @@ async def update_subscription(subscription_id: int):
async def delete_subscription(subscription_id: int): async def delete_subscription(subscription_id: int):
"""Delete a subscription and all its sources.""" """Delete a subscription and all its sources."""
async with current_app.db_engine.connect() as conn: async with current_app.db_engine.connect() as conn:
session = AsyncSession(bind=conn) async with AsyncSession(bind=conn) as session:
result = await session.execute( result = await session.execute(
select(Subscription).where(Subscription.id == subscription_id) select(Subscription).where(Subscription.id == subscription_id)
) )
@@ -201,8 +196,7 @@ async def delete_subscription(subscription_id: int):
async def list_subscription_sources(subscription_id: int): async def list_subscription_sources(subscription_id: int):
"""List all sources for a subscription.""" """List all sources for a subscription."""
async with current_app.db_engine.connect() as conn: async with current_app.db_engine.connect() as conn:
session = AsyncSession(bind=conn) async with AsyncSession(bind=conn) as session:
result = await session.execute( result = await session.execute(
select(Subscription) select(Subscription)
.options(selectinload(Subscription.sources)) .options(selectinload(Subscription.sources))
@@ -237,8 +231,7 @@ async def add_source_to_subscription(subscription_id: int):
return jsonify({"error": f"Invalid platform. Must be one of: {', '.join(valid_platforms)}"}), 400 return jsonify({"error": f"Invalid platform. Must be one of: {', '.join(valid_platforms)}"}), 400
async with current_app.db_engine.connect() as conn: async with current_app.db_engine.connect() as conn:
session = AsyncSession(bind=conn) async with AsyncSession(bind=conn) as session:
# Check subscription exists # Check subscription exists
result = await session.execute( result = await session.execute(
select(Subscription).where(Subscription.id == subscription_id) select(Subscription).where(Subscription.id == subscription_id)
@@ -290,8 +283,7 @@ async def add_source_to_subscription(subscription_id: int):
async def trigger_subscription_check(subscription_id: int): async def trigger_subscription_check(subscription_id: int):
"""Trigger download check for all enabled sources in a subscription.""" """Trigger download check for all enabled sources in a subscription."""
async with current_app.db_engine.connect() as conn: async with current_app.db_engine.connect() as conn:
session = AsyncSession(bind=conn) async with AsyncSession(bind=conn) as session:
result = await session.execute( result = await session.execute(
select(Subscription) select(Subscription)
.options(selectinload(Subscription.sources)) .options(selectinload(Subscription.sources))