tuning ui and adding adaptive themeing to extension

This commit is contained in:
Bryan Van Deusen
2026-01-25 18:47:06 -05:00
parent dc6755a0c2
commit 997f31ae27
23 changed files with 978 additions and 114 deletions
+5 -1
View File
@@ -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,
})
+40
View File
@@ -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."""
+80 -1
View File
@@ -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)})
+26 -5
View File
@@ -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)
+4 -4
View File
@@ -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),
}
+5 -5
View File
@@ -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
+3 -3
View File
@@ -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]