diff --git a/Dockerfile b/Dockerfile index 293a65a..8db39b8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,7 @@ WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ gcc \ libpq-dev \ + gosu \ && rm -rf /var/lib/apt/lists/* # Install Python dependencies @@ -28,14 +29,18 @@ COPY backend/ . # Copy built frontend from Stage 1 COPY --from=frontend-build /frontend/dist /app/static +# Copy and set up entrypoint script +COPY docker-entrypoint.sh /usr/local/bin/ +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + # Create data directories RUN mkdir -p /data/downloads /data/config /data/cookies -# Run as non-root user +# Create non-root user (entrypoint will switch to this user after fixing permissions) RUN useradd -m -u 1000 appuser && \ chown -R appuser:appuser /app /data -USER appuser EXPOSE 8080 +ENTRYPOINT ["docker-entrypoint.sh"] CMD ["hypercorn", "app.main:app", "--bind", "0.0.0.0:8080"] diff --git a/backend/app/api/credentials.py b/backend/app/api/credentials.py index 2260a92..c7540fe 100644 --- a/backend/app/api/credentials.py +++ b/backend/app/api/credentials.py @@ -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, }) diff --git a/backend/app/api/downloads.py b/backend/app/api/downloads.py index 07d30c9..9ab4d7a 100644 --- a/backend/app/api/downloads.py +++ b/backend/app/api/downloads.py @@ -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.""" diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 576a1f7..b7d6972 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -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)}) diff --git a/backend/app/models/base.py b/backend/app/models/base.py index 19b0a95..8c454ab 100644 --- a/backend/app/models/base.py +++ b/backend/app/models/base.py @@ -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) diff --git a/backend/app/models/download.py b/backend/app/models/download.py index 2069590..82231f4 100644 --- a/backend/app/models/download.py +++ b/backend/app/models/download.py @@ -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), } diff --git a/backend/app/models/source.py b/backend/app/models/source.py index d520daa..ec6e0a6 100644 --- a/backend/app/models/source.py +++ b/backend/app/models/source.py @@ -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 diff --git a/backend/app/models/subscription.py b/backend/app/models/subscription.py index 7a16b58..4716df1 100644 --- a/backend/app/models/subscription.py +++ b/backend/app/models/subscription.py @@ -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] diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..19de845 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,41 @@ +#!/bin/bash +set -e + +# Fix ownership of data directories (needed for bind mounts) +# Only fix top-level dirs to avoid slow recursive chown on large downloads +echo "Fixing data directory permissions..." +chown appuser:appuser /data /data/downloads /data/config /data/cookies 2>/dev/null || true + +# Wait for database to be ready +echo "Waiting for database..." +until gosu appuser python -c " +import asyncio +from sqlalchemy.ext.asyncio import create_async_engine +from app.config import get_settings + +async def check(): + settings = get_settings() + engine = create_async_engine(settings.async_database_url) + async with engine.connect() as conn: + await conn.execute(__import__('sqlalchemy').text('SELECT 1')) + await engine.dispose() + +asyncio.run(check()) +" 2>/dev/null; do + echo "Database not ready, waiting..." + sleep 2 +done +echo "Database is ready!" + +# Only run migrations from the app container (not celery worker) +# This prevents race conditions when multiple containers start simultaneously +if [[ "$1" == "hypercorn" ]]; then + echo "Running database migrations..." + gosu appuser alembic upgrade head + echo "Migrations complete!" +else + echo "Skipping migrations (will be run by app container)" +fi + +# Start the application as non-root user +exec gosu appuser "$@" diff --git a/extension/icons/icon.svg b/extension/icons/icon.svg new file mode 100644 index 0000000..1fcf038 --- /dev/null +++ b/extension/icons/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/extension/manifest.json b/extension/manifest.json index d2cfc83..e215e4d 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -19,12 +19,7 @@ "browser_action": { "default_popup": "popup/popup.html", - "default_icon": { - "16": "icons/icon-16.png", - "32": "icons/icon-32.png", - "48": "icons/icon-48.png", - "128": "icons/icon-128.png" - }, + "default_icon": "icons/icon.svg", "default_title": "GallerySubscriber" }, @@ -44,9 +39,7 @@ }, "icons": { - "16": "icons/icon-16.png", - "32": "icons/icon-32.png", - "48": "icons/icon-48.png", - "128": "icons/icon-128.png" + "48": "icons/icon.svg", + "96": "icons/icon.svg" } } diff --git a/extension/options/options.html b/extension/options/options.html index 1c5c84f..8e971d9 100644 --- a/extension/options/options.html +++ b/extension/options/options.html @@ -4,8 +4,10 @@ GallerySubscriber Settings diff --git a/frontend/src/views/Subscriptions.vue b/frontend/src/views/Subscriptions.vue index 352d096..9bc64b8 100644 --- a/frontend/src/views/Subscriptions.vue +++ b/frontend/src/views/Subscriptions.vue @@ -5,6 +5,29 @@ Add Subscription + + Export + + + Import + + + + + + +
+ {{ selectedIds.length }} selected + Enable All + Disable All + Delete All + + Clear Selection +
+
+
+
+