rapid interations of server side app and firefox extension
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(docker logs:*)",
|
||||
"Bash(docker-compose up:*)",
|
||||
"Bash(docker-compose exec:*)",
|
||||
"Bash(docker compose logs:*)",
|
||||
"Bash(docker compose exec celery sh -c \"grep -i session /data/cookies/patreon_cookies.txt || echo ''No session cookie found''\")",
|
||||
"Bash(docker compose exec celery sh -c \"gallery-dl --cookies /data/cookies/patreon_cookies.txt -v ''https://www.patreon.com/knuxy'' 2>&1 | head -20\")",
|
||||
"Bash(docker compose exec:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
# ===========================================
|
||||
# Required - You MUST change these values
|
||||
# ===========================================
|
||||
|
||||
# Database password for PostgreSQL
|
||||
DB_PASSWORD=change_me_to_secure_password
|
||||
|
||||
# Application secret key (use: openssl rand -hex 32)
|
||||
SECRET_KEY=change_me_to_random_string_at_least_32_characters
|
||||
|
||||
# ===========================================
|
||||
# Optional - These have sensible defaults
|
||||
# ===========================================
|
||||
|
||||
# External port to access the application
|
||||
PORT=8080
|
||||
|
||||
# Download settings
|
||||
DOWNLOAD_PARALLEL_LIMIT=3
|
||||
DOWNLOAD_RATE_LIMIT=3.0
|
||||
DEFAULT_CHECK_INTERVAL=3600
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=INFO
|
||||
DEBUG=false
|
||||
|
||||
# ===========================================
|
||||
# Advanced - Only change if you know what you're doing
|
||||
# ===========================================
|
||||
|
||||
# Paths inside container (mapped to Docker volumes)
|
||||
DOWNLOAD_PATH=/data/downloads
|
||||
CONFIG_PATH=/data/config
|
||||
COOKIES_PATH=/data/cookies
|
||||
|
||||
# These are constructed by docker-compose, only set for local development
|
||||
# DATABASE_URL=postgresql://gdl:password@localhost:5432/gallery_subscriber
|
||||
# REDIS_URL=redis://localhost:6379/0
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
venv/
|
||||
.venv/
|
||||
ENV/
|
||||
env/
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# Node / Frontend
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
frontend/node_modules/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.npm
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.project
|
||||
.classpath
|
||||
.settings/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
|
||||
# Data directories (created at runtime)
|
||||
data/
|
||||
*.db
|
||||
*.sqlite3
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Secrets and credentials
|
||||
*.pem
|
||||
*.key
|
||||
cookies/
|
||||
config/
|
||||
|
||||
# Build artifacts
|
||||
backend/static/
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
# Stage 1: Build frontend
|
||||
FROM node:20-alpine AS frontend-build
|
||||
|
||||
WORKDIR /frontend
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm install
|
||||
COPY frontend/ .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Python backend with frontend static files
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python dependencies
|
||||
COPY backend/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy backend application
|
||||
COPY backend/ .
|
||||
|
||||
# Copy built frontend from Stage 1
|
||||
COPY --from=frontend-build /frontend/dist /app/static
|
||||
|
||||
# Create data directories
|
||||
RUN mkdir -p /data/downloads /data/config /data/cookies
|
||||
|
||||
# Run as non-root user
|
||||
RUN useradd -m -u 1000 appuser && \
|
||||
chown -R appuser:appuser /app /data
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["hypercorn", "app.main:app", "--bind", "0.0.0.0:8080"]
|
||||
+548
@@ -0,0 +1,548 @@
|
||||
# Gallery Subscriber
|
||||
|
||||
A Docker-based web application for managing gallery-dl downloads with a modern web interface.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Project Overview](#project-overview)
|
||||
2. [Architecture](#architecture)
|
||||
3. [Technology Stack](#technology-stack)
|
||||
4. [Project Structure](#project-structure)
|
||||
5. [Database Schema](#database-schema)
|
||||
6. [API Specification](#api-specification)
|
||||
7. [Firefox Extension](#firefox-extension)
|
||||
8. [Configuration](#configuration)
|
||||
9. [Development Phases](#development-phases)
|
||||
10. [Migration from Legacy Project](#migration-from-legacy-project)
|
||||
|
||||
---
|
||||
|
||||
## Project Overview
|
||||
|
||||
### Goals
|
||||
|
||||
- **Containerized Deployment**: Run the entire download manager in Docker for portability
|
||||
- **Web Interface**: Modern UI for managing subscriptions, viewing downloads, and monitoring new content
|
||||
- **Flexible Authentication**: Firefox extension for cookie export OR manual cookie upload via web UI
|
||||
- **Notifications**: Real-time updates when new content is downloaded
|
||||
- **Reliability**: Maintain the robust scheduling and error handling from the original project
|
||||
|
||||
### Non-Goals (Initial Release)
|
||||
|
||||
- Multi-user support (single-user initially)
|
||||
- Mobile app
|
||||
- SSL termination (handled by external reverse proxy)
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ External Reverse Proxy │
|
||||
│ (Traefik/Caddy/nginx - handles SSL) │
|
||||
└──────────────────────────┬──────────────────────────────────────┘
|
||||
│ HTTP (port 8080)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Docker Compose Stack │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ Quart Backend │ │
|
||||
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────┐ │ │
|
||||
│ │ │ REST API │ │WebSocket │ │ Static │ │ gallery-dl│ │ │
|
||||
│ │ │ /api/* │ │ /ws/* │ │ Files │ │ Wrapper │ │ │
|
||||
│ │ └──────────┘ └──────────┘ └──────────┘ └───────────┘ │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────────────────────┴───────────────────────────────┐ │
|
||||
│ │ Celery Worker │ │
|
||||
│ │ • Download tasks • Scheduled checks │ │
|
||||
│ │ • Manifest generation • Error retry logic │ │
|
||||
│ └───────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────┐ ┌─────────┴───────┐ ┌──────────────────┐ │
|
||||
│ │ PostgreSQL │ │ Redis │ │ Vue Frontend │ │
|
||||
│ │ (data) │ │ (task queue) │ │ (static build) │ │
|
||||
│ └──────────────┘ └─────────────────┘ └──────────────────┘ │
|
||||
│ │
|
||||
│ Volumes: │
|
||||
│ ├── /data/downloads (downloaded content) │
|
||||
│ ├── /data/config (gallery-dl.conf) │
|
||||
│ ├── /data/cookies (cookie files) │
|
||||
│ └── /data/db (PostgreSQL data) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Technology Stack
|
||||
|
||||
### Backend
|
||||
| Component | Technology | Rationale |
|
||||
|-----------|------------|-----------|
|
||||
| Web Framework | **Quart** | Flask-like API with native async, WebSocket support |
|
||||
| Task Queue | **Celery + Redis** | Reliable background tasks, scheduling with Celery Beat |
|
||||
| Database | **PostgreSQL** | JSON fields for metadata, robust concurrency |
|
||||
| ORM | **SQLAlchemy 2.0** | Async support, mature ecosystem |
|
||||
| Migrations | **Alembic** | Standard for SQLAlchemy |
|
||||
|
||||
### Frontend
|
||||
| Component | Technology | Rationale |
|
||||
|-----------|------------|-----------|
|
||||
| Framework | **Vue 3** | Reactive, component-based |
|
||||
| UI Library | **Vuetify 3** | Material Design, comprehensive components |
|
||||
| State | **Pinia** | Official Vue state management |
|
||||
| HTTP Client | **Axios** | Request interceptors for auth |
|
||||
| WebSocket | **Native** | Real-time updates |
|
||||
|
||||
### Infrastructure
|
||||
| Component | Technology | Rationale |
|
||||
|-----------|------------|-----------|
|
||||
| Container | **Docker Compose** | Easy deployment |
|
||||
| Process Manager | **Supervisor** | Manage Quart + Celery in container |
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
gallery-subscriber/
|
||||
├── docker-compose.yml
|
||||
├── docker-compose.dev.yml
|
||||
├── .env.example
|
||||
├── README.md
|
||||
│
|
||||
├── backend/
|
||||
│ ├── Dockerfile
|
||||
│ ├── requirements.txt
|
||||
│ ├── supervisord.conf
|
||||
│ │
|
||||
│ ├── app/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── main.py # Quart app factory
|
||||
│ │ ├── config.py # Settings (from env)
|
||||
│ │ │
|
||||
│ │ ├── api/
|
||||
│ │ │ ├── __init__.py
|
||||
│ │ │ ├── sources.py # Source CRUD
|
||||
│ │ │ ├── downloads.py # Download history
|
||||
│ │ │ ├── credentials.py # Cookie management
|
||||
│ │ │ ├── settings.py # App settings
|
||||
│ │ │ └── websocket.py # Real-time events
|
||||
│ │ │
|
||||
│ │ ├── models/
|
||||
│ │ │ ├── __init__.py
|
||||
│ │ │ ├── source.py
|
||||
│ │ │ ├── download.py
|
||||
│ │ │ ├── credential.py
|
||||
│ │ │ └── content.py
|
||||
│ │ │
|
||||
│ │ ├── services/
|
||||
│ │ │ ├── __init__.py
|
||||
│ │ │ ├── gallery_dl.py # gallery-dl subprocess wrapper
|
||||
│ │ │ ├── download_manager.py
|
||||
│ │ │ ├── credential_manager.py
|
||||
│ │ │ └── manifest.py # Manifest generation
|
||||
│ │ │
|
||||
│ │ ├── tasks/
|
||||
│ │ │ ├── __init__.py
|
||||
│ │ │ ├── celery_app.py # Celery configuration
|
||||
│ │ │ ├── downloads.py # Download tasks
|
||||
│ │ │ └── scheduler.py # Scheduled jobs
|
||||
│ │ │
|
||||
│ │ └── utils/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── cookies.py # Cookie parsing
|
||||
│ │ └── encryption.py # Credential encryption
|
||||
│ │
|
||||
│ ├── alembic/
|
||||
│ │ ├── env.py
|
||||
│ │ └── versions/
|
||||
│ │
|
||||
│ └── tests/
|
||||
│
|
||||
├── frontend/
|
||||
│ ├── Dockerfile
|
||||
│ ├── package.json
|
||||
│ ├── vite.config.js
|
||||
│ │
|
||||
│ └── src/
|
||||
│ ├── main.js
|
||||
│ ├── App.vue
|
||||
│ ├── router/
|
||||
│ ├── stores/
|
||||
│ ├── views/
|
||||
│ │ ├── Dashboard.vue
|
||||
│ │ ├── Sources.vue
|
||||
│ │ ├── Downloads.vue
|
||||
│ │ ├── NewContent.vue
|
||||
│ │ └── Settings.vue
|
||||
│ ├── components/
|
||||
│ └── services/
|
||||
│ └── api.js
|
||||
│
|
||||
├── extension/
|
||||
│ ├── manifest.json
|
||||
│ ├── popup/
|
||||
│ │ ├── popup.html
|
||||
│ │ ├── popup.css
|
||||
│ │ └── popup.js
|
||||
│ ├── background/
|
||||
│ │ └── background.js
|
||||
│ └── icons/
|
||||
│
|
||||
└── scripts/
|
||||
├── migrate_legacy.py # Import from old project
|
||||
└── init_db.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
|
||||
```sql
|
||||
-- Sources (artists/creators to download from)
|
||||
CREATE TABLE sources (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
platform VARCHAR(50) NOT NULL, -- patreon, subscribestar, hentaifoundry, discord
|
||||
url TEXT NOT NULL,
|
||||
enabled BOOLEAN DEFAULT true,
|
||||
priority INTEGER DEFAULT 0,
|
||||
check_interval INTEGER DEFAULT 3600, -- seconds between checks
|
||||
last_check TIMESTAMP,
|
||||
last_success TIMESTAMP,
|
||||
error_count INTEGER DEFAULT 0,
|
||||
metadata JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(platform, url)
|
||||
);
|
||||
|
||||
-- Downloads (individual download jobs)
|
||||
CREATE TABLE downloads (
|
||||
id SERIAL PRIMARY KEY,
|
||||
source_id INTEGER REFERENCES sources(id) ON DELETE SET NULL,
|
||||
url TEXT NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'pending', -- pending, running, completed, failed, skipped
|
||||
error_type VARCHAR(50), -- auth_error, rate_limited, not_found, etc.
|
||||
error_message TEXT,
|
||||
file_count INTEGER DEFAULT 0,
|
||||
started_at TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
metadata JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_downloads_status ON downloads(status);
|
||||
CREATE INDEX idx_downloads_source ON downloads(source_id);
|
||||
|
||||
-- Content Items (discovered files)
|
||||
CREATE TABLE content_items (
|
||||
id SERIAL PRIMARY KEY,
|
||||
source_id INTEGER REFERENCES sources(id) ON DELETE CASCADE,
|
||||
download_id INTEGER REFERENCES downloads(id) ON DELETE SET NULL,
|
||||
external_id VARCHAR(255), -- Platform-specific ID
|
||||
title VARCHAR(500),
|
||||
content_type VARCHAR(50), -- image, video, attachment
|
||||
file_path VARCHAR(500),
|
||||
file_size BIGINT,
|
||||
thumbnail_path VARCHAR(500),
|
||||
published_at TIMESTAMP,
|
||||
discovered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
metadata JSONB DEFAULT '{}',
|
||||
UNIQUE(source_id, external_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_content_discovered ON content_items(discovered_at DESC);
|
||||
|
||||
-- Credentials (encrypted cookie/token storage)
|
||||
CREATE TABLE credentials (
|
||||
id SERIAL PRIMARY KEY,
|
||||
platform VARCHAR(50) NOT NULL UNIQUE,
|
||||
credential_type VARCHAR(20) NOT NULL, -- cookies, token
|
||||
data BYTEA NOT NULL, -- Encrypted
|
||||
expires_at TIMESTAMP,
|
||||
last_verified TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Settings (key-value store)
|
||||
CREATE TABLE settings (
|
||||
key VARCHAR(100) PRIMARY KEY,
|
||||
value JSONB NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Specification
|
||||
|
||||
### Sources
|
||||
|
||||
```
|
||||
GET /api/sources List all sources (with pagination)
|
||||
POST /api/sources Add new source
|
||||
GET /api/sources/:id Get source details
|
||||
PATCH /api/sources/:id Update source
|
||||
DELETE /api/sources/:id Delete source
|
||||
POST /api/sources/:id/check Trigger immediate download check
|
||||
```
|
||||
|
||||
### Downloads
|
||||
|
||||
```
|
||||
GET /api/downloads List download history
|
||||
GET /api/downloads/:id Get download details with logs
|
||||
POST /api/downloads/:id/retry Retry failed download
|
||||
GET /api/downloads/stats Get statistics (by platform, by day)
|
||||
```
|
||||
|
||||
### Content
|
||||
|
||||
```
|
||||
GET /api/content List content items (filterable)
|
||||
GET /api/content/new Get new content since timestamp
|
||||
```
|
||||
|
||||
### Credentials
|
||||
|
||||
```
|
||||
GET /api/credentials List credential status (no secrets)
|
||||
POST /api/credentials Upload cookies (manual or from extension)
|
||||
DELETE /api/credentials/:platform Remove credentials for platform
|
||||
POST /api/credentials/verify Verify credentials are working
|
||||
```
|
||||
|
||||
### Settings
|
||||
|
||||
```
|
||||
GET /api/settings Get all settings
|
||||
PATCH /api/settings Update settings
|
||||
GET /api/settings/gallery-dl Get gallery-dl config
|
||||
PUT /api/settings/gallery-dl Update gallery-dl config
|
||||
```
|
||||
|
||||
### WebSocket
|
||||
|
||||
```
|
||||
WS /ws/events
|
||||
|
||||
Events (server -> client):
|
||||
download.started { download_id, source_id, url }
|
||||
download.progress { download_id, file_count }
|
||||
download.completed { download_id, file_count }
|
||||
download.failed { download_id, error_type, error_message }
|
||||
content.new { content_id, source_name, title, thumbnail }
|
||||
credential.expiring { platform, expires_in_hours }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Firefox Extension
|
||||
|
||||
### Features
|
||||
|
||||
- Export cookies for supported platforms (Patreon, SubscribeStar, HentaiFoundry)
|
||||
- Export Discord token
|
||||
- Auto-refresh option (re-export when cookies change)
|
||||
- Connection status indicator
|
||||
- Manual export button per platform
|
||||
|
||||
### Supported Platforms
|
||||
|
||||
| Platform | Auth Type | Domains |
|
||||
|----------|-----------|---------|
|
||||
| Patreon | Cookies | `.patreon.com` |
|
||||
| SubscribeStar | Cookies | `.subscribestar.com`, `.subscribestar.adult` |
|
||||
| HentaiFoundry | Cookies | `.hentai-foundry.com` |
|
||||
| Discord | Token | `.discord.com` |
|
||||
|
||||
### Manual Alternative
|
||||
|
||||
For users who don't want the extension:
|
||||
1. Export cookies using browser dev tools or "cookies.txt" extension
|
||||
2. Upload via Settings page in web UI
|
||||
3. Paste cookie content or upload file
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables (.env)
|
||||
|
||||
```bash
|
||||
# Database
|
||||
DATABASE_URL=postgresql://gdl:password@db:5432/gallery_subscriber
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# Security
|
||||
SECRET_KEY=your-secret-key-at-least-32-characters
|
||||
EXTENSION_API_KEY=key-for-firefox-extension
|
||||
|
||||
# Paths (inside container)
|
||||
DOWNLOAD_PATH=/data/downloads
|
||||
CONFIG_PATH=/data/config
|
||||
COOKIES_PATH=/data/cookies
|
||||
|
||||
# Download settings
|
||||
DOWNLOAD_PARALLEL_LIMIT=3
|
||||
DOWNLOAD_RATE_LIMIT=3.0
|
||||
DEFAULT_CHECK_INTERVAL=3600
|
||||
|
||||
# Server
|
||||
HOST=0.0.0.0
|
||||
PORT=8080
|
||||
```
|
||||
|
||||
### docker-compose.yml
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
build: ./backend
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://gdl:${DB_PASSWORD}@db:5432/gallery_subscriber
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- EXTENSION_API_KEY=${EXTENSION_API_KEY}
|
||||
volumes:
|
||||
- downloads:/data/downloads
|
||||
- config:/data/config
|
||||
- cookies:/data/cookies
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
restart: unless-stopped
|
||||
|
||||
celery:
|
||||
build: ./backend
|
||||
command: celery -A app.tasks.celery_app worker -B --loglevel=info
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://gdl:${DB_PASSWORD}@db:5432/gallery_subscriber
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
volumes:
|
||||
- downloads:/data/downloads
|
||||
- config:/data/config
|
||||
- cookies:/data/cookies
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
restart: unless-stopped
|
||||
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
environment:
|
||||
- POSTGRES_USER=gdl
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
- POSTGRES_DB=gallery_subscriber
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
downloads:
|
||||
config:
|
||||
cookies:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development Phases
|
||||
|
||||
### Phase 1: Foundation
|
||||
- [ ] Set up project structure
|
||||
- [ ] Create Docker Compose development environment
|
||||
- [ ] Initialize Quart app with basic routing
|
||||
- [ ] Set up PostgreSQL + Alembic migrations
|
||||
- [ ] Create database models
|
||||
- [ ] Basic API endpoints (sources CRUD)
|
||||
|
||||
### Phase 2: Download Engine
|
||||
- [ ] Port gallery-dl wrapper from legacy project
|
||||
- [ ] Implement Celery tasks for downloads
|
||||
- [ ] Add platform-specific locking (from legacy)
|
||||
- [ ] Error categorization and handling
|
||||
- [ ] Download history tracking
|
||||
|
||||
### Phase 3: Scheduling & State
|
||||
- [ ] Celery Beat for scheduled downloads
|
||||
- [ ] Round-robin scheduling logic (from legacy)
|
||||
- [ ] Priority queue for failed tasks
|
||||
- [ ] State persistence in PostgreSQL
|
||||
|
||||
### Phase 4: Frontend
|
||||
- [ ] Vue 3 + Vuetify scaffold
|
||||
- [ ] Dashboard view
|
||||
- [ ] Sources management UI
|
||||
- [ ] Download history view
|
||||
- [ ] Settings page
|
||||
|
||||
### Phase 5: Authentication
|
||||
- [ ] Credential storage (encrypted)
|
||||
- [ ] Manual cookie upload via web UI
|
||||
- [ ] Firefox extension
|
||||
- [ ] Credential verification
|
||||
|
||||
### Phase 6: Real-time & Content
|
||||
- [ ] WebSocket event streaming
|
||||
- [ ] New content feed
|
||||
- [ ] Content browsing with thumbnails
|
||||
- [ ] Notifications
|
||||
|
||||
### Phase 7: Polish & Migration
|
||||
- [ ] Migration script for legacy sources.yaml
|
||||
- [ ] Import legacy gallery-dl.conf
|
||||
- [ ] Testing
|
||||
- [ ] Documentation
|
||||
|
||||
---
|
||||
|
||||
## Migration from Legacy Project
|
||||
|
||||
### What Gets Migrated
|
||||
|
||||
| Legacy File | Destination |
|
||||
|-------------|-------------|
|
||||
| `sources.yaml` | `sources` table |
|
||||
| `gallery-dl.conf` | `/data/config/gallery-dl.conf` (paths updated) |
|
||||
| `state.json` | `sources` table (last_check, error_count) |
|
||||
| Download history | Optional - start fresh or import logs |
|
||||
|
||||
### Migration Script Usage
|
||||
|
||||
```bash
|
||||
python scripts/migrate_legacy.py \
|
||||
--sources /path/to/sources.yaml \
|
||||
--config /path/to/gallery-dl.conf \
|
||||
--state /path/to/state.json
|
||||
```
|
||||
|
||||
### Key Patterns Preserved from Legacy
|
||||
|
||||
1. **Platform locking** - One download per platform at a time
|
||||
2. **Round-robin scheduling** - Fair distribution across sources
|
||||
3. **Priority queue** - Failed tasks get priority on next run
|
||||
4. **Error categorization** - AUTH_ERROR, RATE_LIMITED, NOT_FOUND, etc.
|
||||
5. **Rate limiting** - Configurable delay between requests
|
||||
|
||||
---
|
||||
|
||||
*Document Version: 2.0*
|
||||
*Created: 2025-01-24*
|
||||
*Project: Gallery Subscriber*
|
||||
@@ -0,0 +1,167 @@
|
||||
# Gallery Subscriber
|
||||
|
||||
A Docker-based web application for managing gallery-dl downloads with a modern web interface.
|
||||
|
||||
## Features
|
||||
|
||||
- **Web Dashboard** - Monitor downloads, view statistics, and manage sources
|
||||
- **Source Management** - Add, edit, and organize download sources from multiple platforms
|
||||
- **Per-Source Configuration** - Customize content types, rate limits, and patterns for each source
|
||||
- **Credential Management** - Securely store cookies/tokens for authenticated platforms
|
||||
- **Scheduled Downloads** - Automatic periodic checks for new content
|
||||
- **Real-time Updates** - WebSocket notifications for download progress
|
||||
- **Supported Platforms**: Patreon, SubscribeStar, Hentai Foundry, Discord
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Copy environment file and configure:**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your settings (especially DB_PASSWORD and SECRET_KEY)
|
||||
```
|
||||
|
||||
2. **Start the services:**
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
3. **Run database migrations:**
|
||||
```bash
|
||||
docker-compose exec app alembic upgrade head
|
||||
```
|
||||
|
||||
4. **Access the application:**
|
||||
- Web UI: http://localhost:8080
|
||||
- API: http://localhost:8080/api
|
||||
- Health check: http://localhost:8080/health
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `DB_PASSWORD` | PostgreSQL password | (required) |
|
||||
| `SECRET_KEY` | App secret key (32+ chars) | (required) |
|
||||
| `EXTENSION_API_KEY` | Firefox extension auth key | (required) |
|
||||
| `PORT` | External port | 8080 |
|
||||
| `DOWNLOAD_PARALLEL_LIMIT` | Max concurrent downloads | 3 |
|
||||
| `DOWNLOAD_RATE_LIMIT` | Delay between downloads (sec) | 3.0 |
|
||||
| `DEFAULT_CHECK_INTERVAL` | Check interval (sec) | 3600 |
|
||||
|
||||
### Settings Page
|
||||
|
||||
All configuration options are available in the web UI under Settings:
|
||||
- Download settings (parallel limit, rate limit, retries)
|
||||
- Notification settings (webhooks)
|
||||
- Raw gallery-dl.conf editor
|
||||
- Platform defaults reference
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Dashboard
|
||||
- Overview of sources and download statistics
|
||||
- Recent downloads with status
|
||||
- Sources needing attention
|
||||
|
||||
### Sources
|
||||
- List all sources with filtering
|
||||
- Enable/disable sources
|
||||
- Trigger manual checks
|
||||
- Configure per-source options
|
||||
|
||||
### Downloads
|
||||
- Full download history
|
||||
- Filter by status, source, date
|
||||
- View detailed logs
|
||||
- Retry failed downloads
|
||||
|
||||
### Credentials
|
||||
- Platform credential status
|
||||
- Upload cookies/tokens
|
||||
- Verify credentials
|
||||
|
||||
### Settings
|
||||
- Application configuration
|
||||
- Gallery-dl config editor
|
||||
- Platform documentation
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Endpoint | Description |
|
||||
|----------|-------------|
|
||||
| `GET /api/sources` | List all sources |
|
||||
| `POST /api/sources` | Create a new source |
|
||||
| `GET /api/sources/:id` | Get source details |
|
||||
| `PATCH /api/sources/:id` | Update a source |
|
||||
| `DELETE /api/sources/:id` | Delete a source |
|
||||
| `POST /api/sources/:id/check` | Trigger download check |
|
||||
| `GET /api/downloads` | List download history |
|
||||
| `GET /api/downloads/stats` | Get download statistics |
|
||||
| `POST /api/downloads/:id/retry` | Retry failed download |
|
||||
| `GET /api/credentials` | List credential status |
|
||||
| `POST /api/credentials` | Upload cookies/tokens |
|
||||
| `DELETE /api/credentials/:platform` | Remove credentials |
|
||||
| `GET /api/settings` | Get application settings |
|
||||
| `PATCH /api/settings` | Update settings |
|
||||
| `GET /api/platforms` | List supported platforms |
|
||||
| `WS /ws/events` | Real-time event stream |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
gallery-subscriber/
|
||||
├── backend/ # Quart API server
|
||||
│ ├── app/
|
||||
│ │ ├── api/ # REST + WebSocket endpoints
|
||||
│ │ ├── models/ # SQLAlchemy database models
|
||||
│ │ ├── services/ # Business logic (gallery-dl wrapper)
|
||||
│ │ ├── tasks/ # Celery background tasks
|
||||
│ │ └── utils/ # Encryption, cookies, etc.
|
||||
│ └── alembic/ # Database migrations
|
||||
├── frontend/ # Vue 3 + Vuetify SPA
|
||||
│ └── src/
|
||||
│ ├── views/ # Page components
|
||||
│ ├── stores/ # Pinia state management
|
||||
│ └── services/ # API client
|
||||
├── extension/ # Firefox extension (planned)
|
||||
├── scripts/ # Migration utilities
|
||||
└── Dockerfile # Multi-stage build (frontend + backend)
|
||||
```
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Backend:** Quart (async Flask) + SQLAlchemy + PostgreSQL
|
||||
- **Task Queue:** Celery + Redis
|
||||
- **Frontend:** Vue 3 + Vuetify 3 + Pinia (compiled to static files, served by Quart)
|
||||
- **Downloader:** gallery-dl
|
||||
|
||||
## Development
|
||||
|
||||
For development with hot-reload:
|
||||
|
||||
```bash
|
||||
docker-compose -f docker-compose.yml -f docker-compose.dev.yml up
|
||||
```
|
||||
|
||||
Frontend development (standalone):
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Migrating from Legacy Project
|
||||
|
||||
If you have an existing Gallery-DL-scripting setup:
|
||||
|
||||
```bash
|
||||
# Coming soon: migration script
|
||||
python scripts/migrate_legacy.py \
|
||||
--sources /path/to/sources.yaml \
|
||||
--config /path/to/gallery-dl.conf
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,24 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application
|
||||
COPY . .
|
||||
|
||||
# Create data directories
|
||||
RUN mkdir -p /data/downloads /data/config /data/cookies
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
# Development mode with reload
|
||||
CMD ["hypercorn", "app.main:app", "--bind", "0.0.0.0:8080", "--reload"]
|
||||
@@ -0,0 +1,42 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Alembic migration environment."""
|
||||
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from alembic import context
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.base import Base
|
||||
from app.models.source import Source
|
||||
from app.models.download import Download
|
||||
from app.models.credential import Credential
|
||||
from app.models.content import ContentItem
|
||||
from app.models.setting import Setting
|
||||
|
||||
config = context.config
|
||||
settings = get_settings()
|
||||
|
||||
# Override sqlalchemy.url with actual database URL
|
||||
config.set_main_option("sqlalchemy.url", settings.async_database_url)
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode."""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
"""Run migrations in 'online' mode with async engine."""
|
||||
connectable = async_engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode."""
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Initial schema
|
||||
|
||||
Revision ID: 001
|
||||
Revises:
|
||||
Create Date: 2025-01-24
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = '001'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Sources table
|
||||
op.create_table(
|
||||
'sources',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('platform', sa.String(50), nullable=False),
|
||||
sa.Column('url', sa.Text(), nullable=False),
|
||||
sa.Column('enabled', sa.Boolean(), default=True),
|
||||
sa.Column('priority', sa.Integer(), default=0),
|
||||
sa.Column('check_interval', sa.Integer(), default=3600),
|
||||
sa.Column('last_check', sa.DateTime(), nullable=True),
|
||||
sa.Column('last_success', sa.DateTime(), nullable=True),
|
||||
sa.Column('error_count', sa.Integer(), default=0),
|
||||
sa.Column('metadata', postgresql.JSONB(), default={}),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('platform', 'url', name='uq_source_platform_url')
|
||||
)
|
||||
op.create_index('ix_sources_platform', 'sources', ['platform'])
|
||||
|
||||
# Downloads table
|
||||
op.create_table(
|
||||
'downloads',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('source_id', sa.Integer(), nullable=True),
|
||||
sa.Column('url', sa.Text(), nullable=False),
|
||||
sa.Column('status', sa.String(20), default='pending'),
|
||||
sa.Column('error_type', sa.String(50), nullable=True),
|
||||
sa.Column('error_message', sa.Text(), nullable=True),
|
||||
sa.Column('file_count', sa.Integer(), default=0),
|
||||
sa.Column('total_size', sa.BigInteger(), nullable=True),
|
||||
sa.Column('started_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('completed_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('metadata', postgresql.JSONB(), default={}),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.ForeignKeyConstraint(['source_id'], ['sources.id'], ondelete='SET NULL')
|
||||
)
|
||||
op.create_index('ix_downloads_status', 'downloads', ['status'])
|
||||
op.create_index('ix_downloads_source_id', 'downloads', ['source_id'])
|
||||
|
||||
# Content items table
|
||||
op.create_table(
|
||||
'content_items',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('source_id', sa.Integer(), nullable=False),
|
||||
sa.Column('download_id', sa.Integer(), nullable=True),
|
||||
sa.Column('external_id', sa.String(255), nullable=True),
|
||||
sa.Column('title', sa.String(500), nullable=True),
|
||||
sa.Column('content_type', sa.String(50), default='other'),
|
||||
sa.Column('file_path', sa.String(500), nullable=True),
|
||||
sa.Column('file_size', sa.BigInteger(), nullable=True),
|
||||
sa.Column('thumbnail_path', sa.String(500), nullable=True),
|
||||
sa.Column('published_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('discovered_at', sa.DateTime(), server_default=sa.func.now()),
|
||||
sa.Column('metadata', postgresql.JSONB(), default={}),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.ForeignKeyConstraint(['source_id'], ['sources.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['download_id'], ['downloads.id'], ondelete='SET NULL'),
|
||||
sa.UniqueConstraint('source_id', 'external_id', name='uq_content_source_external')
|
||||
)
|
||||
op.create_index('ix_content_items_source_id', 'content_items', ['source_id'])
|
||||
op.create_index('ix_content_items_discovered_at', 'content_items', ['discovered_at'])
|
||||
|
||||
# Credentials table
|
||||
op.create_table(
|
||||
'credentials',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('platform', sa.String(50), nullable=False),
|
||||
sa.Column('credential_type', sa.String(20), nullable=False),
|
||||
sa.Column('data', sa.LargeBinary(), nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('last_verified', sa.DateTime(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('platform', name='uq_credential_platform')
|
||||
)
|
||||
op.create_index('ix_credentials_platform', 'credentials', ['platform'])
|
||||
|
||||
# Settings table
|
||||
op.create_table(
|
||||
'settings',
|
||||
sa.Column('key', sa.String(100), nullable=False),
|
||||
sa.Column('value', postgresql.JSONB(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.PrimaryKeyConstraint('key')
|
||||
)
|
||||
|
||||
# Insert default settings
|
||||
op.execute("""
|
||||
INSERT INTO settings (key, value) VALUES
|
||||
('download.parallel_limit', '3'),
|
||||
('download.rate_limit', '3.0'),
|
||||
('download.retry_count', '3'),
|
||||
('download.schedule_interval', '3600'),
|
||||
('notification.enabled', 'false'),
|
||||
('notification.webhook_url', 'null')
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('settings')
|
||||
op.drop_table('credentials')
|
||||
op.drop_table('content_items')
|
||||
op.drop_table('downloads')
|
||||
op.drop_table('sources')
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Add subscriptions table and link sources
|
||||
|
||||
Revision ID: 002
|
||||
Revises: 001
|
||||
Create Date: 2025-01-25
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = '002'
|
||||
down_revision: Union[str, None] = '001'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create subscriptions table
|
||||
op.create_table(
|
||||
'subscriptions',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('enabled', sa.Boolean(), default=True),
|
||||
sa.Column('priority', sa.Integer(), default=0),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('metadata', postgresql.JSONB(), default={}),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('ix_subscriptions_name', 'subscriptions', ['name'], unique=True)
|
||||
|
||||
# Migrate existing sources to subscriptions
|
||||
# Each unique source name becomes a subscription
|
||||
conn = op.get_bind()
|
||||
|
||||
# Get existing sources
|
||||
existing_sources = conn.execute(
|
||||
sa.text("SELECT DISTINCT name FROM sources ORDER BY name")
|
||||
).fetchall()
|
||||
|
||||
# Create subscriptions for each unique source name
|
||||
for (name,) in existing_sources:
|
||||
conn.execute(
|
||||
sa.text("INSERT INTO subscriptions (name, enabled, priority) VALUES (:name, true, 0)"),
|
||||
{"name": name}
|
||||
)
|
||||
|
||||
# Add subscription_id column to sources
|
||||
op.add_column('sources', sa.Column('subscription_id', sa.Integer(), nullable=True))
|
||||
op.create_index('ix_sources_subscription_id', 'sources', ['subscription_id'])
|
||||
|
||||
# Update sources with their subscription_id
|
||||
conn.execute(sa.text("""
|
||||
UPDATE sources s
|
||||
SET subscription_id = sub.id
|
||||
FROM subscriptions sub
|
||||
WHERE s.name = sub.name
|
||||
"""))
|
||||
|
||||
# Make subscription_id NOT NULL after data migration
|
||||
op.alter_column('sources', 'subscription_id', nullable=False)
|
||||
|
||||
# Add foreign key constraint
|
||||
op.create_foreign_key(
|
||||
'fk_sources_subscription_id',
|
||||
'sources', 'subscriptions',
|
||||
['subscription_id'], ['id'],
|
||||
ondelete='CASCADE'
|
||||
)
|
||||
|
||||
# Remove name and priority columns from sources (now on subscription)
|
||||
op.drop_column('sources', 'name')
|
||||
op.drop_column('sources', 'priority')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Add back name and priority columns to sources
|
||||
op.add_column('sources', sa.Column('name', sa.String(255), nullable=True))
|
||||
op.add_column('sources', sa.Column('priority', sa.Integer(), default=0))
|
||||
|
||||
# Populate name from subscription
|
||||
conn = op.get_bind()
|
||||
conn.execute(sa.text("""
|
||||
UPDATE sources s
|
||||
SET name = sub.name, priority = sub.priority
|
||||
FROM subscriptions sub
|
||||
WHERE s.subscription_id = sub.id
|
||||
"""))
|
||||
|
||||
op.alter_column('sources', 'name', nullable=False)
|
||||
|
||||
# Remove foreign key and subscription_id column
|
||||
op.drop_constraint('fk_sources_subscription_id', 'sources', type_='foreignkey')
|
||||
op.drop_index('ix_sources_subscription_id', 'sources')
|
||||
op.drop_column('sources', 'subscription_id')
|
||||
|
||||
# Drop subscriptions table
|
||||
op.drop_index('ix_subscriptions_name', 'subscriptions')
|
||||
op.drop_table('subscriptions')
|
||||
@@ -0,0 +1 @@
|
||||
# Gallery Subscriber Backend
|
||||
@@ -0,0 +1,5 @@
|
||||
"""API blueprints."""
|
||||
|
||||
from app.api import sources, downloads, credentials, settings, platforms, websocket
|
||||
|
||||
__all__ = ["sources", "downloads", "credentials", "settings", "platforms", "websocket"]
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Credential management API endpoints."""
|
||||
|
||||
from datetime import datetime
|
||||
from quart import Blueprint, request, jsonify, current_app
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.credential import Credential, CredentialType
|
||||
from app.models.setting import Setting, EXTENSION_API_KEY_SETTING, generate_api_key
|
||||
from app.config import get_settings
|
||||
from app.utils.encryption import encrypt_data, decrypt_data
|
||||
|
||||
bp = Blueprint("credentials", __name__)
|
||||
|
||||
VALID_PLATFORMS = ["patreon", "subscribestar", "hentaifoundry", "discord"]
|
||||
|
||||
|
||||
@bp.route("", methods=["GET"])
|
||||
async def list_credentials():
|
||||
"""List all credential status (without sensitive data)."""
|
||||
async with current_app.db_engine.connect() as conn:
|
||||
session = AsyncSession(bind=conn)
|
||||
|
||||
result = await session.execute(select(Credential))
|
||||
credentials = result.scalars().all()
|
||||
|
||||
return jsonify({
|
||||
"items": [c.to_dict(include_data=False) for c in credentials]
|
||||
})
|
||||
|
||||
|
||||
@bp.route("", methods=["POST"])
|
||||
async def upload_credentials():
|
||||
"""Upload credentials (cookies or token).
|
||||
|
||||
Can be called by:
|
||||
1. Firefox extension (with X-Extension-Key header)
|
||||
2. Web UI manual upload (with session auth)
|
||||
"""
|
||||
settings = get_settings()
|
||||
|
||||
# Check extension API key if present
|
||||
extension_key = request.headers.get("X-Extension-Key")
|
||||
if extension_key:
|
||||
# Look up API key from database
|
||||
async with current_app.db_engine.connect() as conn:
|
||||
session = AsyncSession(bind=conn)
|
||||
result = await session.execute(
|
||||
select(Setting).where(Setting.key == EXTENSION_API_KEY_SETTING)
|
||||
)
|
||||
setting = result.scalar()
|
||||
stored_key = setting.value if setting else None
|
||||
|
||||
if not stored_key or extension_key != stored_key:
|
||||
return jsonify({"error": "Invalid extension API key"}), 401
|
||||
|
||||
data = await request.get_json()
|
||||
|
||||
# Validate required fields
|
||||
if not data.get("platform"):
|
||||
return jsonify({"error": "Platform is required"}), 400
|
||||
if not data.get("credential_type"):
|
||||
return jsonify({"error": "Credential type is required"}), 400
|
||||
if not data.get("data"):
|
||||
return jsonify({"error": "Credential data is required"}), 400
|
||||
|
||||
platform = data["platform"]
|
||||
cred_type = data["credential_type"]
|
||||
cred_data = data["data"]
|
||||
|
||||
# Validate platform
|
||||
if platform not in VALID_PLATFORMS:
|
||||
return jsonify({"error": f"Invalid platform. Must be one of: {', '.join(VALID_PLATFORMS)}"}), 400
|
||||
|
||||
# Validate credential type
|
||||
if cred_type not in [CredentialType.COOKIES, CredentialType.TOKEN]:
|
||||
return jsonify({"error": "Invalid credential type. Must be 'cookies' or 'token'"}), 400
|
||||
|
||||
async with current_app.db_engine.connect() as conn:
|
||||
session = AsyncSession(bind=conn)
|
||||
|
||||
# Check for existing credential
|
||||
result = await session.execute(
|
||||
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)
|
||||
|
||||
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"])
|
||||
async def delete_credentials(platform: str):
|
||||
"""Delete credentials for a platform."""
|
||||
if platform not in VALID_PLATFORMS:
|
||||
return jsonify({"error": f"Invalid platform. Must be one of: {', '.join(VALID_PLATFORMS)}"}), 400
|
||||
|
||||
async with current_app.db_engine.connect() as conn:
|
||||
session = AsyncSession(bind=conn)
|
||||
|
||||
result = await session.execute(
|
||||
select(Credential).where(Credential.platform == platform)
|
||||
)
|
||||
credential = result.scalar()
|
||||
|
||||
if not credential:
|
||||
return jsonify({"error": "Credentials not found for this platform"}), 404
|
||||
|
||||
await session.delete(credential)
|
||||
await session.commit()
|
||||
|
||||
current_app.logger.info(f"Deleted credentials for platform: {platform}")
|
||||
return "", 204
|
||||
|
||||
|
||||
@bp.route("/verify", methods=["POST"])
|
||||
async def verify_credentials():
|
||||
"""Verify that credentials are working.
|
||||
|
||||
Attempts to make a test request to the platform.
|
||||
"""
|
||||
data = await request.get_json()
|
||||
platform = data.get("platform")
|
||||
|
||||
if not platform:
|
||||
return jsonify({"error": "Platform is required"}), 400
|
||||
|
||||
if platform not in VALID_PLATFORMS:
|
||||
return jsonify({"error": f"Invalid platform. Must be one of: {', '.join(VALID_PLATFORMS)}"}), 400
|
||||
|
||||
async with current_app.db_engine.connect() as conn:
|
||||
session = AsyncSession(bind=conn)
|
||||
|
||||
result = await session.execute(
|
||||
select(Credential).where(Credential.platform == platform)
|
||||
)
|
||||
credential = result.scalar()
|
||||
|
||||
if not credential:
|
||||
return jsonify({
|
||||
"platform": platform,
|
||||
"is_valid": False,
|
||||
"error": "No credentials stored for this platform",
|
||||
})
|
||||
|
||||
# TODO: Implement actual verification by making test request
|
||||
# For now, just mark as verified
|
||||
credential.last_verified = datetime.utcnow()
|
||||
await session.commit()
|
||||
|
||||
return jsonify({
|
||||
"platform": platform,
|
||||
"is_valid": True,
|
||||
"last_verified": credential.last_verified.isoformat(),
|
||||
})
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Download history API endpoints."""
|
||||
|
||||
from datetime import datetime
|
||||
from quart import Blueprint, request, jsonify, current_app
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.download import Download, DownloadStatus
|
||||
from app.models.source import Source
|
||||
from app.tasks.downloads import process_download
|
||||
|
||||
bp = Blueprint("downloads", __name__)
|
||||
|
||||
|
||||
@bp.route("", methods=["GET"])
|
||||
async def list_downloads():
|
||||
"""List download history with filtering and pagination."""
|
||||
# Query parameters
|
||||
source_id = request.args.get("source_id", type=int)
|
||||
status = request.args.get("status")
|
||||
from_date = request.args.get("from_date")
|
||||
to_date = request.args.get("to_date")
|
||||
page = int(request.args.get("page", 1))
|
||||
per_page = min(int(request.args.get("per_page", 50)), 100)
|
||||
|
||||
async with current_app.db_engine.connect() as conn:
|
||||
session = AsyncSession(bind=conn)
|
||||
|
||||
# Build query
|
||||
query = select(Download)
|
||||
count_query = select(func.count()).select_from(Download)
|
||||
|
||||
filters = []
|
||||
if source_id:
|
||||
filters.append(Download.source_id == source_id)
|
||||
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))
|
||||
|
||||
if filters:
|
||||
query = query.where(and_(*filters))
|
||||
count_query = count_query.where(and_(*filters))
|
||||
|
||||
# Get total count
|
||||
total_result = await session.execute(count_query)
|
||||
total = total_result.scalar()
|
||||
|
||||
# Apply pagination and ordering
|
||||
query = query.order_by(Download.created_at.desc())
|
||||
query = query.offset((page - 1) * per_page).limit(per_page)
|
||||
|
||||
result = await session.execute(query)
|
||||
downloads = result.scalars().all()
|
||||
|
||||
return jsonify({
|
||||
"items": [d.to_dict() for d in downloads],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"pages": (total + per_page - 1) // per_page,
|
||||
})
|
||||
|
||||
|
||||
@bp.route("/<int:download_id>", methods=["GET"])
|
||||
async def get_download(download_id: int):
|
||||
"""Get download details."""
|
||||
async with current_app.db_engine.connect() as conn:
|
||||
session = AsyncSession(bind=conn)
|
||||
|
||||
result = await session.execute(select(Download).where(Download.id == download_id))
|
||||
download = result.scalar()
|
||||
|
||||
if not download:
|
||||
return jsonify({"error": "Download not found"}), 404
|
||||
|
||||
# Include source info if available
|
||||
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"])
|
||||
async def retry_download(download_id: int):
|
||||
"""Retry a failed download."""
|
||||
async with current_app.db_engine.connect() as conn:
|
||||
session = AsyncSession(bind=conn)
|
||||
|
||||
result = await session.execute(select(Download).where(Download.id == download_id))
|
||||
download = result.scalar()
|
||||
|
||||
if not download:
|
||||
return jsonify({"error": "Download not found"}), 404
|
||||
|
||||
if download.status != DownloadStatus.FAILED:
|
||||
return jsonify({"error": "Can only retry failed downloads"}), 400
|
||||
|
||||
# Reset status to pending
|
||||
download.status = DownloadStatus.PENDING
|
||||
download.error_type = None
|
||||
download.error_message = None
|
||||
await session.commit()
|
||||
|
||||
# Queue Celery task to retry the download
|
||||
task = process_download.delay(download_id)
|
||||
|
||||
current_app.logger.info(f"Queued retry for download: {download_id}")
|
||||
return jsonify({
|
||||
"message": "Retry queued",
|
||||
"download_id": download_id,
|
||||
"task_id": task.id,
|
||||
}), 202
|
||||
|
||||
|
||||
@bp.route("/stats", methods=["GET"])
|
||||
async def get_stats():
|
||||
"""Get download statistics."""
|
||||
period = request.args.get("period", "week") # day, week, month
|
||||
|
||||
async with current_app.db_engine.connect() as conn:
|
||||
session = AsyncSession(bind=conn)
|
||||
|
||||
# Total counts by status
|
||||
status_query = select(
|
||||
Download.status,
|
||||
func.count(Download.id)
|
||||
).group_by(Download.status)
|
||||
|
||||
status_result = await session.execute(status_query)
|
||||
status_counts = {row[0]: row[1] for row in status_result}
|
||||
|
||||
# Counts by platform (through source)
|
||||
platform_query = select(
|
||||
Source.platform,
|
||||
func.count(Download.id)
|
||||
).join(Source, Download.source_id == Source.id).group_by(Source.platform)
|
||||
|
||||
platform_result = await session.execute(platform_query)
|
||||
platform_counts = {row[0]: row[1] for row in platform_result}
|
||||
|
||||
# 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()
|
||||
|
||||
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),
|
||||
"pending": status_counts.get(DownloadStatus.PENDING, 0),
|
||||
})
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Platform information API endpoints.
|
||||
|
||||
Provides information about supported platforms and their configuration options.
|
||||
"""
|
||||
|
||||
from quart import Blueprint, jsonify
|
||||
|
||||
from app.services.gallery_dl import GalleryDLService
|
||||
|
||||
bp = Blueprint("platforms", __name__)
|
||||
|
||||
|
||||
@bp.route("", methods=["GET"])
|
||||
async def list_platforms():
|
||||
"""List all supported platforms with their configuration options."""
|
||||
gdl_service = GalleryDLService()
|
||||
|
||||
platforms = {
|
||||
"patreon": {
|
||||
"name": "Patreon",
|
||||
"description": "Download posts from Patreon creators",
|
||||
"requires_auth": True,
|
||||
"auth_type": "cookies",
|
||||
"url_pattern": "https://www.patreon.com/{creator}",
|
||||
"url_examples": [
|
||||
"https://www.patreon.com/example_artist",
|
||||
"https://www.patreon.com/user?u=12345678",
|
||||
],
|
||||
"content_types": gdl_service.get_platform_content_types("patreon"),
|
||||
"content_type_descriptions": {
|
||||
"images": "Standard post images",
|
||||
"image_large": "High-resolution image versions",
|
||||
"attachments": "File attachments (ZIP, PSD, etc.)",
|
||||
"postfile": "Post-specific files",
|
||||
"content": "Embedded content in post body",
|
||||
},
|
||||
"default_config": gdl_service.get_default_config_for_platform("patreon"),
|
||||
},
|
||||
"subscribestar": {
|
||||
"name": "SubscribeStar",
|
||||
"description": "Download posts from SubscribeStar creators",
|
||||
"requires_auth": True,
|
||||
"auth_type": "cookies",
|
||||
"url_pattern": "https://subscribestar.{domain}/{creator}",
|
||||
"url_examples": [
|
||||
"https://subscribestar.adult/example_artist",
|
||||
"https://www.subscribestar.com/example_artist",
|
||||
],
|
||||
"content_types": gdl_service.get_platform_content_types("subscribestar"),
|
||||
"content_type_descriptions": {
|
||||
"all": "All available content",
|
||||
},
|
||||
"default_config": gdl_service.get_default_config_for_platform("subscribestar"),
|
||||
},
|
||||
"hentaifoundry": {
|
||||
"name": "Hentai Foundry",
|
||||
"description": "Download artwork from Hentai Foundry artists",
|
||||
"requires_auth": False,
|
||||
"auth_type": "cookies", # Optional but improves access
|
||||
"url_pattern": "https://www.hentai-foundry.com/user/{artist}",
|
||||
"url_examples": [
|
||||
"https://www.hentai-foundry.com/user/example_artist",
|
||||
"https://www.hentai-foundry.com/pictures/user/example_artist",
|
||||
],
|
||||
"content_types": gdl_service.get_platform_content_types("hentaifoundry"),
|
||||
"content_type_descriptions": {
|
||||
"pictures": "Artwork/pictures",
|
||||
"stories": "Written stories",
|
||||
},
|
||||
"default_config": gdl_service.get_default_config_for_platform("hentaifoundry"),
|
||||
},
|
||||
"discord": {
|
||||
"name": "Discord",
|
||||
"description": "Download attachments from Discord channels",
|
||||
"requires_auth": True,
|
||||
"auth_type": "token",
|
||||
"url_pattern": "https://discord.com/channels/{server}/{channel}",
|
||||
"url_examples": [
|
||||
"https://discord.com/channels/123456789/987654321",
|
||||
],
|
||||
"content_types": gdl_service.get_platform_content_types("discord"),
|
||||
"content_type_descriptions": {
|
||||
"all": "All attachments and embeds",
|
||||
},
|
||||
"default_config": gdl_service.get_default_config_for_platform("discord"),
|
||||
"notes": "Requires Discord user token (not bot token)",
|
||||
},
|
||||
}
|
||||
|
||||
return jsonify({"platforms": platforms})
|
||||
|
||||
|
||||
@bp.route("/<platform>", methods=["GET"])
|
||||
async def get_platform(platform: str):
|
||||
"""Get detailed information about a specific platform."""
|
||||
gdl_service = GalleryDLService()
|
||||
|
||||
if platform not in ["patreon", "subscribestar", "hentaifoundry", "discord"]:
|
||||
return jsonify({"error": f"Unknown platform: {platform}"}), 404
|
||||
|
||||
# Get the full platform info from list_platforms
|
||||
all_platforms = (await list_platforms()).get_json()
|
||||
platform_info = all_platforms["platforms"].get(platform)
|
||||
|
||||
if not platform_info:
|
||||
return jsonify({"error": f"Platform not found: {platform}"}), 404
|
||||
|
||||
return jsonify(platform_info)
|
||||
|
||||
|
||||
@bp.route("/<platform>/config-schema", methods=["GET"])
|
||||
async def get_config_schema(platform: str):
|
||||
"""Get the configuration schema for a platform.
|
||||
|
||||
This defines what options can be set per-source for this platform.
|
||||
Useful for building dynamic forms in the frontend.
|
||||
"""
|
||||
if platform not in ["patreon", "subscribestar", "hentaifoundry", "discord"]:
|
||||
return jsonify({"error": f"Unknown platform: {platform}"}), 404
|
||||
|
||||
gdl_service = GalleryDLService()
|
||||
content_types = gdl_service.get_platform_content_types(platform)
|
||||
defaults = gdl_service.get_default_config_for_platform(platform)
|
||||
|
||||
schema = {
|
||||
"platform": platform,
|
||||
"fields": [
|
||||
{
|
||||
"name": "content_types",
|
||||
"type": "multiselect",
|
||||
"label": "Content Types to Download",
|
||||
"description": "Select which types of content to download from this source",
|
||||
"options": content_types,
|
||||
"default": defaults.get("content_types", ["all"]),
|
||||
},
|
||||
{
|
||||
"name": "sleep",
|
||||
"type": "number",
|
||||
"label": "Delay Between Downloads (seconds)",
|
||||
"description": "Time to wait between downloading files. Higher values are safer but slower.",
|
||||
"min": 0.5,
|
||||
"max": 30.0,
|
||||
"step": 0.5,
|
||||
"default": defaults.get("sleep", 3.0),
|
||||
},
|
||||
{
|
||||
"name": "sleep_request",
|
||||
"type": "number",
|
||||
"label": "Delay Between Requests (seconds)",
|
||||
"description": "Time to wait between API requests. Helps avoid rate limiting.",
|
||||
"min": 0.5,
|
||||
"max": 15.0,
|
||||
"step": 0.5,
|
||||
"default": defaults.get("sleep_request", 1.5),
|
||||
},
|
||||
{
|
||||
"name": "skip_existing",
|
||||
"type": "boolean",
|
||||
"label": "Skip Already Downloaded",
|
||||
"description": "Skip files that have already been downloaded (uses archive database)",
|
||||
"default": True,
|
||||
},
|
||||
{
|
||||
"name": "save_metadata",
|
||||
"type": "boolean",
|
||||
"label": "Save Metadata",
|
||||
"description": "Save JSON metadata file alongside each downloaded file",
|
||||
"default": True,
|
||||
},
|
||||
{
|
||||
"name": "timeout",
|
||||
"type": "number",
|
||||
"label": "Download Timeout (seconds)",
|
||||
"description": "Maximum time to wait for a single download before giving up",
|
||||
"min": 60,
|
||||
"max": 7200,
|
||||
"step": 60,
|
||||
"default": 3600,
|
||||
},
|
||||
{
|
||||
"name": "directory_pattern",
|
||||
"type": "text",
|
||||
"label": "Directory Pattern (Advanced)",
|
||||
"description": "Custom directory structure pattern. Leave empty for platform default.",
|
||||
"placeholder": defaults.get("directory_pattern", ""),
|
||||
"default": None,
|
||||
},
|
||||
{
|
||||
"name": "filename_pattern",
|
||||
"type": "text",
|
||||
"label": "Filename Pattern (Advanced)",
|
||||
"description": "Custom filename pattern. Leave empty for platform default.",
|
||||
"placeholder": defaults.get("filename_pattern", ""),
|
||||
"default": None,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
return jsonify(schema)
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Settings API endpoints."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from quart import Blueprint, request, jsonify, current_app
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.setting import Setting, DEFAULT_SETTINGS, EXTENSION_API_KEY_SETTING, generate_api_key
|
||||
from app.config import get_settings
|
||||
|
||||
bp = Blueprint("settings", __name__)
|
||||
|
||||
|
||||
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(
|
||||
select(Setting).where(Setting.key == EXTENSION_API_KEY_SETTING)
|
||||
)
|
||||
setting = result.scalar()
|
||||
|
||||
if setting:
|
||||
return setting.value
|
||||
|
||||
# Generate new key
|
||||
new_key = generate_api_key()
|
||||
setting = Setting(key=EXTENSION_API_KEY_SETTING, value=new_key)
|
||||
session.add(setting)
|
||||
await session.commit()
|
||||
|
||||
return new_key
|
||||
|
||||
|
||||
@bp.route("", methods=["GET"])
|
||||
async def get_all_settings():
|
||||
"""Get all application settings."""
|
||||
async with current_app.db_engine.connect() as conn:
|
||||
session = AsyncSession(bind=conn)
|
||||
|
||||
result = await session.execute(select(Setting))
|
||||
settings = result.scalars().all()
|
||||
|
||||
# Build settings dict with defaults
|
||||
settings_dict = dict(DEFAULT_SETTINGS)
|
||||
for s in settings:
|
||||
settings_dict[s.key] = s.value
|
||||
|
||||
# Ensure extension API key exists
|
||||
api_key = await get_or_create_extension_api_key(session)
|
||||
settings_dict[EXTENSION_API_KEY_SETTING] = api_key
|
||||
|
||||
return jsonify({"settings": settings_dict})
|
||||
|
||||
|
||||
@bp.route("", methods=["PATCH"])
|
||||
async def update_settings():
|
||||
"""Update application settings."""
|
||||
data = await request.get_json()
|
||||
|
||||
if not data:
|
||||
return jsonify({"error": "No settings provided"}), 400
|
||||
|
||||
async with current_app.db_engine.connect() as conn:
|
||||
session = AsyncSession(bind=conn)
|
||||
|
||||
for key, value in data.items():
|
||||
# Check if setting exists
|
||||
result = await session.execute(select(Setting).where(Setting.key == key))
|
||||
setting = result.scalar()
|
||||
|
||||
if setting:
|
||||
setting.value = value
|
||||
else:
|
||||
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
|
||||
|
||||
current_app.logger.info(f"Updated settings: {list(data.keys())}")
|
||||
return jsonify({"settings": settings_dict})
|
||||
|
||||
|
||||
@bp.route("/api-key", methods=["GET"])
|
||||
async def get_extension_api_key():
|
||||
"""Get the extension API key."""
|
||||
async with current_app.db_engine.connect() as conn:
|
||||
session = AsyncSession(bind=conn)
|
||||
api_key = await get_or_create_extension_api_key(session)
|
||||
return jsonify({"api_key": api_key})
|
||||
|
||||
|
||||
@bp.route("/api-key/regenerate", methods=["POST"])
|
||||
async def regenerate_extension_api_key():
|
||||
"""Regenerate the extension API key."""
|
||||
async with current_app.db_engine.connect() as conn:
|
||||
session = AsyncSession(bind=conn)
|
||||
|
||||
# Find existing key
|
||||
result = await session.execute(
|
||||
select(Setting).where(Setting.key == EXTENSION_API_KEY_SETTING)
|
||||
)
|
||||
setting = result.scalar()
|
||||
|
||||
# Generate new key
|
||||
new_key = generate_api_key()
|
||||
|
||||
if setting:
|
||||
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"})
|
||||
|
||||
|
||||
@bp.route("/gallery-dl", methods=["GET"])
|
||||
async def get_gallery_dl_config():
|
||||
"""Get gallery-dl configuration."""
|
||||
config = get_settings()
|
||||
config_path = Path(config.config_path) / "gallery-dl.conf"
|
||||
|
||||
if not config_path.exists():
|
||||
return jsonify({"config": {}, "message": "No configuration file found"})
|
||||
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
gallery_dl_config = json.load(f)
|
||||
return jsonify({"config": gallery_dl_config})
|
||||
except json.JSONDecodeError as e:
|
||||
return jsonify({"error": f"Invalid JSON in config file: {e}"}), 500
|
||||
|
||||
|
||||
@bp.route("/gallery-dl", methods=["PUT"])
|
||||
async def update_gallery_dl_config():
|
||||
"""Update gallery-dl configuration."""
|
||||
data = await request.get_json()
|
||||
|
||||
if not data.get("config"):
|
||||
return jsonify({"error": "Config object is required"}), 400
|
||||
|
||||
config = get_settings()
|
||||
config_path = Path(config.config_path) / "gallery-dl.conf"
|
||||
|
||||
# Ensure config directory exists
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
with open(config_path, "w") as f:
|
||||
json.dump(data["config"], f, indent=4)
|
||||
|
||||
current_app.logger.info("Updated gallery-dl configuration")
|
||||
return jsonify({"message": "Configuration updated", "config": data["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
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Source management API endpoints."""
|
||||
|
||||
from quart import Blueprint, request, jsonify, current_app
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.source import Source
|
||||
from app.models.subscription import Subscription
|
||||
from app.tasks.downloads import download_source
|
||||
|
||||
bp = Blueprint("sources", __name__)
|
||||
|
||||
|
||||
@bp.route("", methods=["GET"])
|
||||
async def list_sources():
|
||||
"""List all sources with optional filtering and pagination."""
|
||||
# Query parameters
|
||||
platform = request.args.get("platform")
|
||||
subscription_id = request.args.get("subscription_id")
|
||||
enabled = request.args.get("enabled")
|
||||
page = int(request.args.get("page", 1))
|
||||
per_page = min(int(request.args.get("per_page", 50)), 100)
|
||||
|
||||
async with current_app.db_engine.connect() as conn:
|
||||
session = AsyncSession(bind=conn)
|
||||
|
||||
# Build query with subscription eager loading
|
||||
query = select(Source).options(selectinload(Source.subscription))
|
||||
|
||||
if platform:
|
||||
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"))
|
||||
|
||||
# Get total count
|
||||
count_query = select(func.count()).select_from(Source)
|
||||
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)
|
||||
total = total_result.scalar()
|
||||
|
||||
# Apply pagination - order by subscription name, then platform
|
||||
query = query.join(Subscription).order_by(Subscription.name, Source.platform)
|
||||
query = query.offset((page - 1) * per_page).limit(per_page)
|
||||
|
||||
result = await session.execute(query)
|
||||
sources = result.scalars().all()
|
||||
|
||||
return jsonify({
|
||||
"items": [s.to_dict() for s in sources],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"pages": (total + per_page - 1) // per_page if total > 0 else 0,
|
||||
})
|
||||
|
||||
|
||||
@bp.route("", methods=["POST"])
|
||||
async def create_source():
|
||||
"""Create a new source for a subscription."""
|
||||
data = await request.get_json()
|
||||
|
||||
# Validate required fields
|
||||
required = ["subscription_id", "platform", "url"]
|
||||
missing = [f for f in required if not data.get(f)]
|
||||
if missing:
|
||||
return jsonify({"error": f"Missing required fields: {', '.join(missing)}"}), 400
|
||||
|
||||
# Validate platform
|
||||
valid_platforms = ["patreon", "subscribestar", "hentaifoundry", "discord"]
|
||||
if data["platform"] not in valid_platforms:
|
||||
return jsonify({"error": f"Invalid platform. Must be one of: {', '.join(valid_platforms)}"}), 400
|
||||
|
||||
async with current_app.db_engine.connect() as conn:
|
||||
session = AsyncSession(bind=conn)
|
||||
|
||||
# Check subscription exists
|
||||
sub_result = await session.execute(
|
||||
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"]
|
||||
)
|
||||
)
|
||||
if existing.scalar():
|
||||
return jsonify({"error": "Source with this platform and URL already exists"}), 409
|
||||
|
||||
source = Source(
|
||||
subscription_id=data["subscription_id"],
|
||||
platform=data["platform"],
|
||||
url=data["url"],
|
||||
enabled=data.get("enabled", True),
|
||||
check_interval=data.get("check_interval", 3600),
|
||||
metadata_=data.get("metadata", {}),
|
||||
)
|
||||
|
||||
session.add(source)
|
||||
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"])
|
||||
async def get_source(source_id: int):
|
||||
"""Get a single source by ID."""
|
||||
async with current_app.db_engine.connect() as conn:
|
||||
session = AsyncSession(bind=conn)
|
||||
|
||||
result = await session.execute(
|
||||
select(Source)
|
||||
.options(selectinload(Source.subscription))
|
||||
.where(Source.id == source_id)
|
||||
)
|
||||
source = result.scalar()
|
||||
|
||||
if not source:
|
||||
return jsonify({"error": "Source not found"}), 404
|
||||
|
||||
return jsonify(source.to_dict())
|
||||
|
||||
|
||||
@bp.route("/<int:source_id>", methods=["PATCH"])
|
||||
async def update_source(source_id: int):
|
||||
"""Update a source."""
|
||||
data = await request.get_json()
|
||||
|
||||
async with current_app.db_engine.connect() as conn:
|
||||
session = AsyncSession(bind=conn)
|
||||
|
||||
result = await session.execute(
|
||||
select(Source)
|
||||
.options(selectinload(Source.subscription))
|
||||
.where(Source.id == source_id)
|
||||
)
|
||||
source = result.scalar()
|
||||
|
||||
if not source:
|
||||
return jsonify({"error": "Source not found"}), 404
|
||||
|
||||
# Update allowed fields
|
||||
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])
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(source)
|
||||
|
||||
current_app.logger.info(f"Updated source: {source.subscription.name}/{source.platform}")
|
||||
return jsonify(source.to_dict())
|
||||
|
||||
|
||||
@bp.route("/<int:source_id>", methods=["DELETE"])
|
||||
async def delete_source(source_id: int):
|
||||
"""Delete a source."""
|
||||
async with current_app.db_engine.connect() as conn:
|
||||
session = AsyncSession(bind=conn)
|
||||
|
||||
result = await session.execute(
|
||||
select(Source)
|
||||
.options(selectinload(Source.subscription))
|
||||
.where(Source.id == source_id)
|
||||
)
|
||||
source = result.scalar()
|
||||
|
||||
if not source:
|
||||
return jsonify({"error": "Source not found"}), 404
|
||||
|
||||
info = f"{source.subscription.name}/{source.platform}"
|
||||
await session.delete(source)
|
||||
await session.commit()
|
||||
|
||||
current_app.logger.info(f"Deleted source: {info}")
|
||||
return "", 204
|
||||
|
||||
|
||||
@bp.route("/<int:source_id>/check", methods=["POST"])
|
||||
async def trigger_check(source_id: int):
|
||||
"""Trigger an immediate download check for a source."""
|
||||
async with current_app.db_engine.connect() as conn:
|
||||
session = AsyncSession(bind=conn)
|
||||
|
||||
result = await session.execute(
|
||||
select(Source)
|
||||
.options(selectinload(Source.subscription))
|
||||
.where(Source.id == source_id)
|
||||
)
|
||||
source = result.scalar()
|
||||
|
||||
if not source:
|
||||
return jsonify({"error": "Source not found"}), 404
|
||||
|
||||
# Queue Celery task for immediate download
|
||||
task = download_source.delay(source_id)
|
||||
|
||||
current_app.logger.info(f"Triggered check for source: {source.subscription.name}/{source.platform}")
|
||||
return jsonify({
|
||||
"message": "Check queued",
|
||||
"source_id": source_id,
|
||||
"subscription_name": source.subscription.name,
|
||||
"platform": source.platform,
|
||||
"task_id": task.id,
|
||||
}), 202
|
||||
@@ -0,0 +1,307 @@
|
||||
"""Subscription management API endpoints."""
|
||||
|
||||
from quart import Blueprint, request, jsonify, current_app
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.subscription import Subscription
|
||||
from app.models.source import Source
|
||||
from app.tasks.downloads import download_source
|
||||
|
||||
bp = Blueprint("subscriptions", __name__)
|
||||
|
||||
|
||||
@bp.route("", methods=["GET"])
|
||||
async def list_subscriptions():
|
||||
"""List all subscriptions with optional filtering and pagination."""
|
||||
# Query parameters
|
||||
enabled = request.args.get("enabled")
|
||||
search = request.args.get("search")
|
||||
page = int(request.args.get("page", 1))
|
||||
per_page = min(int(request.args.get("per_page", 50)), 100)
|
||||
|
||||
async with current_app.db_engine.connect() as conn:
|
||||
session = AsyncSession(bind=conn)
|
||||
|
||||
# Build query with eager loading of sources
|
||||
query = select(Subscription).options(selectinload(Subscription.sources))
|
||||
|
||||
if enabled is not None:
|
||||
query = query.where(Subscription.enabled == (enabled.lower() == "true"))
|
||||
if search:
|
||||
query = query.where(Subscription.name.ilike(f"%{search}%"))
|
||||
|
||||
# Get total count
|
||||
count_query = select(func.count()).select_from(Subscription)
|
||||
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)
|
||||
total = total_result.scalar()
|
||||
|
||||
# Apply pagination
|
||||
query = query.order_by(Subscription.priority.desc(), Subscription.name)
|
||||
query = query.offset((page - 1) * per_page).limit(per_page)
|
||||
|
||||
result = await session.execute(query)
|
||||
subscriptions = result.scalars().unique().all()
|
||||
|
||||
return jsonify({
|
||||
"items": [s.to_dict() for s in subscriptions],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"pages": (total + per_page - 1) // per_page if total > 0 else 0,
|
||||
})
|
||||
|
||||
|
||||
@bp.route("", methods=["POST"])
|
||||
async def create_subscription():
|
||||
"""Create a new subscription with optional sources."""
|
||||
data = await request.get_json()
|
||||
|
||||
# Validate required fields
|
||||
if not data.get("name"):
|
||||
return jsonify({"error": "Name is required"}), 400
|
||||
|
||||
async with current_app.db_engine.connect() as conn:
|
||||
session = AsyncSession(bind=conn)
|
||||
|
||||
# Check for duplicate 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
|
||||
|
||||
subscription = Subscription(
|
||||
name=data["name"],
|
||||
enabled=data.get("enabled", True),
|
||||
priority=data.get("priority", 0),
|
||||
description=data.get("description"),
|
||||
metadata_=data.get("metadata", {}),
|
||||
)
|
||||
|
||||
# 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", 3600),
|
||||
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"])
|
||||
async def get_subscription(subscription_id: int):
|
||||
"""Get a single subscription by ID with its sources."""
|
||||
async with current_app.db_engine.connect() as conn:
|
||||
session = AsyncSession(bind=conn)
|
||||
|
||||
result = await session.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.sources))
|
||||
.where(Subscription.id == subscription_id)
|
||||
)
|
||||
subscription = result.scalar()
|
||||
|
||||
if not subscription:
|
||||
return jsonify({"error": "Subscription not found"}), 404
|
||||
|
||||
return jsonify(subscription.to_dict())
|
||||
|
||||
|
||||
@bp.route("/<int:subscription_id>", methods=["PATCH"])
|
||||
async def update_subscription(subscription_id: int):
|
||||
"""Update a subscription."""
|
||||
data = await request.get_json()
|
||||
|
||||
async with current_app.db_engine.connect() as conn:
|
||||
session = AsyncSession(bind=conn)
|
||||
|
||||
result = await session.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.sources))
|
||||
.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():
|
||||
return jsonify({"error": "Subscription with this name already exists"}), 409
|
||||
|
||||
# Update allowed fields
|
||||
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"])
|
||||
async def delete_subscription(subscription_id: int):
|
||||
"""Delete a subscription and all its sources."""
|
||||
async with current_app.db_engine.connect() as conn:
|
||||
session = AsyncSession(bind=conn)
|
||||
|
||||
result = await session.execute(
|
||||
select(Subscription).where(Subscription.id == subscription_id)
|
||||
)
|
||||
subscription = result.scalar()
|
||||
|
||||
if not subscription:
|
||||
return jsonify({"error": "Subscription not found"}), 404
|
||||
|
||||
name = subscription.name
|
||||
await session.delete(subscription)
|
||||
await session.commit()
|
||||
|
||||
current_app.logger.info(f"Deleted subscription: {name}")
|
||||
return "", 204
|
||||
|
||||
|
||||
@bp.route("/<int:subscription_id>/sources", methods=["GET"])
|
||||
async def list_subscription_sources(subscription_id: int):
|
||||
"""List all sources for a subscription."""
|
||||
async with current_app.db_engine.connect() as conn:
|
||||
session = AsyncSession(bind=conn)
|
||||
|
||||
result = await session.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.sources))
|
||||
.where(Subscription.id == subscription_id)
|
||||
)
|
||||
subscription = result.scalar()
|
||||
|
||||
if not subscription:
|
||||
return jsonify({"error": "Subscription not found"}), 404
|
||||
|
||||
return jsonify({
|
||||
"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"])
|
||||
async def add_source_to_subscription(subscription_id: int):
|
||||
"""Add a new source to a subscription."""
|
||||
data = await request.get_json()
|
||||
|
||||
# Validate required fields
|
||||
required = ["platform", "url"]
|
||||
missing = [f for f in required if not data.get(f)]
|
||||
if missing:
|
||||
return jsonify({"error": f"Missing required fields: {', '.join(missing)}"}), 400
|
||||
|
||||
# Validate platform
|
||||
valid_platforms = ["patreon", "subscribestar", "hentaifoundry", "discord"]
|
||||
if data["platform"] not in valid_platforms:
|
||||
return jsonify({"error": f"Invalid platform. Must be one of: {', '.join(valid_platforms)}"}), 400
|
||||
|
||||
async with current_app.db_engine.connect() as conn:
|
||||
session = AsyncSession(bind=conn)
|
||||
|
||||
# Check subscription exists
|
||||
result = await session.execute(
|
||||
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"]
|
||||
)
|
||||
)
|
||||
if existing.scalar():
|
||||
return jsonify({"error": "Source with this platform and URL already exists"}), 409
|
||||
|
||||
# Store name before commit (attributes expire after commit)
|
||||
subscription_name = subscription.name
|
||||
|
||||
source = Source(
|
||||
subscription_id=subscription_id,
|
||||
platform=data["platform"],
|
||||
url=data["url"],
|
||||
enabled=data.get("enabled", True),
|
||||
check_interval=data.get("check_interval", 3600),
|
||||
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"])
|
||||
async def trigger_subscription_check(subscription_id: int):
|
||||
"""Trigger download check for all enabled sources in a subscription."""
|
||||
async with current_app.db_engine.connect() as conn:
|
||||
session = AsyncSession(bind=conn)
|
||||
|
||||
result = await session.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.sources))
|
||||
.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]
|
||||
|
||||
# Queue Celery tasks for each enabled source
|
||||
task_ids = []
|
||||
for source in enabled_sources:
|
||||
task = download_source.delay(source.id)
|
||||
task_ids.append(task.id)
|
||||
|
||||
current_app.logger.info(f"Triggered check for subscription: {subscription.name} ({len(enabled_sources)} sources)")
|
||||
return jsonify({
|
||||
"message": "Checks queued",
|
||||
"subscription_id": subscription_id,
|
||||
"source_count": len(enabled_sources),
|
||||
"task_ids": task_ids,
|
||||
}), 202
|
||||
@@ -0,0 +1,217 @@
|
||||
"""WebSocket API for real-time updates."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Set
|
||||
from quart import Blueprint, websocket, current_app
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from app.config import get_settings
|
||||
from app.events import EVENTS_CHANNEL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint("websocket", __name__)
|
||||
|
||||
# Connected WebSocket clients
|
||||
connected_clients: Set = set()
|
||||
|
||||
|
||||
class WebSocketManager:
|
||||
"""Manages WebSocket connections and broadcasts."""
|
||||
|
||||
_instance = None
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance.clients = set()
|
||||
return cls._instance
|
||||
|
||||
async def connect(self, ws):
|
||||
"""Register a new WebSocket connection."""
|
||||
self.clients.add(ws)
|
||||
logger.info(f"WebSocket connected. Total clients: {len(self.clients)}")
|
||||
|
||||
async def disconnect(self, ws):
|
||||
"""Remove a WebSocket connection."""
|
||||
self.clients.discard(ws)
|
||||
logger.info(f"WebSocket disconnected. Total clients: {len(self.clients)}")
|
||||
|
||||
async def broadcast(self, event_type: str, data: dict):
|
||||
"""Broadcast an event to all connected clients."""
|
||||
if not self.clients:
|
||||
return
|
||||
|
||||
message = json.dumps({
|
||||
"type": event_type,
|
||||
"data": data,
|
||||
})
|
||||
|
||||
disconnected = set()
|
||||
for client in self.clients:
|
||||
try:
|
||||
await client.send(message)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to send to client: {e}")
|
||||
disconnected.add(client)
|
||||
|
||||
# Clean up disconnected clients
|
||||
self.clients -= disconnected
|
||||
|
||||
async def send_to_client(self, ws, event_type: str, data: dict):
|
||||
"""Send an event to a specific client."""
|
||||
message = json.dumps({
|
||||
"type": event_type,
|
||||
"data": data,
|
||||
})
|
||||
try:
|
||||
await ws.send(message)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to send to client: {e}")
|
||||
|
||||
|
||||
# Global manager instance
|
||||
ws_manager = WebSocketManager()
|
||||
|
||||
|
||||
@bp.websocket("/events")
|
||||
async def events():
|
||||
"""WebSocket endpoint for real-time events.
|
||||
|
||||
Events sent from server:
|
||||
- download.started: { download_id, source_id, url }
|
||||
- download.progress: { download_id, file_count }
|
||||
- download.completed: { download_id, file_count, duration }
|
||||
- download.failed: { download_id, error_type, error_message }
|
||||
- source.updated: { source_id, name, last_check }
|
||||
- credential.expiring: { platform, expires_in_hours }
|
||||
"""
|
||||
await ws_manager.connect(websocket._get_current_object())
|
||||
|
||||
try:
|
||||
# Send initial connection confirmation
|
||||
await websocket.send(json.dumps({
|
||||
"type": "connected",
|
||||
"data": {"message": "Connected to Gallery Subscriber"},
|
||||
}))
|
||||
|
||||
# Keep connection alive and handle incoming messages
|
||||
while True:
|
||||
try:
|
||||
# Wait for messages (ping/pong or commands)
|
||||
message = await asyncio.wait_for(websocket.receive(), timeout=30)
|
||||
|
||||
# Handle ping
|
||||
if message == "ping":
|
||||
await websocket.send("pong")
|
||||
else:
|
||||
# Parse JSON commands if needed
|
||||
try:
|
||||
data = json.loads(message)
|
||||
await handle_client_message(websocket._get_current_object(), data)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
# Send keepalive ping
|
||||
try:
|
||||
await websocket.send(json.dumps({"type": "ping"}))
|
||||
except Exception:
|
||||
break
|
||||
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
await ws_manager.disconnect(websocket._get_current_object())
|
||||
|
||||
|
||||
async def handle_client_message(ws, data: dict):
|
||||
"""Handle messages from WebSocket clients."""
|
||||
msg_type = data.get("type")
|
||||
|
||||
if msg_type == "subscribe":
|
||||
# Client wants to subscribe to specific events
|
||||
# For now, all clients receive all events
|
||||
await ws_manager.send_to_client(ws, "subscribed", {"status": "ok"})
|
||||
|
||||
elif msg_type == "ping":
|
||||
await ws_manager.send_to_client(ws, "pong", {})
|
||||
|
||||
|
||||
# Helper functions for broadcasting events from other parts of the app
|
||||
|
||||
async def broadcast_download_started(download_id: int, source_id: int, url: str):
|
||||
"""Broadcast that a download has started."""
|
||||
await ws_manager.broadcast("download.started", {
|
||||
"download_id": download_id,
|
||||
"source_id": source_id,
|
||||
"url": url,
|
||||
})
|
||||
|
||||
|
||||
async def broadcast_download_completed(download_id: int, file_count: int, duration: float):
|
||||
"""Broadcast that a download has completed."""
|
||||
await ws_manager.broadcast("download.completed", {
|
||||
"download_id": download_id,
|
||||
"file_count": file_count,
|
||||
"duration_seconds": duration,
|
||||
})
|
||||
|
||||
|
||||
async def broadcast_download_failed(download_id: int, error_type: str, error_message: str):
|
||||
"""Broadcast that a download has failed."""
|
||||
await ws_manager.broadcast("download.failed", {
|
||||
"download_id": download_id,
|
||||
"error_type": error_type,
|
||||
"error_message": error_message,
|
||||
})
|
||||
|
||||
|
||||
async def broadcast_source_updated(source_id: int, name: str, last_check: str):
|
||||
"""Broadcast that a source has been updated."""
|
||||
await ws_manager.broadcast("source.updated", {
|
||||
"source_id": source_id,
|
||||
"name": name,
|
||||
"last_check": last_check,
|
||||
})
|
||||
|
||||
|
||||
# Redis Pub/Sub integration for cross-process events (from Celery tasks)
|
||||
|
||||
async def start_redis_subscriber():
|
||||
"""Start a background task that subscribes to Redis events and broadcasts them.
|
||||
|
||||
This should be called when the Quart app starts.
|
||||
"""
|
||||
settings = get_settings()
|
||||
|
||||
try:
|
||||
redis_client = aioredis.from_url(settings.redis_url)
|
||||
pubsub = redis_client.pubsub()
|
||||
await pubsub.subscribe(EVENTS_CHANNEL)
|
||||
|
||||
logger.info(f"Started Redis subscriber on channel: {EVENTS_CHANNEL}")
|
||||
|
||||
async for message in pubsub.listen():
|
||||
if message["type"] == "message":
|
||||
try:
|
||||
data = json.loads(message["data"])
|
||||
event_type = data.get("type")
|
||||
event_data = data.get("data", {})
|
||||
|
||||
# Broadcast to all WebSocket clients
|
||||
await ws_manager.broadcast(event_type, event_data)
|
||||
logger.debug(f"Broadcast event from Redis: {event_type}")
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"Invalid JSON in Redis message: {message['data']}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error broadcasting Redis event: {e}")
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Redis subscriber cancelled")
|
||||
await pubsub.unsubscribe(EVENTS_CHANNEL)
|
||||
await redis_client.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Redis subscriber error: {e}")
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Application configuration using pydantic-settings."""
|
||||
|
||||
from functools import lru_cache
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables."""
|
||||
|
||||
# Database
|
||||
database_url: str = "postgresql://gdl:password@localhost:5432/gallery_subscriber"
|
||||
|
||||
# Redis
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
|
||||
# Security
|
||||
secret_key: str = "change-me-in-production"
|
||||
# Note: extension_api_key is now stored in database and auto-generated
|
||||
|
||||
# Paths
|
||||
download_path: str = "/data/downloads"
|
||||
config_path: str = "/data/config"
|
||||
cookies_path: str = "/data/cookies"
|
||||
|
||||
# Download settings
|
||||
download_parallel_limit: int = 3
|
||||
download_rate_limit: float = 3.0
|
||||
default_check_interval: int = 3600 # seconds
|
||||
|
||||
# Server
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8080
|
||||
debug: bool = False
|
||||
log_level: str = "INFO"
|
||||
|
||||
@property
|
||||
def async_database_url(self) -> str:
|
||||
"""Convert database URL to async version for asyncpg."""
|
||||
return self.database_url.replace("postgresql://", "postgresql+asyncpg://")
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
"""Get cached settings instance."""
|
||||
return Settings()
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Cross-process event publishing via Redis.
|
||||
|
||||
This module provides synchronous event publishing for use in Celery tasks.
|
||||
The events are consumed by the WebSocket module which broadcasts them to clients.
|
||||
"""
|
||||
|
||||
import json
|
||||
import redis
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
# Redis pub/sub channel for cross-process events
|
||||
EVENTS_CHANNEL = "gallery_subscriber:events"
|
||||
|
||||
|
||||
def publish_event(event_type: str, data: dict):
|
||||
"""Publish an event to Redis (for use in Celery tasks).
|
||||
|
||||
This is a sync function that can be called from Celery tasks.
|
||||
The Quart app subscribes to these events and broadcasts to WebSocket clients.
|
||||
|
||||
Args:
|
||||
event_type: Type of event (e.g., "download.started", "download.completed")
|
||||
data: Event payload data
|
||||
"""
|
||||
settings = get_settings()
|
||||
r = redis.from_url(settings.redis_url)
|
||||
message = json.dumps({"type": event_type, "data": data})
|
||||
r.publish(EVENTS_CHANNEL, message)
|
||||
r.close()
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Main Quart application factory."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from quart import Quart, send_from_directory, send_file
|
||||
from quart_cors import cors
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import init_db
|
||||
from app.api import subscriptions, sources, downloads, credentials, settings as settings_api, platforms, websocket
|
||||
from app.api.websocket import start_redis_subscriber
|
||||
|
||||
# Path to static frontend files (built Vue app)
|
||||
STATIC_DIR = Path(__file__).parent.parent / "static"
|
||||
|
||||
|
||||
def create_app() -> Quart:
|
||||
"""Create and configure the Quart application."""
|
||||
app = Quart(__name__, static_folder=None) # Disable default static handling
|
||||
config = get_settings()
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, config.log_level.upper()),
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
|
||||
# Enable CORS for API routes only
|
||||
app = cors(app, allow_origin="*")
|
||||
|
||||
# Register API blueprints
|
||||
app.register_blueprint(subscriptions.bp, url_prefix="/api/subscriptions")
|
||||
app.register_blueprint(sources.bp, url_prefix="/api/sources")
|
||||
app.register_blueprint(downloads.bp, url_prefix="/api/downloads")
|
||||
app.register_blueprint(credentials.bp, url_prefix="/api/credentials")
|
||||
app.register_blueprint(settings_api.bp, url_prefix="/api/settings")
|
||||
app.register_blueprint(platforms.bp, url_prefix="/api/platforms")
|
||||
app.register_blueprint(websocket.bp, url_prefix="/ws")
|
||||
|
||||
@app.before_serving
|
||||
async def startup():
|
||||
"""Initialize database connection on startup."""
|
||||
app.db_engine = await init_db(config.async_database_url)
|
||||
app.logger.info("Database connection initialized")
|
||||
app.logger.info(f"Static files directory: {STATIC_DIR} (exists: {STATIC_DIR.exists()})")
|
||||
|
||||
# Start Redis subscriber for WebSocket events from Celery tasks
|
||||
app.redis_subscriber_task = asyncio.create_task(start_redis_subscriber())
|
||||
app.logger.info("Redis subscriber started for WebSocket events")
|
||||
|
||||
@app.after_serving
|
||||
async def shutdown():
|
||||
"""Close database connection on shutdown."""
|
||||
# Cancel Redis subscriber task
|
||||
if hasattr(app, "redis_subscriber_task"):
|
||||
app.redis_subscriber_task.cancel()
|
||||
try:
|
||||
await app.redis_subscriber_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
app.logger.info("Redis subscriber stopped")
|
||||
|
||||
if hasattr(app, "db_engine"):
|
||||
await app.db_engine.dispose()
|
||||
app.logger.info("Database connection closed")
|
||||
|
||||
@app.route("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint."""
|
||||
return {"status": "healthy"}
|
||||
|
||||
@app.route("/api")
|
||||
async def api_info():
|
||||
"""API information endpoint."""
|
||||
return {
|
||||
"name": "Gallery Subscriber API",
|
||||
"version": "1.0.0",
|
||||
"endpoints": {
|
||||
"subscriptions": "/api/subscriptions",
|
||||
"sources": "/api/sources",
|
||||
"downloads": "/api/downloads",
|
||||
"credentials": "/api/credentials",
|
||||
"settings": "/api/settings",
|
||||
"platforms": "/api/platforms",
|
||||
"websocket": "/ws/events",
|
||||
},
|
||||
}
|
||||
|
||||
# Serve static assets (JS, CSS, images, fonts)
|
||||
@app.route("/assets/<path:filename>")
|
||||
async def serve_assets(filename):
|
||||
"""Serve static assets from the frontend build."""
|
||||
return await send_from_directory(STATIC_DIR / "assets", filename)
|
||||
|
||||
# Serve other static files (favicon, etc.)
|
||||
@app.route("/<path:filename>")
|
||||
async def serve_static(filename):
|
||||
"""Serve static files or fall back to index.html for Vue Router."""
|
||||
file_path = STATIC_DIR / filename
|
||||
if file_path.exists() and file_path.is_file():
|
||||
return await send_from_directory(STATIC_DIR, filename)
|
||||
# Fall back to index.html for Vue Router (SPA)
|
||||
return await send_file(STATIC_DIR / "index.html")
|
||||
|
||||
# Serve index.html for root path
|
||||
@app.route("/")
|
||||
async def serve_index():
|
||||
"""Serve the frontend index.html."""
|
||||
return await send_file(STATIC_DIR / "index.html")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# Create app instance for hypercorn
|
||||
app = create_app()
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Database models and initialization."""
|
||||
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.models.subscription import Subscription
|
||||
from app.models.source import Source
|
||||
from app.models.download import Download
|
||||
from app.models.credential import Credential
|
||||
from app.models.content import ContentItem
|
||||
from app.models.setting import Setting
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for all models."""
|
||||
pass
|
||||
|
||||
|
||||
# Re-export models
|
||||
__all__ = [
|
||||
"Base",
|
||||
"Subscription",
|
||||
"Source",
|
||||
"Download",
|
||||
"Credential",
|
||||
"ContentItem",
|
||||
"Setting",
|
||||
"init_db",
|
||||
"get_session",
|
||||
]
|
||||
|
||||
# Global engine and session factory
|
||||
_engine = None
|
||||
_session_factory = None
|
||||
|
||||
|
||||
async def init_db(database_url: str):
|
||||
"""Initialize database engine and create tables."""
|
||||
global _engine, _session_factory
|
||||
|
||||
_engine = create_async_engine(database_url, echo=False)
|
||||
_session_factory = async_sessionmaker(_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
# Import all models to register them with Base
|
||||
from app.models.subscription import Subscription
|
||||
from app.models.source import Source
|
||||
from app.models.download import Download
|
||||
from app.models.credential import Credential
|
||||
from app.models.content import ContentItem
|
||||
from app.models.setting import Setting
|
||||
|
||||
return _engine
|
||||
|
||||
|
||||
async def get_session() -> AsyncSession:
|
||||
"""Get a database session."""
|
||||
if _session_factory is None:
|
||||
raise RuntimeError("Database not initialized. Call init_db first.")
|
||||
async with _session_factory() as session:
|
||||
yield session
|
||||
@@ -0,0 +1,17 @@
|
||||
"""SQLAlchemy base model."""
|
||||
|
||||
from datetime import datetime
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for all models."""
|
||||
pass
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
"""Mixin for created_at and updated_at timestamps."""
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
||||
@@ -0,0 +1,83 @@
|
||||
"""ContentItem model - discovered files/content."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlalchemy import String, Integer, Text, ForeignKey, BigInteger, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class ContentType:
|
||||
"""Content type constants."""
|
||||
IMAGE = "image"
|
||||
VIDEO = "video"
|
||||
ATTACHMENT = "attachment"
|
||||
AUDIO = "audio"
|
||||
OTHER = "other"
|
||||
|
||||
|
||||
class ContentItem(Base):
|
||||
"""A content item represents a discovered/downloaded file."""
|
||||
|
||||
__tablename__ = "content_items"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("source_id", "external_id", name="uq_content_source_external"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
source_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("sources.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
download_id: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("downloads.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
# Content identification
|
||||
external_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
title: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
content_type: Mapped[str] = mapped_column(String(50), default=ContentType.OTHER)
|
||||
|
||||
# File information
|
||||
file_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
file_size: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True)
|
||||
thumbnail_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
|
||||
# Timestamps
|
||||
published_at: Mapped[Optional[datetime]] = mapped_column(nullable=True)
|
||||
discovered_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
|
||||
|
||||
# Flexible metadata
|
||||
metadata_: Mapped[dict] = mapped_column("metadata", JSONB, default=dict)
|
||||
|
||||
# Relationships
|
||||
source: Mapped["Source"] = relationship(back_populates="content_items")
|
||||
download: Mapped[Optional["Download"]] = relationship(back_populates="content_items")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ContentItem {self.id}: {self.title or 'untitled'}>"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for API responses."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"source_id": self.source_id,
|
||||
"download_id": self.download_id,
|
||||
"external_id": self.external_id,
|
||||
"title": self.title,
|
||||
"content_type": self.content_type,
|
||||
"file_path": self.file_path,
|
||||
"file_size": self.file_size,
|
||||
"thumbnail_path": self.thumbnail_path,
|
||||
"published_at": self.published_at.isoformat() if self.published_at else None,
|
||||
"discovered_at": self.discovered_at.isoformat() if self.discovered_at else None,
|
||||
"metadata": self.metadata_,
|
||||
}
|
||||
|
||||
|
||||
# Import for type hints
|
||||
from typing import TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from app.models.source import Source
|
||||
from app.models.download import Download
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Credential model - encrypted cookie/token storage."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlalchemy import String, LargeBinary, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
|
||||
|
||||
class CredentialType:
|
||||
"""Credential type constants."""
|
||||
COOKIES = "cookies"
|
||||
TOKEN = "token"
|
||||
|
||||
|
||||
class Credential(Base, TimestampMixin):
|
||||
"""Encrypted credential storage for platform authentication."""
|
||||
|
||||
__tablename__ = "credentials"
|
||||
__table_args__ = (UniqueConstraint("platform", name="uq_credential_platform"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
platform: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
credential_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
|
||||
# Encrypted credential data
|
||||
data: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
|
||||
|
||||
# Status tracking
|
||||
expires_at: Mapped[Optional[datetime]] = mapped_column(nullable=True)
|
||||
last_verified: Mapped[Optional[datetime]] = mapped_column(nullable=True)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Credential {self.platform} ({self.credential_type})>"
|
||||
|
||||
def to_dict(self, include_data: bool = False) -> dict:
|
||||
"""Convert to dictionary for API responses.
|
||||
|
||||
By default, does NOT include the encrypted data for security.
|
||||
"""
|
||||
result = {
|
||||
"id": self.id,
|
||||
"platform": self.platform,
|
||||
"credential_type": self.credential_type,
|
||||
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
|
||||
"last_verified": self.last_verified.isoformat() if self.last_verified else None,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
if include_data:
|
||||
result["data"] = self.data
|
||||
return result
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Download model - individual download jobs."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
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
|
||||
|
||||
|
||||
class DownloadStatus:
|
||||
"""Download status constants."""
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
SKIPPED = "skipped"
|
||||
|
||||
|
||||
class ErrorType:
|
||||
"""Error type constants matching legacy project."""
|
||||
AUTH_ERROR = "auth_error"
|
||||
RATE_LIMITED = "rate_limited"
|
||||
NOT_FOUND = "not_found"
|
||||
ACCESS_DENIED = "access_denied"
|
||||
NETWORK_ERROR = "network_error"
|
||||
NO_NEW_CONTENT = "no_new_content"
|
||||
TIMEOUT = "timeout"
|
||||
HTTP_ERROR = "http_error"
|
||||
UNSUPPORTED_URL = "unsupported_url"
|
||||
UNKNOWN_ERROR = "unknown_error"
|
||||
|
||||
|
||||
class Download(Base, TimestampMixin):
|
||||
"""A download represents a single download job for a source."""
|
||||
|
||||
__tablename__ = "downloads"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
source_id: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("sources.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
url: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
# Status
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), default=DownloadStatus.PENDING, index=True
|
||||
)
|
||||
error_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Results
|
||||
file_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
total_size: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True)
|
||||
|
||||
# Timing
|
||||
started_at: Mapped[Optional[datetime]] = mapped_column(nullable=True)
|
||||
completed_at: Mapped[Optional[datetime]] = mapped_column(nullable=True)
|
||||
|
||||
# Flexible metadata
|
||||
metadata_: Mapped[dict] = mapped_column("metadata", JSONB, default=dict)
|
||||
|
||||
# Relationships
|
||||
source: Mapped[Optional["Source"]] = relationship(back_populates="downloads")
|
||||
content_items: Mapped[list["ContentItem"]] = relationship(back_populates="download")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Download {self.id} ({self.status})>"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for API responses."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"source_id": self.source_id,
|
||||
"url": self.url,
|
||||
"status": self.status,
|
||||
"error_type": self.error_type,
|
||||
"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,
|
||||
"metadata": self.metadata_,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
# Import for type hints
|
||||
from typing import TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from app.models.source import Source
|
||||
from app.models.content import ContentItem
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Setting model - key-value application settings."""
|
||||
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
def generate_api_key() -> str:
|
||||
"""Generate a secure random API key."""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
class Setting(Base):
|
||||
"""Key-value setting storage."""
|
||||
|
||||
__tablename__ = "settings"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(100), primary_key=True)
|
||||
value: Mapped[dict] = mapped_column(JSONB, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Setting {self.key}>"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for API responses."""
|
||||
return {
|
||||
"key": self.key,
|
||||
"value": self.value,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
# Default settings that should exist
|
||||
DEFAULT_SETTINGS = {
|
||||
"download.parallel_limit": 3,
|
||||
"download.rate_limit": 3.0,
|
||||
"download.retry_count": 3,
|
||||
"download.schedule_interval": 3600,
|
||||
"notification.enabled": False,
|
||||
"notification.webhook_url": None,
|
||||
}
|
||||
|
||||
# Key for the extension API key setting
|
||||
EXTENSION_API_KEY_SETTING = "security.extension_api_key"
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Source model - platform URLs for subscriptions."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlalchemy import String, Boolean, Integer, Text, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
|
||||
|
||||
class Source(Base, TimestampMixin):
|
||||
"""A source represents a platform URL for a subscription.
|
||||
|
||||
Sources belong to subscriptions - one subscription (artist) can have
|
||||
multiple sources (Patreon, SubscribeStar, etc.).
|
||||
"""
|
||||
|
||||
__tablename__ = "sources"
|
||||
__table_args__ = (UniqueConstraint("platform", "url", name="uq_source_platform_url"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
subscription_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("subscriptions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
platform: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
url: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
check_interval: Mapped[int] = mapped_column(Integer, default=3600) # seconds
|
||||
|
||||
# Status tracking
|
||||
last_check: Mapped[Optional[datetime]] = mapped_column(nullable=True)
|
||||
last_success: Mapped[Optional[datetime]] = mapped_column(nullable=True)
|
||||
error_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
# Flexible metadata storage (per-source config overrides)
|
||||
metadata_: Mapped[dict] = mapped_column("metadata", JSONB, default=dict)
|
||||
|
||||
# Relationships
|
||||
subscription: Mapped["Subscription"] = relationship(back_populates="sources")
|
||||
downloads: Mapped[list["Download"]] = relationship(back_populates="source")
|
||||
content_items: Mapped[list["ContentItem"]] = relationship(back_populates="source")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Source {self.subscription.name if self.subscription else '?'}/{self.platform}>"
|
||||
|
||||
def to_dict(self, include_subscription: bool = True) -> dict:
|
||||
"""Convert to dictionary for API responses."""
|
||||
result = {
|
||||
"id": self.id,
|
||||
"subscription_id": self.subscription_id,
|
||||
"platform": self.platform,
|
||||
"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,
|
||||
"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,
|
||||
}
|
||||
if include_subscription and self.subscription:
|
||||
result["subscription_name"] = self.subscription.name
|
||||
return result
|
||||
|
||||
|
||||
# Import for type hints (avoid circular imports)
|
||||
from typing import TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from app.models.subscription import Subscription
|
||||
from app.models.download import Download
|
||||
from app.models.content import ContentItem
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Subscription model - represents an artist/creator to track."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
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
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.source import Source
|
||||
|
||||
|
||||
class Subscription(Base, TimestampMixin):
|
||||
"""A subscription represents an artist/creator being tracked.
|
||||
|
||||
Each subscription can have multiple sources (platform URLs).
|
||||
The subscription name is used for the directory structure:
|
||||
/{download_path}/{subscription.name}/{source.platform}/{post}
|
||||
"""
|
||||
|
||||
__tablename__ = "subscriptions"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
priority: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
# Optional description/notes
|
||||
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Flexible metadata storage (tags, custom settings, etc.)
|
||||
metadata_: Mapped[dict] = mapped_column("metadata", JSONB, default=dict)
|
||||
|
||||
# Relationships
|
||||
sources: Mapped[list["Source"]] = relationship(
|
||||
back_populates="subscription",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Subscription {self.name}>"
|
||||
|
||||
@property
|
||||
def platform_count(self) -> int:
|
||||
"""Number of platforms/sources for this subscription."""
|
||||
return len(self.sources) if self.sources else 0
|
||||
|
||||
@property
|
||||
def enabled_source_count(self) -> int:
|
||||
"""Number of enabled sources."""
|
||||
return sum(1 for s in self.sources if s.enabled) if self.sources else 0
|
||||
|
||||
def to_dict(self, include_sources: bool = True) -> dict:
|
||||
"""Convert to dictionary for API responses."""
|
||||
result = {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"enabled": self.enabled,
|
||||
"priority": self.priority,
|
||||
"description": self.description,
|
||||
"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,
|
||||
}
|
||||
if include_sources and self.sources:
|
||||
result["sources"] = [s.to_dict(include_subscription=False) for s in self.sources]
|
||||
return result
|
||||
@@ -0,0 +1 @@
|
||||
"""Service modules."""
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Credential management service.
|
||||
|
||||
Handles retrieving, decrypting, and writing cookie files for gallery-dl.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.credential import Credential
|
||||
from app.utils.encryption import decrypt_data
|
||||
from app.utils.cookies import parse_netscape_cookies, write_cookie_file
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CredentialManager:
|
||||
"""Manages platform credentials for gallery-dl authentication."""
|
||||
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
self.settings = get_settings()
|
||||
|
||||
async def get_cookies_path(self, platform: str) -> Optional[str]:
|
||||
"""Get or create a cookies file for a platform.
|
||||
|
||||
If credentials exist for the platform, decrypts them and writes
|
||||
to a temporary cookies file that gallery-dl can use.
|
||||
|
||||
Args:
|
||||
platform: Platform name (patreon, subscribestar, etc.)
|
||||
|
||||
Returns:
|
||||
Path to cookies file, or None if no credentials stored
|
||||
"""
|
||||
result = await self.session.execute(
|
||||
select(Credential).where(Credential.platform == platform)
|
||||
)
|
||||
credential = result.scalar()
|
||||
|
||||
if not credential:
|
||||
logger.debug(f"No credentials stored for platform: {platform}")
|
||||
return None
|
||||
|
||||
try:
|
||||
# Decrypt credential data
|
||||
decrypted = decrypt_data(credential.data, self.settings.secret_key)
|
||||
|
||||
# Write to cookies file
|
||||
cookies_dir = Path(self.settings.cookies_path)
|
||||
cookies_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
cookies_path = cookies_dir / f"{platform}_cookies.txt"
|
||||
|
||||
# If it's already in Netscape format, write directly
|
||||
if decrypted.strip().startswith("#") or "\t" in decrypted:
|
||||
cookies_path.write_text(decrypted)
|
||||
else:
|
||||
# Assume it's JSON and needs conversion
|
||||
import json
|
||||
try:
|
||||
cookies_list = json.loads(decrypted)
|
||||
if isinstance(cookies_list, list):
|
||||
write_cookie_file(cookies_list, cookies_path)
|
||||
else:
|
||||
# Single cookie object
|
||||
write_cookie_file([cookies_list], cookies_path)
|
||||
except json.JSONDecodeError:
|
||||
# Not JSON, write as-is
|
||||
cookies_path.write_text(decrypted)
|
||||
|
||||
logger.debug(f"Wrote cookies file for {platform}: {cookies_path}")
|
||||
return str(cookies_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to prepare cookies for {platform}: {e}")
|
||||
return None
|
||||
|
||||
async def get_token(self, platform: str) -> Optional[str]:
|
||||
"""Get a decrypted token for a platform.
|
||||
|
||||
Used for platforms like Discord that use token auth instead of cookies.
|
||||
|
||||
Args:
|
||||
platform: Platform name
|
||||
|
||||
Returns:
|
||||
Decrypted token string, or None if not stored
|
||||
"""
|
||||
result = await self.session.execute(
|
||||
select(Credential).where(Credential.platform == platform)
|
||||
)
|
||||
credential = result.scalar()
|
||||
|
||||
if not credential or credential.credential_type != "token":
|
||||
return None
|
||||
|
||||
try:
|
||||
return decrypt_data(credential.data, self.settings.secret_key)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to decrypt token for {platform}: {e}")
|
||||
return None
|
||||
|
||||
async def has_credentials(self, platform: str) -> bool:
|
||||
"""Check if credentials exist for a platform."""
|
||||
result = await self.session.execute(
|
||||
select(Credential).where(Credential.platform == platform)
|
||||
)
|
||||
return result.scalar() is not None
|
||||
@@ -0,0 +1,534 @@
|
||||
"""Gallery-dl wrapper service.
|
||||
|
||||
This service provides a clean interface for executing gallery-dl downloads
|
||||
with support for per-source configuration overrides.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ErrorType(str, Enum):
|
||||
"""Error types for categorizing download failures."""
|
||||
AUTH_ERROR = "auth_error"
|
||||
RATE_LIMITED = "rate_limited"
|
||||
NOT_FOUND = "not_found"
|
||||
ACCESS_DENIED = "access_denied"
|
||||
NETWORK_ERROR = "network_error"
|
||||
NO_NEW_CONTENT = "no_new_content"
|
||||
TIMEOUT = "timeout"
|
||||
HTTP_ERROR = "http_error"
|
||||
UNSUPPORTED_URL = "unsupported_url"
|
||||
UNKNOWN_ERROR = "unknown_error"
|
||||
|
||||
|
||||
class ContentType(str, Enum):
|
||||
"""Content types that can be downloaded from platforms."""
|
||||
# Patreon content types
|
||||
IMAGES = "images"
|
||||
IMAGE_LARGE = "image_large"
|
||||
ATTACHMENTS = "attachments"
|
||||
POSTFILE = "postfile"
|
||||
CONTENT = "content" # Embedded content in post body
|
||||
|
||||
# Generic types
|
||||
ALL = "all"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SourceConfig:
|
||||
"""Per-source configuration that can override global settings.
|
||||
|
||||
These settings are stored in the source's metadata field and merged
|
||||
with global defaults when executing downloads.
|
||||
"""
|
||||
# Content types to download (platform-specific)
|
||||
# For Patreon: ["images", "image_large", "attachments", "postfile", "content"]
|
||||
# For others: typically just ["all"] or specific types
|
||||
content_types: list[str] = field(default_factory=lambda: ["all"])
|
||||
|
||||
# Rate limiting overrides
|
||||
sleep: Optional[float] = None # Delay between downloads
|
||||
sleep_request: Optional[float] = None # Delay between requests
|
||||
|
||||
# Directory/filename pattern overrides
|
||||
directory_pattern: Optional[str] = None
|
||||
filename_pattern: Optional[str] = None
|
||||
|
||||
# Other options
|
||||
skip_existing: bool = True # Skip files already in archive
|
||||
save_metadata: bool = True # Save JSON metadata alongside files
|
||||
timeout: int = 3600 # Download timeout in seconds
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "SourceConfig":
|
||||
"""Create SourceConfig from a dictionary (e.g., from source.metadata)."""
|
||||
return cls(
|
||||
content_types=data.get("content_types", ["all"]),
|
||||
sleep=data.get("sleep"),
|
||||
sleep_request=data.get("sleep_request"),
|
||||
directory_pattern=data.get("directory_pattern"),
|
||||
filename_pattern=data.get("filename_pattern"),
|
||||
skip_existing=data.get("skip_existing", True),
|
||||
save_metadata=data.get("save_metadata", True),
|
||||
timeout=data.get("timeout", 3600),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for storage."""
|
||||
return {
|
||||
"content_types": self.content_types,
|
||||
"sleep": self.sleep,
|
||||
"sleep_request": self.sleep_request,
|
||||
"directory_pattern": self.directory_pattern,
|
||||
"filename_pattern": self.filename_pattern,
|
||||
"skip_existing": self.skip_existing,
|
||||
"save_metadata": self.save_metadata,
|
||||
"timeout": self.timeout,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class DownloadResult:
|
||||
"""Result of a gallery-dl download execution."""
|
||||
success: bool
|
||||
url: str
|
||||
subscription_name: str
|
||||
platform: str
|
||||
|
||||
# Output details
|
||||
files_downloaded: int = 0
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
return_code: int = 0
|
||||
|
||||
# Error details (if failed)
|
||||
error_type: Optional[ErrorType] = None
|
||||
error_message: Optional[str] = None
|
||||
|
||||
# Timing
|
||||
duration_seconds: float = 0.0
|
||||
started_at: Optional[str] = None
|
||||
completed_at: Optional[str] = None
|
||||
|
||||
|
||||
class GalleryDLService:
|
||||
"""Service for executing gallery-dl downloads.
|
||||
|
||||
Handles:
|
||||
- Building gallery-dl configuration with per-source overrides
|
||||
- Executing the subprocess with proper error handling
|
||||
- Parsing output and categorizing errors
|
||||
- Managing temporary config files
|
||||
"""
|
||||
|
||||
# Platform-specific default content types
|
||||
# Directory patterns are relative - subscription_name/platform is prepended automatically
|
||||
PLATFORM_DEFAULTS = {
|
||||
"patreon": {
|
||||
"content_types": ["images", "image_large", "attachments", "postfile", "content"],
|
||||
"directory": ["{date:%Y-%m-%d}_{id}_{title[:40]}"],
|
||||
"filename": "{num:>02}_{filename}.{extension}",
|
||||
},
|
||||
"subscribestar": {
|
||||
"content_types": ["all"],
|
||||
"directory": ["{date:%Y-%m-%d}_{id}_{title[:40]}"],
|
||||
"filename": "{num:>02}_{filename}.{extension}",
|
||||
},
|
||||
"hentaifoundry": {
|
||||
"content_types": ["pictures"],
|
||||
"directory": [], # Flat structure within platform folder
|
||||
"filename": "{category}_{index:>03}_{title[:50]}.{extension}",
|
||||
},
|
||||
"discord": {
|
||||
"content_types": ["all"],
|
||||
"directory": ["{channel}"],
|
||||
"filename": "{date:%Y%m%d}_{message_id}_{filename}.{extension}",
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self.settings = get_settings()
|
||||
self._base_config = self._load_base_config()
|
||||
|
||||
def _load_base_config(self) -> dict:
|
||||
"""Load the base gallery-dl configuration file."""
|
||||
config_path = Path(self.settings.config_path) / "gallery-dl.conf"
|
||||
|
||||
if config_path.exists():
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
return json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"Invalid gallery-dl.conf, using defaults: {e}")
|
||||
|
||||
# Return sensible defaults if no config exists
|
||||
return self._get_default_config()
|
||||
|
||||
def _get_default_config(self) -> dict:
|
||||
"""Get default gallery-dl configuration."""
|
||||
return {
|
||||
"extractor": {
|
||||
"base-directory": self.settings.download_path,
|
||||
"archive": str(Path(self.settings.config_path) / "archive.sqlite3"),
|
||||
"skip": True,
|
||||
"sleep": self.settings.download_rate_limit,
|
||||
"sleep-request": max(1.0, self.settings.download_rate_limit / 2),
|
||||
"retries": 3,
|
||||
"timeout": 30.0,
|
||||
"verify": True,
|
||||
"postprocessors": [
|
||||
{
|
||||
"name": "metadata",
|
||||
"mode": "json",
|
||||
"directory": ".",
|
||||
"filename": "{filename}.json",
|
||||
}
|
||||
],
|
||||
},
|
||||
"downloader": {
|
||||
"part": True,
|
||||
"part-directory": str(Path(self.settings.config_path) / "temp"),
|
||||
"retries": 3,
|
||||
"timeout": 120.0,
|
||||
},
|
||||
"output": {
|
||||
"progress": True,
|
||||
},
|
||||
}
|
||||
|
||||
def _build_config_for_source(
|
||||
self,
|
||||
platform: str,
|
||||
source_config: SourceConfig,
|
||||
destination: str,
|
||||
) -> dict:
|
||||
"""Build a merged configuration for a specific source.
|
||||
|
||||
Merges: base config → platform defaults → source overrides
|
||||
"""
|
||||
config = json.loads(json.dumps(self._base_config)) # Deep copy
|
||||
|
||||
# Get platform defaults
|
||||
platform_defaults = self.PLATFORM_DEFAULTS.get(platform, {})
|
||||
|
||||
# Ensure extractor section exists
|
||||
if "extractor" not in config:
|
||||
config["extractor"] = {}
|
||||
|
||||
# Apply base directory
|
||||
config["extractor"]["base-directory"] = destination
|
||||
|
||||
# Apply rate limiting overrides
|
||||
if source_config.sleep is not None:
|
||||
config["extractor"]["sleep"] = source_config.sleep
|
||||
if source_config.sleep_request is not None:
|
||||
config["extractor"]["sleep-request"] = source_config.sleep_request
|
||||
|
||||
# Apply skip setting
|
||||
config["extractor"]["skip"] = source_config.skip_existing
|
||||
|
||||
# Configure metadata postprocessor
|
||||
if source_config.save_metadata:
|
||||
config["extractor"]["postprocessors"] = [
|
||||
{
|
||||
"name": "metadata",
|
||||
"mode": "json",
|
||||
"directory": ".",
|
||||
"filename": "{filename}.json",
|
||||
}
|
||||
]
|
||||
else:
|
||||
config["extractor"].pop("postprocessors", None)
|
||||
|
||||
# Build platform-specific section
|
||||
platform_config = {}
|
||||
|
||||
# Content types (platform-specific handling)
|
||||
if platform == "patreon":
|
||||
# Patreon uses "files" array for content types
|
||||
if "all" in source_config.content_types:
|
||||
platform_config["files"] = ["images", "image_large", "attachments", "postfile", "content"]
|
||||
else:
|
||||
platform_config["files"] = source_config.content_types
|
||||
elif platform == "hentaifoundry":
|
||||
# HentaiFoundry uses "include" for content filtering
|
||||
if "pictures" in source_config.content_types or "all" in source_config.content_types:
|
||||
platform_config["include"] = "pictures"
|
||||
|
||||
# Directory pattern
|
||||
if source_config.directory_pattern:
|
||||
platform_config["directory"] = source_config.directory_pattern
|
||||
elif "directory" in platform_defaults:
|
||||
platform_config["directory"] = platform_defaults["directory"]
|
||||
|
||||
# Filename pattern
|
||||
if source_config.filename_pattern:
|
||||
platform_config["filename"] = source_config.filename_pattern
|
||||
elif "filename" in platform_defaults:
|
||||
platform_config["filename"] = platform_defaults["filename"]
|
||||
|
||||
# Always save metadata at platform level too
|
||||
platform_config["metadata"] = source_config.save_metadata
|
||||
|
||||
# Add platform config to extractor
|
||||
config["extractor"][platform] = platform_config
|
||||
|
||||
return config
|
||||
|
||||
def _categorize_error(self, return_code: int, stdout: str, stderr: str) -> tuple[ErrorType, str]:
|
||||
"""Categorize the error based on output and return code."""
|
||||
combined = f"{stdout} {stderr}".lower()
|
||||
|
||||
if "401" in combined or "unauthorized" in combined or "login" in combined:
|
||||
return ErrorType.AUTH_ERROR, "Authentication failed - cookies may be expired or invalid"
|
||||
|
||||
if "429" in combined or "rate limit" in combined or "too many requests" in combined:
|
||||
return ErrorType.RATE_LIMITED, "Rate limited by server - try increasing sleep time"
|
||||
|
||||
if "404" in combined or "not found" in combined:
|
||||
return ErrorType.NOT_FOUND, "URL not found - artist may have changed username or deleted content"
|
||||
|
||||
if "timeout" in combined or "connection" in combined or "network" in combined:
|
||||
return ErrorType.NETWORK_ERROR, "Network error - check internet connection"
|
||||
|
||||
if "403" in combined or "forbidden" in combined or "access denied" in combined:
|
||||
return ErrorType.ACCESS_DENIED, "Access denied - may need higher subscription tier"
|
||||
|
||||
if return_code == 1 and ("no" in combined or "skip" in combined or not stdout.strip()):
|
||||
return ErrorType.NO_NEW_CONTENT, "No new content to download"
|
||||
|
||||
if "http error" in combined:
|
||||
return ErrorType.HTTP_ERROR, "HTTP error occurred"
|
||||
|
||||
if "no suitable" in combined or "unsupported" in combined:
|
||||
return ErrorType.UNSUPPORTED_URL, "URL format not supported by gallery-dl"
|
||||
|
||||
return ErrorType.UNKNOWN_ERROR, f"Unknown error (return code: {return_code})"
|
||||
|
||||
def _count_downloaded_files(self, stdout: str) -> int:
|
||||
"""Count the number of files downloaded from stdout.
|
||||
|
||||
Gallery-dl outputs one line per downloaded file.
|
||||
Lines starting with '#' or containing 'skip' are not downloads.
|
||||
"""
|
||||
if not stdout:
|
||||
return 0
|
||||
|
||||
count = 0
|
||||
for line in stdout.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "skip" not in line.lower():
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
async def download(
|
||||
self,
|
||||
url: str,
|
||||
subscription_name: str,
|
||||
platform: str,
|
||||
source_config: Optional[SourceConfig] = None,
|
||||
cookies_path: Optional[str] = None,
|
||||
) -> DownloadResult:
|
||||
"""Execute a gallery-dl download.
|
||||
|
||||
Args:
|
||||
url: The URL to download from
|
||||
subscription_name: Name of the subscription/artist (used for destination folder)
|
||||
platform: Platform name (patreon, subscribestar, etc.)
|
||||
source_config: Optional per-source configuration overrides
|
||||
cookies_path: Optional path to cookies file
|
||||
|
||||
Returns:
|
||||
DownloadResult with success status and details
|
||||
|
||||
Downloads are saved to: {download_path}/{subscription_name}/{platform}/{post_folders}
|
||||
"""
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
|
||||
start_time = time.time()
|
||||
started_at = datetime.utcnow().isoformat()
|
||||
|
||||
# Use default config if none provided
|
||||
if source_config is None:
|
||||
source_config = SourceConfig()
|
||||
|
||||
# Build destination path: subscription_name/platform
|
||||
destination = str(Path(self.settings.download_path) / subscription_name / platform)
|
||||
|
||||
# Build merged configuration
|
||||
config = self._build_config_for_source(platform, source_config, destination)
|
||||
|
||||
# Add cookies if provided
|
||||
if cookies_path:
|
||||
config["extractor"]["cookies"] = cookies_path
|
||||
|
||||
# Write temporary config file
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
suffix=".json",
|
||||
delete=False,
|
||||
dir=self.settings.config_path,
|
||||
) as f:
|
||||
json.dump(config, f, indent=2)
|
||||
temp_config_path = f.name
|
||||
|
||||
try:
|
||||
# Build command
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"gallery_dl",
|
||||
"--config",
|
||||
temp_config_path,
|
||||
"--verbose",
|
||||
url,
|
||||
]
|
||||
|
||||
logger.info(f"Executing gallery-dl for {subscription_name}/{platform}: {url}")
|
||||
logger.debug(f"Command: {' '.join(cmd)}")
|
||||
|
||||
# Execute subprocess
|
||||
# Run in thread pool to not block async event loop
|
||||
loop = asyncio.get_event_loop()
|
||||
proc = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=source_config.timeout,
|
||||
),
|
||||
)
|
||||
|
||||
duration = time.time() - start_time
|
||||
completed_at = datetime.utcnow().isoformat()
|
||||
|
||||
# Log output
|
||||
if proc.stdout:
|
||||
logger.debug(f"STDOUT for {subscription_name}/{platform}:\n{proc.stdout}")
|
||||
if proc.stderr:
|
||||
logger.debug(f"STDERR for {subscription_name}/{platform}:\n{proc.stderr}")
|
||||
|
||||
# Determine success
|
||||
if proc.returncode == 0:
|
||||
return DownloadResult(
|
||||
success=True,
|
||||
url=url,
|
||||
subscription_name=subscription_name,
|
||||
platform=platform,
|
||||
files_downloaded=self._count_downloaded_files(proc.stdout),
|
||||
stdout=proc.stdout,
|
||||
stderr=proc.stderr,
|
||||
return_code=proc.returncode,
|
||||
duration_seconds=duration,
|
||||
started_at=started_at,
|
||||
completed_at=completed_at,
|
||||
)
|
||||
|
||||
# Handle errors
|
||||
error_type, error_message = self._categorize_error(
|
||||
proc.returncode, proc.stdout, proc.stderr
|
||||
)
|
||||
|
||||
# NO_NEW_CONTENT is considered successful
|
||||
success = error_type == ErrorType.NO_NEW_CONTENT
|
||||
|
||||
return DownloadResult(
|
||||
success=success,
|
||||
url=url,
|
||||
subscription_name=subscription_name,
|
||||
platform=platform,
|
||||
files_downloaded=self._count_downloaded_files(proc.stdout) if success else 0,
|
||||
stdout=proc.stdout,
|
||||
stderr=proc.stderr,
|
||||
return_code=proc.returncode,
|
||||
error_type=error_type,
|
||||
error_message=error_message,
|
||||
duration_seconds=duration,
|
||||
started_at=started_at,
|
||||
completed_at=completed_at,
|
||||
)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
duration = time.time() - start_time
|
||||
logger.error(f"Download timeout for {subscription_name}/{platform} after {duration:.1f}s")
|
||||
|
||||
return DownloadResult(
|
||||
success=False,
|
||||
url=url,
|
||||
subscription_name=subscription_name,
|
||||
platform=platform,
|
||||
error_type=ErrorType.TIMEOUT,
|
||||
error_message=f"Download timed out after {source_config.timeout} seconds",
|
||||
duration_seconds=duration,
|
||||
started_at=started_at,
|
||||
completed_at=datetime.utcnow().isoformat(),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
duration = time.time() - start_time
|
||||
logger.exception(f"Unexpected error downloading {subscription_name}/{platform}: {e}")
|
||||
|
||||
return DownloadResult(
|
||||
success=False,
|
||||
url=url,
|
||||
subscription_name=subscription_name,
|
||||
platform=platform,
|
||||
error_type=ErrorType.UNKNOWN_ERROR,
|
||||
error_message=str(e),
|
||||
duration_seconds=duration,
|
||||
started_at=started_at,
|
||||
completed_at=datetime.utcnow().isoformat(),
|
||||
)
|
||||
|
||||
finally:
|
||||
# Clean up temporary config file
|
||||
try:
|
||||
Path(temp_config_path).unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_platform_content_types(self, platform: str) -> list[str]:
|
||||
"""Get available content types for a platform.
|
||||
|
||||
Useful for UI to show what options are available.
|
||||
"""
|
||||
if platform == "patreon":
|
||||
return ["images", "image_large", "attachments", "postfile", "content"]
|
||||
elif platform == "hentaifoundry":
|
||||
return ["pictures", "stories"]
|
||||
elif platform == "subscribestar":
|
||||
return ["all"] # No granular control
|
||||
elif platform == "discord":
|
||||
return ["all"] # No granular control
|
||||
else:
|
||||
return ["all"]
|
||||
|
||||
def get_default_config_for_platform(self, platform: str) -> dict:
|
||||
"""Get the default configuration for a platform.
|
||||
|
||||
Useful for showing defaults in UI.
|
||||
"""
|
||||
defaults = self.PLATFORM_DEFAULTS.get(platform, {})
|
||||
return {
|
||||
"content_types": defaults.get("content_types", ["all"]),
|
||||
"directory_pattern": defaults.get("directory"),
|
||||
"filename_pattern": defaults.get("filename"),
|
||||
"sleep": self.settings.download_rate_limit,
|
||||
"sleep_request": max(1.0, self.settings.download_rate_limit / 2),
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Celery tasks."""
|
||||
|
||||
from app.tasks.celery_app import celery_app
|
||||
|
||||
__all__ = ["celery_app"]
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Celery application configuration."""
|
||||
|
||||
from celery import Celery
|
||||
from celery.schedules import crontab
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
celery_app = Celery(
|
||||
"gallery_subscriber",
|
||||
broker=settings.redis_url,
|
||||
backend=settings.redis_url,
|
||||
include=["app.tasks.downloads"],
|
||||
)
|
||||
|
||||
# Celery configuration
|
||||
celery_app.conf.update(
|
||||
task_serializer="json",
|
||||
accept_content=["json"],
|
||||
result_serializer="json",
|
||||
timezone="UTC",
|
||||
enable_utc=True,
|
||||
task_track_started=True,
|
||||
task_time_limit=3600, # 1 hour max per task
|
||||
worker_prefetch_multiplier=1, # Process one task at a time
|
||||
worker_concurrency=settings.download_parallel_limit,
|
||||
)
|
||||
|
||||
# Celery Beat schedule for periodic tasks
|
||||
celery_app.conf.beat_schedule = {
|
||||
"check-sources-hourly": {
|
||||
"task": "app.tasks.downloads.scheduled_check",
|
||||
"schedule": settings.default_check_interval, # Default: every hour
|
||||
},
|
||||
"cleanup-old-downloads-daily": {
|
||||
"task": "app.tasks.downloads.cleanup_old_downloads",
|
||||
"schedule": crontab(hour=3, minute=0), # Daily at 3 AM
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
"""Download-related Celery tasks."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select, and_, or_
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import get_settings
|
||||
from app.tasks.celery_app import celery_app
|
||||
from app.models.source import Source
|
||||
from app.models.subscription import Subscription
|
||||
from app.models.download import Download, DownloadStatus, ErrorType as DBErrorType
|
||||
from app.services.gallery_dl import GalleryDLService, SourceConfig, DownloadResult
|
||||
from app.services.credential_manager import CredentialManager
|
||||
from app.events import publish_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Settings for database connection
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def get_async_session():
|
||||
"""Get async session factory for Celery tasks.
|
||||
|
||||
Creates a fresh engine and session factory each time to avoid
|
||||
event loop issues when asyncio.run() creates new loops.
|
||||
"""
|
||||
engine = create_async_engine(settings.async_database_url, echo=False)
|
||||
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
return session_factory, engine
|
||||
|
||||
|
||||
async def _cleanup_engine(engine):
|
||||
"""Properly dispose of the async engine."""
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def _download_source_async(source_id: int) -> dict:
|
||||
"""Async implementation of source download.
|
||||
|
||||
Args:
|
||||
source_id: ID of the source to download
|
||||
|
||||
Returns:
|
||||
Dict with download results
|
||||
"""
|
||||
session_factory, engine = get_async_session()
|
||||
|
||||
try:
|
||||
async with session_factory() as session:
|
||||
# Get source with subscription
|
||||
result = await session.execute(
|
||||
select(Source)
|
||||
.options(selectinload(Source.subscription))
|
||||
.where(Source.id == source_id)
|
||||
)
|
||||
source = result.scalar()
|
||||
|
||||
if not source:
|
||||
logger.error(f"Source {source_id} not found")
|
||||
return {"source_id": source_id, "status": "error", "error": "Source not found"}
|
||||
|
||||
if not source.enabled:
|
||||
logger.info(f"Source {source.subscription.name}/{source.platform} is disabled, skipping")
|
||||
return {"source_id": source_id, "status": "skipped", "reason": "disabled"}
|
||||
|
||||
subscription_name = source.subscription.name
|
||||
logger.info(f"Starting download for {subscription_name}/{source.platform}")
|
||||
|
||||
# Create download record
|
||||
download = Download(
|
||||
source_id=source.id,
|
||||
url=source.url,
|
||||
status=DownloadStatus.RUNNING,
|
||||
started_at=datetime.utcnow(),
|
||||
)
|
||||
session.add(download)
|
||||
await session.commit()
|
||||
await session.refresh(download)
|
||||
|
||||
# Broadcast download started event
|
||||
publish_event("download.started", {
|
||||
"download_id": download.id,
|
||||
"source_id": source.id,
|
||||
"subscription_name": subscription_name,
|
||||
"platform": source.platform,
|
||||
"url": source.url,
|
||||
})
|
||||
|
||||
try:
|
||||
# Get credentials
|
||||
cred_manager = CredentialManager(session)
|
||||
cookies_path = await cred_manager.get_cookies_path(source.platform)
|
||||
|
||||
if not cookies_path and source.platform in ["patreon", "subscribestar"]:
|
||||
logger.warning(f"No credentials for {source.platform}, download may fail")
|
||||
|
||||
# Build source config from metadata
|
||||
source_config = SourceConfig.from_dict(source.metadata_ or {})
|
||||
|
||||
# Execute download
|
||||
gdl_service = GalleryDLService()
|
||||
dl_result = await gdl_service.download(
|
||||
url=source.url,
|
||||
subscription_name=subscription_name,
|
||||
platform=source.platform,
|
||||
source_config=source_config,
|
||||
cookies_path=cookies_path,
|
||||
)
|
||||
|
||||
# Update download record
|
||||
download.completed_at = datetime.utcnow()
|
||||
download.file_count = dl_result.files_downloaded
|
||||
download.metadata_ = {
|
||||
"stdout": dl_result.stdout[:10000] if dl_result.stdout else None, # Truncate
|
||||
"stderr": dl_result.stderr[:5000] if dl_result.stderr else None,
|
||||
"duration_seconds": dl_result.duration_seconds,
|
||||
}
|
||||
|
||||
if dl_result.success:
|
||||
download.status = DownloadStatus.COMPLETED
|
||||
source.last_success = datetime.utcnow()
|
||||
source.error_count = 0
|
||||
logger.info(f"Download completed for {subscription_name}/{source.platform}: {dl_result.files_downloaded} files")
|
||||
|
||||
# Broadcast download completed event
|
||||
publish_event("download.completed", {
|
||||
"download_id": download.id,
|
||||
"source_id": source.id,
|
||||
"subscription_name": subscription_name,
|
||||
"platform": source.platform,
|
||||
"file_count": dl_result.files_downloaded,
|
||||
"duration_seconds": dl_result.duration_seconds,
|
||||
})
|
||||
else:
|
||||
download.status = DownloadStatus.FAILED
|
||||
download.error_type = dl_result.error_type.value if dl_result.error_type else None
|
||||
download.error_message = dl_result.error_message
|
||||
source.error_count = (source.error_count or 0) + 1
|
||||
logger.warning(f"Download failed for {subscription_name}/{source.platform}: {dl_result.error_message}")
|
||||
|
||||
# Broadcast download failed event
|
||||
publish_event("download.failed", {
|
||||
"download_id": download.id,
|
||||
"source_id": source.id,
|
||||
"subscription_name": subscription_name,
|
||||
"platform": source.platform,
|
||||
"error_type": dl_result.error_type.value if dl_result.error_type else None,
|
||||
"error_message": dl_result.error_message,
|
||||
})
|
||||
|
||||
# Update source last_check
|
||||
source.last_check = datetime.utcnow()
|
||||
await session.commit()
|
||||
|
||||
return {
|
||||
"source_id": source_id,
|
||||
"download_id": download.id,
|
||||
"status": "completed" if dl_result.success else "failed",
|
||||
"files_downloaded": dl_result.files_downloaded,
|
||||
"error_type": dl_result.error_type.value if dl_result.error_type else None,
|
||||
"error_message": dl_result.error_message,
|
||||
"duration_seconds": dl_result.duration_seconds,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Unexpected error downloading {subscription_name}/{source.platform}: {e}")
|
||||
|
||||
download.status = DownloadStatus.FAILED
|
||||
download.error_type = DBErrorType.UNKNOWN_ERROR
|
||||
download.error_message = str(e)
|
||||
download.completed_at = datetime.utcnow()
|
||||
source.error_count = (source.error_count or 0) + 1
|
||||
source.last_check = datetime.utcnow()
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Broadcast download failed event
|
||||
publish_event("download.failed", {
|
||||
"download_id": download.id,
|
||||
"source_id": source.id,
|
||||
"subscription_name": subscription_name,
|
||||
"platform": source.platform,
|
||||
"error_type": "unknown_error",
|
||||
"error_message": str(e),
|
||||
})
|
||||
|
||||
return {
|
||||
"source_id": source_id,
|
||||
"download_id": download.id,
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
}
|
||||
finally:
|
||||
await _cleanup_engine(engine)
|
||||
|
||||
|
||||
async def _scheduled_check_async() -> dict:
|
||||
"""Async implementation of scheduled source check.
|
||||
|
||||
Returns:
|
||||
Dict with check results
|
||||
"""
|
||||
session_factory, engine = get_async_session()
|
||||
|
||||
try:
|
||||
async with session_factory() as session:
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Find sources due for checking:
|
||||
# - enabled = True
|
||||
# - last_check is NULL OR (now - last_check) > check_interval
|
||||
# Order by subscription priority, then by when last checked
|
||||
query = (
|
||||
select(Source)
|
||||
.options(selectinload(Source.subscription))
|
||||
.join(Subscription)
|
||||
.where(
|
||||
and_(
|
||||
Source.enabled == True,
|
||||
Subscription.enabled == True, # Subscription must also be enabled
|
||||
or_(
|
||||
Source.last_check == None,
|
||||
Source.last_check < now - timedelta(seconds=1) # Will be refined below
|
||||
)
|
||||
)
|
||||
)
|
||||
.order_by(
|
||||
Subscription.priority.desc(), # Higher subscription priority first
|
||||
Source.last_check.asc().nullsfirst(), # Never checked first
|
||||
)
|
||||
)
|
||||
|
||||
result = await session.execute(query)
|
||||
sources = result.scalars().all()
|
||||
|
||||
# Filter by check_interval
|
||||
due_sources = []
|
||||
for source in sources:
|
||||
if source.last_check is None:
|
||||
due_sources.append(source)
|
||||
else:
|
||||
time_since_check = (now - source.last_check).total_seconds()
|
||||
if time_since_check >= source.check_interval:
|
||||
due_sources.append(source)
|
||||
|
||||
logger.info(f"Found {len(due_sources)} sources due for checking")
|
||||
|
||||
# Queue download tasks for each source
|
||||
queued = 0
|
||||
for source in due_sources:
|
||||
download_source.delay(source.id)
|
||||
queued += 1
|
||||
logger.debug(f"Queued download for {source.subscription.name}/{source.platform}")
|
||||
|
||||
return {
|
||||
"checked": len(sources),
|
||||
"queued": queued,
|
||||
"timestamp": now.isoformat(),
|
||||
}
|
||||
finally:
|
||||
await _cleanup_engine(engine)
|
||||
|
||||
|
||||
async def _cleanup_old_downloads_async() -> dict:
|
||||
"""Async implementation of download cleanup.
|
||||
|
||||
Returns:
|
||||
Dict with cleanup results
|
||||
"""
|
||||
session_factory, engine = get_async_session()
|
||||
|
||||
try:
|
||||
async with session_factory() as session:
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Delete completed downloads older than 30 days
|
||||
completed_cutoff = now - timedelta(days=30)
|
||||
completed_query = select(Download).where(
|
||||
and_(
|
||||
Download.status == DownloadStatus.COMPLETED,
|
||||
Download.created_at < completed_cutoff
|
||||
)
|
||||
)
|
||||
completed_result = await session.execute(completed_query)
|
||||
completed_old = completed_result.scalars().all()
|
||||
|
||||
# Delete failed downloads older than 7 days
|
||||
failed_cutoff = now - timedelta(days=7)
|
||||
failed_query = select(Download).where(
|
||||
and_(
|
||||
Download.status == DownloadStatus.FAILED,
|
||||
Download.created_at < failed_cutoff
|
||||
)
|
||||
)
|
||||
failed_result = await session.execute(failed_query)
|
||||
failed_old = failed_result.scalars().all()
|
||||
|
||||
deleted_count = 0
|
||||
for download in completed_old + failed_old:
|
||||
await session.delete(download)
|
||||
deleted_count += 1
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"Cleaned up {deleted_count} old download records")
|
||||
return {"deleted": deleted_count}
|
||||
finally:
|
||||
await _cleanup_engine(engine)
|
||||
|
||||
|
||||
# Celery tasks that wrap async functions
|
||||
|
||||
@celery_app.task(bind=True, max_retries=3)
|
||||
def download_source(self, source_id: int):
|
||||
"""Download new content from a single source.
|
||||
|
||||
Args:
|
||||
source_id: ID of the source to download from
|
||||
"""
|
||||
try:
|
||||
return asyncio.run(_download_source_async(source_id))
|
||||
except Exception as e:
|
||||
logger.exception(f"Task failed for source {source_id}: {e}")
|
||||
# Retry with exponential backoff
|
||||
raise self.retry(exc=e, countdown=60 * (2 ** self.request.retries))
|
||||
|
||||
|
||||
@celery_app.task
|
||||
def scheduled_check():
|
||||
"""Check all enabled sources that are due for a check.
|
||||
|
||||
This is called periodically by Celery Beat.
|
||||
"""
|
||||
return asyncio.run(_scheduled_check_async())
|
||||
|
||||
|
||||
@celery_app.task
|
||||
def cleanup_old_downloads():
|
||||
"""Clean up old download records.
|
||||
|
||||
Keeps the database from growing indefinitely.
|
||||
"""
|
||||
return asyncio.run(_cleanup_old_downloads_async())
|
||||
|
||||
|
||||
@celery_app.task(bind=True, max_retries=2)
|
||||
def process_download(self, download_id: int):
|
||||
"""Process a single download record (for retries).
|
||||
|
||||
Args:
|
||||
download_id: ID of the download to process
|
||||
"""
|
||||
async def _process():
|
||||
session_factory, engine = get_async_session()
|
||||
|
||||
try:
|
||||
async with session_factory() as session:
|
||||
result = await session.execute(
|
||||
select(Download).where(Download.id == download_id)
|
||||
)
|
||||
download = result.scalar()
|
||||
|
||||
if not download:
|
||||
return {"download_id": download_id, "status": "error", "error": "Download not found"}
|
||||
|
||||
if not download.source_id:
|
||||
return {"download_id": download_id, "status": "error", "error": "No source associated"}
|
||||
|
||||
# Delegate to download_source
|
||||
return await _download_source_async(download.source_id)
|
||||
finally:
|
||||
await _cleanup_engine(engine)
|
||||
|
||||
try:
|
||||
return asyncio.run(_process())
|
||||
except Exception as e:
|
||||
logger.exception(f"Task failed for download {download_id}: {e}")
|
||||
raise self.retry(exc=e, countdown=60 * (2 ** self.request.retries))
|
||||
@@ -0,0 +1 @@
|
||||
"""Utility modules."""
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Cookie parsing and formatting utilities."""
|
||||
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def parse_netscape_cookies(cookie_text: str) -> list[dict]:
|
||||
"""Parse Netscape cookie format into a list of cookie dicts.
|
||||
|
||||
Netscape format: domain<TAB>flag<TAB>path<TAB>secure<TAB>expiration<TAB>name<TAB>value
|
||||
|
||||
Args:
|
||||
cookie_text: Cookie file content in Netscape format
|
||||
|
||||
Returns:
|
||||
List of cookie dictionaries
|
||||
"""
|
||||
cookies = []
|
||||
|
||||
for line in cookie_text.strip().split("\n"):
|
||||
line = line.strip()
|
||||
|
||||
# Skip comments and empty lines
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
|
||||
parts = line.split("\t")
|
||||
if len(parts) < 7:
|
||||
continue
|
||||
|
||||
cookies.append({
|
||||
"domain": parts[0],
|
||||
"flag": parts[1] == "TRUE",
|
||||
"path": parts[2],
|
||||
"secure": parts[3] == "TRUE",
|
||||
"expiration": int(parts[4]) if parts[4] != "0" else None,
|
||||
"name": parts[5],
|
||||
"value": parts[6],
|
||||
})
|
||||
|
||||
return cookies
|
||||
|
||||
|
||||
def to_netscape_format(cookies: list[dict]) -> str:
|
||||
"""Convert cookie list to Netscape format.
|
||||
|
||||
Args:
|
||||
cookies: List of cookie dictionaries
|
||||
|
||||
Returns:
|
||||
Cookie file content in Netscape format
|
||||
"""
|
||||
lines = ["# Netscape HTTP Cookie File"]
|
||||
|
||||
for c in cookies:
|
||||
line = "\t".join([
|
||||
c["domain"],
|
||||
"TRUE" if c.get("flag", True) else "FALSE",
|
||||
c.get("path", "/"),
|
||||
"TRUE" if c.get("secure", False) else "FALSE",
|
||||
str(c.get("expiration", 0) or 0),
|
||||
c["name"],
|
||||
c["value"],
|
||||
])
|
||||
lines.append(line)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def write_cookie_file(cookies: list[dict], path: Path) -> None:
|
||||
"""Write cookies to a file in Netscape format.
|
||||
|
||||
Args:
|
||||
cookies: List of cookie dictionaries
|
||||
path: Path to write the cookie file
|
||||
"""
|
||||
content = to_netscape_format(cookies)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content)
|
||||
|
||||
|
||||
def get_platform_from_domain(domain: str) -> Optional[str]:
|
||||
"""Detect platform from cookie domain.
|
||||
|
||||
Args:
|
||||
domain: Cookie domain (e.g., '.patreon.com')
|
||||
|
||||
Returns:
|
||||
Platform name or None
|
||||
"""
|
||||
domain_lower = domain.lower()
|
||||
|
||||
if "patreon.com" in domain_lower:
|
||||
return "patreon"
|
||||
elif "subscribestar" in domain_lower:
|
||||
return "subscribestar"
|
||||
elif "hentai-foundry.com" in domain_lower:
|
||||
return "hentaifoundry"
|
||||
elif "discord.com" in domain_lower:
|
||||
return "discord"
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Credential encryption utilities."""
|
||||
|
||||
import base64
|
||||
from cryptography.fernet import Fernet
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
|
||||
|
||||
def _get_fernet(secret_key: str) -> Fernet:
|
||||
"""Derive a Fernet key from the secret key."""
|
||||
kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=b"gallery-subscriber-salt", # Static salt is OK for this use case
|
||||
iterations=100000,
|
||||
)
|
||||
derived_key = base64.urlsafe_b64encode(kdf.derive(secret_key.encode()))
|
||||
return Fernet(derived_key)
|
||||
|
||||
|
||||
def encrypt_data(data: str, secret_key: str) -> bytes:
|
||||
"""Encrypt string data using the secret key.
|
||||
|
||||
Args:
|
||||
data: Plain text data to encrypt
|
||||
secret_key: Application secret key
|
||||
|
||||
Returns:
|
||||
Encrypted bytes
|
||||
"""
|
||||
fernet = _get_fernet(secret_key)
|
||||
return fernet.encrypt(data.encode())
|
||||
|
||||
|
||||
def decrypt_data(encrypted: bytes, secret_key: str) -> str:
|
||||
"""Decrypt data using the secret key.
|
||||
|
||||
Args:
|
||||
encrypted: Encrypted bytes
|
||||
secret_key: Application secret key
|
||||
|
||||
Returns:
|
||||
Decrypted string
|
||||
"""
|
||||
fernet = _get_fernet(secret_key)
|
||||
return fernet.decrypt(encrypted).decode()
|
||||
@@ -0,0 +1,28 @@
|
||||
# Web framework
|
||||
quart==0.19.4
|
||||
quart-cors==0.7.0
|
||||
hypercorn==0.16.0
|
||||
flask>=3.0.0,<3.1.0
|
||||
|
||||
# Database
|
||||
sqlalchemy[asyncio]==2.0.25
|
||||
asyncpg==0.29.0
|
||||
alembic==1.13.1
|
||||
|
||||
# Task queue
|
||||
celery[redis]==5.3.6
|
||||
redis==5.0.1
|
||||
|
||||
# Utilities
|
||||
pydantic==2.5.3
|
||||
pydantic-settings==2.1.0
|
||||
python-dotenv==1.0.0
|
||||
cryptography==41.0.7
|
||||
pyyaml==6.0.1
|
||||
|
||||
# gallery-dl
|
||||
gallery-dl>=1.31.0
|
||||
|
||||
# Testing
|
||||
pytest==7.4.4
|
||||
pytest-asyncio==0.23.3
|
||||
@@ -0,0 +1,35 @@
|
||||
# Development overrides - use with: docker-compose -f docker-compose.yml -f docker-compose.dev.yml up
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile.dev
|
||||
volumes:
|
||||
- ./backend/app:/app/app:ro
|
||||
- downloads:/data/downloads
|
||||
- config:/data/config
|
||||
- cookies:/data/cookies
|
||||
environment:
|
||||
- DEBUG=true
|
||||
- LOG_LEVEL=DEBUG
|
||||
|
||||
celery:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile.dev
|
||||
volumes:
|
||||
- ./backend/app:/app/app:ro
|
||||
- downloads:/data/downloads
|
||||
- config:/data/config
|
||||
- cookies:/data/cookies
|
||||
environment:
|
||||
- DEBUG=true
|
||||
- LOG_LEVEL=DEBUG
|
||||
|
||||
db:
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
redis:
|
||||
ports:
|
||||
- "6379:6379"
|
||||
@@ -0,0 +1,81 @@
|
||||
services:
|
||||
# Combined backend API + frontend static files
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "${PORT:-8080}:8080"
|
||||
environment:
|
||||
- TZ=${TZ:-America/New_York}
|
||||
- DATABASE_URL=postgresql://gdl:${DB_PASSWORD}@db:5432/gallery_subscriber
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- DOWNLOAD_PATH=/data/downloads
|
||||
- CONFIG_PATH=/data/config
|
||||
- COOKIES_PATH=/data/cookies
|
||||
- DOWNLOAD_PARALLEL_LIMIT=${DOWNLOAD_PARALLEL_LIMIT:-3}
|
||||
- DOWNLOAD_RATE_LIMIT=${DOWNLOAD_RATE_LIMIT:-3.0}
|
||||
- DEFAULT_CHECK_INTERVAL=${DEFAULT_CHECK_INTERVAL:-3600}
|
||||
volumes:
|
||||
- downloads:/data/downloads
|
||||
- config:/data/config
|
||||
- cookies:/data/cookies
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
restart: unless-stopped
|
||||
|
||||
# Celery worker for background tasks
|
||||
celery:
|
||||
build: .
|
||||
command: celery -A app.tasks.celery_app worker -B --loglevel=info
|
||||
environment:
|
||||
- TZ=${TZ:-America/New_York}
|
||||
- DATABASE_URL=postgresql://gdl:${DB_PASSWORD}@db:5432/gallery_subscriber
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- DOWNLOAD_PATH=/data/downloads
|
||||
- CONFIG_PATH=/data/config
|
||||
- COOKIES_PATH=/data/cookies
|
||||
- DOWNLOAD_PARALLEL_LIMIT=${DOWNLOAD_PARALLEL_LIMIT:-3}
|
||||
- DOWNLOAD_RATE_LIMIT=${DOWNLOAD_RATE_LIMIT:-3.0}
|
||||
- DEFAULT_CHECK_INTERVAL=${DEFAULT_CHECK_INTERVAL:-3600}
|
||||
volumes:
|
||||
- downloads:/data/downloads
|
||||
- config:/data/config
|
||||
- cookies:/data/cookies
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
restart: unless-stopped
|
||||
|
||||
# PostgreSQL database
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
environment:
|
||||
- TZ=${TZ:-America/New_York}
|
||||
- POSTGRES_USER=gdl
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
- POSTGRES_DB=gallery_subscriber
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U gdl -d gallery_subscriber"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
# Redis for Celery task queue
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
downloads:
|
||||
config:
|
||||
cookies:
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Background script - handles cookie operations and API calls
|
||||
*/
|
||||
|
||||
// Initialize API on startup
|
||||
browser.runtime.onInstalled.addListener(async () => {
|
||||
console.log('GallerySubscriber extension installed');
|
||||
await api.init();
|
||||
});
|
||||
|
||||
browser.runtime.onStartup.addListener(async () => {
|
||||
await api.init();
|
||||
});
|
||||
|
||||
// Handle messages from popup and options pages
|
||||
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
handleMessage(message)
|
||||
.then(sendResponse)
|
||||
.catch(error => {
|
||||
console.error('Message handler error:', error);
|
||||
sendResponse({ error: error.message });
|
||||
});
|
||||
return true; // Keep channel open for async response
|
||||
});
|
||||
|
||||
/**
|
||||
* Route messages to appropriate handlers
|
||||
* @param {object} message - Message object with type property
|
||||
* @returns {Promise<object>} - Response data
|
||||
*/
|
||||
async function handleMessage(message) {
|
||||
switch (message.type) {
|
||||
case 'EXPORT_COOKIES':
|
||||
return await exportCookies(message.platform);
|
||||
|
||||
case 'EXPORT_ALL_COOKIES':
|
||||
return await exportAllCookies();
|
||||
|
||||
case 'GET_PLATFORM_STATUS':
|
||||
return await getPlatformStatus();
|
||||
|
||||
case 'TEST_CONNECTION':
|
||||
return await testConnection();
|
||||
|
||||
case 'GET_CONFIG':
|
||||
return await getConfig();
|
||||
|
||||
case 'SAVE_CONFIG':
|
||||
return await saveConfig(message.config);
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown message type: ${message.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export cookies for a single platform to the backend
|
||||
* @param {string} platformKey - Platform identifier
|
||||
* @returns {Promise<object>} - Result object
|
||||
*/
|
||||
async function exportCookies(platformKey) {
|
||||
await api.init();
|
||||
|
||||
const platform = PLATFORMS[platformKey];
|
||||
if (!platform) {
|
||||
throw new Error(`Unknown platform: ${platformKey}`);
|
||||
}
|
||||
|
||||
let credentialData;
|
||||
let credentialType;
|
||||
|
||||
if (platform.authType === 'token') {
|
||||
// Discord and other token-based platforms require manual entry
|
||||
throw new Error(`${platform.name} requires manual token entry in the web UI. Token extraction is not supported for security reasons.`);
|
||||
}
|
||||
|
||||
// Cookie-based auth
|
||||
const cookies = await extractCookiesForPlatform(platformKey);
|
||||
|
||||
if (cookies.length === 0) {
|
||||
throw new Error(`No cookies found for ${platform.name}. Please ensure you are logged in.`);
|
||||
}
|
||||
|
||||
credentialData = toNetscapeFormat(cookies);
|
||||
credentialType = 'cookies';
|
||||
|
||||
// Send to backend
|
||||
const result = await api.uploadCredentials(platformKey, credentialType, credentialData);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
platform: platformKey,
|
||||
cookieCount: cookies.length,
|
||||
message: result.message || 'Credentials exported successfully'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Export cookies for all cookie-based platforms
|
||||
* @returns {Promise<object>} - Results for each platform
|
||||
*/
|
||||
async function exportAllCookies() {
|
||||
await api.init();
|
||||
|
||||
const results = {};
|
||||
const platforms = Object.keys(PLATFORMS);
|
||||
|
||||
for (const platformKey of platforms) {
|
||||
const platform = PLATFORMS[platformKey];
|
||||
|
||||
// Skip token-based platforms
|
||||
if (platform.authType === 'token') {
|
||||
results[platformKey] = {
|
||||
success: false,
|
||||
platform: platformKey,
|
||||
skipped: true,
|
||||
message: 'Token-based auth requires manual entry'
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
results[platformKey] = await exportCookies(platformKey);
|
||||
} catch (error) {
|
||||
results[platformKey] = {
|
||||
success: false,
|
||||
platform: platformKey,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status information for all platforms
|
||||
* @returns {Promise<object>} - Status for each platform
|
||||
*/
|
||||
async function getPlatformStatus() {
|
||||
const status = {};
|
||||
|
||||
for (const [key, platform] of Object.entries(PLATFORMS)) {
|
||||
try {
|
||||
if (platform.authType === 'token') {
|
||||
// Token-based platforms
|
||||
status[key] = {
|
||||
name: platform.name,
|
||||
authType: 'token',
|
||||
hasCookies: false,
|
||||
cookieCount: 0,
|
||||
note: platform.note || 'Requires manual token entry'
|
||||
};
|
||||
} else {
|
||||
// Cookie-based platforms
|
||||
const cookies = await extractCookiesForPlatform(key);
|
||||
status[key] = {
|
||||
name: platform.name,
|
||||
authType: 'cookies',
|
||||
hasCookies: cookies.length > 0,
|
||||
cookieCount: cookies.length
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
status[key] = {
|
||||
name: platform.name,
|
||||
authType: platform.authType,
|
||||
hasCookies: false,
|
||||
cookieCount: 0,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection to the backend
|
||||
* @returns {Promise<object>} - Connection status
|
||||
*/
|
||||
async function testConnection() {
|
||||
await api.init();
|
||||
|
||||
if (!api.isConfigured()) {
|
||||
return { connected: false, error: 'Not configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
await api.testConnection();
|
||||
return { connected: true };
|
||||
} catch (error) {
|
||||
return { connected: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current configuration
|
||||
* @returns {Promise<object>} - Config object
|
||||
*/
|
||||
async function getConfig() {
|
||||
const config = await browser.storage.local.get(['apiUrl', 'apiKey']);
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save configuration
|
||||
* @param {object} config - Config object with apiUrl and apiKey
|
||||
* @returns {Promise<object>} - Success status
|
||||
*/
|
||||
async function saveConfig(config) {
|
||||
await browser.storage.local.set(config);
|
||||
await api.init();
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Create minimal placeholder PNG icons using only standard library.
|
||||
These are simple solid-color squares that Firefox will accept.
|
||||
"""
|
||||
|
||||
import struct
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
def create_png(width, height, rgb_color):
|
||||
"""Create a minimal PNG file with a solid color."""
|
||||
r, g, b = rgb_color
|
||||
|
||||
def png_chunk(chunk_type, data):
|
||||
"""Create a PNG chunk with CRC."""
|
||||
chunk = chunk_type + data
|
||||
crc = zlib.crc32(chunk) & 0xffffffff
|
||||
return struct.pack('>I', len(data)) + chunk + struct.pack('>I', crc)
|
||||
|
||||
# PNG signature
|
||||
signature = b'\x89PNG\r\n\x1a\n'
|
||||
|
||||
# IHDR chunk (image header)
|
||||
ihdr_data = struct.pack('>IIBBBBB', width, height, 8, 2, 0, 0, 0)
|
||||
ihdr = png_chunk(b'IHDR', ihdr_data)
|
||||
|
||||
# IDAT chunk (image data)
|
||||
# Create raw pixel data: each row starts with filter byte 0, then RGB pixels
|
||||
raw_data = b''
|
||||
for y in range(height):
|
||||
raw_data += b'\x00' # Filter byte (none)
|
||||
for x in range(width):
|
||||
raw_data += bytes([r, g, b])
|
||||
|
||||
compressed = zlib.compress(raw_data, 9)
|
||||
idat = png_chunk(b'IDAT', compressed)
|
||||
|
||||
# IEND chunk
|
||||
iend = png_chunk(b'IEND', b'')
|
||||
|
||||
return signature + ihdr + idat + iend
|
||||
|
||||
def main():
|
||||
icons_dir = Path(__file__).parent / 'icons'
|
||||
icons_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Primary blue color (#1976d2)
|
||||
color = (25, 118, 210)
|
||||
|
||||
sizes = [16, 32, 48, 128]
|
||||
|
||||
for size in sizes:
|
||||
png_data = create_png(size, size, color)
|
||||
output_path = icons_dir / f'icon-{size}.png'
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(png_data)
|
||||
print(f'Created {output_path} ({len(png_data)} bytes)')
|
||||
|
||||
print('\nPlaceholder icons created!')
|
||||
print('Replace with custom icons for a better look.')
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate placeholder icons for the GallerySubscriber extension.
|
||||
Run this script from the extension directory.
|
||||
|
||||
Requirements: Pillow (pip install Pillow)
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw
|
||||
except ImportError:
|
||||
print("Pillow not installed. Install with: pip install Pillow")
|
||||
print("Or manually create icon-16.png, icon-32.png, icon-48.png, icon-128.png")
|
||||
exit(1)
|
||||
|
||||
# Icon configuration
|
||||
BACKGROUND_COLOR = '#1976d2' # Primary blue
|
||||
SIZES = [16, 32, 48, 128]
|
||||
|
||||
def create_icon(size):
|
||||
"""Create a simple colored square icon with rounded corners effect."""
|
||||
img = Image.new('RGBA', (size, size), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Draw filled rounded rectangle (approximated with circle corners for small sizes)
|
||||
margin = max(1, size // 16)
|
||||
radius = max(2, size // 8)
|
||||
|
||||
# For simplicity, just draw a solid square with the primary color
|
||||
draw.rectangle([margin, margin, size - margin - 1, size - margin - 1],
|
||||
fill=BACKGROUND_COLOR)
|
||||
|
||||
# Draw a simple "G" or download arrow for larger sizes
|
||||
if size >= 32:
|
||||
# Draw a simple download arrow
|
||||
center_x = size // 2
|
||||
center_y = size // 2
|
||||
arrow_size = size // 3
|
||||
|
||||
# Arrow body (vertical line)
|
||||
line_width = max(2, size // 12)
|
||||
draw.rectangle([
|
||||
center_x - line_width // 2,
|
||||
center_y - arrow_size // 2,
|
||||
center_x + line_width // 2,
|
||||
center_y + arrow_size // 4
|
||||
], fill='white')
|
||||
|
||||
# Arrow head (triangle pointing down)
|
||||
head_size = arrow_size // 2
|
||||
draw.polygon([
|
||||
(center_x - head_size, center_y),
|
||||
(center_x + head_size, center_y),
|
||||
(center_x, center_y + head_size)
|
||||
], fill='white')
|
||||
|
||||
# Base line
|
||||
draw.rectangle([
|
||||
center_x - arrow_size // 2,
|
||||
center_y + arrow_size // 2,
|
||||
center_x + arrow_size // 2,
|
||||
center_y + arrow_size // 2 + line_width
|
||||
], fill='white')
|
||||
|
||||
return img
|
||||
|
||||
def main():
|
||||
icons_dir = Path(__file__).parent / 'icons'
|
||||
icons_dir.mkdir(exist_ok=True)
|
||||
|
||||
for size in SIZES:
|
||||
icon = create_icon(size)
|
||||
output_path = icons_dir / f'icon-{size}.png'
|
||||
icon.save(output_path, 'PNG')
|
||||
print(f'Created {output_path}')
|
||||
|
||||
print('\nDone! Icons created successfully.')
|
||||
print('You can replace these with custom icons later.')
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,40 @@
|
||||
# Extension Icons
|
||||
|
||||
This directory should contain PNG icons for the extension in the following sizes:
|
||||
- icon-16.png (16x16 pixels)
|
||||
- icon-32.png (32x32 pixels)
|
||||
- icon-48.png (48x48 pixels)
|
||||
- icon-128.png (128x128 pixels)
|
||||
|
||||
## Quick Setup
|
||||
|
||||
You can generate placeholder icons using any image editor or online tool.
|
||||
The icons should ideally be a simple "GS" logo or download icon.
|
||||
|
||||
## Recommended Design
|
||||
|
||||
- Background: #1976d2 (primary blue)
|
||||
- Foreground: White
|
||||
- Style: Simple, recognizable at small sizes
|
||||
- Consider a download arrow or gallery grid icon
|
||||
|
||||
## Generate with ImageMagick
|
||||
|
||||
If you have ImageMagick installed:
|
||||
|
||||
```bash
|
||||
# Create a simple colored square as placeholder
|
||||
for size in 16 32 48 128; do
|
||||
convert -size ${size}x${size} xc:#1976d2 icon-${size}.png
|
||||
done
|
||||
```
|
||||
|
||||
## Generate with Python/Pillow
|
||||
|
||||
```python
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
for size in [16, 32, 48, 128]:
|
||||
img = Image.new('RGB', (size, size), '#1976d2')
|
||||
img.save(f'icon-{size}.png')
|
||||
```
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 258 B |
Binary file not shown.
|
After Width: | Height: | Size: 79 B |
Binary file not shown.
|
After Width: | Height: | Size: 99 B |
Binary file not shown.
|
After Width: | Height: | Size: 116 B |
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"manifest_version": 2,
|
||||
"name": "GallerySubscriber",
|
||||
"version": "1.0.0",
|
||||
"description": "Export cookies from supported platforms to GallerySubscriber for automated downloads",
|
||||
|
||||
"permissions": [
|
||||
"cookies",
|
||||
"storage",
|
||||
"tabs",
|
||||
"activeTab",
|
||||
"*://*.patreon.com/*",
|
||||
"*://*.subscribestar.com/*",
|
||||
"*://*.subscribestar.adult/*",
|
||||
"*://*.hentai-foundry.com/*",
|
||||
"*://*.discord.com/*"
|
||||
],
|
||||
|
||||
"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_title": "GallerySubscriber"
|
||||
},
|
||||
|
||||
"background": {
|
||||
"scripts": [
|
||||
"lib/platforms.js",
|
||||
"lib/cookies.js",
|
||||
"lib/api.js",
|
||||
"background/background.js"
|
||||
],
|
||||
"persistent": false
|
||||
},
|
||||
|
||||
"options_ui": {
|
||||
"page": "options/options.html",
|
||||
"browser_style": true
|
||||
},
|
||||
|
||||
"icons": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>GallerySubscriber Settings</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #1976d2;
|
||||
--success-color: #4caf50;
|
||||
--error-color: #f44336;
|
||||
--bg-color: #f5f5f5;
|
||||
--card-bg: #ffffff;
|
||||
--text-color: #212121;
|
||||
--text-secondary: #757575;
|
||||
--border-color: #e0e0e0;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
color: var(--text-color);
|
||||
background: var(--bg-color);
|
||||
padding: 24px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 24px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="url"],
|
||||
input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.help-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.input-with-button {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input-with-button input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #1565c0;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f5f5f5;
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #eeeeee;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.result {
|
||||
font-size: 13px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.result.success {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.result.error {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.connection-test {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #9e9e9e;
|
||||
}
|
||||
|
||||
.status-dot.connected {
|
||||
background: var(--success-color);
|
||||
}
|
||||
|
||||
.status-dot.error {
|
||||
background: var(--error-color);
|
||||
}
|
||||
|
||||
.instructions {
|
||||
background: #e3f2fd;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.instructions h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
.instructions ol {
|
||||
margin-left: 20px;
|
||||
font-size: 13px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.instructions li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.instructions code {
|
||||
background: rgba(0,0,0,0.08);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>GallerySubscriber Extension Settings</h1>
|
||||
|
||||
<div class="instructions">
|
||||
<h3>Setup Instructions</h3>
|
||||
<ol>
|
||||
<li>Open your GallerySubscriber web interface</li>
|
||||
<li>Go to <strong>Settings</strong> page</li>
|
||||
<li>Copy the <strong>Extension API Key</strong></li>
|
||||
<li>Paste the API URL and key below</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Backend Connection</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="api-url">API URL</label>
|
||||
<input
|
||||
type="url"
|
||||
id="api-url"
|
||||
placeholder="http://localhost:8080/api"
|
||||
>
|
||||
<p class="help-text">
|
||||
The URL of your GallerySubscriber backend. Include <code>/api</code> at the end.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="api-key">Extension API Key</label>
|
||||
<div class="input-with-button">
|
||||
<input
|
||||
type="password"
|
||||
id="api-key"
|
||||
placeholder="Your API key from Settings page"
|
||||
>
|
||||
<button type="button" id="toggle-key-btn" class="btn btn-secondary btn-small">
|
||||
Show
|
||||
</button>
|
||||
</div>
|
||||
<p class="help-text">
|
||||
Find this in GallerySubscriber: Settings > Extension API Key
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="connection-test">
|
||||
<button id="test-btn" class="btn btn-secondary btn-small">Test Connection</button>
|
||||
<span id="test-status" class="status-dot"></span>
|
||||
<span id="test-message"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button id="save-btn" class="btn btn-primary">Save Settings</button>
|
||||
<span id="save-result" class="result"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="options.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Options page script
|
||||
*/
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await loadSettings();
|
||||
setupEventListeners();
|
||||
});
|
||||
|
||||
/**
|
||||
* Load saved settings into form
|
||||
*/
|
||||
async function loadSettings() {
|
||||
const config = await browser.storage.local.get(['apiUrl', 'apiKey']);
|
||||
|
||||
document.getElementById('api-url').value = config.apiUrl || '';
|
||||
document.getElementById('api-key').value = config.apiKey || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event listeners
|
||||
*/
|
||||
function setupEventListeners() {
|
||||
// Toggle API key visibility
|
||||
const toggleBtn = document.getElementById('toggle-key-btn');
|
||||
const apiKeyInput = document.getElementById('api-key');
|
||||
|
||||
toggleBtn.addEventListener('click', () => {
|
||||
if (apiKeyInput.type === 'password') {
|
||||
apiKeyInput.type = 'text';
|
||||
toggleBtn.textContent = 'Hide';
|
||||
} else {
|
||||
apiKeyInput.type = 'password';
|
||||
toggleBtn.textContent = 'Show';
|
||||
}
|
||||
});
|
||||
|
||||
// Test connection
|
||||
document.getElementById('test-btn').addEventListener('click', testConnection);
|
||||
|
||||
// Save settings
|
||||
document.getElementById('save-btn').addEventListener('click', saveSettings);
|
||||
|
||||
// Auto-save on enter key
|
||||
document.getElementById('api-url').addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') saveSettings();
|
||||
});
|
||||
document.getElementById('api-key').addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') saveSettings();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection to backend
|
||||
*/
|
||||
async function testConnection() {
|
||||
const testBtn = document.getElementById('test-btn');
|
||||
const statusDot = document.getElementById('test-status');
|
||||
const testMessage = document.getElementById('test-message');
|
||||
|
||||
testBtn.disabled = true;
|
||||
testBtn.textContent = 'Testing...';
|
||||
statusDot.className = 'status-dot';
|
||||
testMessage.textContent = '';
|
||||
|
||||
// Get current values
|
||||
const apiUrl = normalizeApiUrl(document.getElementById('api-url').value.trim());
|
||||
const apiKey = document.getElementById('api-key').value.trim();
|
||||
|
||||
if (!apiUrl || !apiKey) {
|
||||
statusDot.className = 'status-dot error';
|
||||
testMessage.textContent = 'Please fill in both fields';
|
||||
testBtn.disabled = false;
|
||||
testBtn.textContent = 'Test Connection';
|
||||
return;
|
||||
}
|
||||
|
||||
// Temporarily save for testing
|
||||
await browser.storage.local.set({ apiUrl, apiKey });
|
||||
|
||||
try {
|
||||
const result = await browser.runtime.sendMessage({ type: 'TEST_CONNECTION' });
|
||||
|
||||
if (result.connected) {
|
||||
statusDot.className = 'status-dot connected';
|
||||
testMessage.textContent = 'Connected successfully!';
|
||||
} else {
|
||||
statusDot.className = 'status-dot error';
|
||||
testMessage.textContent = result.error || 'Connection failed';
|
||||
}
|
||||
} catch (error) {
|
||||
statusDot.className = 'status-dot error';
|
||||
testMessage.textContent = error.message;
|
||||
} finally {
|
||||
testBtn.disabled = false;
|
||||
testBtn.textContent = 'Test Connection';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save settings
|
||||
*/
|
||||
async function saveSettings() {
|
||||
const saveBtn = document.getElementById('save-btn');
|
||||
const saveResult = document.getElementById('save-result');
|
||||
|
||||
saveBtn.disabled = true;
|
||||
saveResult.textContent = '';
|
||||
saveResult.className = 'result';
|
||||
|
||||
let apiUrl = document.getElementById('api-url').value.trim();
|
||||
const apiKey = document.getElementById('api-key').value.trim();
|
||||
|
||||
// Validate
|
||||
if (!apiUrl) {
|
||||
showSaveResult('API URL is required', 'error');
|
||||
saveBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
showSaveResult('API Key is required', 'error');
|
||||
saveBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize URL
|
||||
apiUrl = normalizeApiUrl(apiUrl);
|
||||
document.getElementById('api-url').value = apiUrl;
|
||||
|
||||
// Save
|
||||
try {
|
||||
await browser.storage.local.set({ apiUrl, apiKey });
|
||||
await browser.runtime.sendMessage({
|
||||
type: 'SAVE_CONFIG',
|
||||
config: { apiUrl, apiKey }
|
||||
});
|
||||
|
||||
showSaveResult('Settings saved!', 'success');
|
||||
} catch (error) {
|
||||
showSaveResult(`Failed to save: ${error.message}`, 'error');
|
||||
} finally {
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize API URL to ensure it ends with /api
|
||||
* @param {string} url - Input URL
|
||||
* @returns {string} - Normalized URL
|
||||
*/
|
||||
function normalizeApiUrl(url) {
|
||||
if (!url) return '';
|
||||
|
||||
// Remove trailing slash
|
||||
url = url.replace(/\/+$/, '');
|
||||
|
||||
// Ensure it ends with /api
|
||||
if (!url.endsWith('/api')) {
|
||||
url = url + '/api';
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show save result message
|
||||
* @param {string} message - Message to display
|
||||
* @param {string} type - 'success' or 'error'
|
||||
*/
|
||||
function showSaveResult(message, type) {
|
||||
const saveResult = document.getElementById('save-result');
|
||||
saveResult.textContent = message;
|
||||
saveResult.className = `result ${type}`;
|
||||
|
||||
// Auto-hide success messages
|
||||
if (type === 'success') {
|
||||
setTimeout(() => {
|
||||
saveResult.textContent = '';
|
||||
saveResult.className = 'result';
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
:root {
|
||||
--primary-color: #1976d2;
|
||||
--success-color: #4caf50;
|
||||
--warning-color: #ff9800;
|
||||
--error-color: #f44336;
|
||||
--bg-color: #ffffff;
|
||||
--text-color: #212121;
|
||||
--text-secondary: #757575;
|
||||
--border-color: #e0e0e0;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
color: var(--text-color);
|
||||
background: var(--bg-color);
|
||||
min-width: 320px;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.popup-container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--error-color);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.status-indicator.connected {
|
||||
background: var(--success-color);
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.platforms-grid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.platform-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.platform-card:hover:not(.disabled) {
|
||||
border-color: var(--primary-color);
|
||||
background: #f5f9ff;
|
||||
}
|
||||
|
||||
.platform-card.disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.platform-card .platform-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 10px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.platform-card .info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.platform-card .name {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.platform-card .status {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.platform-card .status.ready {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.platform-card .status.no-cookies {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
.platform-card .status.error {
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
.platform-card .action-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
opacity: 0.5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.platform-card.loading .action-icon {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #1565c0;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f5f5f5;
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #eeeeee;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 6px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: #f5f5f5;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mt-2 {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.alert strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.alert p {
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background: #fff8e1;
|
||||
border: 1px solid #ffcc02;
|
||||
color: #795548;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status-message.success {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
border: 1px solid #a5d6a7;
|
||||
}
|
||||
|
||||
.status-message.error {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
border: 1px solid #ef9a9a;
|
||||
}
|
||||
|
||||
.status-message.warning {
|
||||
background: #fff8e1;
|
||||
color: #795548;
|
||||
border: 1px solid #ffcc02;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.version {
|
||||
font-size: 11px;
|
||||
color: #9e9e9e;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="stylesheet" href="popup.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="popup-container">
|
||||
<header class="header">
|
||||
<img src="../icons/icon-32.png" alt="GallerySubscriber" class="logo">
|
||||
<h1>GallerySubscriber</h1>
|
||||
<span id="connection-status" class="status-indicator" title="Disconnected"></span>
|
||||
</header>
|
||||
|
||||
<!-- Not Configured State -->
|
||||
<div id="setup-required" class="section hidden">
|
||||
<div class="alert alert-warning">
|
||||
<strong>Setup Required</strong>
|
||||
<p>Please configure your API URL and key in settings.</p>
|
||||
</div>
|
||||
<button id="open-settings-btn" class="btn btn-primary full-width">
|
||||
Open Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div id="main-content" class="hidden">
|
||||
<!-- Cookie Export Section -->
|
||||
<section class="section">
|
||||
<h2>Export Cookies</h2>
|
||||
<div id="platforms-list" class="platforms-grid">
|
||||
<!-- Populated by JavaScript -->
|
||||
</div>
|
||||
<button id="export-all-btn" class="btn btn-secondary full-width mt-2">
|
||||
Export All Platforms
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<!-- Status Messages -->
|
||||
<div id="status-message" class="status-message hidden"></div>
|
||||
</div>
|
||||
|
||||
<footer class="footer">
|
||||
<button id="settings-btn" class="btn-icon" title="Settings">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.21,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.21,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.67 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="version">v1.0.0</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="../lib/platforms.js"></script>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Popup script - handles UI interactions
|
||||
*/
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await init();
|
||||
});
|
||||
|
||||
/**
|
||||
* Initialize the popup
|
||||
*/
|
||||
async function init() {
|
||||
// Check configuration
|
||||
const config = await browser.runtime.sendMessage({ type: 'GET_CONFIG' });
|
||||
|
||||
if (!config.apiUrl || !config.apiKey) {
|
||||
showSetupRequired();
|
||||
return;
|
||||
}
|
||||
|
||||
// Test connection
|
||||
const connectionStatus = await browser.runtime.sendMessage({ type: 'TEST_CONNECTION' });
|
||||
updateConnectionIndicator(connectionStatus.connected);
|
||||
|
||||
// Show main content
|
||||
document.getElementById('setup-required').classList.add('hidden');
|
||||
document.getElementById('main-content').classList.remove('hidden');
|
||||
|
||||
if (!connectionStatus.connected) {
|
||||
showError(`Cannot connect to backend: ${connectionStatus.error}`);
|
||||
}
|
||||
|
||||
// Load platform status
|
||||
await loadPlatformStatus();
|
||||
|
||||
// Setup event listeners
|
||||
setupEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the setup required message
|
||||
*/
|
||||
function showSetupRequired() {
|
||||
document.getElementById('setup-required').classList.remove('hidden');
|
||||
document.getElementById('main-content').classList.add('hidden');
|
||||
|
||||
document.getElementById('open-settings-btn').addEventListener('click', () => {
|
||||
browser.runtime.openOptionsPage();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the connection status indicator
|
||||
* @param {boolean} connected - Whether connected to backend
|
||||
*/
|
||||
function updateConnectionIndicator(connected) {
|
||||
const indicator = document.getElementById('connection-status');
|
||||
indicator.classList.toggle('connected', connected);
|
||||
indicator.title = connected ? 'Connected to GallerySubscriber' : 'Disconnected';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and display platform status
|
||||
*/
|
||||
async function loadPlatformStatus() {
|
||||
const status = await browser.runtime.sendMessage({ type: 'GET_PLATFORM_STATUS' });
|
||||
const container = document.getElementById('platforms-list');
|
||||
container.innerHTML = '';
|
||||
|
||||
for (const [key, platform] of Object.entries(PLATFORMS)) {
|
||||
const platformStatus = status[key] || {};
|
||||
const card = createPlatformCard(key, platform, platformStatus);
|
||||
container.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a platform card element
|
||||
* @param {string} key - Platform key
|
||||
* @param {object} platform - Platform definition
|
||||
* @param {object} status - Platform status
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
function createPlatformCard(key, platform, status) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'platform-card';
|
||||
card.dataset.platform = key;
|
||||
|
||||
const isDisabled = platform.authType === 'token';
|
||||
if (isDisabled) {
|
||||
card.classList.add('disabled');
|
||||
}
|
||||
|
||||
const statusText = getStatusText(status, platform);
|
||||
const statusClass = getStatusClass(status, platform);
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="platform-icon" style="background-color: ${platform.color}">
|
||||
${platform.name.charAt(0)}
|
||||
</div>
|
||||
<div class="info">
|
||||
<div class="name">${platform.name}</div>
|
||||
<div class="status ${statusClass}">${statusText}</div>
|
||||
</div>
|
||||
<span class="action-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z"/>
|
||||
</svg>
|
||||
</span>
|
||||
`;
|
||||
|
||||
if (!isDisabled) {
|
||||
card.addEventListener('click', () => exportPlatformCookies(key, card));
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status text for display
|
||||
* @param {object} status - Platform status
|
||||
* @param {object} platform - Platform definition
|
||||
* @returns {string}
|
||||
*/
|
||||
function getStatusText(status, platform) {
|
||||
if (platform.authType === 'token') {
|
||||
return 'Manual token entry required';
|
||||
}
|
||||
if (status.error) {
|
||||
return 'Error checking cookies';
|
||||
}
|
||||
if (!status.hasCookies || status.cookieCount === 0) {
|
||||
return 'No cookies found - log in first';
|
||||
}
|
||||
return `${status.cookieCount} cookies ready`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status CSS class
|
||||
* @param {object} status - Platform status
|
||||
* @param {object} platform - Platform definition
|
||||
* @returns {string}
|
||||
*/
|
||||
function getStatusClass(status, platform) {
|
||||
if (platform.authType === 'token') {
|
||||
return 'no-cookies';
|
||||
}
|
||||
if (status.error) {
|
||||
return 'error';
|
||||
}
|
||||
if (!status.hasCookies || status.cookieCount === 0) {
|
||||
return 'no-cookies';
|
||||
}
|
||||
return 'ready';
|
||||
}
|
||||
|
||||
/**
|
||||
* Export cookies for a single platform
|
||||
* @param {string} platformKey - Platform key
|
||||
* @param {HTMLElement} cardElement - The card element
|
||||
*/
|
||||
async function exportPlatformCookies(platformKey, cardElement) {
|
||||
cardElement.classList.add('loading');
|
||||
hideStatusMessage();
|
||||
|
||||
try {
|
||||
const result = await browser.runtime.sendMessage({
|
||||
type: 'EXPORT_COOKIES',
|
||||
platform: platformKey
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
showError(result.error);
|
||||
} else {
|
||||
showSuccess(`${PLATFORMS[platformKey].name}: ${result.cookieCount} cookies exported!`);
|
||||
// Refresh status
|
||||
await loadPlatformStatus();
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
} finally {
|
||||
cardElement.classList.remove('loading');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export cookies for all platforms
|
||||
*/
|
||||
async function exportAllCookies() {
|
||||
const btn = document.getElementById('export-all-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Exporting...';
|
||||
hideStatusMessage();
|
||||
|
||||
try {
|
||||
const results = await browser.runtime.sendMessage({ type: 'EXPORT_ALL_COOKIES' });
|
||||
|
||||
const successes = Object.values(results).filter(r => r.success).length;
|
||||
const skipped = Object.values(results).filter(r => r.skipped).length;
|
||||
const failures = Object.values(results).filter(r => !r.success && !r.skipped).length;
|
||||
|
||||
if (failures === 0 && successes > 0) {
|
||||
showSuccess(`Exported ${successes} platform(s) successfully!`);
|
||||
} else if (successes > 0) {
|
||||
showWarning(`${successes} succeeded, ${failures} failed`);
|
||||
} else if (failures > 0) {
|
||||
showError('All exports failed. Are you logged in?');
|
||||
} else {
|
||||
showWarning('No platforms to export');
|
||||
}
|
||||
|
||||
await loadPlatformStatus();
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Export All Platforms';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event listeners
|
||||
*/
|
||||
function setupEventListeners() {
|
||||
document.getElementById('export-all-btn').addEventListener('click', exportAllCookies);
|
||||
document.getElementById('settings-btn').addEventListener('click', () => {
|
||||
browser.runtime.openOptionsPage();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show success message
|
||||
* @param {string} message - Message to display
|
||||
*/
|
||||
function showSuccess(message) {
|
||||
showStatusMessage(message, 'success');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show error message
|
||||
* @param {string} message - Message to display
|
||||
*/
|
||||
function showError(message) {
|
||||
showStatusMessage(message, 'error');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show warning message
|
||||
* @param {string} message - Message to display
|
||||
*/
|
||||
function showWarning(message) {
|
||||
showStatusMessage(message, 'warning');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a status message
|
||||
* @param {string} message - Message text
|
||||
* @param {string} type - Message type (success, error, warning)
|
||||
*/
|
||||
function showStatusMessage(message, type) {
|
||||
const el = document.getElementById('status-message');
|
||||
el.textContent = message;
|
||||
el.className = `status-message ${type}`;
|
||||
el.classList.remove('hidden');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the status message
|
||||
*/
|
||||
function hideStatusMessage() {
|
||||
document.getElementById('status-message').classList.add('hidden');
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Gallery Subscriber</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "gallery-subscriber-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.15",
|
||||
"vue-router": "^4.2.5",
|
||||
"vuetify": "^3.5.1",
|
||||
"pinia": "^2.1.7",
|
||||
"axios": "^1.6.5",
|
||||
"@mdi/font": "^7.4.47"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.3",
|
||||
"vite": "^5.0.11",
|
||||
"sass": "^1.70.0"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<v-app>
|
||||
<v-navigation-drawer v-model="drawer" app>
|
||||
<v-list-item
|
||||
prepend-icon="mdi-image-multiple"
|
||||
title="Gallery Subscriber"
|
||||
subtitle="Download Manager"
|
||||
/>
|
||||
<v-divider />
|
||||
<v-list nav density="compact">
|
||||
<v-list-item
|
||||
v-for="item in navItems"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
:prepend-icon="item.icon"
|
||||
:title="item.title"
|
||||
/>
|
||||
</v-list>
|
||||
</v-navigation-drawer>
|
||||
|
||||
<v-app-bar app elevation="1">
|
||||
<v-app-bar-nav-icon @click="drawer = !drawer" />
|
||||
<v-toolbar-title>{{ currentPageTitle }}</v-toolbar-title>
|
||||
<v-spacer />
|
||||
<v-btn icon @click="toggleTheme">
|
||||
<v-icon>{{ isDark ? 'mdi-weather-sunny' : 'mdi-weather-night' }}</v-icon>
|
||||
</v-btn>
|
||||
</v-app-bar>
|
||||
|
||||
<v-main>
|
||||
<v-container fluid>
|
||||
<router-view />
|
||||
</v-container>
|
||||
</v-main>
|
||||
|
||||
<v-snackbar
|
||||
v-model="notification.show"
|
||||
:color="notification.color"
|
||||
:timeout="3000"
|
||||
>
|
||||
{{ notification.message }}
|
||||
<template v-slot:actions>
|
||||
<v-btn variant="text" @click="notification.show = false">Close</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useTheme } from 'vuetify'
|
||||
import { useNotificationStore } from './stores/notifications'
|
||||
import { useWebSocketStore } from './stores/websocket'
|
||||
|
||||
const theme = useTheme()
|
||||
const route = useRoute()
|
||||
const notificationStore = useNotificationStore()
|
||||
const wsStore = useWebSocketStore()
|
||||
|
||||
// Initialize WebSocket listeners
|
||||
onMounted(() => {
|
||||
wsStore.init()
|
||||
})
|
||||
|
||||
const drawer = ref(true)
|
||||
|
||||
const navItems = [
|
||||
{ title: 'Dashboard', icon: 'mdi-view-dashboard', path: '/' },
|
||||
{ title: 'Subscriptions', icon: 'mdi-account-multiple', path: '/subscriptions' },
|
||||
{ title: 'Downloads', icon: 'mdi-download', path: '/downloads' },
|
||||
{ title: 'Credentials', icon: 'mdi-key', path: '/credentials' },
|
||||
{ title: 'Settings', icon: 'mdi-cog', path: '/settings' },
|
||||
]
|
||||
|
||||
const currentPageTitle = computed(() => {
|
||||
const item = navItems.find(item => item.path === route.path)
|
||||
return item?.title || 'Gallery Subscriber'
|
||||
})
|
||||
|
||||
const isDark = computed(() => theme.global.current.value.dark)
|
||||
|
||||
const toggleTheme = () => {
|
||||
theme.global.name.value = isDark.value ? 'light' : 'dark'
|
||||
}
|
||||
|
||||
const notification = computed(() => notificationStore)
|
||||
</script>
|
||||
@@ -0,0 +1,136 @@
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const WS_URL = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws/events`
|
||||
|
||||
// Singleton WebSocket connection
|
||||
let ws = null
|
||||
let reconnectTimeout = null
|
||||
const listeners = new Map()
|
||||
const isConnected = ref(false)
|
||||
const lastEvent = ref(null)
|
||||
|
||||
function connect() {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
ws = new WebSocket(WS_URL)
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connected')
|
||||
isConnected.value = true
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('WebSocket disconnected')
|
||||
isConnected.value = false
|
||||
ws = null
|
||||
|
||||
// Reconnect after 5 seconds
|
||||
if (!reconnectTimeout) {
|
||||
reconnectTimeout = setTimeout(() => {
|
||||
reconnectTimeout = null
|
||||
connect()
|
||||
}, 5000)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error)
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
lastEvent.value = data
|
||||
|
||||
// Handle ping
|
||||
if (data.type === 'ping') {
|
||||
ws.send(JSON.stringify({ type: 'pong' }))
|
||||
return
|
||||
}
|
||||
|
||||
// Notify all listeners for this event type
|
||||
const typeListeners = listeners.get(data.type) || []
|
||||
typeListeners.forEach(callback => {
|
||||
try {
|
||||
callback(data.data)
|
||||
} catch (e) {
|
||||
console.error('Error in WebSocket listener:', e)
|
||||
}
|
||||
})
|
||||
|
||||
// Also notify wildcard listeners
|
||||
const wildcardListeners = listeners.get('*') || []
|
||||
wildcardListeners.forEach(callback => {
|
||||
try {
|
||||
callback(data.type, data.data)
|
||||
} catch (e) {
|
||||
console.error('Error in WebSocket listener:', e)
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Failed to parse WebSocket message:', e)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to create WebSocket:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (reconnectTimeout) {
|
||||
clearTimeout(reconnectTimeout)
|
||||
reconnectTimeout = null
|
||||
}
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
isConnected.value = false
|
||||
}
|
||||
|
||||
function subscribe(eventType, callback) {
|
||||
if (!listeners.has(eventType)) {
|
||||
listeners.set(eventType, [])
|
||||
}
|
||||
listeners.get(eventType).push(callback)
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
const typeListeners = listeners.get(eventType)
|
||||
if (typeListeners) {
|
||||
const index = typeListeners.indexOf(callback)
|
||||
if (index > -1) {
|
||||
typeListeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function send(type, data = {}) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type, data }))
|
||||
}
|
||||
}
|
||||
|
||||
export function useWebSocket() {
|
||||
onMounted(() => {
|
||||
connect()
|
||||
})
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
lastEvent,
|
||||
subscribe,
|
||||
send,
|
||||
connect,
|
||||
disconnect,
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-connect on import
|
||||
if (typeof window !== 'undefined') {
|
||||
connect()
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import vuetify from './plugins/vuetify'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(vuetify)
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'vuetify/styles'
|
||||
import '@mdi/font/css/materialdesignicons.css'
|
||||
import { createVuetify } from 'vuetify'
|
||||
import * as components from 'vuetify/components'
|
||||
import * as directives from 'vuetify/directives'
|
||||
|
||||
export default createVuetify({
|
||||
components,
|
||||
directives,
|
||||
theme: {
|
||||
defaultTheme: 'dark',
|
||||
themes: {
|
||||
dark: {
|
||||
colors: {
|
||||
primary: '#1976D2',
|
||||
secondary: '#424242',
|
||||
accent: '#82B1FF',
|
||||
error: '#FF5252',
|
||||
info: '#2196F3',
|
||||
success: '#4CAF50',
|
||||
warning: '#FFC107',
|
||||
},
|
||||
},
|
||||
light: {
|
||||
colors: {
|
||||
primary: '#1976D2',
|
||||
secondary: '#424242',
|
||||
accent: '#82B1FF',
|
||||
error: '#FF5252',
|
||||
info: '#2196F3',
|
||||
success: '#4CAF50',
|
||||
warning: '#FFC107',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
defaults: {
|
||||
VCard: {
|
||||
elevation: 2,
|
||||
},
|
||||
VBtn: {
|
||||
variant: 'flat',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Dashboard',
|
||||
component: () => import('../views/Dashboard.vue'),
|
||||
},
|
||||
{
|
||||
path: '/subscriptions',
|
||||
name: 'Subscriptions',
|
||||
component: () => import('../views/Subscriptions.vue'),
|
||||
},
|
||||
// Legacy route redirects
|
||||
{
|
||||
path: '/sources',
|
||||
redirect: '/subscriptions',
|
||||
},
|
||||
{
|
||||
path: '/downloads',
|
||||
name: 'Downloads',
|
||||
component: () => import('../views/Downloads.vue'),
|
||||
},
|
||||
{
|
||||
path: '/credentials',
|
||||
name: 'Credentials',
|
||||
component: () => import('../views/Credentials.vue'),
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'Settings',
|
||||
component: () => import('../views/Settings.vue'),
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,66 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// Subscriptions API
|
||||
export const subscriptionsApi = {
|
||||
list: (params = {}) => api.get('/subscriptions', { params }),
|
||||
get: (id) => api.get(`/subscriptions/${id}`),
|
||||
create: (data) => api.post('/subscriptions', data),
|
||||
update: (id, data) => api.patch(`/subscriptions/${id}`, data),
|
||||
delete: (id) => api.delete(`/subscriptions/${id}`),
|
||||
triggerCheck: (id) => api.post(`/subscriptions/${id}/check`),
|
||||
listSources: (id) => api.get(`/subscriptions/${id}/sources`),
|
||||
addSource: (id, data) => api.post(`/subscriptions/${id}/sources`, data),
|
||||
}
|
||||
|
||||
// Sources API
|
||||
export const sourcesApi = {
|
||||
list: (params = {}) => api.get('/sources', { params }),
|
||||
get: (id) => api.get(`/sources/${id}`),
|
||||
create: (data) => api.post('/sources', data),
|
||||
update: (id, data) => api.patch(`/sources/${id}`, data),
|
||||
delete: (id) => api.delete(`/sources/${id}`),
|
||||
triggerCheck: (id) => api.post(`/sources/${id}/check`),
|
||||
}
|
||||
|
||||
// Downloads API
|
||||
export const downloadsApi = {
|
||||
list: (params = {}) => api.get('/downloads', { params }),
|
||||
get: (id) => api.get(`/downloads/${id}`),
|
||||
retry: (id) => api.post(`/downloads/${id}/retry`),
|
||||
stats: (params = {}) => api.get('/downloads/stats', { params }),
|
||||
}
|
||||
|
||||
// Credentials API
|
||||
export const credentialsApi = {
|
||||
list: () => api.get('/credentials'),
|
||||
upload: (data) => api.post('/credentials', data),
|
||||
delete: (platform) => api.delete(`/credentials/${platform}`),
|
||||
verify: (platform) => api.post('/credentials/verify', { platform }),
|
||||
}
|
||||
|
||||
// Settings API
|
||||
export const settingsApi = {
|
||||
get: () => api.get('/settings'),
|
||||
update: (data) => api.patch('/settings', data),
|
||||
getGalleryDL: () => api.get('/settings/gallery-dl'),
|
||||
updateGalleryDL: (config) => api.put('/settings/gallery-dl', { config }),
|
||||
getApiKey: () => api.get('/settings/api-key'),
|
||||
regenerateApiKey: () => api.post('/settings/api-key/regenerate'),
|
||||
}
|
||||
|
||||
// Platforms API
|
||||
export const platformsApi = {
|
||||
list: () => api.get('/platforms'),
|
||||
get: (platform) => api.get(`/platforms/${platform}`),
|
||||
getConfigSchema: (platform) => api.get(`/platforms/${platform}/config-schema`),
|
||||
}
|
||||
|
||||
export default api
|
||||
@@ -0,0 +1,50 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { downloadsApi } from '../services/api'
|
||||
|
||||
export const useDownloadsStore = defineStore('downloads', () => {
|
||||
const downloads = ref([])
|
||||
const stats = ref(null)
|
||||
const loading = ref(false)
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
async function fetchDownloads(params = {}) {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await downloadsApi.list({
|
||||
page: currentPage.value,
|
||||
per_page: perPage.value,
|
||||
...params,
|
||||
})
|
||||
downloads.value = response.data.items
|
||||
total.value = response.data.total
|
||||
return response.data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStats(params = {}) {
|
||||
const response = await downloadsApi.stats(params)
|
||||
stats.value = response.data
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function retryDownload(id) {
|
||||
return await downloadsApi.retry(id)
|
||||
}
|
||||
|
||||
return {
|
||||
downloads,
|
||||
stats,
|
||||
loading,
|
||||
total,
|
||||
currentPage,
|
||||
perPage,
|
||||
fetchDownloads,
|
||||
fetchStats,
|
||||
retryDownload,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useNotificationStore = defineStore('notifications', () => {
|
||||
const show = ref(false)
|
||||
const message = ref('')
|
||||
const color = ref('info')
|
||||
|
||||
function notify(msg, type = 'info') {
|
||||
message.value = msg
|
||||
color.value = type
|
||||
show.value = true
|
||||
}
|
||||
|
||||
function success(msg) {
|
||||
notify(msg, 'success')
|
||||
}
|
||||
|
||||
function error(msg) {
|
||||
notify(msg, 'error')
|
||||
}
|
||||
|
||||
function warning(msg) {
|
||||
notify(msg, 'warning')
|
||||
}
|
||||
|
||||
function info(msg) {
|
||||
notify(msg, 'info')
|
||||
}
|
||||
|
||||
return {
|
||||
show,
|
||||
message,
|
||||
color,
|
||||
notify,
|
||||
success,
|
||||
error,
|
||||
warning,
|
||||
info,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { settingsApi } from '../services/api'
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
const settings = ref({})
|
||||
const galleryDLConfig = ref({})
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchSettings() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await settingsApi.get()
|
||||
settings.value = response.data.settings
|
||||
return response.data.settings
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSettings(data) {
|
||||
const response = await settingsApi.update(data)
|
||||
settings.value = response.data.settings
|
||||
return response.data.settings
|
||||
}
|
||||
|
||||
async function fetchGalleryDLConfig() {
|
||||
const response = await settingsApi.getGalleryDL()
|
||||
galleryDLConfig.value = response.data.config
|
||||
return response.data.config
|
||||
}
|
||||
|
||||
async function updateGalleryDLConfig(config) {
|
||||
const response = await settingsApi.updateGalleryDL(config)
|
||||
galleryDLConfig.value = response.data.config
|
||||
return response.data.config
|
||||
}
|
||||
|
||||
return {
|
||||
settings,
|
||||
galleryDLConfig,
|
||||
loading,
|
||||
fetchSettings,
|
||||
updateSettings,
|
||||
fetchGalleryDLConfig,
|
||||
updateGalleryDLConfig,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { sourcesApi } from '../services/api'
|
||||
|
||||
export const useSourcesStore = defineStore('sources', () => {
|
||||
const sources = ref([])
|
||||
const loading = ref(false)
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
const enabledSources = computed(() => sources.value.filter(s => s.enabled))
|
||||
const disabledSources = computed(() => sources.value.filter(s => !s.enabled))
|
||||
|
||||
async function fetchSources(params = {}) {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await sourcesApi.list({
|
||||
page: currentPage.value,
|
||||
per_page: perPage.value,
|
||||
...params,
|
||||
})
|
||||
sources.value = response.data.items
|
||||
total.value = response.data.total
|
||||
return response.data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createSource(data) {
|
||||
const response = await sourcesApi.create(data)
|
||||
sources.value.push(response.data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function updateSource(id, data) {
|
||||
const response = await sourcesApi.update(id, data)
|
||||
const index = sources.value.findIndex(s => s.id === id)
|
||||
if (index !== -1) {
|
||||
sources.value[index] = response.data
|
||||
}
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function deleteSource(id) {
|
||||
await sourcesApi.delete(id)
|
||||
sources.value = sources.value.filter(s => s.id !== id)
|
||||
}
|
||||
|
||||
async function triggerCheck(id) {
|
||||
return await sourcesApi.triggerCheck(id)
|
||||
}
|
||||
|
||||
return {
|
||||
sources,
|
||||
loading,
|
||||
total,
|
||||
currentPage,
|
||||
perPage,
|
||||
enabledSources,
|
||||
disabledSources,
|
||||
fetchSources,
|
||||
createSource,
|
||||
updateSource,
|
||||
deleteSource,
|
||||
triggerCheck,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { subscriptionsApi, sourcesApi } from '../services/api'
|
||||
|
||||
export const useSubscriptionsStore = defineStore('subscriptions', () => {
|
||||
const subscriptions = ref([])
|
||||
const loading = ref(false)
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
const enabledSubscriptions = computed(() => subscriptions.value.filter(s => s.enabled))
|
||||
const disabledSubscriptions = computed(() => subscriptions.value.filter(s => !s.enabled))
|
||||
|
||||
async function fetchSubscriptions(params = {}) {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await subscriptionsApi.list({
|
||||
page: currentPage.value,
|
||||
per_page: perPage.value,
|
||||
...params,
|
||||
})
|
||||
subscriptions.value = response.data.items
|
||||
total.value = response.data.total
|
||||
return response.data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function getSubscription(id) {
|
||||
const response = await subscriptionsApi.get(id)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function createSubscription(data) {
|
||||
const response = await subscriptionsApi.create(data)
|
||||
subscriptions.value.push(response.data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function updateSubscription(id, data) {
|
||||
const response = await subscriptionsApi.update(id, data)
|
||||
const index = subscriptions.value.findIndex(s => s.id === id)
|
||||
if (index !== -1) {
|
||||
subscriptions.value[index] = response.data
|
||||
}
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function deleteSubscription(id) {
|
||||
await subscriptionsApi.delete(id)
|
||||
subscriptions.value = subscriptions.value.filter(s => s.id !== id)
|
||||
}
|
||||
|
||||
async function triggerCheck(id) {
|
||||
return await subscriptionsApi.triggerCheck(id)
|
||||
}
|
||||
|
||||
async function addSource(subscriptionId, sourceData) {
|
||||
const response = await subscriptionsApi.addSource(subscriptionId, sourceData)
|
||||
// Update the subscription in the store
|
||||
const index = subscriptions.value.findIndex(s => s.id === subscriptionId)
|
||||
if (index !== -1 && subscriptions.value[index].sources) {
|
||||
subscriptions.value[index].sources.push(response.data)
|
||||
}
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function deleteSource(sourceId) {
|
||||
await sourcesApi.delete(sourceId)
|
||||
// Update subscriptions that contain this source
|
||||
for (const sub of subscriptions.value) {
|
||||
if (sub.sources) {
|
||||
sub.sources = sub.sources.filter(s => s.id !== sourceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSource(sourceId, data) {
|
||||
const response = await sourcesApi.update(sourceId, data)
|
||||
// Update in subscriptions
|
||||
for (const sub of subscriptions.value) {
|
||||
if (sub.sources) {
|
||||
const idx = sub.sources.findIndex(s => s.id === sourceId)
|
||||
if (idx !== -1) {
|
||||
sub.sources[idx] = response.data
|
||||
}
|
||||
}
|
||||
}
|
||||
return response.data
|
||||
}
|
||||
|
||||
return {
|
||||
subscriptions,
|
||||
loading,
|
||||
total,
|
||||
currentPage,
|
||||
perPage,
|
||||
enabledSubscriptions,
|
||||
disabledSubscriptions,
|
||||
fetchSubscriptions,
|
||||
getSubscription,
|
||||
createSubscription,
|
||||
updateSubscription,
|
||||
deleteSubscription,
|
||||
triggerCheck,
|
||||
addSource,
|
||||
deleteSource,
|
||||
updateSource,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { useWebSocket } from '../composables/useWebSocket'
|
||||
import { useNotificationStore } from './notifications'
|
||||
import { useDownloadsStore } from './downloads'
|
||||
import { useSourcesStore } from './sources'
|
||||
|
||||
export const useWebSocketStore = defineStore('websocket', () => {
|
||||
const { isConnected, subscribe } = useWebSocket()
|
||||
const initialized = ref(false)
|
||||
|
||||
function init() {
|
||||
if (initialized.value) return
|
||||
initialized.value = true
|
||||
|
||||
const notifications = useNotificationStore()
|
||||
const downloadsStore = useDownloadsStore()
|
||||
const sourcesStore = useSourcesStore()
|
||||
|
||||
// Subscribe to download events
|
||||
subscribe('download.started', (data) => {
|
||||
notifications.info(`Download started for source ${data.source_id}`)
|
||||
})
|
||||
|
||||
subscribe('download.completed', (data) => {
|
||||
notifications.success(`Download completed: ${data.file_count} files`)
|
||||
// Refresh downloads list
|
||||
downloadsStore.fetchDownloads()
|
||||
downloadsStore.fetchStats()
|
||||
})
|
||||
|
||||
subscribe('download.failed', (data) => {
|
||||
notifications.error(`Download failed: ${data.error_message}`)
|
||||
downloadsStore.fetchDownloads()
|
||||
})
|
||||
|
||||
// Subscribe to source events
|
||||
subscribe('source.updated', (data) => {
|
||||
sourcesStore.fetchSources()
|
||||
})
|
||||
|
||||
// Subscribe to credential events
|
||||
subscribe('credential.expiring', (data) => {
|
||||
notifications.warning(`${data.platform} credentials expiring in ${data.expires_in_hours} hours`)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
init,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,339 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-row>
|
||||
<!-- Platform Cards -->
|
||||
<v-col
|
||||
v-for="(info, platform) in platforms"
|
||||
:key="platform"
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title class="d-flex align-center">
|
||||
<v-icon :color="getPlatformColor(platform)" class="mr-2">
|
||||
{{ getPlatformIcon(platform) }}
|
||||
</v-icon>
|
||||
{{ info.name }}
|
||||
<v-spacer />
|
||||
<v-chip
|
||||
:color="getCredentialStatus(platform).color"
|
||||
size="small"
|
||||
>
|
||||
{{ getCredentialStatus(platform).text }}
|
||||
</v-chip>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<p class="text-body-2 mb-4">{{ info.description }}</p>
|
||||
|
||||
<div v-if="getCredential(platform)" class="mb-4">
|
||||
<v-list-item density="compact" class="px-0">
|
||||
<template v-slot:prepend>
|
||||
<v-icon color="success">mdi-check-circle</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>Credentials stored</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
Last verified: {{ formatDate(getCredential(platform).last_verified) }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item
|
||||
v-if="getCredential(platform).expires_at"
|
||||
density="compact"
|
||||
class="px-0"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon :color="isExpiringSoon(platform) ? 'warning' : 'grey'">
|
||||
mdi-clock-outline
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>
|
||||
Expires: {{ formatDate(getCredential(platform).expires_at) }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</div>
|
||||
|
||||
<v-alert
|
||||
v-else
|
||||
type="info"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-4"
|
||||
>
|
||||
No credentials stored for {{ info.name }}
|
||||
</v-alert>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-btn
|
||||
v-if="getCredential(platform)"
|
||||
variant="text"
|
||||
color="primary"
|
||||
@click="verifyCredential(platform)"
|
||||
:loading="verifyingPlatform === platform"
|
||||
>
|
||||
Verify
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="text"
|
||||
color="primary"
|
||||
@click="openUploadDialog(platform, info)"
|
||||
>
|
||||
{{ getCredential(platform) ? 'Update' : 'Add' }} Credentials
|
||||
</v-btn>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
v-if="getCredential(platform)"
|
||||
variant="text"
|
||||
color="error"
|
||||
@click="confirmDelete(platform)"
|
||||
>
|
||||
Remove
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Upload Dialog -->
|
||||
<v-dialog v-model="uploadDialog" max-width="600">
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
{{ uploadPlatform?.name }} Credentials
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<v-alert type="info" variant="tonal" class="mb-4">
|
||||
<template v-if="uploadPlatform?.auth_type === 'token'">
|
||||
Paste your Discord user token below.
|
||||
<strong>Never share your token with anyone!</strong>
|
||||
</template>
|
||||
<template v-else>
|
||||
Paste your cookies in Netscape format, or use the Firefox extension
|
||||
for automatic export.
|
||||
</template>
|
||||
</v-alert>
|
||||
|
||||
<v-textarea
|
||||
v-model="credentialData"
|
||||
:label="uploadPlatform?.auth_type === 'token' ? 'Token' : 'Cookies (Netscape format)'"
|
||||
rows="8"
|
||||
variant="outlined"
|
||||
:placeholder="getPlaceholder()"
|
||||
/>
|
||||
|
||||
<v-expansion-panels class="mt-4">
|
||||
<v-expansion-panel>
|
||||
<v-expansion-panel-title>
|
||||
How to get {{ uploadPlatform?.auth_type === 'token' ? 'your token' : 'cookies' }}
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<template v-if="uploadPlatform?.auth_type === 'token'">
|
||||
<ol>
|
||||
<li>Open Discord in your browser</li>
|
||||
<li>Press F12 to open Developer Tools</li>
|
||||
<li>Go to the Network tab</li>
|
||||
<li>Refresh the page</li>
|
||||
<li>Look for a request to discord.com/api</li>
|
||||
<li>Find the "Authorization" header in the request headers</li>
|
||||
<li>Copy the token value (without "Bearer " prefix)</li>
|
||||
</ol>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p><strong>Option 1: Firefox Extension (Recommended)</strong></p>
|
||||
<p>Install our Firefox extension to automatically export cookies.</p>
|
||||
<p class="mt-4"><strong>Option 2: Browser Export</strong></p>
|
||||
<ol>
|
||||
<li>Install a "cookies.txt" browser extension</li>
|
||||
<li>Visit {{ uploadPlatform?.name }} and log in</li>
|
||||
<li>Use the extension to export cookies</li>
|
||||
<li>Paste the exported text here</li>
|
||||
</ol>
|
||||
</template>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="uploadDialog = false">Cancel</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
:loading="uploading"
|
||||
:disabled="!credentialData"
|
||||
@click="uploadCredentials"
|
||||
>
|
||||
Save
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Delete Confirmation -->
|
||||
<v-dialog v-model="deleteDialog" max-width="400">
|
||||
<v-card>
|
||||
<v-card-title>Remove Credentials</v-card-title>
|
||||
<v-card-text>
|
||||
Are you sure you want to remove credentials for
|
||||
<strong>{{ platforms[deletePlatform]?.name }}</strong>?
|
||||
Downloads from this platform may fail without valid credentials.
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="deleteDialog = false">Cancel</v-btn>
|
||||
<v-btn color="error" @click="deleteCredential">Remove</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { credentialsApi, platformsApi } from '../services/api'
|
||||
import { useNotificationStore } from '../stores/notifications'
|
||||
|
||||
const notifications = useNotificationStore()
|
||||
|
||||
const platforms = ref({})
|
||||
const credentials = ref([])
|
||||
const loading = ref(false)
|
||||
const uploadDialog = ref(false)
|
||||
const uploadPlatform = ref(null)
|
||||
const uploadPlatformKey = ref('')
|
||||
const credentialData = ref('')
|
||||
const uploading = ref(false)
|
||||
const deleteDialog = ref(false)
|
||||
const deletePlatform = ref('')
|
||||
const verifyingPlatform = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
await loadData()
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [platformsRes, credentialsRes] = await Promise.all([
|
||||
platformsApi.list(),
|
||||
credentialsApi.list(),
|
||||
])
|
||||
platforms.value = platformsRes.data.platforms
|
||||
credentials.value = credentialsRes.data.items
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to load: ${error.message}`)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
}
|
||||
return colors[platform] || 'grey'
|
||||
}
|
||||
|
||||
function getCredential(platform) {
|
||||
return credentials.value.find(c => c.platform === platform)
|
||||
}
|
||||
|
||||
function getCredentialStatus(platform) {
|
||||
const cred = getCredential(platform)
|
||||
if (!cred) return { color: 'grey', text: 'Not configured' }
|
||||
if (isExpiringSoon(platform)) return { color: 'warning', text: 'Expiring soon' }
|
||||
return { color: 'success', text: 'Active' }
|
||||
}
|
||||
|
||||
function isExpiringSoon(platform) {
|
||||
const cred = getCredential(platform)
|
||||
if (!cred?.expires_at) return false
|
||||
const expiresAt = new Date(cred.expires_at)
|
||||
const daysUntilExpiry = (expiresAt - new Date()) / (1000 * 60 * 60 * 24)
|
||||
return daysUntilExpiry < 7
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return 'Never'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
|
||||
function openUploadDialog(platform, info) {
|
||||
uploadPlatformKey.value = platform
|
||||
uploadPlatform.value = info
|
||||
credentialData.value = ''
|
||||
uploadDialog.value = true
|
||||
}
|
||||
|
||||
function getPlaceholder() {
|
||||
if (uploadPlatform.value?.auth_type === 'token') {
|
||||
return 'mfa.AbCdEf123456...'
|
||||
}
|
||||
return '# Netscape HTTP Cookie File\n.patreon.com\tTRUE\t/\tTRUE\t0\tsession_id\tabc123...'
|
||||
}
|
||||
|
||||
async function uploadCredentials() {
|
||||
uploading.value = true
|
||||
try {
|
||||
await credentialsApi.upload({
|
||||
platform: uploadPlatformKey.value,
|
||||
credential_type: uploadPlatform.value?.auth_type === 'token' ? 'token' : 'cookies',
|
||||
data: credentialData.value,
|
||||
})
|
||||
notifications.success('Credentials saved')
|
||||
uploadDialog.value = false
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to save: ${error.response?.data?.error || error.message}`)
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyCredential(platform) {
|
||||
verifyingPlatform.value = platform
|
||||
try {
|
||||
const response = await credentialsApi.verify(platform)
|
||||
if (response.data.is_valid) {
|
||||
notifications.success(`${platforms.value[platform].name} credentials verified`)
|
||||
} else {
|
||||
notifications.warning(`Verification failed: ${response.data.error || 'Unknown error'}`)
|
||||
}
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
notifications.error(`Verification failed: ${error.message}`)
|
||||
} finally {
|
||||
verifyingPlatform.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(platform) {
|
||||
deletePlatform.value = platform
|
||||
deleteDialog.value = true
|
||||
}
|
||||
|
||||
async function deleteCredential() {
|
||||
try {
|
||||
await credentialsApi.delete(deletePlatform.value)
|
||||
notifications.success('Credentials removed')
|
||||
deleteDialog.value = false
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to remove: ${error.message}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,283 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-row>
|
||||
<!-- Stats Cards -->
|
||||
<v-col cols="12" md="3">
|
||||
<v-card>
|
||||
<v-card-text class="d-flex align-center">
|
||||
<v-avatar color="primary" size="48" class="mr-4">
|
||||
<v-icon>mdi-account-multiple</v-icon>
|
||||
</v-avatar>
|
||||
<div>
|
||||
<div class="text-h4">{{ sourcesStore.sources.length }}</div>
|
||||
<div class="text-caption">Total Sources</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="3">
|
||||
<v-card>
|
||||
<v-card-text class="d-flex align-center">
|
||||
<v-avatar color="success" size="48" class="mr-4">
|
||||
<v-icon>mdi-check-circle</v-icon>
|
||||
</v-avatar>
|
||||
<div>
|
||||
<div class="text-h4">{{ stats?.completed || 0 }}</div>
|
||||
<div class="text-caption">Completed Downloads</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="3">
|
||||
<v-card>
|
||||
<v-card-text class="d-flex align-center">
|
||||
<v-avatar color="error" size="48" class="mr-4">
|
||||
<v-icon>mdi-alert-circle</v-icon>
|
||||
</v-avatar>
|
||||
<div>
|
||||
<div class="text-h4">{{ stats?.failed || 0 }}</div>
|
||||
<div class="text-caption">Failed Downloads</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="3">
|
||||
<v-card>
|
||||
<v-card-text class="d-flex align-center">
|
||||
<v-avatar color="warning" size="48" class="mr-4">
|
||||
<v-icon>mdi-clock-outline</v-icon>
|
||||
</v-avatar>
|
||||
<div>
|
||||
<div class="text-h4">{{ stats?.pending || 0 }}</div>
|
||||
<div class="text-caption">Pending Downloads</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row class="mt-4">
|
||||
<!-- Downloads by Platform -->
|
||||
<v-col cols="12" md="6">
|
||||
<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"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon :color="getPlatformColor(platform)">
|
||||
{{ getPlatformIcon(platform) }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-list-item-title class="text-capitalize">
|
||||
{{ platform }}
|
||||
</v-list-item-title>
|
||||
<template v-slot:append>
|
||||
<v-chip size="small">{{ count }}</v-chip>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<div v-else class="text-center text-grey py-4">
|
||||
No download data yet
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<v-col cols="12" md="6">
|
||||
<v-card>
|
||||
<v-card-title>Recent Downloads</v-card-title>
|
||||
<v-card-text>
|
||||
<v-list v-if="recentDownloads.length">
|
||||
<v-list-item
|
||||
v-for="download in recentDownloads"
|
||||
:key="download.id"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon :color="getStatusColor(download.status)">
|
||||
{{ getStatusIcon(download.status) }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>
|
||||
{{ truncate(download.url, 40) }}
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
{{ formatDate(download.created_at) }}
|
||||
</v-list-item-subtitle>
|
||||
<template v-slot:append>
|
||||
<v-chip
|
||||
:color="getStatusColor(download.status)"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ download.status }}
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<div v-else class="text-center text-grey py-4">
|
||||
No recent downloads
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn variant="text" to="/downloads">View All Downloads</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row class="mt-4">
|
||||
<!-- Sources needing attention -->
|
||||
<v-col cols="12">
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
Sources Needing Attention
|
||||
<v-chip class="ml-2" color="error" size="small" v-if="sourcesWithErrors.length">
|
||||
{{ sourcesWithErrors.length }}
|
||||
</v-chip>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-table v-if="sourcesWithErrors.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Platform</th>
|
||||
<th>Error Count</th>
|
||||
<th>Last Check</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="source in sourcesWithErrors" :key="source.id">
|
||||
<td>{{ source.subscription_name || source.subscription?.name || 'Unknown' }}</td>
|
||||
<td class="text-capitalize">{{ source.platform }}</td>
|
||||
<td>
|
||||
<v-chip color="error" size="small">
|
||||
{{ source.error_count }} errors
|
||||
</v-chip>
|
||||
</td>
|
||||
<td>{{ formatDate(source.last_check) }}</td>
|
||||
<td>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
color="primary"
|
||||
@click="retrySource(source)"
|
||||
>
|
||||
Retry
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
:to="`/sources/${source.id}/edit`"
|
||||
>
|
||||
Edit
|
||||
</v-btn>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
<div v-else class="text-center text-grey py-4">
|
||||
<v-icon size="48" class="mb-2">mdi-check-circle-outline</v-icon>
|
||||
<div>All sources are healthy!</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useSourcesStore } from '../stores/sources'
|
||||
import { useDownloadsStore } from '../stores/downloads'
|
||||
import { useNotificationStore } from '../stores/notifications'
|
||||
|
||||
const sourcesStore = useSourcesStore()
|
||||
const downloadsStore = useDownloadsStore()
|
||||
const notifications = useNotificationStore()
|
||||
|
||||
const stats = computed(() => downloadsStore.stats)
|
||||
const recentDownloads = computed(() => downloadsStore.downloads.slice(0, 5))
|
||||
const sourcesWithErrors = computed(() =>
|
||||
sourcesStore.sources.filter(s => s.error_count > 0).slice(0, 5)
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
sourcesStore.fetchSources(),
|
||||
downloadsStore.fetchStats(),
|
||||
downloadsStore.fetchDownloads({ per_page: 5 }),
|
||||
])
|
||||
})
|
||||
|
||||
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',
|
||||
}
|
||||
return colors[platform] || 'grey'
|
||||
}
|
||||
|
||||
function getStatusIcon(status) {
|
||||
const icons = {
|
||||
completed: 'mdi-check-circle',
|
||||
failed: 'mdi-alert-circle',
|
||||
running: 'mdi-loading mdi-spin',
|
||||
pending: 'mdi-clock-outline',
|
||||
skipped: 'mdi-skip-next',
|
||||
}
|
||||
return icons[status] || 'mdi-help-circle'
|
||||
}
|
||||
|
||||
function getStatusColor(status) {
|
||||
const colors = {
|
||||
completed: 'success',
|
||||
failed: 'error',
|
||||
running: 'info',
|
||||
pending: 'warning',
|
||||
skipped: 'grey',
|
||||
}
|
||||
return colors[status] || 'grey'
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return 'Never'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
|
||||
function truncate(str, length) {
|
||||
if (!str) return ''
|
||||
if (str.length <= length) return str
|
||||
return str.substring(0, length) + '...'
|
||||
}
|
||||
|
||||
async function retrySource(source) {
|
||||
try {
|
||||
await sourcesStore.triggerCheck(source.id)
|
||||
notifications.success(`Queued check for ${source.subscription_name || source.platform}`)
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to queue check: ${error.message}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,329 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Filters -->
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12" md="3">
|
||||
<v-select
|
||||
v-model="filterStatus"
|
||||
:items="statusOptions"
|
||||
label="Status"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
clearable
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-select
|
||||
v-model="filterSourceId"
|
||||
:items="sourceOptions"
|
||||
label="Source"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
clearable
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-text-field
|
||||
v-model="filterFromDate"
|
||||
label="From Date"
|
||||
type="date"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
clearable
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-text-field
|
||||
v-model="filterToDate"
|
||||
label="To Date"
|
||||
type="date"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
clearable
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Downloads Table -->
|
||||
<v-card>
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="downloadsStore.downloads"
|
||||
:loading="downloadsStore.loading"
|
||||
:items-per-page="20"
|
||||
class="elevation-1"
|
||||
>
|
||||
<template v-slot:item.status="{ item }">
|
||||
<v-chip
|
||||
:color="getStatusColor(item.status)"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
>
|
||||
<v-icon start size="small">{{ getStatusIcon(item.status) }}</v-icon>
|
||||
{{ item.status }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.url="{ item }">
|
||||
<span :title="item.url">{{ truncateUrl(item.url) }}</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.error_type="{ item }">
|
||||
<v-chip
|
||||
v-if="item.error_type"
|
||||
color="error"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ formatErrorType(item.error_type) }}
|
||||
</v-chip>
|
||||
<span v-else class="text-grey">-</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.file_count="{ item }">
|
||||
<v-chip v-if="item.file_count > 0" color="success" size="small">
|
||||
{{ item.file_count }} files
|
||||
</v-chip>
|
||||
<span v-else class="text-grey">0</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.created_at="{ item }">
|
||||
{{ formatDate(item.created_at) }}
|
||||
</template>
|
||||
|
||||
<template v-slot:item.duration="{ item }">
|
||||
{{ formatDuration(item) }}
|
||||
</template>
|
||||
|
||||
<template v-slot:item.actions="{ item }">
|
||||
<v-btn
|
||||
v-if="item.status === 'failed'"
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
color="primary"
|
||||
@click="retryDownload(item)"
|
||||
:loading="retryingIds.includes(item.id)"
|
||||
>
|
||||
<v-icon>mdi-refresh</v-icon>
|
||||
<v-tooltip activator="parent">Retry</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="showDetails(item)"
|
||||
>
|
||||
<v-icon>mdi-information</v-icon>
|
||||
<v-tooltip activator="parent">Details</v-tooltip>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
|
||||
<!-- Details Dialog -->
|
||||
<v-dialog v-model="detailsDialog" max-width="700">
|
||||
<v-card v-if="selectedDownload">
|
||||
<v-card-title>
|
||||
Download Details
|
||||
<v-chip
|
||||
:color="getStatusColor(selectedDownload.status)"
|
||||
size="small"
|
||||
class="ml-2"
|
||||
>
|
||||
{{ selectedDownload.status }}
|
||||
</v-chip>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<v-table density="compact">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="font-weight-bold" width="150">URL</td>
|
||||
<td style="word-break: break-all">{{ selectedDownload.url }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="font-weight-bold">Source ID</td>
|
||||
<td>{{ selectedDownload.source_id || 'N/A' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="font-weight-bold">Files Downloaded</td>
|
||||
<td>{{ selectedDownload.file_count }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="font-weight-bold">Started</td>
|
||||
<td>{{ formatDate(selectedDownload.started_at) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="font-weight-bold">Completed</td>
|
||||
<td>{{ formatDate(selectedDownload.completed_at) }}</td>
|
||||
</tr>
|
||||
<tr v-if="selectedDownload.error_type">
|
||||
<td class="font-weight-bold">Error Type</td>
|
||||
<td class="text-error">{{ formatErrorType(selectedDownload.error_type) }}</td>
|
||||
</tr>
|
||||
<tr v-if="selectedDownload.error_message">
|
||||
<td class="font-weight-bold">Error Message</td>
|
||||
<td class="text-error">{{ selectedDownload.error_message }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
|
||||
<v-expansion-panels class="mt-4" v-if="selectedDownload.metadata">
|
||||
<v-expansion-panel v-if="selectedDownload.metadata.stdout">
|
||||
<v-expansion-panel-title>Output Log</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<pre class="text-caption" style="white-space: pre-wrap; max-height: 300px; overflow: auto">{{ selectedDownload.metadata.stdout }}</pre>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
<v-expansion-panel v-if="selectedDownload.metadata.stderr">
|
||||
<v-expansion-panel-title>Error Log</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<pre class="text-caption text-error" style="white-space: pre-wrap; max-height: 300px; overflow: auto">{{ selectedDownload.metadata.stderr }}</pre>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
v-if="selectedDownload.status === 'failed'"
|
||||
color="primary"
|
||||
@click="retryDownload(selectedDownload); detailsDialog = false"
|
||||
>
|
||||
Retry Download
|
||||
</v-btn>
|
||||
<v-btn variant="text" @click="detailsDialog = false">Close</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useDownloadsStore } from '../stores/downloads'
|
||||
import { useSourcesStore } from '../stores/sources'
|
||||
import { useNotificationStore } from '../stores/notifications'
|
||||
|
||||
const downloadsStore = useDownloadsStore()
|
||||
const sourcesStore = useSourcesStore()
|
||||
const notifications = useNotificationStore()
|
||||
|
||||
const filterStatus = ref(null)
|
||||
const filterSourceId = ref(null)
|
||||
const filterFromDate = ref(null)
|
||||
const filterToDate = ref(null)
|
||||
const detailsDialog = ref(false)
|
||||
const selectedDownload = ref(null)
|
||||
const retryingIds = ref([])
|
||||
|
||||
const statusOptions = [
|
||||
{ title: 'Pending', value: 'pending' },
|
||||
{ title: 'Running', value: 'running' },
|
||||
{ title: 'Completed', value: 'completed' },
|
||||
{ title: 'Failed', value: 'failed' },
|
||||
{ title: 'Skipped', value: 'skipped' },
|
||||
]
|
||||
|
||||
const sourceOptions = computed(() =>
|
||||
sourcesStore.sources.map(s => ({ title: s.name, value: s.id }))
|
||||
)
|
||||
|
||||
const headers = [
|
||||
{ title: 'Status', key: 'status', width: 120 },
|
||||
{ title: 'URL', key: 'url' },
|
||||
{ title: 'Error', key: 'error_type', width: 150 },
|
||||
{ title: 'Files', key: 'file_count', width: 100 },
|
||||
{ title: 'Date', key: 'created_at', width: 180 },
|
||||
{ title: 'Duration', key: 'duration', width: 100 },
|
||||
{ title: 'Actions', key: 'actions', width: 100, sortable: false },
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
loadDownloads(),
|
||||
sourcesStore.fetchSources(),
|
||||
])
|
||||
})
|
||||
|
||||
watch([filterStatus, filterSourceId, filterFromDate, filterToDate], () => {
|
||||
loadDownloads()
|
||||
})
|
||||
|
||||
async function loadDownloads() {
|
||||
const params = {}
|
||||
if (filterStatus.value) params.status = filterStatus.value
|
||||
if (filterSourceId.value) params.source_id = filterSourceId.value
|
||||
if (filterFromDate.value) params.from_date = filterFromDate.value
|
||||
if (filterToDate.value) params.to_date = filterToDate.value
|
||||
await downloadsStore.fetchDownloads(params)
|
||||
}
|
||||
|
||||
function getStatusIcon(status) {
|
||||
const icons = {
|
||||
completed: 'mdi-check-circle',
|
||||
failed: 'mdi-alert-circle',
|
||||
running: 'mdi-loading',
|
||||
pending: 'mdi-clock-outline',
|
||||
skipped: 'mdi-skip-next',
|
||||
}
|
||||
return icons[status] || 'mdi-help-circle'
|
||||
}
|
||||
|
||||
function getStatusColor(status) {
|
||||
const colors = {
|
||||
completed: 'success',
|
||||
failed: 'error',
|
||||
running: 'info',
|
||||
pending: 'warning',
|
||||
skipped: 'grey',
|
||||
}
|
||||
return colors[status] || 'grey'
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
|
||||
function formatDuration(download) {
|
||||
if (!download.started_at || !download.completed_at) return '-'
|
||||
const start = new Date(download.started_at)
|
||||
const end = new Date(download.completed_at)
|
||||
const seconds = Math.round((end - start) / 1000)
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
return `${minutes}m ${seconds % 60}s`
|
||||
}
|
||||
|
||||
function formatErrorType(errorType) {
|
||||
if (!errorType) return ''
|
||||
return errorType.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
|
||||
}
|
||||
|
||||
function truncateUrl(url) {
|
||||
if (!url) return ''
|
||||
if (url.length <= 50) return url
|
||||
return url.substring(0, 47) + '...'
|
||||
}
|
||||
|
||||
function showDetails(download) {
|
||||
selectedDownload.value = download
|
||||
detailsDialog.value = true
|
||||
}
|
||||
|
||||
async function retryDownload(download) {
|
||||
retryingIds.value.push(download.id)
|
||||
try {
|
||||
await downloadsStore.retryDownload(download.id)
|
||||
notifications.success('Download queued for retry')
|
||||
await loadDownloads()
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to retry: ${error.message}`)
|
||||
} finally {
|
||||
retryingIds.value = retryingIds.value.filter(id => id !== download.id)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,474 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-card :loading="loading" class="mb-6">
|
||||
<v-card-title>
|
||||
<v-icon class="mr-2">mdi-cog</v-icon>
|
||||
Application Settings
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<v-form ref="settingsForm">
|
||||
<!-- Download Settings -->
|
||||
<h3 class="text-h6 mb-4">Download Settings</h3>
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model.number="settings['download.parallel_limit']"
|
||||
label="Parallel Download Limit"
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
hint="Maximum number of concurrent downloads (1-10)"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model.number="settings['download.rate_limit']"
|
||||
label="Rate Limit (seconds)"
|
||||
type="number"
|
||||
min="0.5"
|
||||
max="30"
|
||||
step="0.5"
|
||||
hint="Delay between downloads to avoid rate limiting"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model.number="settings['download.retry_count']"
|
||||
label="Retry Count"
|
||||
type="number"
|
||||
min="0"
|
||||
max="10"
|
||||
hint="Number of times to retry failed downloads"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model.number="settings['download.schedule_interval']"
|
||||
label="Schedule Interval (seconds)"
|
||||
type="number"
|
||||
min="300"
|
||||
hint="How often to check for new content (minimum 5 minutes)"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-divider class="my-6" />
|
||||
|
||||
<!-- Notification Settings -->
|
||||
<h3 class="text-h6 mb-4">Notifications</h3>
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<v-switch
|
||||
v-model="settings['notification.enabled']"
|
||||
label="Enable Notifications"
|
||||
color="primary"
|
||||
hint="Receive notifications when new content is downloaded"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="settings['notification.webhook_url']"
|
||||
label="Webhook URL (optional)"
|
||||
:disabled="!settings['notification.enabled']"
|
||||
hint="Discord/Slack webhook for push notifications"
|
||||
persistent-hint
|
||||
placeholder="https://discord.com/api/webhooks/..."
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-divider class="my-6" />
|
||||
|
||||
<!-- Extension API Key -->
|
||||
<h3 class="text-h6 mb-4">Extension API Key</h3>
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-alert type="info" variant="tonal" class="mb-4">
|
||||
Use this API key to configure the browser extension. Keep it secret - anyone with this key can add subscriptions to your account.
|
||||
</v-alert>
|
||||
<v-text-field
|
||||
v-model="settings['security.extension_api_key']"
|
||||
label="API Key"
|
||||
readonly
|
||||
:type="showApiKey ? 'text' : 'password'"
|
||||
:append-inner-icon="showApiKey ? 'mdi-eye-off' : 'mdi-eye'"
|
||||
@click:append-inner="showApiKey = !showApiKey"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="copyApiKey"
|
||||
>
|
||||
<v-icon>mdi-content-copy</v-icon>
|
||||
<v-tooltip activator="parent">Copy to clipboard</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
size="small"
|
||||
color="warning"
|
||||
@click="confirmRegenerateKey"
|
||||
>
|
||||
<v-icon>mdi-refresh</v-icon>
|
||||
<v-tooltip activator="parent">Regenerate key</v-tooltip>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
variant="text"
|
||||
@click="loadSettings"
|
||||
:disabled="saving"
|
||||
>
|
||||
Reset
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
@click="saveSettings"
|
||||
:loading="saving"
|
||||
>
|
||||
Save Settings
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
|
||||
<!-- Gallery-dl Configuration -->
|
||||
<v-card class="mb-6">
|
||||
<v-card-title>
|
||||
<v-icon class="mr-2">mdi-code-json</v-icon>
|
||||
Gallery-dl Configuration
|
||||
<v-chip class="ml-2" size="small" color="warning" variant="tonal">
|
||||
Advanced
|
||||
</v-chip>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<v-alert type="warning" variant="tonal" class="mb-4">
|
||||
This is the raw gallery-dl configuration file. Only modify if you know what you're doing.
|
||||
Invalid configuration may cause downloads to fail.
|
||||
</v-alert>
|
||||
|
||||
<v-textarea
|
||||
v-model="galleryDLConfigText"
|
||||
label="gallery-dl.conf (JSON)"
|
||||
rows="20"
|
||||
variant="outlined"
|
||||
:error-messages="configError"
|
||||
@input="validateConfig"
|
||||
class="font-monospace"
|
||||
/>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-btn
|
||||
variant="text"
|
||||
href="https://github.com/mikf/gallery-dl/blob/master/docs/configuration.rst"
|
||||
target="_blank"
|
||||
>
|
||||
<v-icon start>mdi-open-in-new</v-icon>
|
||||
Documentation
|
||||
</v-btn>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
variant="text"
|
||||
@click="loadGalleryDLConfig"
|
||||
:disabled="savingConfig"
|
||||
>
|
||||
Reset
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
@click="saveGalleryDLConfig"
|
||||
:loading="savingConfig"
|
||||
:disabled="!!configError"
|
||||
>
|
||||
Save Configuration
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
|
||||
<!-- Platform Default Settings -->
|
||||
<v-card class="mb-6">
|
||||
<v-card-title>
|
||||
<v-icon class="mr-2">mdi-web</v-icon>
|
||||
Platform Defaults
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<p class="text-body-2 mb-4">
|
||||
Default settings for each platform. These can be overridden per-source.
|
||||
</p>
|
||||
|
||||
<v-expansion-panels>
|
||||
<v-expansion-panel
|
||||
v-for="(info, platform) in platforms"
|
||||
:key="platform"
|
||||
>
|
||||
<v-expansion-panel-title>
|
||||
<v-icon :color="getPlatformColor(platform)" class="mr-2">
|
||||
{{ getPlatformIcon(platform) }}
|
||||
</v-icon>
|
||||
{{ info.name }}
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<v-table density="compact">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td width="200"><strong>Auth Required</strong></td>
|
||||
<td>
|
||||
<v-chip :color="info.requires_auth ? 'warning' : 'success'" size="small">
|
||||
{{ info.requires_auth ? 'Yes' : 'No' }}
|
||||
</v-chip>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Auth Type</strong></td>
|
||||
<td class="text-capitalize">{{ info.auth_type }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Content Types</strong></td>
|
||||
<td>
|
||||
<v-chip
|
||||
v-for="type in info.content_types"
|
||||
:key="type"
|
||||
size="small"
|
||||
class="mr-1 mb-1"
|
||||
>
|
||||
{{ type }}
|
||||
</v-chip>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Default Sleep</strong></td>
|
||||
<td>{{ info.default_config?.sleep || 3.0 }} seconds</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>URL Examples</strong></td>
|
||||
<td>
|
||||
<div v-for="url in info.url_examples" :key="url" class="text-caption">
|
||||
{{ url }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- System Information -->
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
<v-icon class="mr-2">mdi-information</v-icon>
|
||||
System Information
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<v-table density="compact">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td width="200"><strong>Version</strong></td>
|
||||
<td>1.0.0</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>API Status</strong></td>
|
||||
<td>
|
||||
<v-chip :color="apiHealthy ? 'success' : 'error'" size="small">
|
||||
{{ apiHealthy ? 'Connected' : 'Disconnected' }}
|
||||
</v-chip>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Download Path</strong></td>
|
||||
<td>/data/downloads</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
import { useNotificationStore } from '../stores/notifications'
|
||||
import { platformsApi } from '../services/api'
|
||||
import api from '../services/api'
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
const notifications = useNotificationStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const savingConfig = ref(false)
|
||||
const settings = ref({})
|
||||
const galleryDLConfigText = ref('')
|
||||
const configError = ref('')
|
||||
const platforms = ref({})
|
||||
const apiHealthy = ref(true)
|
||||
const showApiKey = ref(false)
|
||||
const regenerateDialog = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadAll()
|
||||
})
|
||||
|
||||
async function loadAll() {
|
||||
loading.value = true
|
||||
try {
|
||||
await Promise.all([
|
||||
loadSettings(),
|
||||
loadGalleryDLConfig(),
|
||||
loadPlatforms(),
|
||||
checkApiHealth(),
|
||||
])
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const data = await settingsStore.fetchSettings()
|
||||
settings.value = { ...data }
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to load settings: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
saving.value = true
|
||||
try {
|
||||
await settingsStore.updateSettings(settings.value)
|
||||
notifications.success('Settings saved')
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to save: ${error.message}`)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGalleryDLConfig() {
|
||||
try {
|
||||
const config = await settingsStore.fetchGalleryDLConfig()
|
||||
galleryDLConfigText.value = JSON.stringify(config, null, 2)
|
||||
configError.value = ''
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to load config: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
function validateConfig() {
|
||||
try {
|
||||
JSON.parse(galleryDLConfigText.value)
|
||||
configError.value = ''
|
||||
} catch (e) {
|
||||
configError.value = `Invalid JSON: ${e.message}`
|
||||
}
|
||||
}
|
||||
|
||||
async function saveGalleryDLConfig() {
|
||||
if (configError.value) return
|
||||
|
||||
savingConfig.value = true
|
||||
try {
|
||||
const config = JSON.parse(galleryDLConfigText.value)
|
||||
await settingsStore.updateGalleryDLConfig(config)
|
||||
notifications.success('Gallery-dl configuration saved')
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to save: ${error.message}`)
|
||||
} finally {
|
||||
savingConfig.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPlatforms() {
|
||||
try {
|
||||
const response = await platformsApi.list()
|
||||
platforms.value = response.data.platforms
|
||||
} catch (error) {
|
||||
console.error('Failed to load platforms:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function checkApiHealth() {
|
||||
try {
|
||||
await api.get('/health')
|
||||
apiHealthy.value = true
|
||||
} catch {
|
||||
apiHealthy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
}
|
||||
return colors[platform] || 'grey'
|
||||
}
|
||||
|
||||
async function copyApiKey() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(settings.value['security.extension_api_key'])
|
||||
notifications.success('API key copied to clipboard')
|
||||
} catch (error) {
|
||||
notifications.error('Failed to copy to clipboard')
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRegenerateKey() {
|
||||
if (confirm('Are you sure you want to regenerate the API key? The old key will stop working immediately.')) {
|
||||
await regenerateApiKey()
|
||||
}
|
||||
}
|
||||
|
||||
async function regenerateApiKey() {
|
||||
try {
|
||||
const response = await api.post('/settings/api-key/regenerate')
|
||||
settings.value['security.extension_api_key'] = response.data.api_key
|
||||
notifications.success('API key regenerated')
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to regenerate: ${error.message}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.font-monospace {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,316 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-btn
|
||||
variant="text"
|
||||
to="/subscriptions"
|
||||
prepend-icon="mdi-arrow-left"
|
||||
class="mb-4"
|
||||
>
|
||||
Back to Subscriptions
|
||||
</v-btn>
|
||||
|
||||
<v-card :loading="loading">
|
||||
<v-card-title>
|
||||
{{ isEdit ? 'Edit Source' : 'Add New Source' }}
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<v-form ref="form" v-model="valid">
|
||||
<v-row>
|
||||
<!-- Subscription (required when creating) -->
|
||||
<v-col cols="12" md="6" v-if="!isEdit">
|
||||
<v-select
|
||||
v-model="formData.subscription_id"
|
||||
:items="subscriptionOptions"
|
||||
label="Subscription"
|
||||
:rules="[rules.required]"
|
||||
hint="Select the subscription to add this source to"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
v-model="formData.platform"
|
||||
:items="platformOptions"
|
||||
label="Platform"
|
||||
:rules="[rules.required]"
|
||||
:disabled="isEdit"
|
||||
@update:model-value="onPlatformChange"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12">
|
||||
<v-text-field
|
||||
v-model="formData.url"
|
||||
label="URL"
|
||||
:rules="[rules.required, rules.url]"
|
||||
:hint="urlHint"
|
||||
persistent-hint
|
||||
:disabled="isEdit"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-switch
|
||||
v-model="formData.enabled"
|
||||
label="Enabled"
|
||||
color="success"
|
||||
hint="Disabled sources won't be checked automatically"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model.number="formData.check_interval"
|
||||
label="Check Interval (seconds)"
|
||||
type="number"
|
||||
:rules="[rules.minValue(60)]"
|
||||
hint="Minimum time between automatic checks"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Platform-Specific Config -->
|
||||
<v-divider class="my-6" />
|
||||
<h3 class="text-h6 mb-4">Download Options</h3>
|
||||
|
||||
<v-row v-if="configSchema">
|
||||
<v-col
|
||||
v-for="field in configSchema.fields"
|
||||
:key="field.name"
|
||||
cols="12"
|
||||
:md="field.type === 'text' ? 12 : 6"
|
||||
>
|
||||
<!-- Multiselect (content types) -->
|
||||
<v-select
|
||||
v-if="field.type === 'multiselect'"
|
||||
v-model="formData.metadata[field.name]"
|
||||
:items="field.options"
|
||||
:label="field.label"
|
||||
:hint="field.description"
|
||||
persistent-hint
|
||||
multiple
|
||||
chips
|
||||
/>
|
||||
|
||||
<!-- Number input -->
|
||||
<v-text-field
|
||||
v-else-if="field.type === 'number'"
|
||||
v-model.number="formData.metadata[field.name]"
|
||||
:label="field.label"
|
||||
:hint="field.description"
|
||||
persistent-hint
|
||||
type="number"
|
||||
:min="field.min"
|
||||
:max="field.max"
|
||||
:step="field.step"
|
||||
/>
|
||||
|
||||
<!-- Boolean switch -->
|
||||
<v-switch
|
||||
v-else-if="field.type === 'boolean'"
|
||||
v-model="formData.metadata[field.name]"
|
||||
:label="field.label"
|
||||
:hint="field.description"
|
||||
persistent-hint
|
||||
color="primary"
|
||||
/>
|
||||
|
||||
<!-- Text input -->
|
||||
<v-text-field
|
||||
v-else-if="field.type === 'text'"
|
||||
v-model="formData.metadata[field.name]"
|
||||
:label="field.label"
|
||||
:hint="field.description"
|
||||
:placeholder="field.placeholder"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-alert
|
||||
v-if="!formData.platform"
|
||||
type="info"
|
||||
variant="tonal"
|
||||
class="mt-4"
|
||||
>
|
||||
Select a platform to see available download options
|
||||
</v-alert>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" to="/subscriptions">Cancel</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
:loading="saving"
|
||||
:disabled="!valid"
|
||||
@click="save"
|
||||
>
|
||||
{{ isEdit ? 'Update' : 'Create' }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useSourcesStore } from '../stores/sources'
|
||||
import { useSubscriptionsStore } from '../stores/subscriptions'
|
||||
import { useNotificationStore } from '../stores/notifications'
|
||||
import { platformsApi, sourcesApi } from '../services/api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const sourcesStore = useSourcesStore()
|
||||
const subscriptionsStore = useSubscriptionsStore()
|
||||
const notifications = useNotificationStore()
|
||||
|
||||
const form = ref(null)
|
||||
const valid = ref(false)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const configSchema = ref(null)
|
||||
const platforms = ref({})
|
||||
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
|
||||
const subscriptionOptions = computed(() =>
|
||||
subscriptionsStore.subscriptions.map(s => ({ title: s.name, value: s.id }))
|
||||
)
|
||||
|
||||
const formData = ref({
|
||||
subscription_id: null,
|
||||
platform: '',
|
||||
url: '',
|
||||
enabled: true,
|
||||
check_interval: 3600,
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
const platformOptions = [
|
||||
{ title: 'Patreon', value: 'patreon' },
|
||||
{ title: 'SubscribeStar', value: 'subscribestar' },
|
||||
{ title: 'Hentai Foundry', value: 'hentaifoundry' },
|
||||
{ title: 'Discord', value: 'discord' },
|
||||
]
|
||||
|
||||
const urlHint = computed(() => {
|
||||
if (!formData.value.platform || !platforms.value[formData.value.platform]) {
|
||||
return 'Enter the URL for this source'
|
||||
}
|
||||
const examples = platforms.value[formData.value.platform].url_examples
|
||||
return `Example: ${examples?.[0] || ''}`
|
||||
})
|
||||
|
||||
const rules = {
|
||||
required: v => !!v || 'Required',
|
||||
url: v => {
|
||||
try {
|
||||
new URL(v)
|
||||
return true
|
||||
} catch {
|
||||
return 'Must be a valid URL'
|
||||
}
|
||||
},
|
||||
minValue: min => v => v >= min || `Minimum value is ${min}`,
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
// Load platform info and subscriptions in parallel
|
||||
const [platformsResponse] = await Promise.all([
|
||||
platformsApi.list(),
|
||||
subscriptionsStore.fetchSubscriptions(),
|
||||
])
|
||||
platforms.value = platformsResponse.data.platforms
|
||||
|
||||
// If editing, load source data
|
||||
if (isEdit.value) {
|
||||
const response = await sourcesApi.get(route.params.id)
|
||||
const source = response.data
|
||||
formData.value = {
|
||||
subscription_id: source.subscription_id,
|
||||
platform: source.platform,
|
||||
url: source.url,
|
||||
enabled: source.enabled,
|
||||
check_interval: source.check_interval,
|
||||
metadata: source.metadata || {},
|
||||
}
|
||||
await loadConfigSchema(source.platform)
|
||||
}
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to load: ${error.message}`)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => formData.value.platform, (newPlatform) => {
|
||||
if (newPlatform && !isEdit.value) {
|
||||
loadConfigSchema(newPlatform)
|
||||
}
|
||||
})
|
||||
|
||||
async function onPlatformChange(platform) {
|
||||
if (!platform) {
|
||||
configSchema.value = null
|
||||
return
|
||||
}
|
||||
await loadConfigSchema(platform)
|
||||
}
|
||||
|
||||
async function loadConfigSchema(platform) {
|
||||
try {
|
||||
const response = await platformsApi.getConfigSchema(platform)
|
||||
configSchema.value = response.data
|
||||
|
||||
// Set defaults for metadata if not already set
|
||||
for (const field of response.data.fields) {
|
||||
if (formData.value.metadata[field.name] === undefined && field.default !== undefined) {
|
||||
formData.value.metadata[field.name] = field.default
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load config schema:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.value.validate()) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await sourcesStore.updateSource(route.params.id, {
|
||||
enabled: formData.value.enabled,
|
||||
check_interval: formData.value.check_interval,
|
||||
metadata: formData.value.metadata,
|
||||
})
|
||||
notifications.success('Source updated')
|
||||
} else {
|
||||
await sourcesStore.createSource({
|
||||
subscription_id: formData.value.subscription_id,
|
||||
platform: formData.value.platform,
|
||||
url: formData.value.url,
|
||||
enabled: formData.value.enabled,
|
||||
check_interval: formData.value.check_interval,
|
||||
metadata: formData.value.metadata,
|
||||
})
|
||||
notifications.success('Source created')
|
||||
}
|
||||
router.push('/subscriptions')
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to save: ${error.response?.data?.error || error.message}`)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,246 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-row class="mb-4">
|
||||
<v-col>
|
||||
<v-btn color="primary" to="/sources/new" prepend-icon="mdi-plus">
|
||||
Add Source
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col cols="auto">
|
||||
<v-select
|
||||
v-model="filterPlatform"
|
||||
:items="platformOptions"
|
||||
label="Filter by Platform"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
clearable
|
||||
style="min-width: 180px"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="auto">
|
||||
<v-select
|
||||
v-model="filterEnabled"
|
||||
:items="enabledOptions"
|
||||
label="Status"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
clearable
|
||||
style="min-width: 140px"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-card>
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="sourcesStore.sources"
|
||||
:loading="sourcesStore.loading"
|
||||
:items-per-page="20"
|
||||
class="elevation-1"
|
||||
>
|
||||
<template v-slot:item.platform="{ item }">
|
||||
<v-chip
|
||||
:color="getPlatformColor(item.platform)"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
>
|
||||
<v-icon start size="small">{{ getPlatformIcon(item.platform) }}</v-icon>
|
||||
{{ item.platform }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.enabled="{ item }">
|
||||
<v-switch
|
||||
:model-value="item.enabled"
|
||||
color="success"
|
||||
hide-details
|
||||
density="compact"
|
||||
@update:model-value="toggleEnabled(item)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.error_count="{ item }">
|
||||
<v-chip
|
||||
v-if="item.error_count > 0"
|
||||
color="error"
|
||||
size="small"
|
||||
>
|
||||
{{ item.error_count }}
|
||||
</v-chip>
|
||||
<span v-else class="text-grey">0</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.last_check="{ item }">
|
||||
{{ formatDate(item.last_check) }}
|
||||
</template>
|
||||
|
||||
<template v-slot:item.last_success="{ item }">
|
||||
{{ formatDate(item.last_success) }}
|
||||
</template>
|
||||
|
||||
<template v-slot:item.actions="{ item }">
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
color="primary"
|
||||
@click="triggerCheck(item)"
|
||||
:loading="checkingIds.includes(item.id)"
|
||||
>
|
||||
<v-icon>mdi-refresh</v-icon>
|
||||
<v-tooltip activator="parent">Check Now</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
:to="`/sources/${item.id}/edit`"
|
||||
>
|
||||
<v-icon>mdi-pencil</v-icon>
|
||||
<v-tooltip activator="parent">Edit</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
color="error"
|
||||
@click="confirmDelete(item)"
|
||||
>
|
||||
<v-icon>mdi-delete</v-icon>
|
||||
<v-tooltip activator="parent">Delete</v-tooltip>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<v-dialog v-model="deleteDialog" max-width="400">
|
||||
<v-card>
|
||||
<v-card-title>Delete Source</v-card-title>
|
||||
<v-card-text>
|
||||
Are you sure you want to delete <strong>{{ sourceToDelete?.name }}</strong>?
|
||||
This will also remove all associated download history.
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="deleteDialog = false">Cancel</v-btn>
|
||||
<v-btn color="error" @click="deleteSource">Delete</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useSourcesStore } from '../stores/sources'
|
||||
import { useNotificationStore } from '../stores/notifications'
|
||||
|
||||
const sourcesStore = useSourcesStore()
|
||||
const notifications = useNotificationStore()
|
||||
|
||||
const filterPlatform = ref(null)
|
||||
const filterEnabled = ref(null)
|
||||
const deleteDialog = ref(false)
|
||||
const sourceToDelete = ref(null)
|
||||
const checkingIds = ref([])
|
||||
|
||||
const platformOptions = [
|
||||
{ title: 'Patreon', value: 'patreon' },
|
||||
{ title: 'SubscribeStar', value: 'subscribestar' },
|
||||
{ title: 'Hentai Foundry', value: 'hentaifoundry' },
|
||||
{ title: 'Discord', value: 'discord' },
|
||||
]
|
||||
|
||||
const enabledOptions = [
|
||||
{ title: 'Enabled', value: 'true' },
|
||||
{ title: 'Disabled', value: 'false' },
|
||||
]
|
||||
|
||||
const headers = [
|
||||
{ title: 'Name', key: 'name', sortable: true },
|
||||
{ title: 'Platform', key: 'platform', sortable: true },
|
||||
{ title: 'Enabled', key: 'enabled', sortable: true },
|
||||
{ title: 'Priority', key: 'priority', sortable: true },
|
||||
{ title: 'Errors', key: 'error_count', sortable: true },
|
||||
{ title: 'Last Check', key: 'last_check', sortable: true },
|
||||
{ title: 'Last Success', key: 'last_success', sortable: true },
|
||||
{ title: 'Actions', key: 'actions', sortable: false },
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
loadSources()
|
||||
})
|
||||
|
||||
watch([filterPlatform, filterEnabled], () => {
|
||||
loadSources()
|
||||
})
|
||||
|
||||
async function loadSources() {
|
||||
const params = {}
|
||||
if (filterPlatform.value) params.platform = filterPlatform.value
|
||||
if (filterEnabled.value) params.enabled = filterEnabled.value
|
||||
await sourcesStore.fetchSources(params)
|
||||
}
|
||||
|
||||
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: 'red',
|
||||
subscribestar: 'amber',
|
||||
hentaifoundry: 'purple',
|
||||
discord: 'indigo',
|
||||
}
|
||||
return colors[platform] || 'grey'
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return 'Never'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
|
||||
async function toggleEnabled(source) {
|
||||
try {
|
||||
await sourcesStore.updateSource(source.id, { enabled: !source.enabled })
|
||||
notifications.success(`${source.name} ${!source.enabled ? 'enabled' : 'disabled'}`)
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to update: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerCheck(source) {
|
||||
checkingIds.value.push(source.id)
|
||||
try {
|
||||
await sourcesStore.triggerCheck(source.id)
|
||||
notifications.success(`Queued check for ${source.name}`)
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to queue check: ${error.message}`)
|
||||
} finally {
|
||||
checkingIds.value = checkingIds.value.filter(id => id !== source.id)
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(source) {
|
||||
sourceToDelete.value = source
|
||||
deleteDialog.value = true
|
||||
}
|
||||
|
||||
async function deleteSource() {
|
||||
try {
|
||||
await sourcesStore.deleteSource(sourceToDelete.value.id)
|
||||
notifications.success(`Deleted ${sourceToDelete.value.name}`)
|
||||
deleteDialog.value = false
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to delete: ${error.message}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,502 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-row class="mb-4">
|
||||
<v-col>
|
||||
<v-btn color="primary" @click="showAddDialog = true" prepend-icon="mdi-plus">
|
||||
Add Subscription
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col cols="auto">
|
||||
<v-text-field
|
||||
v-model="searchQuery"
|
||||
label="Search"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
clearable
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
style="min-width: 200px"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="auto">
|
||||
<v-select
|
||||
v-model="filterEnabled"
|
||||
:items="enabledOptions"
|
||||
label="Status"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
clearable
|
||||
style="min-width: 140px"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-card>
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="subscriptionsStore.subscriptions"
|
||||
:loading="subscriptionsStore.loading"
|
||||
:items-per-page="20"
|
||||
v-model:expanded="expandedIds"
|
||||
item-value="id"
|
||||
class="elevation-1"
|
||||
>
|
||||
<template v-slot:item.name="{ item }">
|
||||
<div class="d-flex align-center cursor-pointer" @click="toggleExpand(item.id)">
|
||||
<v-icon class="mr-2" size="small">
|
||||
{{ expandedIds.includes(item.id) ? 'mdi-chevron-down' : 'mdi-chevron-right' }}
|
||||
</v-icon>
|
||||
<strong>{{ item.name }}</strong>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.platform_count="{ item }">
|
||||
<v-chip size="small" variant="tonal">
|
||||
{{ item.platform_count }} platform{{ item.platform_count !== 1 ? 's' : '' }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.enabled="{ item }">
|
||||
<v-switch
|
||||
:model-value="item.enabled"
|
||||
color="success"
|
||||
hide-details
|
||||
density="compact"
|
||||
@click.stop
|
||||
@update:model-value="toggleEnabled(item)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.actions="{ item }">
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
color="primary"
|
||||
@click.stop="triggerCheck(item)"
|
||||
:loading="checkingIds.includes(item.id)"
|
||||
>
|
||||
<v-icon>mdi-refresh</v-icon>
|
||||
<v-tooltip activator="parent">Check All Sources</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
color="success"
|
||||
@click.stop="openAddSourceDialog(item)"
|
||||
>
|
||||
<v-icon>mdi-plus</v-icon>
|
||||
<v-tooltip activator="parent">Add Source</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
@click.stop="editSubscription(item)"
|
||||
>
|
||||
<v-icon>mdi-pencil</v-icon>
|
||||
<v-tooltip activator="parent">Edit</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
color="error"
|
||||
@click.stop="confirmDelete(item)"
|
||||
>
|
||||
<v-icon>mdi-delete</v-icon>
|
||||
<v-tooltip activator="parent">Delete</v-tooltip>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<!-- Expanded row showing sources -->
|
||||
<template v-slot:expanded-row="{ columns, item }">
|
||||
<tr>
|
||||
<td :colspan="columns.length" class="pa-0">
|
||||
<v-table density="compact" class="ml-8 bg-grey-lighten-4">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Platform</th>
|
||||
<th>URL</th>
|
||||
<th>Enabled</th>
|
||||
<th>Last Check</th>
|
||||
<th>Errors</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="source in item.sources" :key="source.id">
|
||||
<td>
|
||||
<v-chip
|
||||
:color="getPlatformColor(source.platform)"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
>
|
||||
<v-icon start size="small">{{ getPlatformIcon(source.platform) }}</v-icon>
|
||||
{{ source.platform }}
|
||||
</v-chip>
|
||||
</td>
|
||||
<td class="text-truncate" style="max-width: 300px">
|
||||
<a :href="source.url" target="_blank" @click.stop>{{ source.url }}</a>
|
||||
</td>
|
||||
<td>
|
||||
<v-switch
|
||||
:model-value="source.enabled"
|
||||
color="success"
|
||||
hide-details
|
||||
density="compact"
|
||||
@update:model-value="toggleSourceEnabled(source)"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ formatDate(source.last_check) }}</td>
|
||||
<td>
|
||||
<v-chip v-if="source.error_count > 0" color="error" size="x-small">
|
||||
{{ source.error_count }}
|
||||
</v-chip>
|
||||
<span v-else class="text-grey">0</span>
|
||||
</td>
|
||||
<td>
|
||||
<v-btn icon size="x-small" variant="text" color="error" @click.stop="confirmDeleteSource(source, item)">
|
||||
<v-icon>mdi-delete</v-icon>
|
||||
</v-btn>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!item.sources || item.sources.length === 0">
|
||||
<td :colspan="6" class="text-center text-grey py-4">
|
||||
No sources yet. Click "+" to add a platform.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
|
||||
<!-- Add Subscription Dialog -->
|
||||
<v-dialog v-model="showAddDialog" max-width="500">
|
||||
<v-card>
|
||||
<v-card-title>{{ editingSubscription ? 'Edit Subscription' : 'Add Subscription' }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="subscriptionForm.name"
|
||||
label="Name (artist/creator name)"
|
||||
hint="This will be used as the folder name"
|
||||
persistent-hint
|
||||
required
|
||||
/>
|
||||
<v-textarea
|
||||
v-model="subscriptionForm.description"
|
||||
label="Description (optional)"
|
||||
rows="2"
|
||||
class="mt-4"
|
||||
/>
|
||||
<v-slider
|
||||
v-model="subscriptionForm.priority"
|
||||
label="Priority"
|
||||
min="0"
|
||||
max="10"
|
||||
step="1"
|
||||
thumb-label
|
||||
class="mt-4"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="closeAddDialog">Cancel</v-btn>
|
||||
<v-btn color="primary" @click="saveSubscription" :loading="saving">Save</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Add Source Dialog -->
|
||||
<v-dialog v-model="showSourceDialog" max-width="500">
|
||||
<v-card>
|
||||
<v-card-title>Add Source to {{ sourceDialogSubscription?.name }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-select
|
||||
v-model="sourceForm.platform"
|
||||
:items="platformOptions"
|
||||
label="Platform"
|
||||
required
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="sourceForm.url"
|
||||
label="URL"
|
||||
placeholder="https://..."
|
||||
required
|
||||
class="mt-4"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="showSourceDialog = false">Cancel</v-btn>
|
||||
<v-btn color="primary" @click="addSource" :loading="savingSource">Add</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<v-dialog v-model="deleteDialog" max-width="400">
|
||||
<v-card>
|
||||
<v-card-title>Delete Subscription</v-card-title>
|
||||
<v-card-text>
|
||||
Are you sure you want to delete <strong>{{ subscriptionToDelete?.name }}</strong>?
|
||||
This will also remove all sources and download history.
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="deleteDialog = false">Cancel</v-btn>
|
||||
<v-btn color="error" @click="deleteSubscription">Delete</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Delete Source Dialog -->
|
||||
<v-dialog v-model="deleteSourceDialog" max-width="400">
|
||||
<v-card>
|
||||
<v-card-title>Delete Source</v-card-title>
|
||||
<v-card-text>
|
||||
Are you sure you want to remove the {{ sourceToDelete?.platform }} source?
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="deleteSourceDialog = false">Cancel</v-btn>
|
||||
<v-btn color="error" @click="deleteSource">Delete</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useSubscriptionsStore } from '../stores/subscriptions'
|
||||
import { useNotificationStore } from '../stores/notifications'
|
||||
|
||||
const subscriptionsStore = useSubscriptionsStore()
|
||||
const notifications = useNotificationStore()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const filterEnabled = ref(null)
|
||||
const expandedIds = ref([])
|
||||
const checkingIds = ref([])
|
||||
|
||||
// Subscription dialog
|
||||
const showAddDialog = ref(false)
|
||||
const editingSubscription = ref(null)
|
||||
const saving = ref(false)
|
||||
const subscriptionForm = ref({
|
||||
name: '',
|
||||
description: '',
|
||||
priority: 0,
|
||||
})
|
||||
|
||||
// Source dialog
|
||||
const showSourceDialog = ref(false)
|
||||
const sourceDialogSubscription = ref(null)
|
||||
const savingSource = ref(false)
|
||||
const sourceForm = ref({
|
||||
platform: '',
|
||||
url: '',
|
||||
})
|
||||
|
||||
// Delete dialogs
|
||||
const deleteDialog = ref(false)
|
||||
const subscriptionToDelete = ref(null)
|
||||
const deleteSourceDialog = ref(false)
|
||||
const sourceToDelete = ref(null)
|
||||
|
||||
const enabledOptions = [
|
||||
{ title: 'Enabled', value: 'true' },
|
||||
{ title: 'Disabled', value: 'false' },
|
||||
]
|
||||
|
||||
const platformOptions = [
|
||||
{ title: 'Patreon', value: 'patreon' },
|
||||
{ title: 'SubscribeStar', value: 'subscribestar' },
|
||||
{ title: 'Hentai Foundry', value: 'hentaifoundry' },
|
||||
{ title: 'Discord', value: 'discord' },
|
||||
]
|
||||
|
||||
const headers = [
|
||||
{ title: 'Name', key: 'name', sortable: true },
|
||||
{ title: 'Platforms', key: 'platform_count', sortable: true },
|
||||
{ title: 'Enabled', key: 'enabled', sortable: true },
|
||||
{ title: 'Priority', key: 'priority', sortable: true },
|
||||
{ title: 'Actions', key: 'actions', sortable: false },
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
loadSubscriptions()
|
||||
})
|
||||
|
||||
watch([searchQuery, filterEnabled], () => {
|
||||
loadSubscriptions()
|
||||
})
|
||||
|
||||
async function loadSubscriptions() {
|
||||
const params = {}
|
||||
if (searchQuery.value) params.search = searchQuery.value
|
||||
if (filterEnabled.value) params.enabled = filterEnabled.value
|
||||
await subscriptionsStore.fetchSubscriptions(params)
|
||||
}
|
||||
|
||||
function toggleExpand(id) {
|
||||
const idx = expandedIds.value.indexOf(id)
|
||||
if (idx === -1) {
|
||||
expandedIds.value.push(id)
|
||||
} else {
|
||||
expandedIds.value.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
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: 'red',
|
||||
subscribestar: 'amber',
|
||||
hentaifoundry: 'purple',
|
||||
discord: 'indigo',
|
||||
}
|
||||
return colors[platform] || 'grey'
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return 'Never'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
|
||||
async function toggleEnabled(subscription) {
|
||||
try {
|
||||
await subscriptionsStore.updateSubscription(subscription.id, { enabled: !subscription.enabled })
|
||||
notifications.success(`${subscription.name} ${!subscription.enabled ? 'enabled' : 'disabled'}`)
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to update: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleSourceEnabled(source) {
|
||||
try {
|
||||
await subscriptionsStore.updateSource(source.id, { enabled: !source.enabled })
|
||||
notifications.success(`Source ${!source.enabled ? 'enabled' : 'disabled'}`)
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to update: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerCheck(subscription) {
|
||||
checkingIds.value.push(subscription.id)
|
||||
try {
|
||||
await subscriptionsStore.triggerCheck(subscription.id)
|
||||
notifications.success(`Queued checks for ${subscription.name}`)
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to queue checks: ${error.message}`)
|
||||
} finally {
|
||||
checkingIds.value = checkingIds.value.filter(id => id !== subscription.id)
|
||||
}
|
||||
}
|
||||
|
||||
function editSubscription(subscription) {
|
||||
editingSubscription.value = subscription
|
||||
subscriptionForm.value = {
|
||||
name: subscription.name,
|
||||
description: subscription.description || '',
|
||||
priority: subscription.priority,
|
||||
}
|
||||
showAddDialog.value = true
|
||||
}
|
||||
|
||||
function closeAddDialog() {
|
||||
showAddDialog.value = false
|
||||
editingSubscription.value = null
|
||||
subscriptionForm.value = { name: '', description: '', priority: 0 }
|
||||
}
|
||||
|
||||
async function saveSubscription() {
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingSubscription.value) {
|
||||
await subscriptionsStore.updateSubscription(editingSubscription.value.id, subscriptionForm.value)
|
||||
notifications.success('Subscription updated')
|
||||
} else {
|
||||
await subscriptionsStore.createSubscription(subscriptionForm.value)
|
||||
notifications.success('Subscription created')
|
||||
}
|
||||
closeAddDialog()
|
||||
loadSubscriptions()
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to save: ${error.message}`)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(subscription) {
|
||||
subscriptionToDelete.value = subscription
|
||||
deleteDialog.value = true
|
||||
}
|
||||
|
||||
async function deleteSubscription() {
|
||||
try {
|
||||
await subscriptionsStore.deleteSubscription(subscriptionToDelete.value.id)
|
||||
notifications.success(`Deleted ${subscriptionToDelete.value.name}`)
|
||||
deleteDialog.value = false
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to delete: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
function openAddSourceDialog(subscription) {
|
||||
sourceDialogSubscription.value = subscription
|
||||
sourceForm.value = { platform: '', url: '' }
|
||||
showSourceDialog.value = true
|
||||
}
|
||||
|
||||
async function addSource() {
|
||||
savingSource.value = true
|
||||
try {
|
||||
await subscriptionsStore.addSource(sourceDialogSubscription.value.id, sourceForm.value)
|
||||
notifications.success('Source added')
|
||||
showSourceDialog.value = false
|
||||
loadSubscriptions()
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to add source: ${error.message}`)
|
||||
} finally {
|
||||
savingSource.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteSource(source, subscription) {
|
||||
sourceToDelete.value = source
|
||||
sourceDialogSubscription.value = subscription
|
||||
deleteSourceDialog.value = true
|
||||
}
|
||||
|
||||
async function deleteSource() {
|
||||
try {
|
||||
await subscriptionsStore.deleteSource(sourceToDelete.value.id)
|
||||
notifications.success('Source removed')
|
||||
deleteSourceDialog.value = false
|
||||
loadSubscriptions()
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to delete: ${error.message}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:8080',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsDir: 'assets',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user