tuning ui and adding adaptive themeing to extension
This commit is contained in:
@@ -24,8 +24,12 @@ async def list_credentials():
|
||||
result = await session.execute(select(Credential))
|
||||
credentials = result.scalars().all()
|
||||
|
||||
# Also return a simple map of platform -> has_credentials for UI convenience
|
||||
platforms_with_creds = {c.platform: True for c in credentials}
|
||||
|
||||
return jsonify({
|
||||
"items": [c.to_dict(include_data=False) for c in credentials]
|
||||
"items": [c.to_dict(include_data=False) for c in credentials],
|
||||
"platforms": platforms_with_creds,
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -128,6 +128,46 @@ async def retry_download(download_id: int):
|
||||
}), 202
|
||||
|
||||
|
||||
@bp.route("/recent-activity", methods=["GET"])
|
||||
async def recent_activity():
|
||||
"""Get recent noteworthy downloads: failures and completions with new files.
|
||||
|
||||
These are downloads that warrant user attention, not routine "no new content" runs.
|
||||
Results are limited to the last 7 days by default.
|
||||
"""
|
||||
limit = min(int(request.args.get("limit", 10)), 50)
|
||||
days = int(request.args.get("days", 7))
|
||||
|
||||
from datetime import timedelta
|
||||
cutoff = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
async with current_app.db_engine.connect() as conn:
|
||||
session = AsyncSession(bind=conn)
|
||||
|
||||
# Get failed downloads OR completed with files (noteworthy activity)
|
||||
from sqlalchemy import or_
|
||||
query = select(Download).where(
|
||||
and_(
|
||||
Download.created_at >= cutoff,
|
||||
or_(
|
||||
Download.status == DownloadStatus.FAILED,
|
||||
and_(
|
||||
Download.status == DownloadStatus.COMPLETED,
|
||||
Download.file_count > 0
|
||||
)
|
||||
)
|
||||
)
|
||||
).order_by(Download.created_at.desc()).limit(limit)
|
||||
|
||||
result = await session.execute(query)
|
||||
downloads = result.scalars().all()
|
||||
|
||||
return jsonify({
|
||||
"items": [d.to_dict() for d in downloads],
|
||||
"count": len(downloads),
|
||||
})
|
||||
|
||||
|
||||
@bp.route("/stats", methods=["GET"])
|
||||
async def get_stats():
|
||||
"""Get download statistics."""
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Settings API endpoints."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from quart import Blueprint, request, jsonify, current_app
|
||||
from sqlalchemy import select
|
||||
@@ -12,6 +13,45 @@ from app.config import get_settings
|
||||
bp = Blueprint("settings", __name__)
|
||||
|
||||
|
||||
def get_storage_stats():
|
||||
"""Calculate storage statistics for the downloads directory."""
|
||||
config = get_settings()
|
||||
downloads_path = Path(config.downloads_path)
|
||||
|
||||
if not downloads_path.exists():
|
||||
return {"total_size": 0, "total_size_formatted": "0 B", "file_count": 0}
|
||||
|
||||
total_size = 0
|
||||
file_count = 0
|
||||
|
||||
try:
|
||||
for root, dirs, files in os.walk(downloads_path):
|
||||
for f in files:
|
||||
fp = os.path.join(root, f)
|
||||
try:
|
||||
total_size += os.path.getsize(fp)
|
||||
file_count += 1
|
||||
except OSError:
|
||||
pass
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error calculating storage stats: {e}")
|
||||
return {"total_size": 0, "total_size_formatted": "Error", "file_count": 0}
|
||||
|
||||
# Format size
|
||||
def format_size(size):
|
||||
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
|
||||
if size < 1024:
|
||||
return f"{size:.1f} {unit}"
|
||||
size /= 1024
|
||||
return f"{size:.1f} PB"
|
||||
|
||||
return {
|
||||
"total_size": total_size,
|
||||
"total_size_formatted": format_size(total_size),
|
||||
"file_count": file_count,
|
||||
}
|
||||
|
||||
|
||||
async def get_or_create_extension_api_key(session: AsyncSession) -> str:
|
||||
"""Get the extension API key, creating one if it doesn't exist."""
|
||||
result = await session.execute(
|
||||
@@ -72,7 +112,10 @@ async def get_all_settings():
|
||||
api_key = await get_or_create_extension_api_key(session)
|
||||
settings_dict[EXTENSION_API_KEY_SETTING] = api_key
|
||||
|
||||
return jsonify({"settings": settings_dict})
|
||||
# Include storage statistics
|
||||
storage_stats = get_storage_stats()
|
||||
|
||||
return jsonify({"settings": settings_dict, "storage_stats": storage_stats})
|
||||
|
||||
|
||||
@bp.route("", methods=["PATCH"])
|
||||
@@ -186,3 +229,39 @@ async def update_gallery_dl_config():
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Failed to write gallery-dl config: {e}")
|
||||
return jsonify({"error": f"Failed to write config: {e}"}), 500
|
||||
|
||||
|
||||
@bp.route("/logs", methods=["GET"])
|
||||
async def get_logs():
|
||||
"""Get recent download logs from download metadata.
|
||||
|
||||
Returns the stdout/stderr from recent downloads for debugging.
|
||||
"""
|
||||
from app.models.download import Download
|
||||
from sqlalchemy import desc
|
||||
|
||||
limit = min(int(request.args.get("limit", 50)), 200)
|
||||
|
||||
async with current_app.db_engine.connect() as conn:
|
||||
session = AsyncSession(bind=conn)
|
||||
|
||||
# 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()
|
||||
|
||||
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)})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""SQLAlchemy base model."""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
@@ -10,8 +10,29 @@ class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
"""Mixin for created_at and updated_at timestamps."""
|
||||
def utcnow():
|
||||
"""Return current UTC time as timezone-aware datetime."""
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
||||
|
||||
def format_datetime(dt):
|
||||
"""Format datetime as ISO string with UTC marker for API responses.
|
||||
|
||||
If the datetime is naive (no timezone), assumes UTC and appends Z.
|
||||
This ensures JavaScript Date() correctly interprets the timezone.
|
||||
"""
|
||||
if not dt:
|
||||
return None
|
||||
# If naive (no timezone), assume UTC and append Z
|
||||
if dt.tzinfo is None:
|
||||
return dt.isoformat() + "Z"
|
||||
return dt.isoformat()
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
"""Mixin for created_at and updated_at timestamps.
|
||||
|
||||
Uses UTC timestamps for consistency. Frontend should convert to local time.
|
||||
"""
|
||||
created_at: Mapped[datetime] = mapped_column(default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(default=utcnow, onupdate=utcnow)
|
||||
|
||||
@@ -6,7 +6,7 @@ from sqlalchemy import String, Integer, Text, ForeignKey, BigInteger
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
from app.models.base import Base, TimestampMixin, format_datetime
|
||||
|
||||
|
||||
class DownloadStatus:
|
||||
@@ -79,10 +79,10 @@ class Download(Base, TimestampMixin):
|
||||
"error_message": self.error_message,
|
||||
"file_count": self.file_count,
|
||||
"total_size": self.total_size,
|
||||
"started_at": self.started_at.isoformat() if self.started_at else None,
|
||||
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
|
||||
"started_at": format_datetime(self.started_at),
|
||||
"completed_at": format_datetime(self.completed_at),
|
||||
"metadata": self.metadata_,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"created_at": format_datetime(self.created_at),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from sqlalchemy import String, Boolean, Integer, Text, ForeignKey, UniqueConstra
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
from app.models.base import Base, TimestampMixin, format_datetime
|
||||
|
||||
|
||||
class Source(Base, TimestampMixin):
|
||||
@@ -55,12 +55,12 @@ class Source(Base, TimestampMixin):
|
||||
"url": self.url,
|
||||
"enabled": self.enabled,
|
||||
"check_interval": self.check_interval,
|
||||
"last_check": self.last_check.isoformat() if self.last_check else None,
|
||||
"last_success": self.last_success.isoformat() if self.last_success else None,
|
||||
"last_check": format_datetime(self.last_check),
|
||||
"last_success": format_datetime(self.last_success),
|
||||
"error_count": self.error_count,
|
||||
"metadata": self.metadata_,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
"created_at": format_datetime(self.created_at),
|
||||
"updated_at": format_datetime(self.updated_at),
|
||||
}
|
||||
if include_subscription and self.subscription:
|
||||
result["subscription_name"] = self.subscription.name
|
||||
|
||||
@@ -6,7 +6,7 @@ from sqlalchemy import String, Boolean, Integer, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
from app.models.base import Base, TimestampMixin, format_datetime
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.source import Source
|
||||
@@ -64,8 +64,8 @@ class Subscription(Base, TimestampMixin):
|
||||
"metadata": self.metadata_,
|
||||
"platform_count": self.platform_count,
|
||||
"enabled_source_count": self.enabled_source_count,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
"created_at": format_datetime(self.created_at),
|
||||
"updated_at": format_datetime(self.updated_at),
|
||||
}
|
||||
if include_sources and self.sources:
|
||||
result["sources"] = [s.to_dict(include_subscription=False) for s in self.sources]
|
||||
|
||||
Reference in New Issue
Block a user