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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user