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
+7 -2
View File
@@ -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"]
+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]
+41
View File
@@ -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 "$@"
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path fill="#1976D2" d="M20,4H4A2,2 0 0,0 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V6A2,2 0 0,0 20,4M4,18V6H20V18H4M6,10H8V8H6V10M6,14H8V12H6V14M10,10H18V8H10V10M10,14H18V12H10V14Z"/>
</svg>

After

Width:  |  Height:  |  Size: 253 B

+3 -10
View File
@@ -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"
}
}
+73 -11
View File
@@ -4,8 +4,10 @@
<meta charset="UTF-8">
<title>GallerySubscriber Settings</title>
<style>
/* Light theme (default) */
:root {
--primary-color: #1976d2;
--primary-hover: #1565c0;
--success-color: #4caf50;
--error-color: #f44336;
--bg-color: #f5f5f5;
@@ -13,6 +15,64 @@
--text-color: #212121;
--text-secondary: #757575;
--border-color: #e0e0e0;
--input-bg: #ffffff;
--btn-secondary-bg: #f5f5f5;
--btn-secondary-hover: #eeeeee;
--instructions-bg: #e3f2fd;
--instructions-title: #1565c0;
--code-bg: rgba(0,0,0,0.08);
--result-success-bg: #e8f5e9;
--result-success-text: #2e7d32;
--result-error-bg: #ffebee;
--result-error-text: #c62828;
}
/* Dark theme */
[data-theme="dark"] {
--primary-color: #64b5f6;
--primary-hover: #42a5f5;
--success-color: #81c784;
--error-color: #e57373;
--bg-color: #121212;
--card-bg: #1e1e1e;
--text-color: #e0e0e0;
--text-secondary: #9e9e9e;
--border-color: #424242;
--input-bg: #2d2d2d;
--btn-secondary-bg: #2d2d2d;
--btn-secondary-hover: #3d3d3d;
--instructions-bg: #1a2a3a;
--instructions-title: #64b5f6;
--code-bg: rgba(255,255,255,0.1);
--result-success-bg: #1b3d1b;
--result-success-text: #81c784;
--result-error-bg: #3d1b1b;
--result-error-text: #e57373;
}
/* Auto-detect system theme preference */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--primary-color: #64b5f6;
--primary-hover: #42a5f5;
--success-color: #81c784;
--error-color: #e57373;
--bg-color: #121212;
--card-bg: #1e1e1e;
--text-color: #e0e0e0;
--text-secondary: #9e9e9e;
--border-color: #424242;
--input-bg: #2d2d2d;
--btn-secondary-bg: #2d2d2d;
--btn-secondary-hover: #3d3d3d;
--instructions-bg: #1a2a3a;
--instructions-title: #64b5f6;
--code-bg: rgba(255,255,255,0.1);
--result-success-bg: #1b3d1b;
--result-success-text: #81c784;
--result-error-bg: #3d1b1b;
--result-error-text: #e57373;
}
}
* {
@@ -80,6 +140,8 @@
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 14px;
background: var(--input-bg);
color: var(--text-color);
transition: border-color 0.15s;
}
@@ -122,17 +184,17 @@
}
.btn-primary:hover {
background: #1565c0;
background: var(--primary-hover);
}
.btn-secondary {
background: #f5f5f5;
background: var(--btn-secondary-bg);
color: var(--text-color);
border: 1px solid var(--border-color);
}
.btn-secondary:hover {
background: #eeeeee;
background: var(--btn-secondary-hover);
}
.btn:disabled {
@@ -159,13 +221,13 @@
}
.result.success {
background: #e8f5e9;
color: #2e7d32;
background: var(--result-success-bg);
color: var(--result-success-text);
}
.result.error {
background: #ffebee;
color: #c62828;
background: var(--result-error-bg);
color: var(--result-error-text);
}
.connection-test {
@@ -179,7 +241,7 @@
width: 10px;
height: 10px;
border-radius: 50%;
background: #9e9e9e;
background: var(--text-secondary);
}
.status-dot.connected {
@@ -191,7 +253,7 @@
}
.instructions {
background: #e3f2fd;
background: var(--instructions-bg);
border-radius: 6px;
padding: 16px;
margin-bottom: 16px;
@@ -201,7 +263,7 @@
font-size: 14px;
font-weight: 600;
margin-bottom: 8px;
color: #1565c0;
color: var(--instructions-title);
}
.instructions ol {
@@ -215,7 +277,7 @@
}
.instructions code {
background: rgba(0,0,0,0.08);
background: var(--code-bg);
padding: 2px 6px;
border-radius: 3px;
font-family: monospace;
+81 -18
View File
@@ -1,12 +1,75 @@
/* Light theme (default) */
:root {
--primary-color: #1976d2;
--primary-hover: #1565c0;
--success-color: #4caf50;
--warning-color: #ff9800;
--error-color: #f44336;
--bg-color: #ffffff;
--bg-secondary: #f5f5f5;
--bg-hover: #f5f9ff;
--text-color: #212121;
--text-secondary: #757575;
--border-color: #e0e0e0;
--alert-warning-bg: #fff8e1;
--alert-warning-border: #ffcc02;
--alert-warning-text: #795548;
--status-success-bg: #e8f5e9;
--status-success-text: #2e7d32;
--status-success-border: #a5d6a7;
--status-error-bg: #ffebee;
--status-error-text: #c62828;
--status-error-border: #ef9a9a;
}
/* Dark theme */
[data-theme="dark"] {
--primary-color: #64b5f6;
--primary-hover: #42a5f5;
--success-color: #81c784;
--warning-color: #ffb74d;
--error-color: #e57373;
--bg-color: #1e1e1e;
--bg-secondary: #2d2d2d;
--bg-hover: #2a3a4a;
--text-color: #e0e0e0;
--text-secondary: #9e9e9e;
--border-color: #424242;
--alert-warning-bg: #3d3224;
--alert-warning-border: #8b6914;
--alert-warning-text: #ffcc02;
--status-success-bg: #1b3d1b;
--status-success-text: #81c784;
--status-success-border: #2e7d32;
--status-error-bg: #3d1b1b;
--status-error-text: #e57373;
--status-error-border: #c62828;
}
/* Auto-detect system theme preference */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--primary-color: #64b5f6;
--primary-hover: #42a5f5;
--success-color: #81c784;
--warning-color: #ffb74d;
--error-color: #e57373;
--bg-color: #1e1e1e;
--bg-secondary: #2d2d2d;
--bg-hover: #2a3a4a;
--text-color: #e0e0e0;
--text-secondary: #9e9e9e;
--border-color: #424242;
--alert-warning-bg: #3d3224;
--alert-warning-border: #8b6914;
--alert-warning-text: #ffcc02;
--status-success-bg: #1b3d1b;
--status-success-text: #81c784;
--status-success-border: #2e7d32;
--status-error-bg: #3d1b1b;
--status-error-text: #e57373;
--status-error-border: #c62828;
}
}
* {
@@ -91,7 +154,7 @@ body {
.platform-card:hover:not(.disabled) {
border-color: var(--primary-color);
background: #f5f9ff;
background: var(--bg-hover);
}
.platform-card.disabled {
@@ -176,17 +239,17 @@ body {
}
.btn-primary:hover {
background: #1565c0;
background: var(--primary-hover);
}
.btn-secondary {
background: #f5f5f5;
background: var(--bg-secondary);
color: var(--text-color);
border: 1px solid var(--border-color);
}
.btn-secondary:hover {
background: #eeeeee;
background: var(--border-color);
}
.btn:disabled {
@@ -205,7 +268,7 @@ body {
}
.btn-icon:hover {
background: #f5f5f5;
background: var(--bg-secondary);
color: var(--text-color);
}
@@ -234,9 +297,9 @@ body {
}
.alert-warning {
background: #fff8e1;
border: 1px solid #ffcc02;
color: #795548;
background: var(--alert-warning-bg);
border: 1px solid var(--alert-warning-border);
color: var(--alert-warning-text);
}
.status-message {
@@ -247,21 +310,21 @@ body {
}
.status-message.success {
background: #e8f5e9;
color: #2e7d32;
border: 1px solid #a5d6a7;
background: var(--status-success-bg);
color: var(--status-success-text);
border: 1px solid var(--status-success-border);
}
.status-message.error {
background: #ffebee;
color: #c62828;
border: 1px solid #ef9a9a;
background: var(--status-error-bg);
color: var(--status-error-text);
border: 1px solid var(--status-error-border);
}
.status-message.warning {
background: #fff8e1;
color: #795548;
border: 1px solid #ffcc02;
background: var(--alert-warning-bg);
color: var(--alert-warning-text);
border: 1px solid var(--alert-warning-border);
}
.footer {
@@ -274,7 +337,7 @@ body {
.version {
font-size: 11px;
color: #9e9e9e;
color: var(--text-secondary);
}
.hidden {
+1 -1
View File
@@ -7,7 +7,7 @@
<body>
<div class="popup-container">
<header class="header">
<img src="../icons/icon-32.png" alt="GallerySubscriber" class="logo">
<img src="../icons/icon.svg" alt="GallerySubscriber" class="logo">
<h1>GallerySubscriber</h1>
<span id="connection-status" class="status-indicator" title="Disconnected"></span>
</header>
+10 -2
View File
@@ -58,9 +58,14 @@ const route = useRoute()
const notificationStore = useNotificationStore()
const wsStore = useWebSocketStore()
// Initialize WebSocket listeners
// Initialize WebSocket listeners and restore theme
onMounted(() => {
wsStore.init()
// Restore theme preference from localStorage
const savedTheme = localStorage.getItem('theme')
if (savedTheme) {
theme.global.name.value = savedTheme
}
})
const drawer = ref(true)
@@ -70,6 +75,7 @@ const navItems = [
{ title: 'Subscriptions', icon: 'mdi-account-multiple', path: '/subscriptions' },
{ title: 'Downloads', icon: 'mdi-download', path: '/downloads' },
{ title: 'Credentials', icon: 'mdi-key', path: '/credentials' },
{ title: 'Logs', icon: 'mdi-text-box-outline', path: '/logs' },
{ title: 'Settings', icon: 'mdi-cog', path: '/settings' },
]
@@ -81,7 +87,9 @@ const currentPageTitle = computed(() => {
const isDark = computed(() => theme.global.current.value.dark)
const toggleTheme = () => {
theme.global.name.value = isDark.value ? 'light' : 'dark'
const newTheme = isDark.value ? 'light' : 'dark'
theme.global.name.value = newTheme
localStorage.setItem('theme', newTheme)
}
const notification = computed(() => notificationStore)
+5
View File
@@ -31,6 +31,11 @@ const routes = [
name: 'Settings',
component: () => import('../views/Settings.vue'),
},
{
path: '/logs',
name: 'Logs',
component: () => import('../views/Logs.vue'),
},
]
const router = createRouter({
+2
View File
@@ -36,6 +36,7 @@ export const downloadsApi = {
get: (id) => api.get(`/downloads/${id}`),
retry: (id) => api.post(`/downloads/${id}/retry`),
stats: (params = {}) => api.get('/downloads/stats', { params }),
recentActivity: (params = {}) => api.get('/downloads/recent-activity', { params }),
}
// Credentials API
@@ -54,6 +55,7 @@ export const settingsApi = {
updateGalleryDL: (config) => api.put('/settings/gallery-dl', { config }),
getApiKey: () => api.get('/settings/api-key'),
regenerateApiKey: () => api.post('/settings/api-key/regenerate'),
getLogs: (params = {}) => api.get('/settings/logs', { params }),
}
// Platforms API
+33
View File
@@ -0,0 +1,33 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { credentialsApi } from '../services/api'
export const useCredentialsStore = defineStore('credentials', () => {
const credentials = ref([])
const platforms = ref({})
const loading = ref(false)
async function fetchCredentials() {
loading.value = true
try {
const response = await credentialsApi.list()
credentials.value = response.data.items
platforms.value = response.data.platforms || {}
return response.data
} finally {
loading.value = false
}
}
function hasPlatformCredentials(platform) {
return !!platforms.value[platform]
}
return {
credentials,
platforms,
loading,
fetchCredentials,
hasPlatformCredentials,
}
})
+11 -2
View File
@@ -4,6 +4,7 @@ import { downloadsApi } from '../services/api'
export const useDownloadsStore = defineStore('downloads', () => {
const downloads = ref([])
const recentActivity = ref([])
const stats = ref(null)
const loading = ref(false)
const total = ref(0)
@@ -13,9 +14,9 @@ export const useDownloadsStore = defineStore('downloads', () => {
async function fetchDownloads(params = {}) {
loading.value = true
try {
// Use provided per_page or default to 1000 for client-side pagination
const response = await downloadsApi.list({
page: currentPage.value,
per_page: perPage.value,
per_page: params.per_page || 1000,
...params,
})
downloads.value = response.data.items
@@ -26,6 +27,12 @@ export const useDownloadsStore = defineStore('downloads', () => {
}
}
async function fetchRecentActivity(params = {}) {
const response = await downloadsApi.recentActivity(params)
recentActivity.value = response.data.items
return response.data
}
async function fetchStats(params = {}) {
const response = await downloadsApi.stats(params)
stats.value = response.data
@@ -38,12 +45,14 @@ export const useDownloadsStore = defineStore('downloads', () => {
return {
downloads,
recentActivity,
stats,
loading,
total,
currentPage,
perPage,
fetchDownloads,
fetchRecentActivity,
fetchStats,
retryDownload,
}
+2 -2
View File
@@ -15,9 +15,9 @@ export const useSubscriptionsStore = defineStore('subscriptions', () => {
async function fetchSubscriptions(params = {}) {
loading.value = true
try {
// Fetch all items for client-side pagination in v-data-table
const response = await subscriptionsApi.list({
page: currentPage.value,
per_page: perPage.value,
per_page: 1000,
...params,
})
subscriptions.value = response.data.items
+208 -45
View File
@@ -59,45 +59,101 @@
</v-col>
</v-row>
<!-- Quick Actions -->
<v-row class="mt-4">
<!-- Downloads by Platform -->
<v-col cols="12" md="6">
<v-col cols="12">
<v-card>
<v-card-title>Downloads by Platform</v-card-title>
<v-card-text>
<v-list v-if="stats?.by_platform">
<v-list-item
v-for="(count, platform) in stats.by_platform"
:key="platform"
<v-card-text class="d-flex align-center ga-4 flex-wrap">
<v-btn
color="primary"
prepend-icon="mdi-refresh"
:loading="checkingAll"
@click="checkAllSources"
>
<template v-slot:prepend>
<v-icon :color="getPlatformColor(platform)">
{{ getPlatformIcon(platform) }}
Check All Sources
</v-btn>
<v-btn
color="warning"
prepend-icon="mdi-replay"
:loading="retryingAll"
:disabled="!failedCount"
@click="retryAllFailed"
>
Retry All Failed ({{ failedCount }})
</v-btn>
<v-spacer />
<div class="text-body-2 text-grey">
<v-icon size="small" class="mr-1">mdi-harddisk</v-icon>
Storage: {{ storageStats?.total_size_formatted || 'Calculating...' }}
<span v-if="storageStats?.file_count" class="ml-2">
({{ storageStats.file_count.toLocaleString() }} files)
</span>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<v-row class="mt-4">
<!-- Running Downloads (auto-refresh) -->
<v-col cols="12" md="4">
<v-card>
<v-card-title class="d-flex align-center">
<v-icon class="mr-2" :class="{ 'mdi-spin': runningDownloads.length }">
{{ runningDownloads.length ? 'mdi-loading' : 'mdi-download' }}
</v-icon>
</template>
<v-list-item-title class="text-capitalize">
{{ platform }}
Running
<v-chip v-if="runningDownloads.length" size="small" color="info" class="ml-2">
{{ runningDownloads.length }}
</v-chip>
<v-spacer />
<v-btn
v-if="runningDownloads.length"
icon
size="x-small"
variant="text"
@click="refreshRunning"
>
<v-icon>mdi-refresh</v-icon>
</v-btn>
</v-card-title>
<v-card-text>
<v-list v-if="runningDownloads.length" density="compact">
<v-list-item v-for="dl in runningDownloads" :key="dl.id">
<v-list-item-title class="text-body-2">
{{ truncate(dl.url, 30) }}
</v-list-item-title>
<template v-slot:append>
<v-chip size="small">{{ count }}</v-chip>
</template>
<v-list-item-subtitle>
<v-progress-linear
indeterminate
color="info"
height="4"
class="mt-1"
/>
</v-list-item-subtitle>
</v-list-item>
</v-list>
<div v-else class="text-center text-grey py-4">
No download data yet
<v-icon size="32" class="mb-2">mdi-check-circle-outline</v-icon>
<div>No active downloads</div>
</div>
</v-card-text>
</v-card>
</v-col>
<!-- Recent Activity -->
<v-col cols="12" md="6">
<v-col cols="12" md="8">
<v-card>
<v-card-title>Recent Downloads</v-card-title>
<v-card-title>
Recent Activity
<v-chip v-if="recentActivity.length" size="small" class="ml-2" variant="tonal">
Last 7 days
</v-chip>
</v-card-title>
<v-card-text>
<v-list v-if="recentDownloads.length">
<v-list v-if="recentActivity.length">
<v-list-item
v-for="download in recentDownloads"
v-for="download in recentActivity"
:key="download.id"
>
<template v-slot:prepend>
@@ -109,7 +165,10 @@
{{ truncate(download.url, 40) }}
</v-list-item-title>
<v-list-item-subtitle>
{{ formatDate(download.created_at) }}
{{ formatRelativeTime(download.created_at) }}
<span v-if="download.file_count > 0" class="text-success ml-2">
{{ download.file_count }} new file{{ download.file_count !== 1 ? 's' : '' }}
</span>
</v-list-item-subtitle>
<template v-slot:append>
<v-chip
@@ -117,13 +176,13 @@
size="small"
variant="tonal"
>
{{ download.status }}
{{ download.status === 'completed' ? 'new content' : download.status }}
</v-chip>
</template>
</v-list-item>
</v-list>
<div v-else class="text-center text-grey py-4">
No recent downloads
No recent activity (failures or new downloads)
</div>
</v-card-text>
<v-card-actions>
@@ -196,47 +255,135 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useSourcesStore } from '../stores/sources'
import { useDownloadsStore } from '../stores/downloads'
import { useNotificationStore } from '../stores/notifications'
import { settingsApi } from '../services/api'
const sourcesStore = useSourcesStore()
const downloadsStore = useDownloadsStore()
const notifications = useNotificationStore()
// State
const checkingAll = ref(false)
const retryingAll = ref(false)
const storageStats = ref(null)
const runningDownloads = ref([])
let refreshInterval = null
// Computed
const stats = computed(() => downloadsStore.stats)
const recentDownloads = computed(() => downloadsStore.downloads.slice(0, 5))
const recentActivity = computed(() => downloadsStore.recentActivity)
const sourcesWithErrors = computed(() =>
sourcesStore.sources.filter(s => s.error_count > 0).slice(0, 5)
)
const failedCount = computed(() => stats.value?.failed || 0)
onMounted(async () => {
await loadDashboardData()
// Start auto-refresh for running downloads
startAutoRefresh()
})
onUnmounted(() => {
stopAutoRefresh()
})
async function loadDashboardData() {
await Promise.all([
sourcesStore.fetchSources(),
downloadsStore.fetchStats(),
downloadsStore.fetchDownloads({ per_page: 5 }),
downloadsStore.fetchRecentActivity({ limit: 10 }),
fetchRunningDownloads(),
fetchStorageStats(),
])
})
function getPlatformIcon(platform) {
const icons = {
patreon: 'mdi-patreon',
subscribestar: 'mdi-star',
hentaifoundry: 'mdi-palette',
discord: 'mdi-discord',
}
return icons[platform] || 'mdi-web'
}
function getPlatformColor(platform) {
const colors = {
patreon: '#FF424D',
subscribestar: '#FFD700',
hentaifoundry: '#9C27B0',
discord: '#5865F2',
async function fetchRunningDownloads() {
try {
const response = await downloadsStore.fetchDownloads({ status: 'running', per_page: 20 })
runningDownloads.value = response.items.filter(d => d.status === 'running')
} catch (e) {
console.error('Failed to fetch running downloads:', e)
}
}
async function fetchStorageStats() {
try {
const response = await settingsApi.get()
storageStats.value = response.data.storage_stats
} catch (e) {
console.error('Failed to fetch storage stats:', e)
}
}
function startAutoRefresh() {
// Refresh running downloads every 5 seconds when there are active downloads
refreshInterval = setInterval(async () => {
if (runningDownloads.value.length > 0 || stats.value?.pending > 0) {
await fetchRunningDownloads()
await downloadsStore.fetchStats()
await downloadsStore.fetchRecentActivity({ limit: 10 })
}
}, 5000)
}
function stopAutoRefresh() {
if (refreshInterval) {
clearInterval(refreshInterval)
refreshInterval = null
}
}
async function refreshRunning() {
await fetchRunningDownloads()
}
async function checkAllSources() {
checkingAll.value = true
try {
const enabledSources = sourcesStore.sources.filter(s => s.enabled)
let queued = 0
for (const source of enabledSources) {
try {
await sourcesStore.triggerCheck(source.id)
queued++
} catch (e) {
console.error(`Failed to queue ${source.id}:`, e)
}
}
notifications.success(`Queued checks for ${queued} sources`)
await fetchRunningDownloads()
} catch (error) {
notifications.error(`Failed to check sources: ${error.message}`)
} finally {
checkingAll.value = false
}
}
async function retryAllFailed() {
retryingAll.value = true
try {
const response = await downloadsStore.fetchDownloads({ status: 'failed', per_page: 100 })
const failed = response.items
let retried = 0
for (const dl of failed) {
try {
await downloadsStore.retryDownload(dl.id)
retried++
} catch (e) {
console.error(`Failed to retry ${dl.id}:`, e)
}
}
notifications.success(`Queued retry for ${retried} downloads`)
await downloadsStore.fetchStats()
await fetchRunningDownloads()
} catch (error) {
notifications.error(`Failed to retry downloads: ${error.message}`)
} finally {
retryingAll.value = false
}
return colors[platform] || 'grey'
}
function getStatusIcon(status) {
@@ -266,6 +413,22 @@ function formatDate(dateStr) {
return new Date(dateStr).toLocaleString()
}
function formatRelativeTime(dateStr) {
if (!dateStr) return 'Unknown'
const date = new Date(dateStr)
const now = new Date()
const diffMs = now - date
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffMins < 1) return 'Just now'
if (diffMins < 60) return `${diffMins} min${diffMins !== 1 ? 's' : ''} ago`
if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`
if (diffDays < 7) return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`
return date.toLocaleDateString()
}
function truncate(str, length) {
if (!str) return ''
if (str.length <= length) return str
+149
View File
@@ -0,0 +1,149 @@
<template>
<div>
<v-row class="mb-4">
<v-col>
<v-btn
color="primary"
prepend-icon="mdi-refresh"
@click="loadLogs"
:loading="loading"
>
Refresh
</v-btn>
</v-col>
<v-col cols="auto">
<v-select
v-model="filterStatus"
:items="statusOptions"
label="Status"
density="compact"
variant="outlined"
clearable
style="min-width: 140px"
/>
</v-col>
</v-row>
<v-card>
<v-card-title>Recent Download Logs</v-card-title>
<v-card-text>
<div v-if="loading" class="text-center py-8">
<v-progress-circular indeterminate />
</div>
<v-expansion-panels v-else-if="filteredLogs.length">
<v-expansion-panel
v-for="log in filteredLogs"
:key="log.id"
>
<v-expansion-panel-title>
<div class="d-flex align-center ga-2" style="width: 100%">
<v-chip
:color="getStatusColor(log.status)"
size="small"
variant="tonal"
>
{{ log.status }}
</v-chip>
<span class="text-truncate" style="max-width: 400px">
{{ log.url }}
</span>
<v-spacer />
<span class="text-caption text-grey">
{{ formatDate(log.created_at) }}
</span>
</div>
</v-expansion-panel-title>
<v-expansion-panel-text>
<div v-if="log.error_message" class="mb-4">
<div class="text-subtitle-2 text-error">Error</div>
<v-code class="d-block pa-2 bg-error-lighten-5">{{ log.error_message }}</v-code>
</div>
<div v-if="log.stdout" class="mb-4">
<div class="text-subtitle-2">Output</div>
<pre class="log-output bg-grey-lighten-4 pa-2 rounded">{{ log.stdout }}</pre>
</div>
<div v-if="log.stderr">
<div class="text-subtitle-2 text-warning">Errors/Warnings</div>
<pre class="log-output bg-warning-lighten-5 pa-2 rounded">{{ log.stderr }}</pre>
</div>
<div v-if="!log.stdout && !log.stderr && !log.error_message" class="text-grey">
No log output available
</div>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
<div v-else class="text-center text-grey py-8">
<v-icon size="48" class="mb-2">mdi-text-box-outline</v-icon>
<div>No logs available</div>
</div>
</v-card-text>
</v-card>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { settingsApi } from '../services/api'
const loading = ref(false)
const logs = ref([])
const filterStatus = ref(null)
const statusOptions = [
{ title: 'Completed', value: 'completed' },
{ title: 'Failed', value: 'failed' },
{ title: 'Running', value: 'running' },
]
const filteredLogs = computed(() => {
if (!filterStatus.value) return logs.value
return logs.value.filter(l => l.status === filterStatus.value)
})
onMounted(() => {
loadLogs()
})
async function loadLogs() {
loading.value = true
try {
const response = await settingsApi.getLogs({ limit: 100 })
logs.value = response.data.logs
} catch (error) {
console.error('Failed to load logs:', error)
} finally {
loading.value = false
}
}
function getStatusColor(status) {
const colors = {
completed: 'success',
failed: 'error',
running: 'info',
pending: 'warning',
}
return colors[status] || 'grey'
}
function formatDate(dateStr) {
if (!dateStr) return ''
return new Date(dateStr).toLocaleString()
}
</script>
<style scoped>
.log-output {
font-family: monospace;
font-size: 12px;
white-space: pre-wrap;
word-break: break-all;
max-height: 400px;
overflow: auto;
}
</style>
+185 -1
View File
@@ -5,6 +5,29 @@
<v-btn color="primary" @click="showAddDialog = true" prepend-icon="mdi-plus">
Add Subscription
</v-btn>
<v-btn
class="ml-2"
variant="outlined"
prepend-icon="mdi-download"
@click="exportSubscriptions"
>
Export
</v-btn>
<v-btn
class="ml-2"
variant="outlined"
prepend-icon="mdi-upload"
@click="triggerImport"
>
Import
</v-btn>
<input
ref="importInput"
type="file"
accept=".json"
style="display: none"
@change="handleImport"
/>
</v-col>
<v-col cols="auto">
<v-text-field
@@ -30,14 +53,32 @@
</v-col>
</v-row>
<!-- Bulk Actions Bar -->
<v-row v-if="selectedIds.length > 0" class="mb-2">
<v-col>
<v-card color="primary" variant="tonal" class="pa-2">
<div class="d-flex align-center ga-2">
<span>{{ selectedIds.length }} selected</span>
<v-btn size="small" variant="text" @click="bulkEnable(true)">Enable All</v-btn>
<v-btn size="small" variant="text" @click="bulkEnable(false)">Disable All</v-btn>
<v-btn size="small" variant="text" color="error" @click="bulkDelete">Delete All</v-btn>
<v-spacer />
<v-btn size="small" variant="text" @click="selectedIds = []">Clear Selection</v-btn>
</div>
</v-card>
</v-col>
</v-row>
<v-card>
<v-data-table
v-model="selectedIds"
:headers="headers"
:items="subscriptionsStore.subscriptions"
:loading="subscriptionsStore.loading"
:items-per-page="20"
v-model:expanded="expandedIds"
item-value="id"
show-select
class="elevation-1"
>
<template v-slot:item.name="{ item }">
@@ -135,6 +176,12 @@
<v-icon start size="small">{{ getPlatformIcon(source.platform) }}</v-icon>
{{ source.platform }}
</v-chip>
<v-tooltip v-if="!credentialsStore.hasPlatformCredentials(source.platform)" location="top">
<template v-slot:activator="{ props }">
<v-icon v-bind="props" color="warning" size="small" class="ml-1">mdi-alert</v-icon>
</template>
No credentials for {{ source.platform }}. Add them in Settings.
</v-tooltip>
</td>
<td class="text-truncate" style="max-width: 300px">
<a :href="source.url" target="_blank" @click.stop>{{ source.url }}</a>
@@ -148,7 +195,12 @@
@update:model-value="toggleSourceEnabled(source)"
/>
</td>
<td>{{ formatDate(source.last_check) }}</td>
<td>
<div>{{ formatDate(source.last_check) }}</div>
<div v-if="source.enabled && source.last_check" class="text-caption text-grey">
Next: {{ formatNextCheck(source) }}
</div>
</td>
<td>
<v-chip v-if="source.error_count > 0" color="error" size="x-small">
{{ source.error_count }}
@@ -273,15 +325,19 @@
<script setup>
import { ref, watch, onMounted } from 'vue'
import { useSubscriptionsStore } from '../stores/subscriptions'
import { useCredentialsStore } from '../stores/credentials'
import { useNotificationStore } from '../stores/notifications'
const subscriptionsStore = useSubscriptionsStore()
const credentialsStore = useCredentialsStore()
const notifications = useNotificationStore()
const searchQuery = ref('')
const filterEnabled = ref(null)
const expandedIds = ref([])
const checkingIds = ref([])
const selectedIds = ref([])
const importInput = ref(null)
// Subscription dialog
const showAddDialog = ref(false)
@@ -330,6 +386,7 @@ const headers = [
onMounted(() => {
loadSubscriptions()
credentialsStore.fetchCredentials()
})
watch([searchQuery, filterEnabled], () => {
@@ -377,6 +434,23 @@ function formatDate(dateStr) {
return new Date(dateStr).toLocaleString()
}
function formatNextCheck(source) {
if (!source.last_check || !source.check_interval) return 'Unknown'
const lastCheck = new Date(source.last_check)
const nextCheck = new Date(lastCheck.getTime() + source.check_interval * 1000)
const now = new Date()
if (nextCheck <= now) return 'Due now'
const diffMs = nextCheck - now
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
if (diffMins < 60) return `in ${diffMins}m`
if (diffHours < 24) return `in ${diffHours}h`
return nextCheck.toLocaleString()
}
async function toggleEnabled(subscription) {
try {
await subscriptionsStore.updateSubscription(subscription.id, { enabled: !subscription.enabled })
@@ -493,6 +567,116 @@ async function deleteSource() {
notifications.error(`Failed to delete: ${error.message}`)
}
}
// Bulk actions
async function bulkEnable(enabled) {
try {
for (const id of selectedIds.value) {
await subscriptionsStore.updateSubscription(id, { enabled })
}
notifications.success(`${selectedIds.value.length} subscriptions ${enabled ? 'enabled' : 'disabled'}`)
selectedIds.value = []
loadSubscriptions()
} catch (error) {
notifications.error(`Failed to update: ${error.message}`)
}
}
async function bulkDelete() {
if (!confirm(`Delete ${selectedIds.value.length} subscriptions? This cannot be undone.`)) return
try {
for (const id of selectedIds.value) {
await subscriptionsStore.deleteSubscription(id)
}
notifications.success(`${selectedIds.value.length} subscriptions deleted`)
selectedIds.value = []
loadSubscriptions()
} catch (error) {
notifications.error(`Failed to delete: ${error.message}`)
}
}
// Import/Export
function exportSubscriptions() {
const data = {
version: 1,
exported_at: new Date().toISOString(),
subscriptions: subscriptionsStore.subscriptions.map(sub => ({
name: sub.name,
description: sub.description,
priority: sub.priority,
enabled: sub.enabled,
sources: sub.sources?.map(s => ({
platform: s.platform,
url: s.url,
enabled: s.enabled,
check_interval: s.check_interval,
})) || [],
})),
}
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `subscriptions-${new Date().toISOString().split('T')[0]}.json`
a.click()
URL.revokeObjectURL(url)
notifications.success(`Exported ${data.subscriptions.length} subscriptions`)
}
function triggerImport() {
importInput.value.click()
}
async function handleImport(event) {
const file = event.target.files[0]
if (!file) return
try {
const text = await file.text()
const data = JSON.parse(text)
if (!data.subscriptions || !Array.isArray(data.subscriptions)) {
throw new Error('Invalid format: missing subscriptions array')
}
let imported = 0
for (const sub of data.subscriptions) {
try {
// Create subscription
const created = await subscriptionsStore.createSubscription({
name: sub.name,
description: sub.description,
priority: sub.priority || 0,
enabled: sub.enabled !== false,
})
// Add sources
if (sub.sources && created.id) {
for (const source of sub.sources) {
await subscriptionsStore.addSource(created.id, {
platform: source.platform,
url: source.url,
enabled: source.enabled !== false,
check_interval: source.check_interval,
})
}
}
imported++
} catch (e) {
console.error(`Failed to import ${sub.name}:`, e)
}
}
notifications.success(`Imported ${imported} of ${data.subscriptions.length} subscriptions`)
loadSubscriptions()
} catch (error) {
notifications.error(`Import failed: ${error.message}`)
}
// Reset input
event.target.value = ''
}
</script>
<style scoped>