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
+80 -84
View File
@@ -19,18 +19,17 @@ 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))
credentials = result.scalars().all()
result = await session.execute(select(Credential)) # Also return a simple map of platform -> has_credentials for UI convenience
credentials = result.scalars().all() platforms_with_creds = {c.platform: True for c in credentials}
# Also return a simple map of platform -> has_credentials for UI convenience return jsonify({
platforms_with_creds = {c.platform: True for c in credentials} "items": [c.to_dict(include_data=False) for c in credentials],
"platforms": platforms_with_creds,
return jsonify({ })
"items": [c.to_dict(include_data=False) for c in credentials],
"platforms": platforms_with_creds,
})
@bp.route("", methods=["POST"]) @bp.route("", methods=["POST"])
@@ -48,15 +47,15 @@ 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)
) )
setting = result.scalar() setting = result.scalar()
stored_key = setting.value if setting else None stored_key = setting.value if setting else None
if not stored_key or extension_key != stored_key: if not stored_key or extension_key != stored_key:
return jsonify({"error": "Invalid extension API key"}), 401 return jsonify({"error": "Invalid extension API key"}), 401
data = await request.get_json() data = await request.get_json()
@@ -81,43 +80,42 @@ 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)
)
credential = result.scalar()
# Encrypt the credential data
encrypted = encrypt_data(cred_data, settings.secret_key)
if credential:
# Update existing
credential.credential_type = cred_type
credential.data = encrypted
credential.expires_at = data.get("expires_at")
credential.last_verified = None # Reset verification
else:
# Create new
credential = Credential(
platform=platform,
credential_type=cred_type,
data=encrypted,
expires_at=data.get("expires_at"),
) )
session.add(credential) credential = result.scalar()
await session.commit() # Encrypt the credential data
await session.refresh(credential) encrypted = encrypt_data(cred_data, settings.secret_key)
current_app.logger.info(f"Updated credentials for platform: {platform}") if credential:
return jsonify({ # Update existing
"message": "Credentials updated", credential.credential_type = cred_type
"platform": platform, credential.data = encrypted
"credential_type": cred_type, credential.expires_at = data.get("expires_at")
"expires_at": credential.expires_at.isoformat() if credential.expires_at else None, credential.last_verified = None # Reset verification
}) else:
# Create new
credential = Credential(
platform=platform,
credential_type=cred_type,
data=encrypted,
expires_at=data.get("expires_at"),
)
session.add(credential)
await session.commit()
await session.refresh(credential)
current_app.logger.info(f"Updated credentials for platform: {platform}")
return jsonify({
"message": "Credentials updated",
"platform": platform,
"credential_type": cred_type,
"expires_at": credential.expires_at.isoformat() if credential.expires_at else None,
})
@bp.route("/<platform>", methods=["DELETE"]) @bp.route("/<platform>", methods=["DELETE"])
@@ -127,21 +125,20 @@ 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(
select(Credential).where(Credential.platform == platform)
)
credential = result.scalar()
result = await session.execute( if not credential:
select(Credential).where(Credential.platform == platform) return jsonify({"error": "Credentials not found for this platform"}), 404
)
credential = result.scalar()
if not credential: await session.delete(credential)
return jsonify({"error": "Credentials not found for this platform"}), 404 await session.commit()
await session.delete(credential) current_app.logger.info(f"Deleted credentials for platform: {platform}")
await session.commit() return "", 204
current_app.logger.info(f"Deleted credentials for platform: {platform}")
return "", 204
@bp.route("/verify", methods=["POST"]) @bp.route("/verify", methods=["POST"])
@@ -160,28 +157,27 @@ 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(
select(Credential).where(Credential.platform == platform)
)
credential = result.scalar()
result = await session.execute( if not credential:
select(Credential).where(Credential.platform == platform) return jsonify({
) "platform": platform,
credential = result.scalar() "is_valid": False,
"error": "No credentials stored for this platform",
})
# TODO: Implement actual verification by making test request
# For now, just mark as verified
verified_at = utcnow()
credential.last_verified = verified_at
await session.commit()
if not credential:
return jsonify({ return jsonify({
"platform": platform, "platform": platform,
"is_valid": False, "is_valid": True,
"error": "No credentials stored for this platform", "last_verified": verified_at.isoformat(),
}) })
# TODO: Implement actual verification by making test request
# For now, just mark as verified
verified_at = utcnow()
credential.last_verified = verified_at
await session.commit()
return jsonify({
"platform": platform,
"is_valid": True,
"last_verified": verified_at.isoformat(),
})
+218 -224
View File
@@ -28,124 +28,121 @@ 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
query = select(Download).options(
selectinload(Download.source).selectinload(Source.subscription)
)
count_query = select(func.count()).select_from(Download)
# Build query - eager load source and subscription for labeling filters = []
query = select(Download).options( if source_id:
selectinload(Download.source).selectinload(Source.subscription) filters.append(Download.source_id == source_id)
) if status:
count_query = select(func.count()).select_from(Download) filters.append(Download.status == status)
if from_date:
filters.append(Download.created_at >= datetime.fromisoformat(from_date))
if to_date:
filters.append(Download.created_at <= datetime.fromisoformat(to_date))
# When filtering failed downloads, optionally exclude ones superseded by a later success
if exclude_superseded and status == DownloadStatus.FAILED:
filters.append(Download.superseded == False)
filters = [] if filters:
if source_id: query = query.where(and_(*filters))
filters.append(Download.source_id == source_id) count_query = count_query.where(and_(*filters))
if status:
filters.append(Download.status == status)
if from_date:
filters.append(Download.created_at >= datetime.fromisoformat(from_date))
if to_date:
filters.append(Download.created_at <= datetime.fromisoformat(to_date))
# When filtering failed downloads, optionally exclude ones superseded by a later success
if exclude_superseded and status == DownloadStatus.FAILED:
filters.append(Download.superseded == False)
if filters: # Get total count
query = query.where(and_(*filters)) total_result = await session.execute(count_query)
count_query = count_query.where(and_(*filters)) total = total_result.scalar()
# Get total count # Apply pagination and ordering
total_result = await session.execute(count_query) query = query.order_by(Download.created_at.desc())
total = total_result.scalar() query = query.offset((page - 1) * per_page).limit(per_page)
# Apply pagination and ordering result = await session.execute(query)
query = query.order_by(Download.created_at.desc()) downloads = result.scalars().all()
query = query.offset((page - 1) * per_page).limit(per_page)
result = await session.execute(query) # Include source info with each download
downloads = result.scalars().all() items = []
for d in downloads:
item = d.to_dict()
if d.source:
item["subscription_name"] = d.source.subscription.name if d.source.subscription else None
item["platform"] = d.source.platform
else:
item["subscription_name"] = None
item["platform"] = None
items.append(item)
# Include source info with each download return jsonify({
items = [] "items": items,
for d in downloads: "total": total,
item = d.to_dict() "page": page,
if d.source: "per_page": per_page,
item["subscription_name"] = d.source.subscription.name if d.source.subscription else None "pages": (total + per_page - 1) // per_page,
item["platform"] = d.source.platform })
else:
item["subscription_name"] = None
item["platform"] = None
items.append(item)
return jsonify({
"items": items,
"total": total,
"page": page,
"per_page": per_page,
"pages": (total + per_page - 1) // per_page,
})
@bp.route("/<int:download_id>", methods=["GET"]) @bp.route("/<int:download_id>", methods=["GET"])
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))
download = result.scalar()
result = await session.execute(select(Download).where(Download.id == download_id)) if not download:
download = result.scalar() return jsonify({"error": "Download not found"}), 404
if not download: # Include source info if available
return jsonify({"error": "Download not found"}), 404 response = download.to_dict()
if download.source_id:
source_result = await session.execute(
select(Source)
.options(selectinload(Source.subscription))
.where(Source.id == download.source_id)
)
source = source_result.scalar()
if source:
response["source"] = {
"id": source.id,
"subscription_name": source.subscription.name if source.subscription else None,
"platform": source.platform,
}
# Include source info if available return jsonify(response)
response = download.to_dict()
if download.source_id:
source_result = await session.execute(
select(Source)
.options(selectinload(Source.subscription))
.where(Source.id == download.source_id)
)
source = source_result.scalar()
if source:
response["source"] = {
"id": source.id,
"subscription_name": source.subscription.name if source.subscription else None,
"platform": source.platform,
}
return jsonify(response)
@bp.route("/<int:download_id>/retry", methods=["POST"]) @bp.route("/<int:download_id>/retry", methods=["POST"])
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))
download = result.scalar()
result = await session.execute(select(Download).where(Download.id == download_id)) if not download:
download = result.scalar() return jsonify({"error": "Download not found"}), 404
if not download: if download.status != DownloadStatus.FAILED:
return jsonify({"error": "Download not found"}), 404 return jsonify({"error": "Can only retry failed downloads"}), 400
if download.status != DownloadStatus.FAILED: # Reset status to queued
return jsonify({"error": "Can only retry failed downloads"}), 400 download.status = DownloadStatus.QUEUED
download.error_type = None
download.error_message = None
await session.commit()
# Reset status to queued # Queue Celery task to retry the download
download.status = DownloadStatus.QUEUED task = process_download.delay(download_id)
download.error_type = None
download.error_message = None
await session.commit()
# Queue Celery task to retry the download current_app.logger.info(f"Queued retry for download: {download_id}")
task = process_download.delay(download_id) return jsonify({
"message": "Retry queued",
current_app.logger.info(f"Queued retry for download: {download_id}") "download_id": download_id,
return jsonify({ "task_id": task.id,
"message": "Retry queued", }), 202
"download_id": download_id,
"task_id": task.id,
}), 202
@bp.route("/recent-activity", methods=["GET"]) @bp.route("/recent-activity", methods=["GET"])
@@ -162,48 +159,47 @@ 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 query = select(Download).options(
query = select(Download).options( selectinload(Download.source).selectinload(Source.subscription)
selectinload(Download.source).selectinload(Source.subscription) ).where(
).where( and_(
and_( Download.created_at >= cutoff,
Download.created_at >= cutoff, or_(
or_( and_(
and_( Download.status == DownloadStatus.FAILED,
Download.status == DownloadStatus.FAILED, Download.superseded == False,
Download.superseded == False, ),
), and_(
and_( Download.status == DownloadStatus.COMPLETED,
Download.status == DownloadStatus.COMPLETED, Download.file_count > 0
Download.file_count > 0 )
) )
) )
) ).order_by(Download.created_at.desc()).limit(limit)
).order_by(Download.created_at.desc()).limit(limit)
result = await session.execute(query) result = await session.execute(query)
downloads = result.scalars().all() downloads = result.scalars().all()
# Include source info with each download # Include source info with each download
items = [] items = []
for d in downloads: for d in downloads:
item = d.to_dict() item = d.to_dict()
if d.source: if d.source:
item["subscription_name"] = d.source.subscription.name if d.source.subscription else None item["subscription_name"] = d.source.subscription.name if d.source.subscription else None
item["platform"] = d.source.platform item["platform"] = d.source.platform
else: else:
item["subscription_name"] = None item["subscription_name"] = None
item["platform"] = None item["platform"] = None
items.append(item) items.append(item)
return jsonify({ return jsonify({
"items": items, "items": items,
"count": len(downloads), "count": len(downloads),
}) })
@bp.route("/reset-orphaned", methods=["POST"]) @bp.route("/reset-orphaned", methods=["POST"])
@@ -286,54 +282,53 @@ 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
status_query = select(
Download.status,
func.count(Download.id)
).group_by(Download.status)
# Total counts by status status_result = await session.execute(status_query)
status_query = select( status_counts = {row[0]: row[1] for row in status_result}
Download.status,
func.count(Download.id)
).group_by(Download.status)
status_result = await session.execute(status_query) # Counts by platform (through source)
status_counts = {row[0]: row[1] for row in status_result} platform_query = select(
Source.platform,
func.count(Download.id)
).join(Source, Download.source_id == Source.id).group_by(Source.platform)
# Counts by platform (through source) platform_result = await session.execute(platform_query)
platform_query = select( platform_counts = {row[0]: row[1] for row in platform_result}
Source.platform,
func.count(Download.id)
).join(Source, Download.source_id == Source.id).group_by(Source.platform)
platform_result = await session.execute(platform_query) # Recent downloads count
platform_counts = {row[0]: row[1] for row in platform_result} recent_query = select(func.count()).select_from(Download).where(
Download.status == DownloadStatus.COMPLETED
# Recent downloads count
recent_query = select(func.count()).select_from(Download).where(
Download.status == DownloadStatus.COMPLETED
)
recent_result = await session.execute(recent_query)
recent_completed = recent_result.scalar()
# Active failed: failures not superseded by a later success for the same source
active_failed_query = select(func.count()).select_from(Download).where(
and_(
Download.status == DownloadStatus.FAILED,
Download.superseded == False,
) )
) recent_result = await session.execute(recent_query)
active_failed_result = await session.execute(active_failed_query) recent_completed = recent_result.scalar()
active_failed = active_failed_result.scalar()
return jsonify({ # Active failed: failures not superseded by a later success for the same source
"total": sum(status_counts.values()), active_failed_query = select(func.count()).select_from(Download).where(
"by_status": status_counts, and_(
"by_platform": platform_counts, Download.status == DownloadStatus.FAILED,
"completed": status_counts.get(DownloadStatus.COMPLETED, 0), Download.superseded == False,
"failed": status_counts.get(DownloadStatus.FAILED, 0), )
"active_failed": active_failed, )
"pending": status_counts.get(DownloadStatus.PENDING, 0), active_failed_result = await session.execute(active_failed_query)
"queued": status_counts.get(DownloadStatus.QUEUED, 0), active_failed = active_failed_result.scalar()
"running": status_counts.get(DownloadStatus.RUNNING, 0),
}) return jsonify({
"total": sum(status_counts.values()),
"by_status": status_counts,
"by_platform": platform_counts,
"completed": status_counts.get(DownloadStatus.COMPLETED, 0),
"failed": status_counts.get(DownloadStatus.FAILED, 0),
"active_failed": active_failed,
"pending": status_counts.get(DownloadStatus.PENDING, 0),
"queued": status_counts.get(DownloadStatus.QUEUED, 0),
"running": status_counts.get(DownloadStatus.RUNNING, 0),
})
@bp.route("/export-failed-logs", methods=["GET"]) @bp.route("/export-failed-logs", methods=["GET"])
@@ -356,69 +351,68 @@ 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_(
Download.status == DownloadStatus.FAILED,
Download.created_at >= cutoff_date,
)
)
if error_type:
query = query.where(Download.error_type == error_type)
# Order by most recent first
query = query.order_by(desc(Download.created_at)).limit(limit)
result = await session.execute(query)
failed_downloads = result.scalars().all()
# Optionally include some successful NO_NEW_CONTENT for comparison
success_downloads = []
if include_success:
success_query = select(Download).options(selectinload(Download.source)).where(
and_( and_(
Download.status == DownloadStatus.COMPLETED, Download.status == DownloadStatus.FAILED,
Download.created_at >= cutoff_date, Download.created_at >= cutoff_date,
Download.file_count == 0, # No new content cases
) )
).order_by(desc(Download.created_at)).limit(limit // 4) )
success_result = await session.execute(success_query)
success_downloads = success_result.scalars().all()
# Format for export if error_type:
exports = [] query = query.where(Download.error_type == error_type)
for download in failed_downloads: # Order by most recent first
exports.append(_format_download_for_export(download, "failed")) query = query.order_by(desc(Download.created_at)).limit(limit)
result = await session.execute(query)
failed_downloads = result.scalars().all()
for download in success_downloads: # Optionally include some successful NO_NEW_CONTENT for comparison
exports.append(_format_download_for_export(download, "success_no_new_content")) success_downloads = []
if include_success:
success_query = select(Download).options(selectinload(Download.source)).where(
and_(
Download.status == DownloadStatus.COMPLETED,
Download.created_at >= cutoff_date,
Download.file_count == 0, # No new content cases
)
).order_by(desc(Download.created_at)).limit(limit // 4)
success_result = await session.execute(success_query)
success_downloads = success_result.scalars().all()
# Build output document # Format for export
output = { exports = []
"export_info": {
"exported_at": utcnow().isoformat(),
"total_records": len(exports),
"failed_count": len(failed_downloads),
"success_comparison_count": len(success_downloads),
"days_included": days,
"error_type_filter": error_type,
},
"analysis_prompt": _generate_analysis_prompt(),
"downloads": exports,
}
# Return as downloadable JSON file for download in failed_downloads:
response = Response( exports.append(_format_download_for_export(download, "failed"))
json.dumps(output, indent=2, default=str),
mimetype="application/json", for download in success_downloads:
headers={ exports.append(_format_download_for_export(download, "success_no_new_content"))
"Content-Disposition": f"attachment; filename=failed_logs_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
# Build output document
output = {
"export_info": {
"exported_at": utcnow().isoformat(),
"total_records": len(exports),
"failed_count": len(failed_downloads),
"success_comparison_count": len(success_downloads),
"days_included": days,
"error_type_filter": error_type,
},
"analysis_prompt": _generate_analysis_prompt(),
"downloads": exports,
} }
)
return response # Return as downloadable JSON file
response = Response(
json.dumps(output, indent=2, default=str),
mimetype="application/json",
headers={
"Content-Disposition": f"attachment; filename=failed_logs_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
}
)
return response
def _format_download_for_export(download: Download, classification: str) -> dict: def _format_download_for_export(download: Download, classification: str) -> dict:
+104 -109
View File
@@ -81,24 +81,23 @@ 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))
settings = result.scalars().all()
result = await session.execute(select(Setting)) # Build settings dict with defaults
settings = result.scalars().all() settings_dict = dict(DEFAULT_SETTINGS)
for s in settings:
settings_dict[s.key] = s.value
# Build settings dict with defaults # Ensure extension API key exists
settings_dict = dict(DEFAULT_SETTINGS) api_key = await get_or_create_extension_api_key(session)
for s in settings: settings_dict[EXTENSION_API_KEY_SETTING] = api_key
settings_dict[s.key] = s.value
# Ensure extension API key exists # Get cached storage statistics (calculated by background task)
api_key = await get_or_create_extension_api_key(session) storage_stats = await get_cached_storage_stats(session)
settings_dict[EXTENSION_API_KEY_SETTING] = api_key
# Get cached storage statistics (calculated by background task) return jsonify({"settings": settings_dict, "storage_stats": storage_stats})
storage_stats = await get_cached_storage_stats(session)
return jsonify({"settings": settings_dict, "storage_stats": storage_stats})
@bp.route("", methods=["PATCH"]) @bp.route("", methods=["PATCH"])
@@ -110,68 +109,66 @@ 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():
# Check if setting exists
result = await session.execute(select(Setting).where(Setting.key == key))
setting = result.scalar()
for key, value in data.items(): if setting:
# Check if setting exists current_app.logger.info(f"Updating setting '{key}': {setting.value} -> {value} (type: {type(value).__name__})")
result = await session.execute(select(Setting).where(Setting.key == key)) setting.value = value
setting = result.scalar() else:
current_app.logger.info(f"Creating setting '{key}': {value} (type: {type(value).__name__})")
setting = Setting(key=key, value=value)
session.add(setting)
if setting: await session.commit()
current_app.logger.info(f"Updating setting '{key}': {setting.value} -> {value} (type: {type(value).__name__})")
setting.value = value
else:
current_app.logger.info(f"Creating setting '{key}': {value} (type: {type(value).__name__})")
setting = Setting(key=key, value=value)
session.add(setting)
await session.commit() # Return updated settings
result = await session.execute(select(Setting))
settings = result.scalars().all()
settings_dict = dict(DEFAULT_SETTINGS)
for s in settings:
settings_dict[s.key] = s.value
# Return updated settings current_app.logger.info(f"Updated settings: {list(data.keys())}")
result = await session.execute(select(Setting)) return jsonify({"settings": settings_dict})
settings = result.scalars().all()
settings_dict = dict(DEFAULT_SETTINGS)
for s in settings:
settings_dict[s.key] = s.value
current_app.logger.info(f"Updated settings: {list(data.keys())}")
return jsonify({"settings": settings_dict})
@bp.route("/api-key", methods=["GET"]) @bp.route("/api-key", methods=["GET"])
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})
@bp.route("/api-key/regenerate", methods=["POST"]) @bp.route("/api-key/regenerate", methods=["POST"])
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
result = await session.execute(
select(Setting).where(Setting.key == EXTENSION_API_KEY_SETTING)
)
setting = result.scalar()
# Find existing key # Generate new key
result = await session.execute( new_key = generate_api_key()
select(Setting).where(Setting.key == EXTENSION_API_KEY_SETTING)
)
setting = result.scalar()
# Generate new key if setting:
new_key = generate_api_key() setting.value = new_key
else:
setting = Setting(key=EXTENSION_API_KEY_SETTING, value=new_key)
session.add(setting)
if setting: await session.commit()
setting.value = new_key
else:
setting = Setting(key=EXTENSION_API_KEY_SETTING, value=new_key)
session.add(setting)
await session.commit() current_app.logger.info("Extension API key regenerated")
return jsonify({"api_key": new_key, "message": "API key regenerated"})
current_app.logger.info("Extension API key regenerated")
return jsonify({"api_key": new_key, "message": "API key regenerated"})
@bp.route("/gallery-dl", methods=["GET"]) @bp.route("/gallery-dl", methods=["GET"])
@@ -246,28 +243,27 @@ 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
query = select(Download).order_by(desc(Download.created_at)).limit(limit)
result = await session.execute(query)
downloads = result.scalars().all()
# Get recent downloads with output logs logs = []
query = select(Download).order_by(desc(Download.created_at)).limit(limit) for dl in downloads:
result = await session.execute(query) metadata = dl.metadata_ or {}
downloads = result.scalars().all() if metadata.get("stdout") or metadata.get("stderr") or dl.error_message:
logs.append({
"id": dl.id,
"url": dl.url,
"status": dl.status,
"created_at": dl.created_at.isoformat() + "Z" if dl.created_at else None,
"stdout": metadata.get("stdout", ""),
"stderr": metadata.get("stderr", ""),
"error_message": dl.error_message,
})
logs = [] return jsonify({"logs": logs, "count": len(logs)})
for dl in downloads:
metadata = dl.metadata_ or {}
if metadata.get("stdout") or metadata.get("stderr") or dl.error_message:
logs.append({
"id": dl.id,
"url": dl.url,
"status": dl.status,
"created_at": dl.created_at.isoformat() + "Z" if dl.created_at else None,
"stdout": metadata.get("stdout", ""),
"stderr": metadata.get("stderr", ""),
"error_message": dl.error_message,
})
return jsonify({"logs": logs, "count": len(logs)})
@bp.route("/system-info", methods=["GET"]) @bp.route("/system-info", methods=["GET"])
@@ -333,39 +329,38 @@ 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
result = await session.execute(
select(Setting).where(Setting.key == "download.schedule_interval")
)
setting = result.scalar()
# Query the raw setting from database if setting:
result = await session.execute( raw_value = setting.value
select(Setting).where(Setting.key == "download.schedule_interval") db_info = {
) "found_in_database": True,
setting = result.scalar() "raw_value": raw_value,
"raw_type": type(raw_value).__name__,
"as_int": int(raw_value) if raw_value is not None else None,
"updated_at": setting.updated_at.isoformat() if setting.updated_at else None,
}
else:
db_info = {
"found_in_database": False,
"raw_value": None,
"raw_type": None,
"as_int": None,
"updated_at": None,
}
if setting: # What the worker would use
raw_value = setting.value fallback_value = DEFAULT_SETTINGS.get("download.schedule_interval", 3600)
db_info = { worker_would_use = db_info["as_int"] if db_info["found_in_database"] else fallback_value
"found_in_database": True,
"raw_value": raw_value,
"raw_type": type(raw_value).__name__,
"as_int": int(raw_value) if raw_value is not None else None,
"updated_at": setting.updated_at.isoformat() if setting.updated_at else None,
}
else:
db_info = {
"found_in_database": False,
"raw_value": None,
"raw_type": None,
"as_int": None,
"updated_at": None,
}
# What the worker would use return jsonify({
fallback_value = DEFAULT_SETTINGS.get("download.schedule_interval", 3600) "database": db_info,
worker_would_use = db_info["as_int"] if db_info["found_in_database"] else fallback_value "default_settings_value": fallback_value,
"worker_would_use": worker_would_use,
return jsonify({ "worker_would_use_hours": worker_would_use / 3600 if worker_would_use else None,
"database": db_info, })
"default_settings_value": fallback_value,
"worker_would_use": worker_would_use,
"worker_would_use_hours": worker_would_use / 3600 if worker_would_use else None,
})
+149 -155
View File
@@ -26,44 +26,43 @@ 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
query = select(Source).options(selectinload(Source.subscription))
# Build query with subscription eager loading if platform:
query = select(Source).options(selectinload(Source.subscription)) query = query.where(Source.platform == platform)
if subscription_id:
query = query.where(Source.subscription_id == int(subscription_id))
if enabled is not None:
query = query.where(Source.enabled == (enabled.lower() == "true"))
if platform: # Get total count
query = query.where(Source.platform == platform) count_query = select(func.count()).select_from(Source)
if subscription_id: if platform:
query = query.where(Source.subscription_id == int(subscription_id)) count_query = count_query.where(Source.platform == platform)
if enabled is not None: if subscription_id:
query = query.where(Source.enabled == (enabled.lower() == "true")) count_query = count_query.where(Source.subscription_id == int(subscription_id))
if enabled is not None:
count_query = count_query.where(Source.enabled == (enabled.lower() == "true"))
# Get total count total_result = await session.execute(count_query)
count_query = select(func.count()).select_from(Source) total = total_result.scalar()
if platform:
count_query = count_query.where(Source.platform == platform)
if subscription_id:
count_query = count_query.where(Source.subscription_id == int(subscription_id))
if enabled is not None:
count_query = count_query.where(Source.enabled == (enabled.lower() == "true"))
total_result = await session.execute(count_query) # Apply pagination - order by subscription name, then platform
total = total_result.scalar() query = query.join(Subscription).order_by(Subscription.name, Source.platform)
query = query.offset((page - 1) * per_page).limit(per_page)
# Apply pagination - order by subscription name, then platform result = await session.execute(query)
query = query.join(Subscription).order_by(Subscription.name, Source.platform) sources = result.scalars().all()
query = query.offset((page - 1) * per_page).limit(per_page)
result = await session.execute(query) return jsonify({
sources = result.scalars().all() "items": [s.to_dict() for s in sources],
"total": total,
return jsonify({ "page": page,
"items": [s.to_dict() for s in sources], "per_page": per_page,
"total": total, "pages": (total + per_page - 1) // per_page if total > 0 else 0,
"page": page, })
"per_page": per_page,
"pages": (total + per_page - 1) // per_page if total > 0 else 0,
})
@bp.route("", methods=["POST"]) @bp.route("", methods=["POST"])
@@ -83,65 +82,63 @@ 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"])
)
subscription = sub_result.scalar()
if not subscription:
return jsonify({"error": "Subscription not found"}), 404
# Check for duplicate platform+url
existing = await session.execute(
select(Source).where(
Source.platform == data["platform"],
Source.url == data["url"]
) )
) subscription = sub_result.scalar()
if existing.scalar(): if not subscription:
return jsonify({"error": "Source with this platform and URL already exists"}), 409 return jsonify({"error": "Subscription not found"}), 404
# Get default check interval from database settings # Check for duplicate platform+url
default_interval = await get_setting_value( existing = await session.execute(
session, "download.schedule_interval", 28800 select(Source).where(
) Source.platform == data["platform"],
Source.url == data["url"]
)
)
if existing.scalar():
return jsonify({"error": "Source with this platform and URL already exists"}), 409
source = Source( # Get default check interval from database settings
subscription_id=data["subscription_id"], default_interval = await get_setting_value(
platform=data["platform"], session, "download.schedule_interval", 28800
url=data["url"], )
enabled=data.get("enabled", True),
check_interval=data.get("check_interval", default_interval),
metadata_=data.get("metadata", {}),
)
session.add(source) source = Source(
await session.commit() subscription_id=data["subscription_id"],
await session.refresh(source) platform=data["platform"],
url=data["url"],
enabled=data.get("enabled", True),
check_interval=data.get("check_interval", default_interval),
metadata_=data.get("metadata", {}),
)
current_app.logger.info(f"Created source: {subscription.name}/{source.platform}") session.add(source)
return jsonify(source.to_dict()), 201 await session.commit()
await session.refresh(source)
current_app.logger.info(f"Created source: {subscription.name}/{source.platform}")
return jsonify(source.to_dict()), 201
@bp.route("/<int:source_id>", methods=["GET"]) @bp.route("/<int:source_id>", methods=["GET"])
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(
select(Source)
.options(selectinload(Source.subscription))
.where(Source.id == source_id)
)
source = result.scalar()
result = await session.execute( if not source:
select(Source) return jsonify({"error": "Source not found"}), 404
.options(selectinload(Source.subscription))
.where(Source.id == source_id)
)
source = result.scalar()
if not source: return jsonify(source.to_dict())
return jsonify({"error": "Source not found"}), 404
return jsonify(source.to_dict())
@bp.route("/<int:source_id>", methods=["PATCH"]) @bp.route("/<int:source_id>", methods=["PATCH"])
@@ -150,103 +147,100 @@ 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(
select(Source)
.options(selectinload(Source.subscription))
.where(Source.id == source_id)
)
source = result.scalar()
result = await session.execute( if not source:
select(Source) return jsonify({"error": "Source not found"}), 404
.options(selectinload(Source.subscription))
.where(Source.id == source_id)
)
source = result.scalar()
if not source: # Update allowed fields
return jsonify({"error": "Source not found"}), 404 allowed_fields = ["enabled", "check_interval", "metadata"]
for field in allowed_fields:
if field in data:
if field == "metadata":
source.metadata_ = data[field]
else:
setattr(source, field, data[field])
# Update allowed fields await session.commit()
allowed_fields = ["enabled", "check_interval", "metadata"] await session.refresh(source)
for field in allowed_fields:
if field in data:
if field == "metadata":
source.metadata_ = data[field]
else:
setattr(source, field, data[field])
await session.commit() current_app.logger.info(f"Updated source: {source.subscription.name}/{source.platform}")
await session.refresh(source) return jsonify(source.to_dict())
current_app.logger.info(f"Updated source: {source.subscription.name}/{source.platform}")
return jsonify(source.to_dict())
@bp.route("/<int:source_id>", methods=["DELETE"]) @bp.route("/<int:source_id>", methods=["DELETE"])
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(
select(Source)
.options(selectinload(Source.subscription))
.where(Source.id == source_id)
)
source = result.scalar()
result = await session.execute( if not source:
select(Source) return jsonify({"error": "Source not found"}), 404
.options(selectinload(Source.subscription))
.where(Source.id == source_id)
)
source = result.scalar()
if not source: info = f"{source.subscription.name}/{source.platform}"
return jsonify({"error": "Source not found"}), 404 await session.delete(source)
await session.commit()
info = f"{source.subscription.name}/{source.platform}" current_app.logger.info(f"Deleted source: {info}")
await session.delete(source) return "", 204
await session.commit()
current_app.logger.info(f"Deleted source: {info}")
return "", 204
@bp.route("/<int:source_id>/check", methods=["POST"]) @bp.route("/<int:source_id>/check", methods=["POST"])
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)) .where(Source.id == source_id)
.where(Source.id == source_id)
)
source = result.scalar()
if not source:
return jsonify({"error": "Source not found"}), 404
# Check for existing queued or running download for this source
existing = await session.execute(
select(Download).where(
Download.source_id == source.id,
Download.status.in_([DownloadStatus.QUEUED, DownloadStatus.RUNNING])
) )
) source = result.scalar()
if existing.scalar():
return jsonify({"error": "A download is already queued or running for this source"}), 409
# Create download record in QUEUED state for visibility if not source:
download = Download( return jsonify({"error": "Source not found"}), 404
source_id=source.id,
url=source.url,
status=DownloadStatus.QUEUED,
)
session.add(download)
await session.commit()
await session.refresh(download)
# Queue Celery task for immediate download # Check for existing queued or running download for this source
task = download_source.delay(source_id, download_id=download.id) existing = await session.execute(
select(Download).where(
Download.source_id == source.id,
Download.status.in_([DownloadStatus.QUEUED, DownloadStatus.RUNNING])
)
)
if existing.scalar():
return jsonify({"error": "A download is already queued or running for this source"}), 409
current_app.logger.info(f"Triggered check for source: {source.subscription.name}/{source.platform}") # Create download record in QUEUED state for visibility
return jsonify({ download = Download(
"message": "Check queued", source_id=source.id,
"source_id": source_id, url=source.url,
"download_id": download.id, status=DownloadStatus.QUEUED,
"subscription_name": source.subscription.name, )
"platform": source.platform, session.add(download)
"task_id": task.id, await session.commit()
}), 202 await session.refresh(download)
# Queue Celery task for immediate download
task = download_source.delay(source_id, download_id=download.id)
current_app.logger.info(f"Triggered check for source: {source.subscription.name}/{source.platform}")
return jsonify({
"message": "Check queued",
"source_id": source_id,
"download_id": download.id,
"subscription_name": source.subscription.name,
"platform": source.platform,
"task_id": task.id,
}), 202
+220 -228
View File
@@ -24,40 +24,39 @@ 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
query = select(Subscription).options(selectinload(Subscription.sources))
# Build query with eager loading of sources if enabled is not None:
query = select(Subscription).options(selectinload(Subscription.sources)) query = query.where(Subscription.enabled == (enabled.lower() == "true"))
if search:
query = query.where(Subscription.name.ilike(f"%{search}%"))
if enabled is not None: # Get total count
query = query.where(Subscription.enabled == (enabled.lower() == "true")) count_query = select(func.count()).select_from(Subscription)
if search: if enabled is not None:
query = query.where(Subscription.name.ilike(f"%{search}%")) count_query = count_query.where(Subscription.enabled == (enabled.lower() == "true"))
if search:
count_query = count_query.where(Subscription.name.ilike(f"%{search}%"))
# Get total count total_result = await session.execute(count_query)
count_query = select(func.count()).select_from(Subscription) total = total_result.scalar()
if enabled is not None:
count_query = count_query.where(Subscription.enabled == (enabled.lower() == "true"))
if search:
count_query = count_query.where(Subscription.name.ilike(f"%{search}%"))
total_result = await session.execute(count_query) # Apply pagination
total = total_result.scalar() query = query.order_by(Subscription.priority.desc(), Subscription.name)
query = query.offset((page - 1) * per_page).limit(per_page)
# Apply pagination result = await session.execute(query)
query = query.order_by(Subscription.priority.desc(), Subscription.name) subscriptions = result.scalars().unique().all()
query = query.offset((page - 1) * per_page).limit(per_page)
result = await session.execute(query) return jsonify({
subscriptions = result.scalars().unique().all() "items": [s.to_dict() for s in subscriptions],
"total": total,
return jsonify({ "page": page,
"items": [s.to_dict() for s in subscriptions], "per_page": per_page,
"total": total, "pages": (total + per_page - 1) // per_page if total > 0 else 0,
"page": page, })
"per_page": per_page,
"pages": (total + per_page - 1) // per_page if total > 0 else 0,
})
@bp.route("", methods=["POST"]) @bp.route("", methods=["POST"])
@@ -70,67 +69,65 @@ 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"])
)
if existing.scalar():
return jsonify({"error": "Subscription with this name already exists"}), 409
subscription = Subscription(
name=data["name"],
enabled=data.get("enabled", True),
priority=data.get("priority", 0),
description=data.get("description"),
metadata_=data.get("metadata", {}),
)
# Get default check interval from database settings
default_interval = await get_setting_value(
session, "download.schedule_interval", 28800
)
# Add sources if provided
sources_data = data.get("sources", [])
for source_data in sources_data:
if not source_data.get("platform") or not source_data.get("url"):
continue
source = Source(
platform=source_data["platform"],
url=source_data["url"],
enabled=source_data.get("enabled", True),
check_interval=source_data.get("check_interval", default_interval),
metadata_=source_data.get("metadata", {}),
) )
subscription.sources.append(source) if existing.scalar():
return jsonify({"error": "Subscription with this name already exists"}), 409
session.add(subscription) subscription = Subscription(
await session.commit() name=data["name"],
await session.refresh(subscription) enabled=data.get("enabled", True),
priority=data.get("priority", 0),
description=data.get("description"),
metadata_=data.get("metadata", {}),
)
current_app.logger.info(f"Created subscription: {subscription.name}") # Get default check interval from database settings
return jsonify(subscription.to_dict()), 201 default_interval = await get_setting_value(
session, "download.schedule_interval", 28800
)
# Add sources if provided
sources_data = data.get("sources", [])
for source_data in sources_data:
if not source_data.get("platform") or not source_data.get("url"):
continue
source = Source(
platform=source_data["platform"],
url=source_data["url"],
enabled=source_data.get("enabled", True),
check_interval=source_data.get("check_interval", default_interval),
metadata_=source_data.get("metadata", {}),
)
subscription.sources.append(source)
session.add(subscription)
await session.commit()
await session.refresh(subscription)
current_app.logger.info(f"Created subscription: {subscription.name}")
return jsonify(subscription.to_dict()), 201
@bp.route("/<int:subscription_id>", methods=["GET"]) @bp.route("/<int:subscription_id>", methods=["GET"])
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(
select(Subscription)
.options(selectinload(Subscription.sources))
.where(Subscription.id == subscription_id)
)
subscription = result.scalar()
result = await session.execute( if not subscription:
select(Subscription) return jsonify({"error": "Subscription not found"}), 404
.options(selectinload(Subscription.sources))
.where(Subscription.id == subscription_id)
)
subscription = result.scalar()
if not subscription: return jsonify(subscription.to_dict())
return jsonify({"error": "Subscription not found"}), 404
return jsonify(subscription.to_dict())
@bp.route("/<int:subscription_id>", methods=["PATCH"]) @bp.route("/<int:subscription_id>", methods=["PATCH"])
@@ -139,85 +136,82 @@ 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)) .where(Subscription.id == subscription_id)
.where(Subscription.id == subscription_id)
)
subscription = result.scalar()
if not subscription:
return jsonify({"error": "Subscription not found"}), 404
# Check for duplicate name if changing
if "name" in data and data["name"] != subscription.name:
existing = await session.execute(
select(Subscription).where(Subscription.name == data["name"])
) )
if existing.scalar(): subscription = result.scalar()
return jsonify({"error": "Subscription with this name already exists"}), 409
# Update allowed fields if not subscription:
allowed_fields = ["name", "enabled", "priority", "description", "metadata"] return jsonify({"error": "Subscription not found"}), 404
for field in allowed_fields:
if field in data:
if field == "metadata":
subscription.metadata_ = data[field]
else:
setattr(subscription, field, data[field])
await session.commit() # Check for duplicate name if changing
await session.refresh(subscription) if "name" in data and data["name"] != subscription.name:
existing = await session.execute(
select(Subscription).where(Subscription.name == data["name"])
)
if existing.scalar():
return jsonify({"error": "Subscription with this name already exists"}), 409
current_app.logger.info(f"Updated subscription: {subscription.name}") # Update allowed fields
return jsonify(subscription.to_dict()) allowed_fields = ["name", "enabled", "priority", "description", "metadata"]
for field in allowed_fields:
if field in data:
if field == "metadata":
subscription.metadata_ = data[field]
else:
setattr(subscription, field, data[field])
await session.commit()
await session.refresh(subscription)
current_app.logger.info(f"Updated subscription: {subscription.name}")
return jsonify(subscription.to_dict())
@bp.route("/<int:subscription_id>", methods=["DELETE"]) @bp.route("/<int:subscription_id>", methods=["DELETE"])
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(
select(Subscription).where(Subscription.id == subscription_id)
)
subscription = result.scalar()
result = await session.execute( if not subscription:
select(Subscription).where(Subscription.id == subscription_id) return jsonify({"error": "Subscription not found"}), 404
)
subscription = result.scalar()
if not subscription: name = subscription.name
return jsonify({"error": "Subscription not found"}), 404 await session.delete(subscription)
await session.commit()
name = subscription.name current_app.logger.info(f"Deleted subscription: {name}")
await session.delete(subscription) return "", 204
await session.commit()
current_app.logger.info(f"Deleted subscription: {name}")
return "", 204
@bp.route("/<int:subscription_id>/sources", methods=["GET"]) @bp.route("/<int:subscription_id>/sources", methods=["GET"])
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(
select(Subscription)
.options(selectinload(Subscription.sources))
.where(Subscription.id == subscription_id)
)
subscription = result.scalar()
result = await session.execute( if not subscription:
select(Subscription) return jsonify({"error": "Subscription not found"}), 404
.options(selectinload(Subscription.sources))
.where(Subscription.id == subscription_id)
)
subscription = result.scalar()
if not subscription: return jsonify({
return jsonify({"error": "Subscription not found"}), 404 "subscription_id": subscription_id,
"subscription_name": subscription.name,
return jsonify({ "sources": [s.to_dict(include_subscription=False) for s in subscription.sources]
"subscription_id": subscription_id, })
"subscription_name": subscription.name,
"sources": [s.to_dict(include_subscription=False) for s in subscription.sources]
})
@bp.route("/<int:subscription_id>/sources", methods=["POST"]) @bp.route("/<int:subscription_id>/sources", methods=["POST"])
@@ -237,113 +231,111 @@ 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)
)
subscription = result.scalar()
if not subscription:
return jsonify({"error": "Subscription not found"}), 404
# Check for duplicate platform+url
existing = await session.execute(
select(Source).where(
Source.platform == data["platform"],
Source.url == data["url"]
) )
) subscription = result.scalar()
if existing.scalar():
return jsonify({"error": "Source with this platform and URL already exists"}), 409
# Store name before commit (attributes expire after commit) if not subscription:
subscription_name = subscription.name return jsonify({"error": "Subscription not found"}), 404
# Get default check interval from database settings # Check for duplicate platform+url
default_interval = await get_setting_value( existing = await session.execute(
session, "download.schedule_interval", 28800 select(Source).where(
) Source.platform == data["platform"],
Source.url == data["url"]
)
)
if existing.scalar():
return jsonify({"error": "Source with this platform and URL already exists"}), 409
source = Source( # Store name before commit (attributes expire after commit)
subscription_id=subscription_id, subscription_name = subscription.name
platform=data["platform"],
url=data["url"],
enabled=data.get("enabled", True),
check_interval=data.get("check_interval", default_interval),
metadata_=data.get("metadata", {}),
)
session.add(source) # Get default check interval from database settings
await session.commit() default_interval = await get_setting_value(
await session.refresh(source) session, "download.schedule_interval", 28800
)
current_app.logger.info(f"Added source to {subscription_name}: {source.platform}") source = Source(
# Use include_subscription=False to avoid lazy loading the relationship subscription_id=subscription_id,
result = source.to_dict(include_subscription=False) platform=data["platform"],
result["subscription_name"] = subscription_name url=data["url"],
return jsonify(result), 201 enabled=data.get("enabled", True),
check_interval=data.get("check_interval", default_interval),
metadata_=data.get("metadata", {}),
)
session.add(source)
await session.commit()
await session.refresh(source)
current_app.logger.info(f"Added source to {subscription_name}: {source.platform}")
# Use include_subscription=False to avoid lazy loading the relationship
result = source.to_dict(include_subscription=False)
result["subscription_name"] = subscription_name
return jsonify(result), 201
@bp.route("/<int:subscription_id>/check", methods=["POST"]) @bp.route("/<int:subscription_id>/check", methods=["POST"])
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)) .where(Subscription.id == subscription_id)
.where(Subscription.id == subscription_id)
)
subscription = result.scalar()
if not subscription:
return jsonify({"error": "Subscription not found"}), 404
enabled_sources = [s for s in subscription.sources if s.enabled]
# Get source IDs that already have a queued or running download
active_result = await session.execute(
select(Download.source_id).where(
Download.source_id.in_([s.id for s in enabled_sources]),
Download.status.in_([DownloadStatus.QUEUED, DownloadStatus.RUNNING])
) )
) subscription = result.scalar()
active_source_ids = set(active_result.scalars().all())
# Create download records and queue Celery tasks for each eligible source if not subscription:
task_ids = [] return jsonify({"error": "Subscription not found"}), 404
download_ids = []
skipped = 0
for source in enabled_sources:
if source.id in active_source_ids:
skipped += 1
continue
download = Download( enabled_sources = [s for s in subscription.sources if s.enabled]
source_id=source.id,
url=source.url, # Get source IDs that already have a queued or running download
status=DownloadStatus.QUEUED, active_result = await session.execute(
select(Download.source_id).where(
Download.source_id.in_([s.id for s in enabled_sources]),
Download.status.in_([DownloadStatus.QUEUED, DownloadStatus.RUNNING])
)
) )
session.add(download) active_source_ids = set(active_result.scalars().all())
await session.commit()
await session.refresh(download)
task = download_source.delay(source.id, download_id=download.id) # Create download records and queue Celery tasks for each eligible source
task_ids.append(task.id) task_ids = []
download_ids.append(download.id) download_ids = []
skipped = 0
for source in enabled_sources:
if source.id in active_source_ids:
skipped += 1
continue
current_app.logger.info( download = Download(
f"Triggered check for subscription: {subscription.name} " source_id=source.id,
f"({len(download_ids)} queued, {skipped} already active)" url=source.url,
) status=DownloadStatus.QUEUED,
return jsonify({ )
"message": "Checks queued", session.add(download)
"subscription_id": subscription_id, await session.commit()
"source_count": len(download_ids), await session.refresh(download)
"skipped_active": skipped,
"task_ids": task_ids, task = download_source.delay(source.id, download_id=download.id)
"download_ids": download_ids, task_ids.append(task.id)
}), 202 download_ids.append(download.id)
current_app.logger.info(
f"Triggered check for subscription: {subscription.name} "
f"({len(download_ids)} queued, {skipped} already active)"
)
return jsonify({
"message": "Checks queued",
"subscription_id": subscription_id,
"source_count": len(download_ids),
"skipped_active": skipped,
"task_ids": task_ids,
"download_ids": download_ids,
}), 202