Compare commits
8
Commits
e8972cae38
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0157f462cc | ||
|
|
3d0658b483 | ||
|
|
f3dc8e46fd | ||
|
|
8f8e2c744d | ||
|
|
6d9a078656 | ||
|
|
7580dc3f94 | ||
|
|
9f5a25fcf5 | ||
|
|
9e61d18bf6 |
+112
@@ -0,0 +1,112 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
.gitmodules
|
||||||
|
|
||||||
|
# Documentation and configs not needed in image
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
docs/
|
||||||
|
.pre-commit-config.yaml
|
||||||
|
.markdown_style.rb
|
||||||
|
.mdlrc
|
||||||
|
_config.yml
|
||||||
|
SCHEDULED_DOWNLOADS_PLAN.md
|
||||||
|
|
||||||
|
# Test files
|
||||||
|
tests/
|
||||||
|
test_*.py
|
||||||
|
*_test.py
|
||||||
|
conftest.py
|
||||||
|
|
||||||
|
# CI/CD
|
||||||
|
.github/
|
||||||
|
tox.ini
|
||||||
|
|
||||||
|
# Scripts and dev tools
|
||||||
|
scripts/
|
||||||
|
devscripts/
|
||||||
|
|
||||||
|
# Local data and logs (these should be mounted as volumes)
|
||||||
|
web_interface/data/*.db
|
||||||
|
web_interface/data/*.db-*
|
||||||
|
*.log
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Environment files (use docker-compose env instead)
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Docker files (don't need docker in docker)
|
||||||
|
Dockerfile*
|
||||||
|
docker-compose*.yml
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# OS specific
|
||||||
|
Thumbs.db
|
||||||
|
ehthumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
$RECYCLE.BIN/
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
*.bak
|
||||||
|
*.backup
|
||||||
+141
@@ -0,0 +1,141 @@
|
|||||||
|
# BDFR Web Interface - Docker Environment Configuration
|
||||||
|
# Copy this file to .env and update the values as needed
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# DOCKER USER CONFIGURATION (Important for file permissions)
|
||||||
|
# ============================================================================
|
||||||
|
# Set these to match your host user's UID and GID to avoid permission issues
|
||||||
|
# Find your UID/GID by running: id
|
||||||
|
# On Linux/Mac: id -u (for UID) and id -g (for GID)
|
||||||
|
# On Windows with WSL: wsl id -u
|
||||||
|
# Default: 1000:1000 (common default user on Linux systems)
|
||||||
|
|
||||||
|
PUID=1000
|
||||||
|
PGID=1000
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# REDDIT OAUTH CONFIGURATION (REQUIRED for authenticated features)
|
||||||
|
# ============================================================================
|
||||||
|
# Get these credentials from: https://www.reddit.com/prefs/apps
|
||||||
|
#
|
||||||
|
# Steps to get OAuth credentials:
|
||||||
|
# 1. Go to https://www.reddit.com/prefs/apps
|
||||||
|
# 2. Click "Create App" or "Create Another App"
|
||||||
|
# 3. Fill in:
|
||||||
|
# - Name: BDFR Web Interface (or your choice)
|
||||||
|
# - App type: Select "web app"
|
||||||
|
# - Redirect URI: http://localhost:8000/auth/callback
|
||||||
|
# 4. Copy the client ID (under the app name) and client secret
|
||||||
|
|
||||||
|
BDFR_CLIENT_ID=your_client_id_here
|
||||||
|
BDFR_CLIENT_SECRET=your_client_secret_here
|
||||||
|
|
||||||
|
# IMPORTANT: This MUST match your Reddit OAuth app's redirect URI exactly
|
||||||
|
# If you change the port, update this accordingly
|
||||||
|
BDFR_REDIRECT_URI=http://localhost:8000/auth/callback
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# SERVER CONFIGURATION (Optional)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Host to bind to (0.0.0.0 for all interfaces, 127.0.0.1 for localhost only)
|
||||||
|
HOST=0.0.0.0
|
||||||
|
|
||||||
|
# Port for the web interface
|
||||||
|
PORT=8000
|
||||||
|
|
||||||
|
# Enable debug mode (true/false) - DO NOT use in production
|
||||||
|
DEBUG=false
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# DOWNLOAD CONFIGURATION (Optional - Docker handles these by default)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Directory where downloads will be stored (inside container)
|
||||||
|
# This is mounted from ./downloads on the host
|
||||||
|
BDFR_DOWNLOAD_DIR=/app/downloads
|
||||||
|
|
||||||
|
# Directory for application data and databases (inside container)
|
||||||
|
# This is mounted from ./data on the host
|
||||||
|
BDFR_DATA_DIR=/app/data
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# BDFR ADVANCED CONFIGURATION (Optional)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# BDFR configuration directory (inside container)
|
||||||
|
# Default: /app/data/bdfr-config (stored in mounted data volume)
|
||||||
|
# BDFR_CONFIG_DIR=/app/data/bdfr-config
|
||||||
|
|
||||||
|
# Maximum wait time for rate limiting (in seconds)
|
||||||
|
# Default: 120
|
||||||
|
# BDFR_MAX_WAIT_TIME=120
|
||||||
|
|
||||||
|
# Time format for {DATE} in filenames
|
||||||
|
# Default: ISO 8601 format (%Y-%m-%dT%H:%M:%S)
|
||||||
|
# BDFR_TIME_FORMAT=%Y-%m-%dT%H:%M:%S
|
||||||
|
|
||||||
|
# File naming scheme
|
||||||
|
# Available variables: {REDDITOR}, {SUBREDDIT}, {POSTID}, {UPVOTES}, {TITLE}, {DATE}, {FLAIR}
|
||||||
|
# Default: {REDDITOR}_{TITLE}_{POSTID}
|
||||||
|
# BDFR_FILE_SCHEME={REDDITOR}_{TITLE}_{POSTID}
|
||||||
|
|
||||||
|
# Folder naming scheme
|
||||||
|
# Default: {SUBREDDIT}
|
||||||
|
# BDFR_FOLDER_SCHEME={SUBREDDIT}
|
||||||
|
|
||||||
|
# Environment variables for BDFR config location
|
||||||
|
# These tell BDFR where to store its configuration files
|
||||||
|
# APPDATA=/app/data/bdfr-config
|
||||||
|
# XDG_CONFIG_HOME=/app/data/bdfr-config
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# DATABASE CONFIGURATION (Optional)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# SQLite database file paths (inside container)
|
||||||
|
# These are stored in the mounted ./data directory
|
||||||
|
# DATABASE_URL=sqlite:////app/data/scheduled_tasks.db
|
||||||
|
# SCHEDULER_DATABASE_URL=sqlite:////app/data/scheduler_jobs.db
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# LOGGING CONFIGURATION (Optional)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||||
|
# LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# Log file path (inside container)
|
||||||
|
# LOG_FILE=/app/logs/bdfr-web.log
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# SECURITY CONFIGURATION (Production Only)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Secret key for session management
|
||||||
|
# Generate a secure random string for production
|
||||||
|
# SECRET_KEY=your-secret-key-here
|
||||||
|
|
||||||
|
# Allowed hosts (comma-separated)
|
||||||
|
# ALLOWED_HOSTS=localhost,127.0.0.1,bdfr.example.com
|
||||||
|
|
||||||
|
# Enable CORS (true/false)
|
||||||
|
# ENABLE_CORS=true
|
||||||
|
|
||||||
|
# CORS allowed origins (comma-separated)
|
||||||
|
# CORS_ORIGINS=http://localhost:8000,https://bdfr.example.com
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# NOTES
|
||||||
|
# ============================================================================
|
||||||
|
#
|
||||||
|
# - After modifying this file, restart the Docker container:
|
||||||
|
# docker-compose restart
|
||||||
|
#
|
||||||
|
# - For sensitive values in production, consider using Docker secrets:
|
||||||
|
# https://docs.docker.com/engine/swarm/secrets/
|
||||||
|
#
|
||||||
|
# - Required variables are marked as REQUIRED
|
||||||
|
# - Optional variables will use sensible defaults if not set
|
||||||
|
#
|
||||||
|
# - For more information, see DOCKER.md
|
||||||
@@ -0,0 +1,514 @@
|
|||||||
|
# BDFR Web Interface - Docker Deployment Guide
|
||||||
|
|
||||||
|
This guide covers running the BDFR Web Interface with full BDFR backend support using Docker.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Docker Engine 20.10 or later
|
||||||
|
- Docker Compose 2.0 or later
|
||||||
|
- At least 2GB of free disk space
|
||||||
|
|
||||||
|
### Basic Setup
|
||||||
|
|
||||||
|
1. **Clone the repository** (if you haven't already):
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd BDFR_Web
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Create required directories**:
|
||||||
|
```bash
|
||||||
|
mkdir -p downloads data config
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Configure environment variables** (optional but recommended):
|
||||||
|
```bash
|
||||||
|
cp web_interface/.env.example .env
|
||||||
|
# Edit .env with your Reddit OAuth credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Start the container**:
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Access the web interface**:
|
||||||
|
Open your browser to `http://localhost:8000`
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Create a `.env` file in the project root with the following variables:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Docker User Configuration (Important for file permissions)
|
||||||
|
# Set these to match your host user's UID/GID to avoid permission issues
|
||||||
|
# Find your UID/GID by running: id (on Linux/Mac) or wsl id (on Windows WSL)
|
||||||
|
PUID=1000
|
||||||
|
PGID=1000
|
||||||
|
|
||||||
|
# Reddit OAuth Configuration (Required for authenticated features)
|
||||||
|
BDFR_CLIENT_ID=your_client_id_here
|
||||||
|
BDFR_CLIENT_SECRET=your_client_secret_here
|
||||||
|
BDFR_REDIRECT_URI=http://localhost:8000/auth/callback
|
||||||
|
|
||||||
|
# Server Configuration (Optional)
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=8000
|
||||||
|
DEBUG=false
|
||||||
|
```
|
||||||
|
|
||||||
|
### Getting Reddit OAuth Credentials
|
||||||
|
|
||||||
|
1. Go to https://www.reddit.com/prefs/apps
|
||||||
|
2. Click "Create App" or "Create Another App"
|
||||||
|
3. Fill in the form:
|
||||||
|
- **Name**: BDFR Web Interface (or your choice)
|
||||||
|
- **App type**: Select "web app"
|
||||||
|
- **Description**: (optional)
|
||||||
|
- **About URL**: (optional)
|
||||||
|
- **Redirect URI**: `http://localhost:8000/auth/callback`
|
||||||
|
4. Click "Create app"
|
||||||
|
5. Copy the **client ID** (under the app name) and **client secret**
|
||||||
|
|
||||||
|
### Volume Mounts
|
||||||
|
|
||||||
|
The Docker setup uses two volume mounts:
|
||||||
|
|
||||||
|
| Host Path | Container Path | Purpose |
|
||||||
|
|-----------|----------------|---------|
|
||||||
|
| `./downloads` | `/app/downloads` | All Reddit downloads are stored here |
|
||||||
|
| `./data` | `/app/data` | SQLite databases, scheduled tasks, and BDFR configuration |
|
||||||
|
|
||||||
|
**Data Directory Structure:**
|
||||||
|
```
|
||||||
|
./data/
|
||||||
|
├── bdfr-config/ # BDFR configuration and OAuth tokens
|
||||||
|
│ ├── config.cfg # BDFR settings
|
||||||
|
│ ├── log_output.txt # BDFR logs
|
||||||
|
│ └── oauth_tokens/ # Reddit authentication tokens
|
||||||
|
├── scheduled_tasks.db # Web interface scheduled tasks
|
||||||
|
└── scheduler_jobs.db # APScheduler job store
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Managing the Container
|
||||||
|
|
||||||
|
**Start the container:**
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
**Stop the container:**
|
||||||
|
```bash
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
**View logs:**
|
||||||
|
```bash
|
||||||
|
docker-compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
**Restart the container:**
|
||||||
|
```bash
|
||||||
|
docker-compose restart
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rebuild after code changes:**
|
||||||
|
```bash
|
||||||
|
docker-compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using the Web Interface
|
||||||
|
|
||||||
|
1. Navigate to `http://localhost:8000`
|
||||||
|
2. Use the web UI to:
|
||||||
|
- Download from subreddits
|
||||||
|
- Download from users
|
||||||
|
- Schedule recurring downloads
|
||||||
|
- Monitor active downloads
|
||||||
|
- View download history
|
||||||
|
|
||||||
|
### Using BDFR CLI Inside Container
|
||||||
|
|
||||||
|
You can also use the BDFR command-line tool directly:
|
||||||
|
|
||||||
|
**Enter the container:**
|
||||||
|
```bash
|
||||||
|
docker exec -it bdfr-web-interface bash
|
||||||
|
```
|
||||||
|
|
||||||
|
**Run BDFR commands:**
|
||||||
|
```bash
|
||||||
|
# Download from a subreddit
|
||||||
|
bdfr download /app/downloads --subreddit Python -L 10
|
||||||
|
|
||||||
|
# Download from a user
|
||||||
|
bdfr download /app/downloads --user reddituser --submitted -L 100
|
||||||
|
|
||||||
|
# Archive posts
|
||||||
|
bdfr archive /app/downloads --subreddit all -L 500
|
||||||
|
|
||||||
|
# Clone (download + archive)
|
||||||
|
bdfr clone /app/downloads --subreddit EarthPorn -L 50
|
||||||
|
```
|
||||||
|
|
||||||
|
**One-line BDFR commands:**
|
||||||
|
```bash
|
||||||
|
# Download without entering the container
|
||||||
|
docker exec bdfr-web-interface bdfr download /app/downloads --subreddit Python -L 10
|
||||||
|
|
||||||
|
# View BDFR version
|
||||||
|
docker exec bdfr-web-interface bdfr --version
|
||||||
|
|
||||||
|
# View BDFR help
|
||||||
|
docker exec bdfr-web-interface bdfr download --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced Configuration
|
||||||
|
|
||||||
|
### Custom Port
|
||||||
|
|
||||||
|
To run on a different port, modify [`docker-compose.yml`](docker-compose.yml:11):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ports:
|
||||||
|
- "3000:8000" # Host port 3000, container port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
Or set the PORT environment variable:
|
||||||
|
```env
|
||||||
|
PORT=3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Download Directory
|
||||||
|
|
||||||
|
Mount a different host directory for downloads:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- /path/to/your/downloads:/app/downloads
|
||||||
|
- ./data:/app/data
|
||||||
|
```
|
||||||
|
|
||||||
|
### BDFR Configuration File
|
||||||
|
|
||||||
|
BDFR configuration is automatically stored in `./data/bdfr-config/`. You can customize it by creating `./data/bdfr-config/config.cfg`:
|
||||||
|
|
||||||
|
```cfg
|
||||||
|
[DEFAULT]
|
||||||
|
client_id = your_client_id
|
||||||
|
client_secret = your_client_secret
|
||||||
|
scopes = identity, read, history, mysubreddits
|
||||||
|
|
||||||
|
[bdfr]
|
||||||
|
max_wait_time = 120
|
||||||
|
time_format = %Y-%m-%d_%H-%M-%S
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** BDFR will automatically create this directory and configuration file on first run.
|
||||||
|
|
||||||
|
### Resource Limits
|
||||||
|
|
||||||
|
Add resource constraints in [`docker-compose.yml`](docker-compose.yml):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
bdfr-web:
|
||||||
|
# ... other configuration ...
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '2'
|
||||||
|
memory: 2G
|
||||||
|
reservations:
|
||||||
|
cpus: '1'
|
||||||
|
memory: 512M
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Container Won't Start
|
||||||
|
|
||||||
|
**Check logs:**
|
||||||
|
```bash
|
||||||
|
docker-compose logs bdfr-web
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verify ports aren't in use:**
|
||||||
|
```bash
|
||||||
|
# Windows
|
||||||
|
netstat -ano | findstr :8000
|
||||||
|
|
||||||
|
# Linux/Mac
|
||||||
|
lsof -i :8000
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rebuild the image:**
|
||||||
|
```bash
|
||||||
|
docker-compose down
|
||||||
|
docker-compose build --no-cache
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Permission Issues
|
||||||
|
|
||||||
|
If you encounter permission errors with volumes (e.g., "Permission denied" when creating directories):
|
||||||
|
|
||||||
|
**Solution 1: Configure PUID/PGID (Recommended)**
|
||||||
|
|
||||||
|
Set `PUID` and `PGID` in your `.env` file to match your host user:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Find your UID and GID
|
||||||
|
id
|
||||||
|
|
||||||
|
# Example output: uid=1001(username) gid=1001(groupname)
|
||||||
|
# Add to .env file:
|
||||||
|
PUID=1001
|
||||||
|
PGID=1001
|
||||||
|
```
|
||||||
|
|
||||||
|
Then restart the container:
|
||||||
|
```bash
|
||||||
|
docker-compose down
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution 2: Fix Host Directory Permissions**
|
||||||
|
|
||||||
|
**Linux/Mac:**
|
||||||
|
```bash
|
||||||
|
sudo chown -R $USER:$USER downloads data config
|
||||||
|
chmod -R 755 downloads data config
|
||||||
|
```
|
||||||
|
|
||||||
|
**TrueNAS/NAS Systems:**
|
||||||
|
When using network shares or NAS storage:
|
||||||
|
1. Find your NAS user's UID/GID (usually in user management settings)
|
||||||
|
2. Set `PUID` and `PGID` in `.env` to match your NAS user
|
||||||
|
3. Ensure the NAS user has read/write permissions on mounted shares
|
||||||
|
|
||||||
|
**Windows:**
|
||||||
|
Ensure Docker Desktop has access to the drive where the project is located (Settings → Resources → File Sharing).
|
||||||
|
|
||||||
|
**Why this matters:**
|
||||||
|
The container runs as a specific user (default UID 1000). If your host directories are owned by a different user, the container won't be able to write to them. Setting `PUID` and `PGID` tells Docker to run the container as your host user, matching permissions.
|
||||||
|
|
||||||
|
### Downloads Not Appearing
|
||||||
|
|
||||||
|
1. Check volume mounts are correct:
|
||||||
|
```bash
|
||||||
|
docker inspect bdfr-web-interface | grep -A 10 Mounts
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Verify download directory inside container:
|
||||||
|
```bash
|
||||||
|
docker exec bdfr-web-interface ls -la /app/downloads
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Check container logs for errors:
|
||||||
|
```bash
|
||||||
|
docker-compose logs -f bdfr-web
|
||||||
|
```
|
||||||
|
|
||||||
|
### Permission Denied Errors
|
||||||
|
|
||||||
|
If you see errors like "Permission denied: '/usr/local/lib/python3.11/site-packages/bdfr/default_config.cfg'":
|
||||||
|
|
||||||
|
1. Verify the BDFR config directory exists and is writable:
|
||||||
|
```bash
|
||||||
|
docker exec bdfr-web-interface ls -la /app/data/bdfr-config
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Check environment variables are set correctly:
|
||||||
|
```bash
|
||||||
|
docker exec bdfr-web-interface env | grep BDFR
|
||||||
|
```
|
||||||
|
|
||||||
|
3. If the directory doesn't exist or has wrong permissions:
|
||||||
|
```bash
|
||||||
|
docker-compose down
|
||||||
|
docker-compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### OAuth Authentication Issues
|
||||||
|
|
||||||
|
1. Verify credentials in `.env` or [`docker-compose.yml`](docker-compose.yml)
|
||||||
|
2. Ensure redirect URI matches exactly: `http://localhost:8000/auth/callback`
|
||||||
|
3. Check Reddit app settings at https://www.reddit.com/prefs/apps
|
||||||
|
4. Restart container after changing credentials:
|
||||||
|
```bash
|
||||||
|
docker-compose restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### High Memory Usage
|
||||||
|
|
||||||
|
BDFR can use significant memory when downloading large files or many files simultaneously:
|
||||||
|
|
||||||
|
1. Monitor memory usage:
|
||||||
|
```bash
|
||||||
|
docker stats bdfr-web-interface
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Add memory limits (see Resource Limits above)
|
||||||
|
|
||||||
|
3. Reduce concurrent downloads by limiting the number of simultaneous operations
|
||||||
|
|
||||||
|
### Database Locked Errors
|
||||||
|
|
||||||
|
If you see SQLite database locked errors:
|
||||||
|
|
||||||
|
1. Ensure only one instance is running:
|
||||||
|
```bash
|
||||||
|
docker ps | grep bdfr
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Stop all instances and restart:
|
||||||
|
```bash
|
||||||
|
docker-compose down
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Maintenance
|
||||||
|
|
||||||
|
### Backing Up Data
|
||||||
|
|
||||||
|
**Backup downloads and database:**
|
||||||
|
```bash
|
||||||
|
# Create backup directory
|
||||||
|
mkdir -p backups
|
||||||
|
|
||||||
|
# Backup downloads
|
||||||
|
tar -czf backups/downloads-$(date +%Y%m%d).tar.gz downloads/
|
||||||
|
|
||||||
|
# Backup database
|
||||||
|
cp -r data/ backups/data-$(date +%Y%m%d)/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Automated backup script** (Linux/Mac):
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# backup.sh
|
||||||
|
BACKUP_DIR="backups"
|
||||||
|
DATE=$(date +%Y%m%d_%H%M%S)
|
||||||
|
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
tar -czf "$BACKUP_DIR/bdfr-backup-$DATE.tar.gz" downloads/ data/
|
||||||
|
|
||||||
|
# Keep only last 7 days of backups
|
||||||
|
find "$BACKUP_DIR" -name "bdfr-backup-*.tar.gz" -mtime +7 -delete
|
||||||
|
```
|
||||||
|
|
||||||
|
### Updating the Container
|
||||||
|
|
||||||
|
**Pull latest changes:**
|
||||||
|
```bash
|
||||||
|
git pull origin main
|
||||||
|
docker-compose down
|
||||||
|
docker-compose build --no-cache
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
**Update only Docker image:**
|
||||||
|
```bash
|
||||||
|
docker-compose pull
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cleaning Up
|
||||||
|
|
||||||
|
**Remove stopped containers:**
|
||||||
|
```bash
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
**Remove containers and volumes:**
|
||||||
|
```bash
|
||||||
|
docker-compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
**Remove images:**
|
||||||
|
```bash
|
||||||
|
docker-compose down --rmi all
|
||||||
|
```
|
||||||
|
|
||||||
|
**Full cleanup (including downloads):**
|
||||||
|
```bash
|
||||||
|
docker-compose down -v --rmi all
|
||||||
|
rm -rf downloads/ data/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### Production Deployment
|
||||||
|
|
||||||
|
For production use, consider:
|
||||||
|
|
||||||
|
1. **Use HTTPS**: Set up a reverse proxy (nginx/Traefik) with SSL
|
||||||
|
2. **Secure credentials**: Use Docker secrets or vault for sensitive data
|
||||||
|
3. **Network isolation**: Use custom networks and restrict access
|
||||||
|
4. **Regular updates**: Keep the container and dependencies updated
|
||||||
|
5. **Monitor logs**: Set up log aggregation and monitoring
|
||||||
|
6. **Backup strategy**: Implement automated backups
|
||||||
|
|
||||||
|
### Example Nginx Reverse Proxy
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name bdfr.example.com;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# WebSocket support
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Optimization
|
||||||
|
|
||||||
|
### Build Performance
|
||||||
|
|
||||||
|
**Use BuildKit for faster builds:**
|
||||||
|
```bash
|
||||||
|
DOCKER_BUILDKIT=1 docker-compose build
|
||||||
|
```
|
||||||
|
|
||||||
|
**Multi-stage build** is already implemented in the [`Dockerfile`](Dockerfile) to minimize image size.
|
||||||
|
|
||||||
|
### Runtime Performance
|
||||||
|
|
||||||
|
1. **Allocate sufficient resources** (see Resource Limits)
|
||||||
|
2. **Use SSD storage** for downloads directory
|
||||||
|
3. **Optimize network** for faster downloads
|
||||||
|
4. **Monitor container health**:
|
||||||
|
```bash
|
||||||
|
docker inspect --format='{{.State.Health.Status}}' bdfr-web-interface
|
||||||
|
```
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues specific to:
|
||||||
|
- **Docker setup**: Check this guide and Docker logs
|
||||||
|
- **BDFR functionality**: See main [README.md](README.md)
|
||||||
|
- **Web interface**: See [web_interface/README.md](web_interface/README.md)
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
- [BDFR Documentation](README.md)
|
||||||
|
- [Docker Documentation](https://docs.docker.com/)
|
||||||
|
- [Docker Compose Documentation](https://docs.docker.com/compose/)
|
||||||
|
- [Reddit API Documentation](https://www.reddit.com/dev/api)
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
# Publishing BDFR Web Interface to Docker Hub
|
||||||
|
|
||||||
|
This guide covers how to build and push the Docker image to Docker Hub.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. **Docker Hub Account**: Create one at https://hub.docker.com if you don't have one
|
||||||
|
2. **Docker installed**: Ensure Docker is running on your machine
|
||||||
|
|
||||||
|
## Step 1: Login to Docker Hub
|
||||||
|
|
||||||
|
Open your terminal and login:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker login
|
||||||
|
```
|
||||||
|
|
||||||
|
Enter your Docker Hub username and password when prompted.
|
||||||
|
|
||||||
|
## Step 2: Build the Image with Proper Tagging
|
||||||
|
|
||||||
|
Build the image with your Docker Hub username and desired repository name:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Replace 'yourusername' with your actual Docker Hub username
|
||||||
|
# Replace 'bdfr-web' with your desired repository name (or keep it)
|
||||||
|
|
||||||
|
docker build -t yourusername/bdfr-web:latest .
|
||||||
|
|
||||||
|
# You can also add version tags
|
||||||
|
docker build -t yourusername/bdfr-web:latest -t yourusername/bdfr-web:1.0.0 .
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
docker build -t danielsmith/bdfr-web:latest -t danielsmith/bdfr-web:1.0.0 .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 3: Test the Image Locally (Optional but Recommended)
|
||||||
|
|
||||||
|
Before pushing, verify the image works:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d -p 8000:8000 \
|
||||||
|
-v $(pwd)/downloads:/downloads \
|
||||||
|
-v $(pwd)/data:/app/data \
|
||||||
|
--name bdfr-test \
|
||||||
|
yourusername/bdfr-web:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Visit `http://localhost:8000` to verify it works, then clean up:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker stop bdfr-test
|
||||||
|
docker rm bdfr-test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 4: Push to Docker Hub
|
||||||
|
|
||||||
|
Push the image to Docker Hub:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Push latest tag
|
||||||
|
docker push yourusername/bdfr-web:latest
|
||||||
|
|
||||||
|
# If you created a version tag, push that too
|
||||||
|
docker push yourusername/bdfr-web:1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 5: Verify the Push
|
||||||
|
|
||||||
|
1. Go to https://hub.docker.com
|
||||||
|
2. Navigate to your repositories
|
||||||
|
3. You should see `bdfr-web` listed
|
||||||
|
4. Click on it to see tags and details
|
||||||
|
|
||||||
|
## Using the Published Image
|
||||||
|
|
||||||
|
Others can now pull and use your image:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull yourusername/bdfr-web:latest
|
||||||
|
docker run -d -p 8000:8000 -v ./downloads:/downloads yourusername/bdfr-web:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Or using docker-compose, update [`docker-compose.yml`](docker-compose.yml):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
bdfr-web:
|
||||||
|
image: yourusername/bdfr-web:latest # Replace 'build:' section with this
|
||||||
|
# ... rest of configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
## Automated Build with Docker Hub
|
||||||
|
|
||||||
|
You can set up automated builds that trigger when you push to GitHub:
|
||||||
|
|
||||||
|
1. Go to https://hub.docker.com
|
||||||
|
2. Navigate to your repository
|
||||||
|
3. Click "Builds" tab
|
||||||
|
4. Click "Configure Automated Builds"
|
||||||
|
5. Connect your GitHub account
|
||||||
|
6. Select your repository
|
||||||
|
7. Configure build rules (e.g., build on push to main branch)
|
||||||
|
|
||||||
|
## Multi-Platform Builds (Optional)
|
||||||
|
|
||||||
|
To build for multiple architectures (amd64, arm64, etc.):
|
||||||
|
|
||||||
|
### Setup buildx (one-time setup)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a new builder
|
||||||
|
docker buildx create --name multiplatform --use
|
||||||
|
|
||||||
|
# Bootstrap the builder
|
||||||
|
docker buildx inspect --bootstrap
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build and push multi-platform image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/amd64,linux/arm64 \
|
||||||
|
-t yourusername/bdfr-web:latest \
|
||||||
|
-t yourusername/bdfr-web:1.0.0 \
|
||||||
|
--push \
|
||||||
|
.
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates images that work on both x86_64 (Intel/AMD) and ARM64 (Apple Silicon, Raspberry Pi, etc.).
|
||||||
|
|
||||||
|
## Updating Your Published Image
|
||||||
|
|
||||||
|
When you make changes:
|
||||||
|
|
||||||
|
1. **Update version**: Increment version in tags (e.g., 1.0.0 → 1.0.1)
|
||||||
|
2. **Rebuild**:
|
||||||
|
```bash
|
||||||
|
docker build -t yourusername/bdfr-web:latest -t yourusername/bdfr-web:1.0.1 .
|
||||||
|
```
|
||||||
|
3. **Push**:
|
||||||
|
```bash
|
||||||
|
docker push yourusername/bdfr-web:latest
|
||||||
|
docker push yourusername/bdfr-web:1.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Tagging Strategy
|
||||||
|
|
||||||
|
- `latest`: Always points to the most recent stable build
|
||||||
|
- `1.0.0`, `1.0.1`: Specific version tags for reproducibility
|
||||||
|
- `1.0`, `1`: Major/minor version tags
|
||||||
|
- `dev`: Development/unstable builds
|
||||||
|
|
||||||
|
Example tagging:
|
||||||
|
```bash
|
||||||
|
docker build -t yourusername/bdfr-web:latest \
|
||||||
|
-t yourusername/bdfr-web:1.0.1 \
|
||||||
|
-t yourusername/bdfr-web:1.0 \
|
||||||
|
-t yourusername/bdfr-web:1 .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Repository Description
|
||||||
|
|
||||||
|
Add a good description to your Docker Hub repository:
|
||||||
|
|
||||||
|
1. Go to your repository on Docker Hub
|
||||||
|
2. Click "Description" tab
|
||||||
|
3. Add a README with:
|
||||||
|
- What the image does
|
||||||
|
- How to use it
|
||||||
|
- Environment variables
|
||||||
|
- Volume mounts
|
||||||
|
- Example docker-compose.yml
|
||||||
|
|
||||||
|
### Size Optimization
|
||||||
|
|
||||||
|
Current image uses multi-stage builds and is already optimized. To check size:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker images yourusername/bdfr-web
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Script
|
||||||
|
|
||||||
|
Here's a complete script to build and push (save as `publish-docker.sh`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
DOCKER_USERNAME="yourusername"
|
||||||
|
IMAGE_NAME="bdfr-web"
|
||||||
|
VERSION="1.0.0"
|
||||||
|
|
||||||
|
# Build
|
||||||
|
echo "Building Docker image..."
|
||||||
|
docker build -t ${DOCKER_USERNAME}/${IMAGE_NAME}:latest \
|
||||||
|
-t ${DOCKER_USERNAME}/${IMAGE_NAME}:${VERSION} .
|
||||||
|
|
||||||
|
# Test
|
||||||
|
echo "Testing image..."
|
||||||
|
docker run --rm ${DOCKER_USERNAME}/${IMAGE_NAME}:latest bdfr --version
|
||||||
|
|
||||||
|
# Login (if not already logged in)
|
||||||
|
echo "Logging in to Docker Hub..."
|
||||||
|
docker login
|
||||||
|
|
||||||
|
# Push
|
||||||
|
echo "Pushing to Docker Hub..."
|
||||||
|
docker push ${DOCKER_USERNAME}/${IMAGE_NAME}:latest
|
||||||
|
docker push ${DOCKER_USERNAME}/${IMAGE_NAME}:${VERSION}
|
||||||
|
|
||||||
|
echo "Done! Image published to:"
|
||||||
|
echo " docker pull ${DOCKER_USERNAME}/${IMAGE_NAME}:latest"
|
||||||
|
echo " docker pull ${DOCKER_USERNAME}/${IMAGE_NAME}:${VERSION}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Make it executable:
|
||||||
|
```bash
|
||||||
|
chmod +x publish-docker.sh
|
||||||
|
./publish-docker.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "denied: requested access to the resource is denied"
|
||||||
|
|
||||||
|
- Make sure you're logged in: `docker login`
|
||||||
|
- Verify your username in the image tag matches your Docker Hub username
|
||||||
|
- Check that the repository exists or that you have permissions
|
||||||
|
|
||||||
|
### "image not found" after push
|
||||||
|
|
||||||
|
- Wait a few minutes - Docker Hub indexing can be delayed
|
||||||
|
- Refresh the Docker Hub web page
|
||||||
|
- Try pulling: `docker pull yourusername/bdfr-web:latest`
|
||||||
|
|
||||||
|
### Large image size
|
||||||
|
|
||||||
|
Current image should be ~500-800MB. If larger:
|
||||||
|
- Check that `.dockerignore` is working
|
||||||
|
- Verify multi-stage build is being used
|
||||||
|
- Clean up any unnecessary files in the image
|
||||||
|
|
||||||
|
## Private Repositories
|
||||||
|
|
||||||
|
To make your repository private:
|
||||||
|
|
||||||
|
1. Go to Docker Hub repository settings
|
||||||
|
2. Change visibility to "Private"
|
||||||
|
3. Users will need to login to pull: `docker login` before `docker pull`
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
- [Docker Hub Documentation](https://docs.docker.com/docker-hub/)
|
||||||
|
- [Dockerfile Best Practices](https://docs.docker.com/develop/develop-images/dockerfile_best-practices/)
|
||||||
|
- [Docker Buildx Documentation](https://docs.docker.com/buildx/working-with-buildx/)
|
||||||
+95
@@ -0,0 +1,95 @@
|
|||||||
|
# Multi-stage Dockerfile for BDFR Web Interface with full BDFR backend support
|
||||||
|
# This image contains both the web interface and the complete BDFR tool
|
||||||
|
|
||||||
|
FROM python:3.11-slim AS builder
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Install build dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
g++ \
|
||||||
|
git \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy BDFR source files and install it
|
||||||
|
COPY pyproject.toml ./
|
||||||
|
COPY bdfr/ ./bdfr/
|
||||||
|
RUN pip install --no-cache-dir .
|
||||||
|
|
||||||
|
# Copy and install web interface requirements
|
||||||
|
COPY web_interface/requirements.txt ./web_requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r web_requirements.txt
|
||||||
|
|
||||||
|
|
||||||
|
# Final stage - minimal runtime image
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Set labels
|
||||||
|
LABEL maintainer="BDFR Web Interface"
|
||||||
|
LABEL description="Complete BDFR with Web Interface - Download Reddit content via CLI or Web UI"
|
||||||
|
LABEL version="1.0.0"
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install runtime dependencies (ffmpeg for video processing, curl for health checks)
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
ffmpeg \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy Python packages from builder (installed globally in /usr/local)
|
||||||
|
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
|
||||||
|
COPY --from=builder /usr/local/bin /usr/local/bin
|
||||||
|
|
||||||
|
# Ensure Python packages are readable by all users
|
||||||
|
# This is critical when running container with custom user via PUID/PGID
|
||||||
|
RUN chmod -R a+rX /usr/local/lib/python3.11/site-packages && \
|
||||||
|
chmod -R a+rx /usr/local/bin
|
||||||
|
|
||||||
|
# Copy default_config.cfg to a world-readable location
|
||||||
|
# This avoids issues with importlib.resources when running as non-root user
|
||||||
|
RUN cp /usr/local/lib/python3.11/site-packages/bdfr/default_config.cfg /tmp/bdfr_default_config.cfg && \
|
||||||
|
chmod 644 /tmp/bdfr_default_config.cfg
|
||||||
|
|
||||||
|
# Copy web interface files
|
||||||
|
COPY web_interface/app/ ./app/
|
||||||
|
COPY web_interface/templates/ ./templates/
|
||||||
|
COPY web_interface/static/ ./static/
|
||||||
|
COPY web_interface/start.py ./start.py
|
||||||
|
|
||||||
|
# Copy entrypoint script
|
||||||
|
COPY docker-entrypoint.sh /usr/local/bin/
|
||||||
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
|
# Create necessary directories with proper permissions
|
||||||
|
# Use 777 to allow any user (set by TrueNAS or docker-compose) to write
|
||||||
|
RUN mkdir -p /app/downloads /app/data /app/logs && \
|
||||||
|
chmod -R 777 /app
|
||||||
|
|
||||||
|
# Note: No USER directive - container will run as the user specified by
|
||||||
|
# the orchestrator (TrueNAS, docker-compose, etc.) or root by default
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV BDFR_DOWNLOAD_DIR=/app/downloads
|
||||||
|
ENV BDFR_DATA_DIR=/app/data
|
||||||
|
ENV BDFR_CONFIG_DIR=/app/data/bdfr-config
|
||||||
|
ENV APPDATA=/app/data/bdfr-config
|
||||||
|
ENV XDG_CONFIG_HOME=/app/data/bdfr-config
|
||||||
|
|
||||||
|
# Expose port for web interface
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Health check - verify web interface is responding
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8000/health || exit 1
|
||||||
|
|
||||||
|
# Set entrypoint to handle runtime directory creation
|
||||||
|
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||||
|
|
||||||
|
# Default command - start web interface
|
||||||
|
# Users can override this to run BDFR CLI commands
|
||||||
|
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -47,6 +47,31 @@ If on Arch Linux or derivative operating systems such as Manjaro, the BDFR can b
|
|||||||
|
|
||||||
If you want to use the source code or make contributions, refer to [CONTRIBUTING](docs/CONTRIBUTING.md#preparing-the-environment-for-development)
|
If you want to use the source code or make contributions, refer to [CONTRIBUTING](docs/CONTRIBUTING.md#preparing-the-environment-for-development)
|
||||||
|
|
||||||
|
### Docker Deployment
|
||||||
|
|
||||||
|
The BDFR Web Interface provides a complete Docker setup with both the CLI tool and web interface. This is the easiest way to get started:
|
||||||
|
|
||||||
|
**Quick Start (Linux/Mac):**
|
||||||
|
```bash
|
||||||
|
chmod +x docker-quick-start.sh
|
||||||
|
./docker-quick-start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**Quick Start (Windows):**
|
||||||
|
```cmd
|
||||||
|
docker-quick-start.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
**Manual Start:**
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The web interface will be available at `http://localhost:8000`. All downloads are stored in the `./downloads` directory on your host machine.
|
||||||
|
|
||||||
|
For detailed Docker documentation, including configuration, volume management, and troubleshooting, see [DOCKER.md](DOCKER.md).
|
||||||
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
The BDFR works by taking submissions from a variety of "sources" from Reddit and then parsing them to download. These sources might be a subreddit, multireddit, a user list, or individual links. These sources are combined and downloaded to disk, according to a naming and organisational scheme defined by the user.
|
The BDFR works by taking submissions from a variety of "sources" from Reddit and then parsing them to download. These sources might be a subreddit, multireddit, a user list, or individual links. These sources are combined and downloaded to disk, according to a naming and organisational scheme defined by the user.
|
||||||
|
|||||||
@@ -0,0 +1,839 @@
|
|||||||
|
# Scheduled Downloads Implementation Plan
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
This document outlines the implementation plan for adding scheduled download functionality to the BDFR Web Interface. Users will be able to configure downloads that run automatically on a daily schedule, perfect for keeping up with new content from their favorite subreddits or users.
|
||||||
|
|
||||||
|
## Requirements Summary
|
||||||
|
- **Database**: SQLite with SQLAlchemy ORM
|
||||||
|
- **Scheduling**: Daily frequency (runs every 24 hours)
|
||||||
|
- **UI Approach**: Simple - checkbox in Advanced Options + management section on main page
|
||||||
|
- **Time Filter**: Automatically set to "last day" for daily runs
|
||||||
|
- **Duplicate Handling**: Works with existing no-dupes functionality
|
||||||
|
- **Deployment**: Docker container environment
|
||||||
|
- **Execution Model**: **Sequential only - one task at a time, queued execution**
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### 1. Database Schema
|
||||||
|
|
||||||
|
#### ScheduledTask Table
|
||||||
|
```python
|
||||||
|
class ScheduledTask:
|
||||||
|
id: UUID (Primary Key)
|
||||||
|
name: str # User-friendly name for the task
|
||||||
|
enabled: bool # Whether task is active
|
||||||
|
|
||||||
|
# Download Configuration
|
||||||
|
source_type: str # "subreddit" or "user"
|
||||||
|
source_name: str # Name of subreddit or username
|
||||||
|
download_mode: str # "download", "archive", or "clone"
|
||||||
|
|
||||||
|
# Filter Options
|
||||||
|
limit: int
|
||||||
|
sort: str # "hot", "top", "new", etc.
|
||||||
|
time_filter: str # Always "day" for daily tasks
|
||||||
|
min_score: int (optional)
|
||||||
|
no_dupes: bool # Always true for scheduled tasks
|
||||||
|
simple_check: bool
|
||||||
|
|
||||||
|
# Scheduling
|
||||||
|
schedule_frequency: str # "daily" (extensible for future: "weekly", "custom")
|
||||||
|
run_time: time # Time of day to run (e.g., "02:00:00")
|
||||||
|
timezone: str # User's timezone (default: UTC)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
last_run_at: datetime (nullable)
|
||||||
|
next_run_at: datetime
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
auth_state: str (nullable) # For authenticated downloads
|
||||||
|
```
|
||||||
|
|
||||||
|
#### TaskExecutionHistory Table
|
||||||
|
```python
|
||||||
|
class TaskExecutionHistory:
|
||||||
|
id: UUID (Primary Key)
|
||||||
|
task_id: UUID (Foreign Key -> ScheduledTask)
|
||||||
|
|
||||||
|
# Execution Details
|
||||||
|
started_at: datetime
|
||||||
|
completed_at: datetime (nullable)
|
||||||
|
status: str # "success", "failed", "running", "queued"
|
||||||
|
|
||||||
|
# Results
|
||||||
|
items_found: int
|
||||||
|
items_downloaded: int
|
||||||
|
error_message: str (nullable)
|
||||||
|
|
||||||
|
# Link to download
|
||||||
|
download_id: str # Links to active_downloads tracking
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Backend Components
|
||||||
|
|
||||||
|
#### File Structure
|
||||||
|
```
|
||||||
|
web_interface/
|
||||||
|
├── app/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── main.py (existing)
|
||||||
|
│ ├── auth.py (existing)
|
||||||
|
│ ├── database.py (NEW - SQLAlchemy setup)
|
||||||
|
│ ├── models.py (NEW - DB models)
|
||||||
|
│ ├── scheduler.py (NEW - APScheduler + Queue integration)
|
||||||
|
│ ├── task_queue.py (NEW - Sequential task queue manager)
|
||||||
|
│ └── scheduled_tasks.py (NEW - Task management logic)
|
||||||
|
├── data/
|
||||||
|
│ └── scheduled_tasks.db (SQLite database - created at runtime)
|
||||||
|
└── requirements.txt (UPDATE - add dependencies)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Dependencies to Add
|
||||||
|
```txt
|
||||||
|
sqlalchemy>=2.0.0
|
||||||
|
alembic>=1.12.0 # For database migrations
|
||||||
|
apscheduler>=3.10.0 # For task scheduling
|
||||||
|
```
|
||||||
|
|
||||||
|
#### API Endpoints
|
||||||
|
|
||||||
|
**Scheduled Tasks CRUD:**
|
||||||
|
- `POST /api/scheduled-tasks` - Create new scheduled task
|
||||||
|
- `GET /api/scheduled-tasks` - List all scheduled tasks
|
||||||
|
- `GET /api/scheduled-tasks/{task_id}` - Get specific task details
|
||||||
|
- `PUT /api/scheduled-tasks/{task_id}` - Update task configuration
|
||||||
|
- `DELETE /api/scheduled-tasks/{task_id}` - Delete task
|
||||||
|
- `POST /api/scheduled-tasks/{task_id}/toggle` - Enable/disable task
|
||||||
|
- `POST /api/scheduled-tasks/{task_id}/run-now` - Trigger immediate execution (adds to queue)
|
||||||
|
|
||||||
|
**Task History & Queue:**
|
||||||
|
- `GET /api/scheduled-tasks/{task_id}/history` - Get execution history
|
||||||
|
- `GET /api/scheduled-tasks/history/recent` - Get recent executions across all tasks
|
||||||
|
- `GET /api/scheduled-tasks/queue` - Get current task queue status
|
||||||
|
|
||||||
|
### 3. Sequential Task Queue System
|
||||||
|
|
||||||
|
#### Task Queue Manager (`task_queue.py`)
|
||||||
|
|
||||||
|
**Core Concept**: Only one scheduled download can run at a time. When multiple tasks are triggered (either by schedule or "Run Now"), they are queued and executed sequentially.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
from typing import Optional, List, Dict
|
||||||
|
from datetime import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class TaskQueue:
|
||||||
|
"""
|
||||||
|
Manages sequential execution of scheduled download tasks.
|
||||||
|
Ensures only one task runs at a time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.queue: asyncio.Queue = asyncio.Queue()
|
||||||
|
self.current_task: Optional[str] = None # Current task_id being executed
|
||||||
|
self.is_processing: bool = False
|
||||||
|
self.worker_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
|
async def add_task(self, task_id: str, priority: int = 0):
|
||||||
|
"""
|
||||||
|
Add a task to the queue.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: UUID of the scheduled task
|
||||||
|
priority: 0 = scheduled (normal), 1 = manual "Run Now" (higher priority)
|
||||||
|
"""
|
||||||
|
await self.queue.put({
|
||||||
|
'task_id': task_id,
|
||||||
|
'priority': priority,
|
||||||
|
'queued_at': datetime.now()
|
||||||
|
})
|
||||||
|
logger.info(f"Task {task_id} added to queue (priority={priority}, queue_size={self.queue.qsize()})")
|
||||||
|
|
||||||
|
# Start worker if not already running
|
||||||
|
if not self.is_processing:
|
||||||
|
await self.start_worker()
|
||||||
|
|
||||||
|
async def start_worker(self):
|
||||||
|
"""Start the queue worker if not already running"""
|
||||||
|
if self.worker_task is None or self.worker_task.done():
|
||||||
|
self.worker_task = asyncio.create_task(self._process_queue())
|
||||||
|
logger.info("Queue worker started")
|
||||||
|
|
||||||
|
async def _process_queue(self):
|
||||||
|
"""Process tasks from queue sequentially"""
|
||||||
|
self.is_processing = True
|
||||||
|
logger.info("Queue worker processing started")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
# Wait for next task (with timeout to allow graceful shutdown)
|
||||||
|
try:
|
||||||
|
task_info = await asyncio.wait_for(
|
||||||
|
self.queue.get(),
|
||||||
|
timeout=60.0
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
# Check if queue is empty
|
||||||
|
if self.queue.empty():
|
||||||
|
logger.info("Queue empty, worker stopping")
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
|
||||||
|
task_id = task_info['task_id']
|
||||||
|
self.current_task = task_id
|
||||||
|
|
||||||
|
logger.info(f"Executing task {task_id} from queue (queue_size={self.queue.qsize()})")
|
||||||
|
|
||||||
|
# Execute the task (this will block until download completes)
|
||||||
|
try:
|
||||||
|
await execute_scheduled_task(task_id)
|
||||||
|
logger.info(f"Task {task_id} completed successfully")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Task {task_id} failed: {e}")
|
||||||
|
finally:
|
||||||
|
self.current_task = None
|
||||||
|
self.queue.task_done()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Queue worker error: {e}")
|
||||||
|
|
||||||
|
self.is_processing = False
|
||||||
|
logger.info("Queue worker stopped")
|
||||||
|
|
||||||
|
def get_queue_status(self) -> Dict:
|
||||||
|
"""Get current queue status"""
|
||||||
|
return {
|
||||||
|
'current_task': self.current_task,
|
||||||
|
'queue_size': self.queue.qsize(),
|
||||||
|
'is_processing': self.is_processing
|
||||||
|
}
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
"""Stop the queue worker gracefully"""
|
||||||
|
logger.info("Stopping queue worker...")
|
||||||
|
if self.worker_task and not self.worker_task.done():
|
||||||
|
# Wait for current task to complete
|
||||||
|
await self.worker_task
|
||||||
|
logger.info("Queue worker stopped")
|
||||||
|
|
||||||
|
# Global queue instance
|
||||||
|
task_queue = TaskQueue()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Scheduler Service (Docker-Aware with Sequential Queue)
|
||||||
|
|
||||||
|
#### APScheduler Configuration
|
||||||
|
```python
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
|
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
# Use database job store for persistence across container restarts
|
||||||
|
jobstores = {
|
||||||
|
'default': SQLAlchemyJobStore(url='sqlite:///data/scheduler_jobs.db')
|
||||||
|
}
|
||||||
|
|
||||||
|
# Configure scheduler for Docker container
|
||||||
|
scheduler = AsyncIOScheduler(
|
||||||
|
jobstores=jobstores,
|
||||||
|
timezone=pytz.UTC, # Container runs in UTC
|
||||||
|
job_defaults={
|
||||||
|
'coalesce': True, # Combine multiple missed executions into one
|
||||||
|
'max_instances': 1, # Only one instance of each job at a time
|
||||||
|
'misfire_grace_time': 3600 # Allow up to 1 hour late execution
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add job for each enabled task
|
||||||
|
def schedule_task(task: ScheduledTask):
|
||||||
|
"""
|
||||||
|
Schedule a task to be added to the queue at specified time.
|
||||||
|
Note: This doesn't execute the task directly, it queues it.
|
||||||
|
"""
|
||||||
|
user_tz = pytz.timezone(task.timezone)
|
||||||
|
hour, minute = task.run_time.hour, task.run_time.minute
|
||||||
|
|
||||||
|
scheduler.add_job(
|
||||||
|
func=queue_scheduled_task, # Add to queue, not execute directly
|
||||||
|
trigger=CronTrigger(hour=hour, minute=minute, timezone=user_tz),
|
||||||
|
args=[task.id],
|
||||||
|
id=str(task.id),
|
||||||
|
replace_existing=True
|
||||||
|
)
|
||||||
|
logger.info(f"Scheduled task {task.id} for {hour:02d}:{minute:02d} {task.timezone}")
|
||||||
|
|
||||||
|
async def queue_scheduled_task(task_id: str):
|
||||||
|
"""
|
||||||
|
Called by scheduler at the configured time.
|
||||||
|
Adds task to queue rather than executing immediately.
|
||||||
|
"""
|
||||||
|
logger.info(f"Scheduler triggered for task {task_id}, adding to queue")
|
||||||
|
await task_queue.add_task(task_id, priority=0) # Normal priority for scheduled tasks
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Task Execution Flow with Queue
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[Scheduler triggers at scheduled time] --> B[Add task to queue]
|
||||||
|
B --> C{Is queue worker running?}
|
||||||
|
C -->|No| D[Start queue worker]
|
||||||
|
C -->|Yes| E[Task waits in queue]
|
||||||
|
D --> F[Worker picks next task from queue]
|
||||||
|
E --> F
|
||||||
|
F --> G[Load task from DB]
|
||||||
|
G --> H[Check if enabled]
|
||||||
|
H -->|Disabled| I[Skip, mark in history]
|
||||||
|
H -->|Enabled| J[Create execution history]
|
||||||
|
J --> K[Set status = 'running']
|
||||||
|
K --> L[Build download parameters]
|
||||||
|
L --> M[Set time_filter=day, no_dupes=true]
|
||||||
|
M --> N[Call BDFR API - BLOCKS until complete]
|
||||||
|
N --> O[Wait for download to finish]
|
||||||
|
O --> P[Update execution history]
|
||||||
|
P --> Q[Update last_run_at]
|
||||||
|
Q --> R[Worker picks next task]
|
||||||
|
R -->|Queue empty| S[Worker idles/stops]
|
||||||
|
R -->|More tasks| F
|
||||||
|
|
||||||
|
style N fill:#ffcccc
|
||||||
|
style O fill:#ffcccc
|
||||||
|
note1[Note: Worker blocks here until download completes]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Integration with BDFR API (Sequential Execution)
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def execute_scheduled_task(task_id: str):
|
||||||
|
"""
|
||||||
|
Execute a scheduled download task.
|
||||||
|
This function BLOCKS until the download is complete,
|
||||||
|
ensuring sequential execution.
|
||||||
|
"""
|
||||||
|
# Load task from database
|
||||||
|
task = get_scheduled_task(task_id)
|
||||||
|
|
||||||
|
if not task.enabled:
|
||||||
|
logger.info(f"Skipping disabled task {task_id}")
|
||||||
|
# Still record in history that it was skipped
|
||||||
|
execution = create_execution_history(task_id)
|
||||||
|
execution.status = 'skipped'
|
||||||
|
execution.completed_at = datetime.now(pytz.UTC)
|
||||||
|
save_execution_history(execution)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create execution history record
|
||||||
|
execution = create_execution_history(task_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"Executing scheduled task {task_id}: {task.source_type}/{task.source_name}")
|
||||||
|
|
||||||
|
# Build download parameters
|
||||||
|
kwargs = {
|
||||||
|
'limit': task.limit,
|
||||||
|
'sort': task.sort,
|
||||||
|
'time_filter': 'day', # Always "day" for daily scheduled tasks
|
||||||
|
'no_dupes': True, # Always enabled for scheduled tasks
|
||||||
|
'simple_check': task.simple_check,
|
||||||
|
'auth_state': task.auth_state
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create download using existing API
|
||||||
|
# This returns immediately with a download_id
|
||||||
|
download_id = await create_download_with_bdfr_api(
|
||||||
|
download_type=task.source_type,
|
||||||
|
name=task.source_name,
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Task {task_id} started as download {download_id}")
|
||||||
|
|
||||||
|
# Track download
|
||||||
|
execution.download_id = download_id
|
||||||
|
execution.status = 'running'
|
||||||
|
save_execution_history(execution)
|
||||||
|
|
||||||
|
# **CRITICAL: Wait for download to complete before returning**
|
||||||
|
# This ensures the next queued task doesn't start until this one finishes
|
||||||
|
await wait_for_download_completion(download_id)
|
||||||
|
|
||||||
|
# Check final status
|
||||||
|
download_status = bdfr_manager.get_download_status(download_id)
|
||||||
|
|
||||||
|
if download_status and download_status['status'] == 'completed':
|
||||||
|
execution.status = 'success'
|
||||||
|
execution.items_found = download_status.get('items_found', 0)
|
||||||
|
execution.items_downloaded = download_status.get('items_processed', 0)
|
||||||
|
logger.info(f"Task {task_id} completed successfully")
|
||||||
|
else:
|
||||||
|
execution.status = 'failed'
|
||||||
|
execution.error_message = download_status.get('error', 'Unknown error')
|
||||||
|
logger.error(f"Task {task_id} failed: {execution.error_message}")
|
||||||
|
|
||||||
|
execution.completed_at = datetime.now(pytz.UTC)
|
||||||
|
save_execution_history(execution)
|
||||||
|
|
||||||
|
# Update task timestamps
|
||||||
|
task.last_run_at = datetime.now(pytz.UTC)
|
||||||
|
task.next_run_at = calculate_next_run(task)
|
||||||
|
save_scheduled_task(task)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Task {task_id} execution error: {e}")
|
||||||
|
execution.status = 'failed'
|
||||||
|
execution.error_message = str(e)
|
||||||
|
execution.completed_at = datetime.now(pytz.UTC)
|
||||||
|
save_execution_history(execution)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def wait_for_download_completion(download_id: str, timeout: int = 3600):
|
||||||
|
"""
|
||||||
|
Wait for a download to complete.
|
||||||
|
Polls the download status until it's no longer running.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
download_id: The download to wait for
|
||||||
|
timeout: Maximum seconds to wait (default 1 hour)
|
||||||
|
"""
|
||||||
|
start_time = datetime.now()
|
||||||
|
check_interval = 5 # Check every 5 seconds
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# Check if timeout exceeded
|
||||||
|
elapsed = (datetime.now() - start_time).total_seconds()
|
||||||
|
if elapsed > timeout:
|
||||||
|
logger.error(f"Download {download_id} timed out after {timeout}s")
|
||||||
|
raise TimeoutError(f"Download exceeded timeout of {timeout}s")
|
||||||
|
|
||||||
|
# Check download status
|
||||||
|
status = bdfr_manager.get_download_status(download_id)
|
||||||
|
|
||||||
|
if not status:
|
||||||
|
logger.warning(f"Download {download_id} status not found")
|
||||||
|
break
|
||||||
|
|
||||||
|
download_status = status.get('status', 'unknown')
|
||||||
|
|
||||||
|
# Check if download is finished (completed, failed, or cancelled)
|
||||||
|
if download_status in ['completed', 'failed', 'cancelled']:
|
||||||
|
logger.info(f"Download {download_id} finished with status: {download_status}")
|
||||||
|
break
|
||||||
|
|
||||||
|
# Still running, wait before checking again
|
||||||
|
await asyncio.sleep(check_interval)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. API Endpoints with Queue Support
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.post("/api/scheduled-tasks/{task_id}/run-now")
|
||||||
|
async def run_task_now(task_id: str):
|
||||||
|
"""
|
||||||
|
Manually trigger a scheduled task to run now.
|
||||||
|
Adds it to the queue with high priority.
|
||||||
|
"""
|
||||||
|
task = get_scheduled_task(task_id)
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
|
||||||
|
# Add to queue with priority (goes ahead of scheduled tasks)
|
||||||
|
await task_queue.add_task(task_id, priority=1)
|
||||||
|
|
||||||
|
queue_status = task_queue.get_queue_status()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": f"Task {task_id} added to queue",
|
||||||
|
"queue_position": queue_status['queue_size'],
|
||||||
|
"currently_running": queue_status['current_task'],
|
||||||
|
"status": "queued" if queue_status['current_task'] else "starting"
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get("/api/scheduled-tasks/queue")
|
||||||
|
async def get_queue_status():
|
||||||
|
"""Get current task queue status"""
|
||||||
|
status = task_queue.get_queue_status()
|
||||||
|
|
||||||
|
# Get details of current task if any
|
||||||
|
current_task_info = None
|
||||||
|
if status['current_task']:
|
||||||
|
task = get_scheduled_task(status['current_task'])
|
||||||
|
if task:
|
||||||
|
current_task_info = {
|
||||||
|
'id': task.id,
|
||||||
|
'name': task.name,
|
||||||
|
'source': f"{task.source_type}/{task.source_name}"
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'queue_size': status['queue_size'],
|
||||||
|
'is_processing': status['is_processing'],
|
||||||
|
'current_task': current_task_info
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Frontend Implementation
|
||||||
|
|
||||||
|
#### UI Modifications to [`index.html`](web_interface/templates/index.html:1)
|
||||||
|
|
||||||
|
**Queue Status Indicator (add to header):**
|
||||||
|
```html
|
||||||
|
<div id="queueStatus" class="queue-status" style="display: none;">
|
||||||
|
<span class="queue-icon">⏳</span>
|
||||||
|
<span id="queueText">Processing scheduled tasks...</span>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Advanced Options Section - Add Checkbox:**
|
||||||
|
```html
|
||||||
|
<!-- After existing checkboxes in Advanced Options -->
|
||||||
|
<label class="checkbox-label" data-tooltip="Run this download automatically every day at a specific time">
|
||||||
|
<input type="checkbox" id="runDaily" name="run_daily">
|
||||||
|
<span class="checkmark"></span>
|
||||||
|
Schedule Daily Run
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Conditionally shown when checkbox is checked -->
|
||||||
|
<div id="scheduleOptions" style="display: none;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="scheduleName">Task Name:</label>
|
||||||
|
<input type="text" id="scheduleName" name="schedule_name"
|
||||||
|
placeholder="e.g., Daily r/Python downloads">
|
||||||
|
<small class="form-help">Give this scheduled task a descriptive name</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="scheduleTime">Run Time:</label>
|
||||||
|
<input type="time" id="scheduleTime" name="schedule_time" value="02:00">
|
||||||
|
<small class="form-help">Time when task will run daily (your local time). Tasks run one at a time in order.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**New Section - Scheduled Tasks List:**
|
||||||
|
```html
|
||||||
|
<!-- After Progress Section -->
|
||||||
|
<section class="scheduled-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>📅 Scheduled Downloads</h2>
|
||||||
|
<div id="queueStatusBadge" class="queue-badge" style="display: none;">
|
||||||
|
<span class="badge-icon">⏳</span>
|
||||||
|
<span id="queueBadgeText">Queue: 0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="scheduledTasksList" class="scheduled-tasks-list">
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div class="no-tasks" id="noTasksMessage">
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">📅</div>
|
||||||
|
<p>No scheduled tasks yet</p>
|
||||||
|
<p>Check "Schedule Daily Run" when creating a download to set up automated daily downloads.</p>
|
||||||
|
<p><strong>Note:</strong> Scheduled tasks run one at a time to prevent server overload.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Task cards will be inserted here -->
|
||||||
|
<div id="scheduledTasksItems"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Task Card Template:**
|
||||||
|
```html
|
||||||
|
<div class="task-card" id="task-{task_id}" data-task-id="{task_id}">
|
||||||
|
<div class="task-header">
|
||||||
|
<div class="task-info">
|
||||||
|
<h4>{task_name}</h4>
|
||||||
|
<div class="task-meta">
|
||||||
|
<span class="task-source">{source_type}: {source_name}</span>
|
||||||
|
<span class="task-schedule">⏰ Runs daily at {run_time}</span>
|
||||||
|
<span class="task-queue-info" style="display: none;">
|
||||||
|
⏳ Queued / Currently Running
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="task-controls">
|
||||||
|
<button class="btn-toggle" onclick="toggleTask('{task_id}')">
|
||||||
|
{enabled ? "✓ Enabled" : "○ Disabled"}
|
||||||
|
</button>
|
||||||
|
<button class="btn-run-now" onclick="runTaskNow('{task_id}')">
|
||||||
|
▶ Run Now
|
||||||
|
</button>
|
||||||
|
<button class="btn-delete" onclick="deleteTask('{task_id}')">
|
||||||
|
🗑 Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="task-details">
|
||||||
|
<div class="task-stat">
|
||||||
|
<span class="stat-label">Last Run:</span>
|
||||||
|
<span class="stat-value">{last_run_at || "Never"}</span>
|
||||||
|
</div>
|
||||||
|
<div class="task-stat">
|
||||||
|
<span class="stat-label">Next Run:</span>
|
||||||
|
<span class="stat-value">{next_run_at}</span>
|
||||||
|
</div>
|
||||||
|
<div class="task-stat">
|
||||||
|
<span class="stat-label">Mode:</span>
|
||||||
|
<span class="stat-value">{download_mode}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### JavaScript Modifications to [`app.js`](web_interface/static/js/app.js:1)
|
||||||
|
|
||||||
|
**Add queue status polling:**
|
||||||
|
```javascript
|
||||||
|
async loadScheduledTasks() {
|
||||||
|
const response = await fetch('/api/scheduled-tasks');
|
||||||
|
const tasks = await response.json();
|
||||||
|
this.renderScheduledTasks(tasks);
|
||||||
|
|
||||||
|
// Also update queue status
|
||||||
|
this.updateQueueStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateQueueStatus() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/scheduled-tasks/queue');
|
||||||
|
const status = await response.json();
|
||||||
|
|
||||||
|
// Update queue badge
|
||||||
|
const queueBadge = document.getElementById('queueStatusBadge');
|
||||||
|
const queueText = document.getElementById('queueBadgeText');
|
||||||
|
|
||||||
|
if (status.queue_size > 0 || status.current_task) {
|
||||||
|
queueBadge.style.display = 'flex';
|
||||||
|
queueText.textContent = `Queue: ${status.queue_size}${status.current_task ? ' (1 running)' : ''}`;
|
||||||
|
} else {
|
||||||
|
queueBadge.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlight currently running task card
|
||||||
|
document.querySelectorAll('.task-card').forEach(card => {
|
||||||
|
const taskId = card.dataset.taskId;
|
||||||
|
const queueInfo = card.querySelector('.task-queue-info');
|
||||||
|
|
||||||
|
if (status.current_task && status.current_task.id === taskId) {
|
||||||
|
card.classList.add('task-running');
|
||||||
|
queueInfo.textContent = '⚡ Currently Running';
|
||||||
|
queueInfo.style.display = 'inline';
|
||||||
|
} else {
|
||||||
|
card.classList.remove('task-running');
|
||||||
|
queueInfo.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update queue status:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async runTaskNow(taskId) {
|
||||||
|
const response = await fetch(`/api/scheduled-tasks/${taskId}/run-now`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.queue_position > 0) {
|
||||||
|
this.showSuccess(`Task added to queue. Position: ${result.queue_position}`);
|
||||||
|
} else {
|
||||||
|
this.showSuccess('Task execution starting...');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateQueueStatus();
|
||||||
|
} else {
|
||||||
|
this.showError('Failed to queue task');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll queue status every 10 seconds
|
||||||
|
startQueuePolling() {
|
||||||
|
setInterval(() => {
|
||||||
|
if (document.querySelectorAll('.task-card').length > 0) {
|
||||||
|
this.updateQueueStatus();
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Implementation Steps
|
||||||
|
|
||||||
|
1. **Backend Foundation** (Steps 4-5)
|
||||||
|
- Add SQLAlchemy and APScheduler to [`requirements.txt`](web_interface/requirements.txt:1)
|
||||||
|
- Create [`database.py`](web_interface/app/database.py) with Docker-aware paths
|
||||||
|
- Create [`models.py`](web_interface/app/models.py) with ScheduledTask and TaskExecutionHistory models
|
||||||
|
- Initialize database on app startup
|
||||||
|
|
||||||
|
2. **Task Queue System** (Step 7)
|
||||||
|
- Create [`task_queue.py`](web_interface/app/task_queue.py) with sequential queue manager
|
||||||
|
- Implement queue worker with blocking execution
|
||||||
|
- Add queue status tracking and reporting
|
||||||
|
|
||||||
|
3. **Scheduler Service** (Step 7 continued)
|
||||||
|
- Create [`scheduler.py`](web_interface/app/scheduler.py) with Docker-aware APScheduler
|
||||||
|
- Integrate with task queue (scheduler adds to queue, doesn't execute directly)
|
||||||
|
- Implement `wait_for_download_completion()` to block until download finishes
|
||||||
|
- Add scheduler lifecycle hooks to [`main.py`](web_interface/app/main.py:1)
|
||||||
|
|
||||||
|
4. **API Endpoints** (Step 6)
|
||||||
|
- Create [`scheduled_tasks.py`](web_interface/app/scheduled_tasks.py) with CRUD operations
|
||||||
|
- Add routes to [`main.py`](web_interface/app/main.py:1)
|
||||||
|
- Implement task toggle, delete, and run-now (with queue)
|
||||||
|
- Add queue status endpoint
|
||||||
|
|
||||||
|
5. **Frontend - Form** (Step 8)
|
||||||
|
- Add "Run Daily" checkbox to Advanced Options in [`index.html`](web_interface/templates/index.html:1)
|
||||||
|
- Add conditional schedule configuration fields
|
||||||
|
- Update form submission logic in [`app.js`](web_interface/static/js/app.js:1)
|
||||||
|
- Auto-detect browser timezone
|
||||||
|
|
||||||
|
6. **Frontend - Management** (Step 9)
|
||||||
|
- Add Scheduled Tasks section to [`index.html`](web_interface/templates/index.html:1)
|
||||||
|
- Add queue status indicator
|
||||||
|
- Implement task card rendering with queue status
|
||||||
|
- Add toggle, delete, and run-now functions to [`app.js`](web_interface/static/js/app.js:1)
|
||||||
|
- Add queue status polling
|
||||||
|
|
||||||
|
7. **Integration** (Steps 10-11)
|
||||||
|
- Connect scheduler to BDFR API via [`create_download_with_bdfr_api()`](web_interface/app/main.py:373)
|
||||||
|
- Implement automatic `time_filter="day"` for scheduled tasks
|
||||||
|
- Add execution history tracking
|
||||||
|
- Ensure sequential execution with proper blocking
|
||||||
|
|
||||||
|
8. **Testing** (Step 14)
|
||||||
|
- Test task creation, editing, deletion
|
||||||
|
- Test queue functionality (multiple tasks, sequential execution)
|
||||||
|
- Test "Run Now" adds to queue correctly
|
||||||
|
- Test priority (manual tasks run before scheduled)
|
||||||
|
- Test container restart persistence
|
||||||
|
- Verify only one task runs at a time
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
### Sequential Execution (NEW)
|
||||||
|
- **Task Queue**: All scheduled downloads go through a FIFO queue
|
||||||
|
- **Blocking Execution**: Each task blocks until its download completes
|
||||||
|
- **No Concurrency**: Only one download runs at a time, preventing system overload
|
||||||
|
- **Priority System**: Manual "Run Now" tasks get priority over scheduled tasks
|
||||||
|
- **Queue Status**: Users can see queue size and currently running task
|
||||||
|
|
||||||
|
### Docker-Specific Features
|
||||||
|
- **Persistent Storage**: SQLite database and downloads persist via volume mounts
|
||||||
|
- **Container Restarts**: APScheduler with job store survives restarts, queue rebuilds on startup
|
||||||
|
- **Timezone Handling**: User timezone stored, converted to UTC for container execution
|
||||||
|
- **Logging**: Structured logging for container environment
|
||||||
|
- **Health Checks**: Scheduler and queue status included in health endpoint
|
||||||
|
|
||||||
|
### Automatic Configuration for Scheduled Tasks
|
||||||
|
- **time_filter**: Always set to "day" - ensures only last 24 hours of content
|
||||||
|
- **no_dupes**: Always enabled - prevents re-downloading same content
|
||||||
|
- **Timezone handling**: Store user timezone, convert to UTC for execution, display in user timezone
|
||||||
|
- **Sequential execution**: Guaranteed one-at-a-time processing
|
||||||
|
|
||||||
|
### Smart Duplicate Prevention
|
||||||
|
When a scheduled task runs:
|
||||||
|
1. BDFR checks existing hashes (if no_dupes enabled)
|
||||||
|
2. Only downloads new content from last 24 hours
|
||||||
|
3. Skips content already downloaded in previous runs
|
||||||
|
|
||||||
|
### Execution Tracking
|
||||||
|
- Every run creates a history record
|
||||||
|
- Tracks success/failure status
|
||||||
|
- Records items found vs. items downloaded
|
||||||
|
- Links to the actual download progress for real-time monitoring
|
||||||
|
- Shows queue position and current task status
|
||||||
|
|
||||||
|
### User Experience
|
||||||
|
- Simple checkbox to schedule any download
|
||||||
|
- Visual indication of enabled/disabled tasks
|
||||||
|
- Queue status badge shows pending tasks
|
||||||
|
- Currently running task highlighted
|
||||||
|
- Next run time displayed in user's local timezone
|
||||||
|
- One-click to add task to queue immediately
|
||||||
|
- Easy enable/disable without deleting task
|
||||||
|
- Queue position shown when manually running tasks
|
||||||
|
|
||||||
|
## Sequential Execution Examples
|
||||||
|
|
||||||
|
### Scenario 1: Multiple Scheduled Tasks
|
||||||
|
```
|
||||||
|
02:00 AM - Task A triggers, added to queue
|
||||||
|
02:00 AM - Task B triggers, added to queue
|
||||||
|
02:00 AM - Task C triggers, added to queue
|
||||||
|
|
||||||
|
Execution Order:
|
||||||
|
1. Task A starts, downloads 100 posts (takes 15 minutes)
|
||||||
|
2. Task B starts at 02:15 AM, downloads 50 posts (takes 8 minutes)
|
||||||
|
3. Task C starts at 02:23 AM, downloads 75 posts (takes 12 minutes)
|
||||||
|
4. All complete by 02:35 AM
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 2: Manual "Run Now" During Scheduled Task
|
||||||
|
```
|
||||||
|
02:00 AM - Task A starts (scheduled, downloading...)
|
||||||
|
02:10 AM - User clicks "Run Now" on Task B
|
||||||
|
02:10 AM - Task B added to queue with priority
|
||||||
|
|
||||||
|
Execution Order:
|
||||||
|
1. Task A continues running (started first)
|
||||||
|
2. Task B waits in queue
|
||||||
|
3. Task A completes at 02:15 AM
|
||||||
|
4. Task B starts immediately at 02:15 AM (priority over other scheduled tasks)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 3: Container Restart During Execution
|
||||||
|
```
|
||||||
|
02:00 AM - Task A starts downloading
|
||||||
|
02:10 AM - Container restarts (Docker update, etc.)
|
||||||
|
02:10 AM - Container comes back up
|
||||||
|
02:10 AM - Task A marked as "failed" with "interrupted" message
|
||||||
|
02:10 AM - Scheduled tasks reload, Task A will retry at next scheduled time (tomorrow 02:00 AM)
|
||||||
|
02:10 AM - Other pending tasks start processing from queue
|
||||||
|
```
|
||||||
|
|
||||||
|
## Future Enhancements (Not in Initial Implementation)
|
||||||
|
|
||||||
|
1. **Parallel Execution**: Optional setting to allow N tasks at once (requires more resources)
|
||||||
|
2. **Smart Scheduling**: Stagger start times automatically if many tasks at same time
|
||||||
|
3. **Queue Priorities**: User-configurable priority levels for tasks
|
||||||
|
4. **Retry Logic**: Auto-retry failed tasks with exponential backoff
|
||||||
|
5. **Additional Frequencies**: Weekly, custom intervals
|
||||||
|
6. **Notification System**: Email/webhook notifications on completion/failure
|
||||||
|
7. **Advanced Filters**: Score thresholds, content type filters
|
||||||
|
8. **Task Templates**: Save and reuse task configurations
|
||||||
|
9. **Execution History Page**: Dedicated page for detailed history with charts
|
||||||
|
10. **Bulk Operations**: Enable/disable/delete multiple tasks at once
|
||||||
|
11. **Export/Import**: Backup and restore scheduled tasks
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
This implementation provides a robust scheduled downloads system with **guaranteed sequential execution**, designed specifically for Docker deployment in resource-constrained environments. The queue-based approach ensures:
|
||||||
|
|
||||||
|
- ✅ Only one download at a time (no resource contention)
|
||||||
|
- ✅ Fair task ordering (FIFO with priority support)
|
||||||
|
- ✅ Data persistence across container restarts
|
||||||
|
- ✅ Reliable scheduling with APScheduler
|
||||||
|
- ✅ Proper timezone handling (user TZ -> container UTC)
|
||||||
|
- ✅ Simple, clean UI
|
||||||
|
- ✅ Integration with existing BDFR API
|
||||||
|
- ✅ Smart defaults (daily schedule, time_filter="day", no_dupes=true)
|
||||||
|
- ✅ Easy management (enable/disable/delete/run now)
|
||||||
|
- ✅ Container-aware logging and health checks
|
||||||
|
- ✅ Transparent queue status for users
|
||||||
|
|
||||||
|
The sequential execution model is perfect for:
|
||||||
|
- Single-user home servers
|
||||||
|
- Docker containers with limited CPU/memory
|
||||||
|
- Preventing Reddit API rate limits
|
||||||
|
- Ensuring reliable, predictable downloads
|
||||||
|
- Avoiding file system contention
|
||||||
|
|
||||||
|
The system is production-ready for Docker deployment and extensible for future enhancements like parallel execution if needed.
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
# BDFR API Layer
|
||||||
|
|
||||||
|
The BDFR API layer provides a direct integration interface for the web interface, eliminating the need for subprocess console parsing. It wraps the existing BDFR core classes and provides a clean API with structured progress callbacks.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The API layer consists of several key components:
|
||||||
|
|
||||||
|
- **BDFRManager**: Main API interface class
|
||||||
|
- **ProgressCallback**: Abstract base class for progress notifications
|
||||||
|
- **ProgressEvent**: Structured progress event data
|
||||||
|
- **DownloadType**: Enumeration of supported download types
|
||||||
|
- **DownloadStatus**: Enumeration of download statuses
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
from bdfr.api import BDFRManager, LoggingCallback
|
||||||
|
|
||||||
|
# Create a manager
|
||||||
|
manager = BDFRManager("./downloads")
|
||||||
|
|
||||||
|
# Download from a subreddit
|
||||||
|
download_id = manager.download_subreddit(
|
||||||
|
"python",
|
||||||
|
limit=50,
|
||||||
|
sort="hot",
|
||||||
|
no_dupes=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
status = manager.get_download_status(download_id)
|
||||||
|
print(f"Progress: {status['progress']}%")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Advanced Usage with Custom Callbacks
|
||||||
|
|
||||||
|
```python
|
||||||
|
from bdfr.api import BDFRManager, ProgressCallback, ProgressEvent
|
||||||
|
|
||||||
|
class WebSocketCallback(ProgressCallback):
|
||||||
|
def __init__(self, websocket):
|
||||||
|
self.websocket = websocket
|
||||||
|
|
||||||
|
async def on_progress(self, event: ProgressEvent):
|
||||||
|
await self.websocket.send_json(event.to_dict())
|
||||||
|
|
||||||
|
async def on_error(self, event: ProgressEvent):
|
||||||
|
await self.websocket.send_json(event.to_dict())
|
||||||
|
|
||||||
|
async def on_completed(self, event: ProgressEvent):
|
||||||
|
await self.websocket.send_json(event.to_dict())
|
||||||
|
|
||||||
|
# Use custom callbacks
|
||||||
|
callbacks = [LoggingCallback(), WebSocketCallback(ws)]
|
||||||
|
download_id = manager.download_subreddit("technology", limit=100, progress_callbacks=callbacks)
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### BDFRManager
|
||||||
|
|
||||||
|
The main API class that manages downloads and provides the primary interface.
|
||||||
|
|
||||||
|
#### Constructor
|
||||||
|
|
||||||
|
```python
|
||||||
|
BDFRManager(download_directory: Optional[Union[str, Path]] = None)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `download_directory`: Base directory for downloads. Defaults to current directory.
|
||||||
|
|
||||||
|
#### Methods
|
||||||
|
|
||||||
|
##### `create_download()`
|
||||||
|
|
||||||
|
Create a new download operation.
|
||||||
|
|
||||||
|
```python
|
||||||
|
create_download(
|
||||||
|
download_type: DownloadType,
|
||||||
|
name: str,
|
||||||
|
config: Optional[Configuration] = None,
|
||||||
|
progress_callbacks: Optional[List[ProgressCallback]] = None
|
||||||
|
) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns a download ID for tracking the operation.
|
||||||
|
|
||||||
|
##### `start_download()`
|
||||||
|
|
||||||
|
Start a download operation.
|
||||||
|
|
||||||
|
```python
|
||||||
|
start_download(download_id: str) -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns `True` if started successfully.
|
||||||
|
|
||||||
|
##### `get_download_status()`
|
||||||
|
|
||||||
|
Get the current status of a download.
|
||||||
|
|
||||||
|
```python
|
||||||
|
get_download_status(download_id: str) -> Optional[Dict[str, Any]]
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns download status information or `None` if not found.
|
||||||
|
|
||||||
|
##### `cancel_download()`
|
||||||
|
|
||||||
|
Cancel a running download.
|
||||||
|
|
||||||
|
```python
|
||||||
|
cancel_download(download_id: str) -> bool
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns `True` if cancelled successfully.
|
||||||
|
|
||||||
|
##### `list_downloads()`
|
||||||
|
|
||||||
|
List all active downloads.
|
||||||
|
|
||||||
|
```python
|
||||||
|
list_downloads() -> Dict[str, Dict[str, Any]]
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns dictionary of download information keyed by download ID.
|
||||||
|
|
||||||
|
##### `cleanup_completed()`
|
||||||
|
|
||||||
|
Clean up old completed downloads.
|
||||||
|
|
||||||
|
```python
|
||||||
|
cleanup_completed(max_age_seconds: int = 3600) -> int
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns number of downloads cleaned up.
|
||||||
|
|
||||||
|
#### Convenience Methods
|
||||||
|
|
||||||
|
##### `download_subreddit()`
|
||||||
|
|
||||||
|
Download content from a subreddit.
|
||||||
|
|
||||||
|
```python
|
||||||
|
download_subreddit(
|
||||||
|
subreddit_name: str,
|
||||||
|
limit: Optional[int] = None,
|
||||||
|
sort: str = "hot",
|
||||||
|
time_filter: str = "all",
|
||||||
|
no_dupes: bool = False,
|
||||||
|
progress_callbacks: Optional[List[ProgressCallback]] = None
|
||||||
|
) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
##### `download_user()`
|
||||||
|
|
||||||
|
Download content from a user.
|
||||||
|
|
||||||
|
```python
|
||||||
|
download_user(
|
||||||
|
username: str,
|
||||||
|
limit: Optional[int] = None,
|
||||||
|
submitted: bool = True,
|
||||||
|
upvoted: bool = False,
|
||||||
|
saved: bool = False,
|
||||||
|
no_dupes: bool = False,
|
||||||
|
progress_callbacks: Optional[List[ProgressCallback]] = None
|
||||||
|
) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
##### `archive_subreddit()`
|
||||||
|
|
||||||
|
Archive subreddit data (metadata only).
|
||||||
|
|
||||||
|
```python
|
||||||
|
archive_subreddit(
|
||||||
|
subreddit_name: str,
|
||||||
|
format_type: str = "json",
|
||||||
|
limit: Optional[int] = None,
|
||||||
|
progress_callbacks: Optional[List[ProgressCallback]] = None
|
||||||
|
) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
##### `clone_subreddit()`
|
||||||
|
|
||||||
|
Clone subreddit (both download and archive).
|
||||||
|
|
||||||
|
```python
|
||||||
|
clone_subreddit(
|
||||||
|
subreddit_name: str,
|
||||||
|
limit: Optional[int] = None,
|
||||||
|
format_type: str = "json",
|
||||||
|
no_dupes: bool = False,
|
||||||
|
progress_callbacks: Optional[List[ProgressCallback]] = None
|
||||||
|
) -> str
|
||||||
|
```
|
||||||
|
|
||||||
|
### ProgressCallback
|
||||||
|
|
||||||
|
Abstract base class for implementing progress callbacks.
|
||||||
|
|
||||||
|
#### Methods
|
||||||
|
|
||||||
|
##### `on_progress(event: ProgressEvent)`
|
||||||
|
|
||||||
|
Called when progress is made.
|
||||||
|
|
||||||
|
##### `on_error(event: ProgressEvent)`
|
||||||
|
|
||||||
|
Called when an error occurs.
|
||||||
|
|
||||||
|
##### `on_completed(event: ProgressEvent)`
|
||||||
|
|
||||||
|
Called when download is completed.
|
||||||
|
|
||||||
|
### ProgressEvent
|
||||||
|
|
||||||
|
Represents a progress event with structured data.
|
||||||
|
|
||||||
|
#### Attributes
|
||||||
|
|
||||||
|
- `event_type`: "progress", "status", "error", or "completed"
|
||||||
|
- `download_id`: Unique identifier for the download
|
||||||
|
- `message`: Human-readable message
|
||||||
|
- `progress`: Progress percentage (0-100)
|
||||||
|
- `data`: Additional structured data
|
||||||
|
- `timestamp`: When the event occurred
|
||||||
|
|
||||||
|
#### Methods
|
||||||
|
|
||||||
|
##### `to_dict() -> Dict[str, Any]`
|
||||||
|
|
||||||
|
Convert to dictionary for JSON serialization.
|
||||||
|
|
||||||
|
### DownloadType
|
||||||
|
|
||||||
|
Enumeration of supported download types:
|
||||||
|
|
||||||
|
- `SUBREDDIT`: Download from subreddit
|
||||||
|
- `USER`: Download from user
|
||||||
|
- `MULTIREDDIT`: Download from multireddit
|
||||||
|
- `SUBMISSIONS`: Download specific submissions
|
||||||
|
- `ARCHIVE`: Archive only (no downloads)
|
||||||
|
- `CLONE`: Both download and archive
|
||||||
|
|
||||||
|
### DownloadStatus
|
||||||
|
|
||||||
|
Enumeration of download statuses:
|
||||||
|
|
||||||
|
- `QUEUED`: Download is queued but not started
|
||||||
|
- `RUNNING`: Download is in progress
|
||||||
|
- `COMPLETED`: Download completed successfully
|
||||||
|
- `FAILED`: Download failed with an error
|
||||||
|
- `CANCELLED`: Download was cancelled
|
||||||
|
- `PAUSED`: Download is paused
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The API uses the existing BDFR `Configuration` class. You can pass a custom configuration to `create_download()` or use the convenience methods with their specific parameters.
|
||||||
|
|
||||||
|
### Common Configuration Options
|
||||||
|
|
||||||
|
- `limit`: Maximum number of posts to process
|
||||||
|
- `sort`: Sort method (hot, top, new, controversial, rising)
|
||||||
|
- `time`: Time filter (all, hour, day, week, month, year)
|
||||||
|
- `no_dupes`: Avoid duplicate downloads
|
||||||
|
- `make_hard_links`: Create hard links for duplicates
|
||||||
|
- `format`: Archive format (json, xml, yaml)
|
||||||
|
|
||||||
|
## Web Interface Integration
|
||||||
|
|
||||||
|
### FastAPI Integration Example
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi import FastAPI, WebSocket
|
||||||
|
from bdfr.api import get_bdfr_manager, WebSocketCallback
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
manager = get_bdfr_manager("./downloads")
|
||||||
|
|
||||||
|
@app.post("/api/download/subreddit")
|
||||||
|
async def download_subreddit(subreddit: str, limit: int = 10):
|
||||||
|
download_id = manager.download_subreddit(subreddit, limit=limit)
|
||||||
|
return {"download_id": download_id}
|
||||||
|
|
||||||
|
@app.websocket("/ws/progress/{download_id}")
|
||||||
|
async def progress_websocket(websocket: WebSocket, download_id: str):
|
||||||
|
await websocket.accept()
|
||||||
|
|
||||||
|
class FastAPICallback(WebSocketCallback):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(None)
|
||||||
|
|
||||||
|
async def on_progress(self, event: ProgressEvent):
|
||||||
|
if event.download_id == download_id:
|
||||||
|
await websocket.send_json(event.to_dict())
|
||||||
|
|
||||||
|
# Add callback to existing download or create new one
|
||||||
|
# (Implementation depends on your specific needs)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Real-time Progress Updates
|
||||||
|
|
||||||
|
The API provides structured progress events that can be easily serialized to JSON for web clients:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Example progress event
|
||||||
|
{
|
||||||
|
"event_type": "progress",
|
||||||
|
"download_id": "123e4567-e89b-12d3-a456-426614174000",
|
||||||
|
"message": "Downloaded submission abc123 from r/python",
|
||||||
|
"progress": 45.2,
|
||||||
|
"data": {
|
||||||
|
"items_processed": 12,
|
||||||
|
"items_found": 25,
|
||||||
|
"current_item": "abc123",
|
||||||
|
"phase": "downloading_submission"
|
||||||
|
},
|
||||||
|
"timestamp": "2023-12-07T10:30:45.123456"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
The API provides comprehensive error handling:
|
||||||
|
|
||||||
|
- **Download errors**: Network issues, authentication problems, etc.
|
||||||
|
- **Configuration errors**: Invalid parameters, missing files, etc.
|
||||||
|
- **System errors**: Disk space, permissions, etc.
|
||||||
|
|
||||||
|
All errors are captured and reported through the progress callback system with detailed error information.
|
||||||
|
|
||||||
|
## Threading and Concurrency
|
||||||
|
|
||||||
|
The API is designed to be thread-safe and supports concurrent downloads:
|
||||||
|
|
||||||
|
- Each download runs in its own thread
|
||||||
|
- Progress callbacks are async-safe
|
||||||
|
- Multiple downloads can run simultaneously
|
||||||
|
- Thread-safe status tracking
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
The API integrates with Python's logging system:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
|
# Use LoggingCallback for automatic log output
|
||||||
|
callbacks = [LoggingCallback("my_app")]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
See `bdfr/examples/api_usage.py` for comprehensive examples including:
|
||||||
|
|
||||||
|
- Basic usage
|
||||||
|
- Custom callbacks
|
||||||
|
- Web integration
|
||||||
|
- Error handling
|
||||||
|
- User downloads
|
||||||
|
- Archive operations
|
||||||
|
|
||||||
|
## Migration from Subprocess
|
||||||
|
|
||||||
|
### Before (Subprocess)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import subprocess
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Start BDFR via subprocess
|
||||||
|
proc = subprocess.Popen([
|
||||||
|
"python", "-m", "bdfr", "download",
|
||||||
|
"--subreddit", "python",
|
||||||
|
"--limit", "50",
|
||||||
|
"./downloads"
|
||||||
|
], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
|
|
||||||
|
# Parse console output for progress
|
||||||
|
while True:
|
||||||
|
line = proc.stdout.readline().decode().strip()
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
# Parse progress from console output...
|
||||||
|
```
|
||||||
|
|
||||||
|
### After (API)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from bdfr.api import BDFRManager, LoggingCallback
|
||||||
|
|
||||||
|
# Use API directly
|
||||||
|
manager = BDFRManager("./downloads")
|
||||||
|
download_id = manager.download_subreddit("python", limit=50)
|
||||||
|
|
||||||
|
# Get structured progress updates
|
||||||
|
status = manager.get_download_status(download_id)
|
||||||
|
print(f"Progress: {status['progress']}%")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
1. **No subprocess overhead**: Direct integration with BDFR core
|
||||||
|
2. **Structured progress**: Rich progress events instead of console parsing
|
||||||
|
3. **Better error handling**: Detailed error information and stack traces
|
||||||
|
4. **Thread-safe**: Concurrent downloads with proper synchronization
|
||||||
|
5. **Web-friendly**: JSON-serializable progress events
|
||||||
|
6. **Extensible**: Custom progress callbacks for different integrations
|
||||||
|
7. **Maintainable**: Clean separation of concerns
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Python 3.7+
|
||||||
|
- Existing BDFR installation
|
||||||
|
- Dependencies: `praw`, `requests`, and other BDFR dependencies
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
The API layer is included with BDFR and requires no additional installation. Simply import and use:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from bdfr.api import BDFRManager
|
||||||
+11
-1
@@ -26,6 +26,11 @@ _common_options = [
|
|||||||
click.option("--file-scheme", default=None, type=str),
|
click.option("--file-scheme", default=None, type=str),
|
||||||
click.option("--filename-restriction-scheme", type=click.Choice(("linux", "windows")), default=None),
|
click.option("--filename-restriction-scheme", type=click.Choice(("linux", "windows")), default=None),
|
||||||
click.option("--folder-scheme", default=None, type=str),
|
click.option("--folder-scheme", default=None, type=str),
|
||||||
|
click.option(
|
||||||
|
"--strip-unicode/--no-strip-unicode",
|
||||||
|
default=None,
|
||||||
|
help="Strip Unicode characters that cause Windows SMB issues (default: enabled)",
|
||||||
|
),
|
||||||
click.option("--ignore-user", type=str, multiple=True, default=None),
|
click.option("--ignore-user", type=str, multiple=True, default=None),
|
||||||
click.option("--include-id-file", multiple=True, default=None),
|
click.option("--include-id-file", multiple=True, default=None),
|
||||||
click.option("--log", type=str, default=None),
|
click.option("--log", type=str, default=None),
|
||||||
@@ -53,7 +58,12 @@ _downloader_options = [
|
|||||||
click.option("--max-wait-time", type=int, default=None),
|
click.option("--max-wait-time", type=int, default=None),
|
||||||
click.option("--no-dupes", is_flag=True, default=None),
|
click.option("--no-dupes", is_flag=True, default=None),
|
||||||
click.option("--search-existing", is_flag=True, default=None),
|
click.option("--search-existing", is_flag=True, default=None),
|
||||||
click.option("--simple-check", is_flag=True, default=None, help="Enable fast URL-based duplicate checking (works with --no-dupes)"),
|
click.option(
|
||||||
|
"--simple-check",
|
||||||
|
is_flag=True,
|
||||||
|
default=None,
|
||||||
|
help="Enable fast URL-based duplicate checking (works with --no-dupes)",
|
||||||
|
),
|
||||||
click.option("--skip", default=None, multiple=True),
|
click.option("--skip", default=None, multiple=True),
|
||||||
click.option("--skip-domain", default=None, multiple=True),
|
click.option("--skip-domain", default=None, multiple=True),
|
||||||
click.option("--skip-subreddit", default=None, multiple=True),
|
click.option("--skip-subreddit", default=None, multiple=True),
|
||||||
|
|||||||
+1438
File diff suppressed because it is too large
Load Diff
+7
-1
@@ -31,6 +31,7 @@ class Archiver(RedditConnector):
|
|||||||
|
|
||||||
def download(self):
|
def download(self):
|
||||||
for generator in self.reddit_lists:
|
for generator in self.reddit_lists:
|
||||||
|
submission = None
|
||||||
try:
|
try:
|
||||||
for submission in generator:
|
for submission in generator:
|
||||||
try:
|
try:
|
||||||
@@ -50,7 +51,12 @@ class Archiver(RedditConnector):
|
|||||||
except prawcore.PrawcoreException as e:
|
except prawcore.PrawcoreException as e:
|
||||||
logger.error(f"Submission {submission.id} failed to be archived due to a PRAW exception: {e}")
|
logger.error(f"Submission {submission.id} failed to be archived due to a PRAW exception: {e}")
|
||||||
except prawcore.PrawcoreException as e:
|
except prawcore.PrawcoreException as e:
|
||||||
logger.error(f"The submission after {submission.id} failed to download due to a PRAW exception: {e}")
|
if submission is not None:
|
||||||
|
logger.error(
|
||||||
|
f"The submission after {submission.id} failed to download due to a PRAW exception: {e}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.error(f"Download failed due to a PRAW exception: {e}")
|
||||||
logger.debug("Waiting 60 seconds to continue")
|
logger.debug("Waiting 60 seconds to continue")
|
||||||
sleep(60)
|
sleep(60)
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -20,6 +20,7 @@ class RedditCloner(RedditDownloader, Archiver):
|
|||||||
|
|
||||||
def download(self):
|
def download(self):
|
||||||
for generator in self.reddit_lists:
|
for generator in self.reddit_lists:
|
||||||
|
submission = None
|
||||||
try:
|
try:
|
||||||
for submission in generator:
|
for submission in generator:
|
||||||
try:
|
try:
|
||||||
@@ -28,6 +29,11 @@ class RedditCloner(RedditDownloader, Archiver):
|
|||||||
except prawcore.PrawcoreException as e:
|
except prawcore.PrawcoreException as e:
|
||||||
logger.error(f"Submission {submission.id} failed to be cloned due to a PRAW exception: {e}")
|
logger.error(f"Submission {submission.id} failed to be cloned due to a PRAW exception: {e}")
|
||||||
except prawcore.PrawcoreException as e:
|
except prawcore.PrawcoreException as e:
|
||||||
logger.error(f"The submission after {submission.id} failed to download due to a PRAW exception: {e}")
|
if submission is not None:
|
||||||
|
logger.error(
|
||||||
|
f"The submission after {submission.id} failed to download due to a PRAW exception: {e}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.error(f"Download failed due to a PRAW exception: {e}")
|
||||||
logger.debug("Waiting 60 seconds to continue")
|
logger.debug("Waiting 60 seconds to continue")
|
||||||
sleep(60)
|
sleep(60)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class Configuration(Namespace):
|
|||||||
self.file_scheme: str = "{REDDITOR}_{TITLE}_{POSTID}"
|
self.file_scheme: str = "{REDDITOR}_{TITLE}_{POSTID}"
|
||||||
self.filename_restriction_scheme = None
|
self.filename_restriction_scheme = None
|
||||||
self.folder_scheme: str = "{SUBREDDIT}"
|
self.folder_scheme: str = "{SUBREDDIT}"
|
||||||
|
self.strip_unicode: bool = True
|
||||||
self.ignore_user = []
|
self.ignore_user = []
|
||||||
self.include_id_file = []
|
self.include_id_file = []
|
||||||
self.limit: Optional[int] = None
|
self.limit: Optional[int] = None
|
||||||
|
|||||||
+49
-5
@@ -195,16 +195,38 @@ class RedditConnector(metaclass=ABCMeta):
|
|||||||
Path(self.config_directory, "config.cfg"),
|
Path(self.config_directory, "config.cfg"),
|
||||||
Path(self.config_directory, "default_config.cfg"),
|
Path(self.config_directory, "default_config.cfg"),
|
||||||
]
|
]
|
||||||
|
logger.debug(f"Config directory is: {self.config_directory}")
|
||||||
|
logger.debug(f"Checking possible config paths: {[str(p) for p in possible_paths]}")
|
||||||
self.config_location = None
|
self.config_location = None
|
||||||
for path in possible_paths:
|
for path in possible_paths:
|
||||||
if path.resolve().expanduser().exists():
|
resolved_path = path.resolve().expanduser()
|
||||||
|
logger.debug(f"Checking if {resolved_path} exists: {resolved_path.exists()}")
|
||||||
|
if resolved_path.exists():
|
||||||
self.config_location = path
|
self.config_location = path
|
||||||
logger.debug(f"Loading configuration from {path}")
|
logger.debug(f"Loading configuration from {path}")
|
||||||
break
|
break
|
||||||
if not self.config_location:
|
if not self.config_location:
|
||||||
with importlib.resources.path("bdfr", "default_config.cfg") as path:
|
# Try to use a fallback location that avoids importlib.resources context manager issues
|
||||||
self.config_location = path
|
# when running as non-root user in Docker
|
||||||
shutil.copy(self.config_location, Path(self.config_directory, "default_config.cfg"))
|
fallback_config = Path("/tmp/bdfr_default_config.cfg")
|
||||||
|
if fallback_config.exists():
|
||||||
|
logger.debug("Using fallback config from /tmp/bdfr_default_config.cfg")
|
||||||
|
shutil.copy(fallback_config, Path(self.config_directory, "default_config.cfg"))
|
||||||
|
self.config_location = Path(self.config_directory, "default_config.cfg")
|
||||||
|
else:
|
||||||
|
# Fall back to importlib.resources if no fallback is available
|
||||||
|
try:
|
||||||
|
with importlib.resources.path("bdfr", "default_config.cfg") as path:
|
||||||
|
self.config_location = path
|
||||||
|
shutil.copy(self.config_location, Path(self.config_directory, "default_config.cfg"))
|
||||||
|
except (PermissionError, OSError) as e:
|
||||||
|
logger.error(f"Failed to access default config via importlib.resources: {e}")
|
||||||
|
# Last resort: try to read from package directory directly
|
||||||
|
package_config = Path("/usr/local/lib/python3.11/site-packages/bdfr/default_config.cfg")
|
||||||
|
if package_config.exists():
|
||||||
|
logger.debug("Using package config directly")
|
||||||
|
shutil.copy(package_config, Path(self.config_directory, "default_config.cfg"))
|
||||||
|
self.config_location = Path(self.config_directory, "default_config.cfg")
|
||||||
if not self.config_location:
|
if not self.config_location:
|
||||||
raise errors.BulkDownloaderException("Could not find a configuration file to load")
|
raise errors.BulkDownloaderException("Could not find a configuration file to load")
|
||||||
self.cfg_parser.read(self.config_location)
|
self.cfg_parser.read(self.config_location)
|
||||||
@@ -386,6 +408,24 @@ class RedditConnector(metaclass=ABCMeta):
|
|||||||
generators.append(self.reddit_instance.redditor(user).saved(limit=self.args.limit))
|
generators.append(self.reddit_instance.redditor(user).saved(limit=self.args.limit))
|
||||||
except prawcore.PrawcoreException as e:
|
except prawcore.PrawcoreException as e:
|
||||||
logger.error(f"User {user} failed to be retrieved due to a PRAW exception: {e}")
|
logger.error(f"User {user} failed to be retrieved due to a PRAW exception: {e}")
|
||||||
|
# Detect HTTP 429 (rate limiting) and propagate as a hard failure so the UI can show 'failed'
|
||||||
|
TooManyRequests = getattr(prawcore.exceptions, "TooManyRequests", None)
|
||||||
|
is_rate_limited = False
|
||||||
|
if TooManyRequests is not None and isinstance(e, TooManyRequests):
|
||||||
|
is_rate_limited = True
|
||||||
|
logger.info(f"Rate limited detected: Exception is TooManyRequests for user {user}")
|
||||||
|
elif (hasattr(e, "response") and getattr(e.response, "status_code", None) == 429) or "429" in str(
|
||||||
|
e
|
||||||
|
):
|
||||||
|
is_rate_limited = True
|
||||||
|
logger.info(
|
||||||
|
f"Rate limited detected: Status code 429 or '429' in error message for user {user}. Error: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_rate_limited:
|
||||||
|
logger.error("Received HTTP 429 (rate limited). Propagating error to fail the download.")
|
||||||
|
raise
|
||||||
|
|
||||||
logger.debug("Waiting 60 seconds to continue")
|
logger.debug("Waiting 60 seconds to continue")
|
||||||
sleep(60)
|
sleep(60)
|
||||||
return generators
|
return generators
|
||||||
@@ -405,7 +445,11 @@ class RedditConnector(metaclass=ABCMeta):
|
|||||||
|
|
||||||
def create_file_name_formatter(self) -> FileNameFormatter:
|
def create_file_name_formatter(self) -> FileNameFormatter:
|
||||||
return FileNameFormatter(
|
return FileNameFormatter(
|
||||||
self.args.file_scheme, self.args.folder_scheme, self.args.time_format, self.args.filename_restriction_scheme
|
self.args.file_scheme,
|
||||||
|
self.args.folder_scheme,
|
||||||
|
self.args.time_format,
|
||||||
|
self.args.filename_restriction_scheme,
|
||||||
|
self.args.strip_unicode,
|
||||||
)
|
)
|
||||||
|
|
||||||
def create_time_filter(self) -> RedditTypes.TimeType:
|
def create_time_filter(self) -> RedditTypes.TimeType:
|
||||||
|
|||||||
+49
-43
@@ -50,11 +50,13 @@ class RedditDownloader(RedditConnector):
|
|||||||
hash_data = self._load_hash_list()
|
hash_data = self._load_hash_list()
|
||||||
|
|
||||||
# Handle both old and new hash file formats
|
# Handle both old and new hash file formats
|
||||||
if isinstance(hash_data, dict) and 'files' in hash_data:
|
if isinstance(hash_data, dict) and "files" in hash_data:
|
||||||
# New format with enhanced structure
|
# New format with enhanced structure
|
||||||
self.master_hash_list = {k: v['path'] for k, v in hash_data['files'].items()}
|
self.master_hash_list = {k: v["path"] for k, v in hash_data["files"].items()}
|
||||||
self.url_list = hash_data.get('urls', {})
|
self.url_list = hash_data.get("urls", {})
|
||||||
logger.info(f"Loaded {len(self.master_hash_list)} hashes and {len(self.url_list)} URLs from enhanced hash file")
|
logger.info(
|
||||||
|
f"Loaded {len(self.master_hash_list)} hashes and {len(self.url_list)} URLs from enhanced hash file"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# Old format - just hashes
|
# Old format - just hashes
|
||||||
self.master_hash_list = hash_data
|
self.master_hash_list = hash_data
|
||||||
@@ -70,8 +72,10 @@ class RedditDownloader(RedditConnector):
|
|||||||
if hash_value not in existing_hashes:
|
if hash_value not in existing_hashes:
|
||||||
self.master_hash_list[hash_value] = file_path
|
self.master_hash_list[hash_value] = file_path
|
||||||
|
|
||||||
logger.info(f"Loaded {len(self.master_hash_list)} total hashes "
|
logger.info(
|
||||||
f"({len(existing_hashes)} from file, {len(all_files_hashes) - len(existing_hashes)} new)")
|
f"Loaded {len(self.master_hash_list)} total hashes "
|
||||||
|
f"({len(existing_hashes)} from file, {len(all_files_hashes) - len(existing_hashes)} new)"
|
||||||
|
)
|
||||||
|
|
||||||
def download(self):
|
def download(self):
|
||||||
for generator in self.reddit_lists:
|
for generator in self.reddit_lists:
|
||||||
@@ -84,7 +88,7 @@ class RedditDownloader(RedditConnector):
|
|||||||
except prawcore.PrawcoreException as e:
|
except prawcore.PrawcoreException as e:
|
||||||
logger.error(f"Submission {submission.id} failed to download due to a PRAW exception: {e}")
|
logger.error(f"Submission {submission.id} failed to download due to a PRAW exception: {e}")
|
||||||
except prawcore.PrawcoreException as e:
|
except prawcore.PrawcoreException as e:
|
||||||
submission_id = last_submission_id or "unknown"
|
submission_id = last_submission_id if last_submission_id is not None else "unknown"
|
||||||
logger.error(f"The submission after {submission_id} failed to download due to a PRAW exception: {e}")
|
logger.error(f"The submission after {submission_id} failed to download due to a PRAW exception: {e}")
|
||||||
logger.debug("Waiting 60 seconds to continue")
|
logger.debug("Waiting 60 seconds to continue")
|
||||||
sleep(60)
|
sleep(60)
|
||||||
@@ -149,11 +153,13 @@ class RedditDownloader(RedditConnector):
|
|||||||
logger.error(f"Site {downloader_class.__name__} failed to download submission {submission.id}: {e}")
|
logger.error(f"Site {downloader_class.__name__} failed to download submission {submission.id}: {e}")
|
||||||
return
|
return
|
||||||
files_processed = 0
|
files_processed = 0
|
||||||
|
logger.debug(f"Processing {len(content)} resources for submission {submission.id}")
|
||||||
for destination, res in self.file_name_formatter.format_resource_paths(content, self.download_directory):
|
for destination, res in self.file_name_formatter.format_resource_paths(content, self.download_directory):
|
||||||
|
logger.debug(f"Resource URL: {res.url}, Extension: {res.extension}, Destination: {destination}")
|
||||||
if destination.exists():
|
if destination.exists():
|
||||||
# Check if we already have this file's hash
|
# Check if we already have this file's hash
|
||||||
if destination in self.master_hash_list.values():
|
if destination in self.master_hash_list.values():
|
||||||
logger.debug(f"File {destination} from submission {submission.id} already exists, continuing")
|
logger.info(f"File {destination.name} from submission {submission.id} already exists")
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
# File exists but not in our hash list - calculate its hash
|
# File exists but not in our hash list - calculate its hash
|
||||||
@@ -162,10 +168,11 @@ class RedditDownloader(RedditConnector):
|
|||||||
self.master_hash_list[existing_file_hash] = destination
|
self.master_hash_list[existing_file_hash] = destination
|
||||||
|
|
||||||
# Store URL mapping for simple-check functionality if URL is available
|
# Store URL mapping for simple-check functionality if URL is available
|
||||||
if hasattr(res, 'url') and self.args.simple_check:
|
if hasattr(res, "url") and self.args.simple_check:
|
||||||
self.url_list[res.url] = existing_file_hash
|
self.url_list[res.url] = existing_file_hash
|
||||||
|
|
||||||
logger.debug(f"Added hash for existing file: {existing_file_hash}")
|
logger.debug(f"Added hash for existing file: {existing_file_hash}")
|
||||||
|
logger.info(f"File {destination.name} from submission {submission.id} already exists")
|
||||||
files_processed += 1
|
files_processed += 1
|
||||||
if self.args.no_dupes:
|
if self.args.no_dupes:
|
||||||
self._save_hash_list()
|
self._save_hash_list()
|
||||||
@@ -185,10 +192,9 @@ class RedditDownloader(RedditConnector):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
resource_hash = res.hash.hexdigest()
|
resource_hash = res.hash.hexdigest()
|
||||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Simple-check: URL-based duplicate detection (fast path)
|
# Simple-check: URL-based duplicate detection (fast path)
|
||||||
if self.args.simple_check and hasattr(res, 'url') and res.url in self.url_list:
|
if self.args.simple_check and hasattr(res, "url") and res.url in self.url_list:
|
||||||
stored_hash = self.url_list[res.url]
|
stored_hash = self.url_list[res.url]
|
||||||
if stored_hash in self.master_hash_list:
|
if stored_hash in self.master_hash_list:
|
||||||
logger.info(f"URL {res.url} from submission {submission.id} already downloaded (simple-check)")
|
logger.info(f"URL {res.url} from submission {submission.id} already downloaded (simple-check)")
|
||||||
@@ -213,10 +219,14 @@ class RedditDownloader(RedditConnector):
|
|||||||
if self.args.no_dupes:
|
if self.args.no_dupes:
|
||||||
self._save_hash_list()
|
self._save_hash_list()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Only create folder if we're actually going to write the file (not a duplicate)
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
try:
|
try:
|
||||||
|
logger.debug(f"Writing {len(res.content)} bytes to {destination}")
|
||||||
with destination.open("wb") as file:
|
with destination.open("wb") as file:
|
||||||
file.write(res.content)
|
file.write(res.content)
|
||||||
logger.debug(f"Written file to {destination}")
|
logger.debug(f"Successfully written file to {destination}")
|
||||||
files_processed += 1
|
files_processed += 1
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
logger.exception(e)
|
logger.exception(e)
|
||||||
@@ -227,7 +237,7 @@ class RedditDownloader(RedditConnector):
|
|||||||
self.master_hash_list[resource_hash] = destination
|
self.master_hash_list[resource_hash] = destination
|
||||||
|
|
||||||
# Store URL mapping for simple-check functionality
|
# Store URL mapping for simple-check functionality
|
||||||
if hasattr(res, 'url') and self.args.simple_check:
|
if hasattr(res, "url") and self.args.simple_check:
|
||||||
self.url_list[res.url] = resource_hash
|
self.url_list[res.url] = resource_hash
|
||||||
|
|
||||||
logger.debug(f"Hash added to master list: {resource_hash}")
|
logger.debug(f"Hash added to master list: {resource_hash}")
|
||||||
@@ -246,7 +256,7 @@ class RedditDownloader(RedditConnector):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def scan_existing_files(directory: Path) -> dict[str, Path]:
|
def scan_existing_files(directory: Path) -> dict[str, Path]:
|
||||||
files = []
|
files = []
|
||||||
for (dirpath, _dirnames, filenames) in os.walk(directory):
|
for dirpath, _dirnames, filenames in os.walk(directory):
|
||||||
files.extend([Path(dirpath, file) for file in filenames])
|
files.extend([Path(dirpath, file) for file in filenames])
|
||||||
logger.info(f"Calculating hashes for {len(files)} files")
|
logger.info(f"Calculating hashes for {len(files)} files")
|
||||||
|
|
||||||
@@ -264,14 +274,14 @@ class RedditDownloader(RedditConnector):
|
|||||||
def _load_hash_list(self) -> dict[str, Path]:
|
def _load_hash_list(self) -> dict[str, Path]:
|
||||||
"""Load existing hash list from .bdfr_hashes.json in download directory."""
|
"""Load existing hash list from .bdfr_hashes.json in download directory."""
|
||||||
logger.debug(f"Loading hash list from directory: {self.download_directory}")
|
logger.debug(f"Loading hash list from directory: {self.download_directory}")
|
||||||
hash_file_path = self.download_directory / '.bdfr_hashes.json'
|
hash_file_path = self.download_directory / ".bdfr_hashes.json"
|
||||||
|
|
||||||
if not hash_file_path.exists():
|
if not hash_file_path.exists():
|
||||||
logger.debug(f"No existing hash file found at {hash_file_path}")
|
logger.debug(f"No existing hash file found at {hash_file_path}")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(hash_file_path, 'r', encoding='utf-8') as f:
|
with open(hash_file_path, "r", encoding="utf-8") as f:
|
||||||
hash_data = json.load(f)
|
hash_data = json.load(f)
|
||||||
|
|
||||||
if not isinstance(hash_data, dict):
|
if not isinstance(hash_data, dict):
|
||||||
@@ -279,16 +289,16 @@ class RedditDownloader(RedditConnector):
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
# Handle new enhanced format
|
# Handle new enhanced format
|
||||||
if 'files' in hash_data and isinstance(hash_data['files'], dict):
|
if "files" in hash_data and isinstance(hash_data["files"], dict):
|
||||||
# New format with enhanced structure
|
# New format with enhanced structure
|
||||||
files_data = hash_data['files']
|
files_data = hash_data["files"]
|
||||||
loaded_hashes = {}
|
loaded_hashes = {}
|
||||||
urls_data = hash_data.get('urls', {})
|
urls_data = hash_data.get("urls", {})
|
||||||
|
|
||||||
for hash_value, file_info in files_data.items():
|
for hash_value, file_info in files_data.items():
|
||||||
if isinstance(file_info, dict) and 'path' in file_info:
|
if isinstance(file_info, dict) and "path" in file_info:
|
||||||
# New format: {"hash": {"path": "relative/path", "url": "http://..."}}
|
# New format: {"hash": {"path": "relative/path", "url": "http://..."}}
|
||||||
relative_path = file_info['path']
|
relative_path = file_info["path"]
|
||||||
absolute_path = self.download_directory / relative_path
|
absolute_path = self.download_directory / relative_path
|
||||||
if absolute_path.exists():
|
if absolute_path.exists():
|
||||||
loaded_hashes[hash_value] = absolute_path
|
loaded_hashes[hash_value] = absolute_path
|
||||||
@@ -296,8 +306,8 @@ class RedditDownloader(RedditConnector):
|
|||||||
logger.debug(f"File {absolute_path} from hash file no longer exists")
|
logger.debug(f"File {absolute_path} from hash file no longer exists")
|
||||||
|
|
||||||
# Load URL mapping for simple-check
|
# Load URL mapping for simple-check
|
||||||
if 'url' in file_info and file_info['url']:
|
if "url" in file_info and file_info["url"]:
|
||||||
self.url_list[file_info['url']] = hash_value
|
self.url_list[file_info["url"]] = hash_value
|
||||||
elif isinstance(file_info, str):
|
elif isinstance(file_info, str):
|
||||||
# Legacy format within new structure: {"hash": "relative/path"}
|
# Legacy format within new structure: {"hash": "relative/path"}
|
||||||
absolute_path = self.download_directory / file_info
|
absolute_path = self.download_directory / file_info
|
||||||
@@ -331,30 +341,30 @@ class RedditDownloader(RedditConnector):
|
|||||||
|
|
||||||
def _save_hash_list(self) -> None:
|
def _save_hash_list(self) -> None:
|
||||||
"""Save current hash list to .bdfr_hashes.json in download directory using atomic write."""
|
"""Save current hash list to .bdfr_hashes.json in download directory using atomic write."""
|
||||||
hash_file_path = self.download_directory / '.bdfr_hashes.json'
|
hash_file_path = self.download_directory / ".bdfr_hashes.json"
|
||||||
|
|
||||||
# Build enhanced data structure for new format
|
# Build enhanced data structure for new format
|
||||||
if self.args.simple_check:
|
if self.args.simple_check:
|
||||||
# New enhanced format with URLs and metadata
|
# New enhanced format with URLs and metadata
|
||||||
hash_data = {
|
hash_data = {
|
||||||
'files': {},
|
"files": {},
|
||||||
'urls': self.url_list.copy(),
|
"urls": self.url_list.copy(),
|
||||||
'metadata': {
|
"metadata": {
|
||||||
'version': '2.0',
|
"version": "2.0",
|
||||||
'created_with': 'simple_check' if self.args.simple_check else 'standard',
|
"created_with": "simple_check" if self.args.simple_check else "standard",
|
||||||
'url_count': len(self.url_list),
|
"url_count": len(self.url_list),
|
||||||
'hash_count': len(self.master_hash_list)
|
"hash_count": len(self.master_hash_list),
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Convert absolute paths to relative paths for portability
|
# Convert absolute paths to relative paths for portability
|
||||||
for hash_value, absolute_path in self.master_hash_list.items():
|
for hash_value, absolute_path in self.master_hash_list.items():
|
||||||
try:
|
try:
|
||||||
relative_path = absolute_path.relative_to(self.download_directory)
|
relative_path = absolute_path.relative_to(self.download_directory)
|
||||||
hash_data['files'][hash_value] = {
|
hash_data["files"][hash_value] = {
|
||||||
'path': str(relative_path),
|
"path": str(relative_path),
|
||||||
'url': next((url for url, h in self.url_list.items() if h == hash_value), None),
|
"url": next((url for url, h in self.url_list.items() if h == hash_value), None),
|
||||||
'check_method': 'hash'
|
"check_method": "hash",
|
||||||
}
|
}
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# File is not relative to download directory, skip it
|
# File is not relative to download directory, skip it
|
||||||
@@ -375,17 +385,13 @@ class RedditDownloader(RedditConnector):
|
|||||||
# Atomic write: write to temporary file first, then rename
|
# Atomic write: write to temporary file first, then rename
|
||||||
try:
|
try:
|
||||||
with tempfile.NamedTemporaryFile(
|
with tempfile.NamedTemporaryFile(
|
||||||
mode='w',
|
mode="w", dir=self.download_directory, suffix=".tmp", delete=False, encoding="utf-8"
|
||||||
dir=self.download_directory,
|
|
||||||
suffix='.tmp',
|
|
||||||
delete=False,
|
|
||||||
encoding='utf-8'
|
|
||||||
) as temp_file:
|
) as temp_file:
|
||||||
json.dump(hash_data, temp_file, indent=2)
|
json.dump(hash_data, temp_file, indent=2)
|
||||||
temp_file_path = temp_file.name
|
temp_file_path = temp_file.name
|
||||||
|
|
||||||
# Atomic rename
|
# Atomic rename
|
||||||
if os.name == 'nt': # Windows
|
if os.name == "nt": # Windows
|
||||||
# On Windows, we need to remove the target file first if it exists
|
# On Windows, we need to remove the target file first if it exists
|
||||||
if hash_file_path.exists():
|
if hash_file_path.exists():
|
||||||
hash_file_path.unlink()
|
hash_file_path.unlink()
|
||||||
@@ -401,7 +407,7 @@ class RedditDownloader(RedditConnector):
|
|||||||
logger.error(f"Unexpected error saving hash file {hash_file_path}: {e}")
|
logger.error(f"Unexpected error saving hash file {hash_file_path}: {e}")
|
||||||
# Clean up temp file if it still exists
|
# Clean up temp file if it still exists
|
||||||
try:
|
try:
|
||||||
if 'temp_file_path' in locals():
|
if "temp_file_path" in locals():
|
||||||
os.unlink(temp_file_path)
|
os.unlink(temp_file_path)
|
||||||
except (OSError, IOError):
|
except (OSError, IOError):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -0,0 +1,383 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
"""
|
||||||
|
BDFR API Usage Examples
|
||||||
|
|
||||||
|
This file demonstrates how to use the BDFR API layer for direct integration
|
||||||
|
with the web interface, eliminating the need for subprocess console parsing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
# Add the parent directory to the path so we can import bdfr
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from bdfr.api import (
|
||||||
|
BDFRManager,
|
||||||
|
DownloadStatus,
|
||||||
|
DownloadType,
|
||||||
|
LoggingCallback,
|
||||||
|
ProgressCallback,
|
||||||
|
ProgressEvent,
|
||||||
|
get_bdfr_manager,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(level=logging.INFO, format="[%(asctime)s] %(levelname)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketCallback(ProgressCallback):
|
||||||
|
"""Example callback that simulates WebSocket updates"""
|
||||||
|
|
||||||
|
def __init__(self, websocket_id: str = "demo"):
|
||||||
|
self.websocket_id = websocket_id
|
||||||
|
|
||||||
|
async def on_progress(self, event: ProgressEvent):
|
||||||
|
"""Send progress update to WebSocket"""
|
||||||
|
print(f"📊 [{self.websocket_id}] Progress: {event.message}")
|
||||||
|
if event.progress is not None:
|
||||||
|
print(f" Progress: {int(round(event.progress))}%")
|
||||||
|
if event.data:
|
||||||
|
print(f" Data: {event.data}")
|
||||||
|
|
||||||
|
async def on_error(self, event: ProgressEvent):
|
||||||
|
"""Send error update to WebSocket"""
|
||||||
|
print(f"❌ [{self.websocket_id}] ERROR: {event.message}")
|
||||||
|
if event.data.get("exception"):
|
||||||
|
print(f" Exception: {event.data['exception']}")
|
||||||
|
|
||||||
|
async def on_completed(self, event: ProgressEvent):
|
||||||
|
"""Send completion update to WebSocket"""
|
||||||
|
status_icon = "✅" if event.event_type == "completed" else "⚠️"
|
||||||
|
print(f"{status_icon} [{self.websocket_id}] {event.message}")
|
||||||
|
if event.data:
|
||||||
|
print(f" Final stats: {event.data}")
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseCallback(ProgressCallback):
|
||||||
|
"""Example callback that saves progress to a database"""
|
||||||
|
|
||||||
|
def __init__(self, db_connection_string: str = "sqlite:///progress.db"):
|
||||||
|
self.db_connection = db_connection_string
|
||||||
|
|
||||||
|
async def on_progress(self, event: ProgressEvent):
|
||||||
|
"""Save progress to database"""
|
||||||
|
# In a real implementation, you would save to your database
|
||||||
|
print(f"💾 [DB] Saved progress for {event.download_id}: {event.progress}%")
|
||||||
|
|
||||||
|
async def on_error(self, event: ProgressEvent):
|
||||||
|
"""Save error to database"""
|
||||||
|
print(f"💾 [DB] Saved error for {event.download_id}: {event.message}")
|
||||||
|
|
||||||
|
async def on_completed(self, event: ProgressEvent):
|
||||||
|
"""Save completion to database"""
|
||||||
|
print(f"💾 [DB] Saved completion for {event.download_id}")
|
||||||
|
|
||||||
|
|
||||||
|
async def example_basic_usage():
|
||||||
|
"""Basic usage example"""
|
||||||
|
print("🚀 Basic BDFR API Usage Example")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# Create a BDFR manager
|
||||||
|
manager = BDFRManager("./downloads")
|
||||||
|
|
||||||
|
# Create a download for a subreddit
|
||||||
|
download_id = manager.download_subreddit(
|
||||||
|
"python", # subreddit name
|
||||||
|
limit=10, # download 10 posts
|
||||||
|
sort="hot", # sort by hot
|
||||||
|
no_dupes=True, # avoid duplicates
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"📋 Created download with ID: {download_id}")
|
||||||
|
|
||||||
|
# Monitor progress
|
||||||
|
while True:
|
||||||
|
status = manager.get_download_status(download_id)
|
||||||
|
if not status:
|
||||||
|
print("❌ Download not found!")
|
||||||
|
break
|
||||||
|
|
||||||
|
print(f"📊 Status: {status['status']} | Progress: {int(round(status['progress']))}%")
|
||||||
|
|
||||||
|
if status["status"] in ["completed", "failed", "cancelled"]:
|
||||||
|
print(f"🏁 Download finished with status: {status['status']}")
|
||||||
|
break
|
||||||
|
|
||||||
|
await asyncio.sleep(2) # Check every 2 seconds
|
||||||
|
|
||||||
|
return download_id
|
||||||
|
|
||||||
|
|
||||||
|
async def example_advanced_usage():
|
||||||
|
"""Advanced usage with custom callbacks"""
|
||||||
|
print("\n🎯 Advanced BDFR API Usage Example")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# Create custom callbacks
|
||||||
|
callbacks = [LoggingCallback("web_interface"), WebSocketCallback("user_123"), DatabaseCallback()]
|
||||||
|
|
||||||
|
# Create manager with custom download directory
|
||||||
|
manager = BDFRManager("./custom_downloads")
|
||||||
|
|
||||||
|
# Download from multiple subreddits
|
||||||
|
subreddits = ["programming", "learnprogramming", "Python"]
|
||||||
|
download_ids = []
|
||||||
|
|
||||||
|
for subreddit in subreddits:
|
||||||
|
download_id = manager.create_download(DownloadType.SUBREDDIT, subreddit, progress_callbacks=callbacks)
|
||||||
|
|
||||||
|
# Start the download
|
||||||
|
manager.start_download(download_id)
|
||||||
|
download_ids.append(download_id)
|
||||||
|
print(f"📋 Started download {download_id} for r/{subreddit}")
|
||||||
|
|
||||||
|
# Monitor all downloads
|
||||||
|
while download_ids:
|
||||||
|
active_downloads = []
|
||||||
|
|
||||||
|
for download_id in download_ids[:]: # Copy list to avoid modification during iteration
|
||||||
|
status = manager.get_download_status(download_id)
|
||||||
|
if not status:
|
||||||
|
print(f"❌ Download {download_id} not found")
|
||||||
|
download_ids.remove(download_id)
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"📊 {download_id}: {status['status']} ({int(round(status['progress']))}%)")
|
||||||
|
|
||||||
|
if status["status"] in ["completed", "failed", "cancelled"]:
|
||||||
|
print(f"🏁 Download {download_id} finished")
|
||||||
|
download_ids.remove(download_id)
|
||||||
|
else:
|
||||||
|
active_downloads.append(download_id)
|
||||||
|
|
||||||
|
if not active_downloads:
|
||||||
|
break
|
||||||
|
|
||||||
|
await asyncio.sleep(3) # Check every 3 seconds
|
||||||
|
|
||||||
|
return len(download_ids) == 0 # Return success status
|
||||||
|
|
||||||
|
|
||||||
|
async def example_user_download():
|
||||||
|
"""Example of downloading user content"""
|
||||||
|
print("\n👤 User Download Example")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
manager = get_bdfr_manager() # Use default manager
|
||||||
|
|
||||||
|
# Download user's submitted posts
|
||||||
|
download_id = manager.download_user(
|
||||||
|
"testuser", limit=25, submitted=True, upvoted=False, saved=False # username # 25 posts
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"📋 Created user download: {download_id}")
|
||||||
|
|
||||||
|
# Check status periodically
|
||||||
|
for _ in range(10): # Check for up to 20 seconds
|
||||||
|
status = manager.get_download_status(download_id)
|
||||||
|
if not status:
|
||||||
|
print("❌ Download not found")
|
||||||
|
break
|
||||||
|
|
||||||
|
print(f"📊 Status: {status['status']} | Progress: {int(round(status['progress']))}%")
|
||||||
|
|
||||||
|
if status["status"] in ["completed", "failed"]:
|
||||||
|
break
|
||||||
|
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
|
return download_id
|
||||||
|
|
||||||
|
|
||||||
|
async def example_archive_operation():
|
||||||
|
"""Example of archiving subreddit data"""
|
||||||
|
print("\n📚 Archive Operation Example")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
manager = BDFRManager("./archives")
|
||||||
|
|
||||||
|
# Archive subreddit data (metadata only)
|
||||||
|
download_id = manager.archive_subreddit("dataisbeautiful", format_type="json", limit=50)
|
||||||
|
|
||||||
|
print(f"📋 Created archive operation: {download_id}")
|
||||||
|
|
||||||
|
# Monitor progress
|
||||||
|
while True:
|
||||||
|
status = manager.get_download_status(download_id)
|
||||||
|
if not status:
|
||||||
|
print("❌ Archive not found")
|
||||||
|
break
|
||||||
|
|
||||||
|
print(f"📊 Archive status: {status['status']} | Progress: {int(round(status['progress']))}%")
|
||||||
|
|
||||||
|
if status["status"] in ["completed", "failed"]:
|
||||||
|
print(f"🏁 Archive finished with status: {status['status']}")
|
||||||
|
break
|
||||||
|
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
|
return download_id
|
||||||
|
|
||||||
|
|
||||||
|
async def example_web_integration():
|
||||||
|
"""Example showing how to integrate with a web application"""
|
||||||
|
print("\n🌐 Web Integration Example")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# Simulate a web application using the API
|
||||||
|
class MockWebApp:
|
||||||
|
def __init__(self):
|
||||||
|
self.manager = BDFRManager("./web_downloads")
|
||||||
|
self.active_sessions = {}
|
||||||
|
|
||||||
|
async def handle_download_request(self, user_id: str, subreddit: str, limit: int):
|
||||||
|
"""Handle a download request from the web interface"""
|
||||||
|
|
||||||
|
# Create custom callback for this user
|
||||||
|
callback = WebSocketCallback(f"ws_{user_id}")
|
||||||
|
|
||||||
|
# Create and start download
|
||||||
|
download_id = self.manager.download_subreddit(subreddit, limit=limit, progress_callbacks=[callback])
|
||||||
|
|
||||||
|
# Track for this user session
|
||||||
|
if user_id not in self.active_sessions:
|
||||||
|
self.active_sessions[user_id] = []
|
||||||
|
self.active_sessions[user_id].append(download_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"download_id": download_id,
|
||||||
|
"message": f"Started download of r/{subreddit} (limit: {limit})",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_user_downloads(self, user_id: str):
|
||||||
|
"""Get all downloads for a user"""
|
||||||
|
if user_id not in self.active_sessions:
|
||||||
|
return []
|
||||||
|
|
||||||
|
downloads = []
|
||||||
|
for download_id in self.active_sessions[user_id]:
|
||||||
|
status = self.manager.get_download_status(download_id)
|
||||||
|
if status:
|
||||||
|
downloads.append(status)
|
||||||
|
|
||||||
|
return downloads
|
||||||
|
|
||||||
|
async def cancel_user_download(self, user_id: str, download_id: str):
|
||||||
|
"""Cancel a specific download for a user"""
|
||||||
|
if user_id in self.active_sessions and download_id in self.active_sessions[user_id]:
|
||||||
|
success = self.manager.cancel_download(download_id)
|
||||||
|
if success:
|
||||||
|
self.active_sessions[user_id].remove(download_id)
|
||||||
|
return {"success": True, "message": "Download cancelled"}
|
||||||
|
else:
|
||||||
|
return {"success": False, "message": "Failed to cancel download"}
|
||||||
|
|
||||||
|
return {"success": False, "message": "Download not found for user"}
|
||||||
|
|
||||||
|
# Simulate web app usage
|
||||||
|
app = MockWebApp()
|
||||||
|
|
||||||
|
# Simulate user requests
|
||||||
|
user_id = "user123"
|
||||||
|
|
||||||
|
# User starts a download
|
||||||
|
result1 = await app.handle_download_request(user_id, "technology", 20)
|
||||||
|
print(f"User request result: {result1}")
|
||||||
|
|
||||||
|
# User starts another download
|
||||||
|
result2 = await app.handle_download_request(user_id, "science", 15)
|
||||||
|
print(f"User request result: {result2}")
|
||||||
|
|
||||||
|
# Check user's downloads
|
||||||
|
user_downloads = await app.get_user_downloads(user_id)
|
||||||
|
print(f"User has {len(user_downloads)} active downloads:")
|
||||||
|
for download in user_downloads:
|
||||||
|
print(f" - {download['id']}: {download['status']} ({int(round(download['progress']))}%)")
|
||||||
|
|
||||||
|
# Cancel one download
|
||||||
|
if user_downloads:
|
||||||
|
cancel_result = await app.cancel_user_download(user_id, user_downloads[0]["id"])
|
||||||
|
print(f"Cancel result: {cancel_result}")
|
||||||
|
|
||||||
|
return len(user_downloads)
|
||||||
|
|
||||||
|
|
||||||
|
async def example_error_handling():
|
||||||
|
"""Example of error handling"""
|
||||||
|
print("\n⚠️ Error Handling Example")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
manager = BDFRManager("./test_downloads")
|
||||||
|
|
||||||
|
# Try to download from a non-existent subreddit
|
||||||
|
download_id = manager.download_subreddit("this_subreddit_does_not_exist", limit=5)
|
||||||
|
|
||||||
|
print(f"📋 Created download for non-existent subreddit: {download_id}")
|
||||||
|
|
||||||
|
# Monitor for error
|
||||||
|
for _ in range(5): # Check for up to 10 seconds
|
||||||
|
status = manager.get_download_status(download_id)
|
||||||
|
if not status:
|
||||||
|
print("❌ Download disappeared")
|
||||||
|
break
|
||||||
|
|
||||||
|
print(f"📊 Status: {status['status']}")
|
||||||
|
|
||||||
|
if status["status"] == "failed":
|
||||||
|
print(f"🏁 Download failed as expected: {status.get('error', 'Unknown error')}")
|
||||||
|
break
|
||||||
|
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
|
return download_id
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Run all examples"""
|
||||||
|
print("🎯 BDFR API Examples")
|
||||||
|
print("=" * 60)
|
||||||
|
print("This demonstrates the new BDFR API layer for direct web integration")
|
||||||
|
print()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Run basic example
|
||||||
|
await example_basic_usage()
|
||||||
|
|
||||||
|
# Run advanced example
|
||||||
|
await example_advanced_usage()
|
||||||
|
|
||||||
|
# Run user download example
|
||||||
|
await example_user_download()
|
||||||
|
|
||||||
|
# Run archive example
|
||||||
|
await example_archive_operation()
|
||||||
|
|
||||||
|
# Run web integration example
|
||||||
|
await example_web_integration()
|
||||||
|
|
||||||
|
# Run error handling example
|
||||||
|
await example_error_handling()
|
||||||
|
|
||||||
|
print("\n🎉 All examples completed!")
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n⏹️ Examples interrupted by user")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ Error running examples: {e}")
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Run the examples
|
||||||
|
asyncio.run(main())
|
||||||
@@ -36,6 +36,7 @@ class FileNameFormatter:
|
|||||||
directory_format_string: str,
|
directory_format_string: str,
|
||||||
time_format_string: str,
|
time_format_string: str,
|
||||||
restriction_scheme: Optional[str] = None,
|
restriction_scheme: Optional[str] = None,
|
||||||
|
strip_unicode: bool = True,
|
||||||
):
|
):
|
||||||
if not self.validate_string(file_format_string):
|
if not self.validate_string(file_format_string):
|
||||||
raise BulkDownloaderException(f'"{file_format_string}" is not a valid format string')
|
raise BulkDownloaderException(f'"{file_format_string}" is not a valid format string')
|
||||||
@@ -43,6 +44,7 @@ class FileNameFormatter:
|
|||||||
self.directory_format_string: list[str] = directory_format_string.split("/")
|
self.directory_format_string: list[str] = directory_format_string.split("/")
|
||||||
self.time_format_string = time_format_string
|
self.time_format_string = time_format_string
|
||||||
self.restiction_scheme = restriction_scheme.lower().strip() if restriction_scheme else None
|
self.restiction_scheme = restriction_scheme.lower().strip() if restriction_scheme else None
|
||||||
|
self.strip_unicode = strip_unicode
|
||||||
if self.restiction_scheme == "windows":
|
if self.restiction_scheme == "windows":
|
||||||
self.max_path = self.WINDOWS_MAX_PATH_LENGTH
|
self.max_path = self.WINDOWS_MAX_PATH_LENGTH
|
||||||
else:
|
else:
|
||||||
@@ -65,12 +67,22 @@ class FileNameFormatter:
|
|||||||
|
|
||||||
result = result.replace("/", "")
|
result = result.replace("/", "")
|
||||||
|
|
||||||
|
# Strip Unicode characters that cause Windows SMB issues if enabled
|
||||||
|
if self.strip_unicode:
|
||||||
|
result = FileNameFormatter._strip_unicode_chars(result)
|
||||||
|
|
||||||
if self.restiction_scheme is None:
|
if self.restiction_scheme is None:
|
||||||
if platform.system() == "Windows":
|
if platform.system() == "Windows":
|
||||||
result = FileNameFormatter._format_for_windows(result)
|
result = FileNameFormatter._format_for_windows(result)
|
||||||
|
# Strip emojis on Windows if strip_unicode is enabled (for backward compatibility)
|
||||||
|
if self.strip_unicode:
|
||||||
|
result = FileNameFormatter._strip_emojis(result)
|
||||||
elif self.restiction_scheme == "windows":
|
elif self.restiction_scheme == "windows":
|
||||||
logger.debug("Forcing Windows-compatible filenames")
|
logger.debug("Forcing Windows-compatible filenames")
|
||||||
result = FileNameFormatter._format_for_windows(result)
|
result = FileNameFormatter._format_for_windows(result)
|
||||||
|
# Strip emojis when forcing Windows compatibility if strip_unicode is enabled
|
||||||
|
if self.strip_unicode:
|
||||||
|
result = FileNameFormatter._strip_emojis(result)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -126,6 +138,7 @@ class FileNameFormatter:
|
|||||||
)
|
)
|
||||||
index = f"_{index}" if index else ""
|
index = f"_{index}" if index else ""
|
||||||
if not resource.extension:
|
if not resource.extension:
|
||||||
|
logger.error(f"Resource from {resource.url} has no extension - URL: {resource.url}")
|
||||||
raise BulkDownloaderException(f"Resource from {resource.url} has no extension")
|
raise BulkDownloaderException(f"Resource from {resource.url} has no extension")
|
||||||
file_name = str(self._format_name(resource.source_submission, self.file_format_string))
|
file_name = str(self._format_name(resource.source_submission, self.file_format_string))
|
||||||
|
|
||||||
@@ -218,9 +231,49 @@ class FileNameFormatter:
|
|||||||
invalid_characters = r'<>:"\/|?*'
|
invalid_characters = r'<>:"\/|?*'
|
||||||
for char in invalid_characters:
|
for char in invalid_characters:
|
||||||
input_string = input_string.replace(char, "")
|
input_string = input_string.replace(char, "")
|
||||||
input_string = FileNameFormatter._strip_emojis(input_string)
|
|
||||||
return input_string
|
return input_string
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _strip_unicode_chars(input_string: str) -> str:
|
||||||
|
"""Strip Unicode characters that cause Windows SMB to create 8.3 short names"""
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
# Remove emoji and symbols that cause Windows SMB issues
|
||||||
|
result = []
|
||||||
|
for char in input_string:
|
||||||
|
# Keep ASCII characters
|
||||||
|
if ord(char) < 0x80:
|
||||||
|
result.append(char)
|
||||||
|
# Keep common Unicode letters, numbers, and punctuation
|
||||||
|
elif unicodedata.category(char) in [
|
||||||
|
"Lu",
|
||||||
|
"Ll",
|
||||||
|
"Lt",
|
||||||
|
"Lm",
|
||||||
|
"Lo",
|
||||||
|
"Nd",
|
||||||
|
"Nl",
|
||||||
|
"No",
|
||||||
|
"Pc",
|
||||||
|
"Pd",
|
||||||
|
"Ps",
|
||||||
|
"Pe",
|
||||||
|
"Pi",
|
||||||
|
"Pf",
|
||||||
|
"Po",
|
||||||
|
]:
|
||||||
|
result.append(char)
|
||||||
|
# Strip emoji, symbols, and other special characters that cause 8.3 names
|
||||||
|
elif unicodedata.category(char).startswith(("S", "So", "Sk", "Sm")): # Symbols
|
||||||
|
continue
|
||||||
|
elif ord(char) > 0x1F000: # High Unicode ranges often contain emoji
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
# Keep other Unicode characters that are generally safe
|
||||||
|
result.append(char)
|
||||||
|
|
||||||
|
return "".join(result)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _strip_emojis(input_string: str) -> str:
|
def _strip_emojis(input_string: str) -> str:
|
||||||
result = input_string.encode("ascii", errors="ignore").decode("utf-8")
|
result = input_string.encode("ascii", errors="ignore").decode("utf-8")
|
||||||
|
|||||||
+84
-2
@@ -24,10 +24,16 @@ class Resource:
|
|||||||
self.content: Optional[bytes] = None
|
self.content: Optional[bytes] = None
|
||||||
self.url = url
|
self.url = url
|
||||||
self.hash: Optional[_hashlib.HASH] = None
|
self.hash: Optional[_hashlib.HASH] = None
|
||||||
self.extension = extension
|
|
||||||
|
# Log the original extension before normalization
|
||||||
|
if extension:
|
||||||
|
logger.debug(f"Resource constructor received extension: '{extension}' for URL: {url}")
|
||||||
|
|
||||||
|
self.extension = self._normalize_extension(extension)
|
||||||
self.download_function = download_function
|
self.download_function = download_function
|
||||||
if not self.extension:
|
if not self.extension:
|
||||||
self.extension = self._determine_extension()
|
self.extension = self._determine_extension()
|
||||||
|
logger.debug(f"Extension determined from URL: '{self.extension}' for URL: {url}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def retry_download(url: str) -> Callable:
|
def retry_download(url: str) -> Callable:
|
||||||
@@ -45,6 +51,13 @@ class Resource:
|
|||||||
raise
|
raise
|
||||||
if content:
|
if content:
|
||||||
self.content = content
|
self.content = content
|
||||||
|
|
||||||
|
# If we didn't have an extension before, try to detect from content
|
||||||
|
if not self.extension:
|
||||||
|
logger.debug(f"Attempting content-based extension detection for {self.url}")
|
||||||
|
detected = self._detect_extension_by_content()
|
||||||
|
self.extension = self._normalize_extension(detected) if detected else None
|
||||||
|
|
||||||
if not self.hash and self.content:
|
if not self.hash and self.content:
|
||||||
self.create_hash()
|
self.create_hash()
|
||||||
|
|
||||||
@@ -54,9 +67,78 @@ class Resource:
|
|||||||
def _determine_extension(self) -> Optional[str]:
|
def _determine_extension(self) -> Optional[str]:
|
||||||
extension_pattern = re.compile(r".*(\..{3,5})$")
|
extension_pattern = re.compile(r".*(\..{3,5})$")
|
||||||
stripped_url = urllib.parse.urlsplit(self.url).path
|
stripped_url = urllib.parse.urlsplit(self.url).path
|
||||||
|
|
||||||
|
# Special handling for Reddit media URLs
|
||||||
|
if self.url.startswith("https://www.reddit.com/media"):
|
||||||
|
logger.debug(f"Detected Reddit media URL: {self.url}")
|
||||||
|
parsed_url = urllib.parse.urlparse(self.url)
|
||||||
|
url_param = urllib.parse.parse_qs(parsed_url.query).get("url", [None])[0]
|
||||||
|
if url_param:
|
||||||
|
decoded_url = urllib.parse.unquote(url_param)
|
||||||
|
logger.debug(f"Reddit media URL decoded to: {decoded_url}")
|
||||||
|
stripped_url = urllib.parse.urlsplit(decoded_url).path
|
||||||
|
|
||||||
|
# Also handle preview.redd.it URLs which might not have extensions
|
||||||
|
elif "preview.redd.it" in self.url and not stripped_url.endswith((".jpg", ".jpeg", ".png", ".gif", ".webp")):
|
||||||
|
logger.debug(f"Detected preview.redd.it URL without extension: {self.url}")
|
||||||
|
# For preview URLs, try to infer from common patterns or add fallback logic
|
||||||
|
|
||||||
match = re.search(extension_pattern, stripped_url)
|
match = re.search(extension_pattern, stripped_url)
|
||||||
if match:
|
if match:
|
||||||
return match.group(1)
|
extension = match.group(1)
|
||||||
|
logger.debug(f"URL {self.url} -> extracted extension: {extension} (from path: {stripped_url})")
|
||||||
|
return self._normalize_extension(extension)
|
||||||
|
else:
|
||||||
|
logger.warning(f"Could not determine extension for URL: {self.url} (path: {stripped_url})")
|
||||||
|
|
||||||
|
# As a last resort, if we have content, try to detect by magic numbers
|
||||||
|
if hasattr(self, "content") and self.content:
|
||||||
|
detected = self._detect_extension_by_content()
|
||||||
|
return self._normalize_extension(detected) if detected else None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _detect_extension_by_content(self) -> Optional[str]:
|
||||||
|
"""Detect file extension by examining file content (magic numbers)"""
|
||||||
|
if not self.content or len(self.content) < 16:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check for common image formats
|
||||||
|
if self.content.startswith(b"\xff\xd8\xff"):
|
||||||
|
logger.debug(f"Detected JPEG by magic number for URL: {self.url}")
|
||||||
|
return ".jpg"
|
||||||
|
elif self.content.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||||
|
logger.debug(f"Detected PNG by magic number for URL: {self.url}")
|
||||||
|
return ".png"
|
||||||
|
elif self.content.startswith(b"GIF87a") or self.content.startswith(b"GIF89a"):
|
||||||
|
logger.debug(f"Detected GIF by magic number for URL: {self.url}")
|
||||||
|
return ".gif"
|
||||||
|
elif self.content.startswith(b"RIFF") and self.content[8:12] == b"WEBP":
|
||||||
|
logger.debug(f"Detected WebP by magic number for URL: {self.url}")
|
||||||
|
return ".webp"
|
||||||
|
elif self.content.startswith(b"BM"):
|
||||||
|
logger.debug(f"Detected BMP by magic number for URL: {self.url}")
|
||||||
|
return ".bmp"
|
||||||
|
|
||||||
|
logger.debug(f"Could not detect file type by magic number for URL: {self.url}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _normalize_extension(self, extension: Optional[str]) -> Optional[str]:
|
||||||
|
"""Normalize extension to lowercase for consistency"""
|
||||||
|
if not extension:
|
||||||
|
return None
|
||||||
|
|
||||||
|
original = extension
|
||||||
|
# Ensure extension starts with a dot
|
||||||
|
if not extension.startswith("."):
|
||||||
|
extension = "." + extension
|
||||||
|
|
||||||
|
normalized = extension.lower()
|
||||||
|
if original != normalized:
|
||||||
|
logger.info(
|
||||||
|
f"Extension normalization: '{original}' -> '{normalized}' for URL: {self.url if hasattr(self, 'url') else 'unknown'}"
|
||||||
|
)
|
||||||
|
return normalized
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def http_download(url: str, download_parameters: dict) -> Optional[bytes]:
|
def http_download(url: str, download_parameters: dict) -> Optional[bytes]:
|
||||||
|
|||||||
@@ -27,11 +27,23 @@ class BaseDownloader(ABC):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def retrieve_url(url: str, cookies: dict = None, headers: dict = None) -> requests.Response:
|
def retrieve_url(url: str, cookies: dict = None, headers: dict = None) -> requests.Response:
|
||||||
try:
|
max_retries = 3
|
||||||
res = requests.get(url, cookies=cookies, headers=headers)
|
for attempt in range(1, max_retries + 1):
|
||||||
except requests.exceptions.RequestException as e:
|
try:
|
||||||
logger.exception(e)
|
res = requests.get(url, cookies=cookies, headers=headers, timeout=10)
|
||||||
raise SiteDownloaderError(f"Failed to get page {url}")
|
if res.status_code != 200:
|
||||||
if res.status_code != 200:
|
logger.error(f"Attempt {attempt}: Server responded with {res.status_code} to {url}")
|
||||||
raise ResourceNotFound(f"Server responded with {res.status_code} to {url}")
|
if attempt == max_retries:
|
||||||
return res
|
raise ResourceNotFound(f"Server responded with {res.status_code} to {url}")
|
||||||
|
else:
|
||||||
|
return res
|
||||||
|
except requests.exceptions.SSLError as ssl_err:
|
||||||
|
logger.error(f"Attempt {attempt}: SSL error for {url}: {ssl_err}")
|
||||||
|
if attempt == max_retries:
|
||||||
|
raise SiteDownloaderError(f"SSL error after {max_retries} attempts for {url}: {ssl_err}")
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"Attempt {attempt}: Request error for {url}: {e}")
|
||||||
|
if attempt == max_retries:
|
||||||
|
raise SiteDownloaderError(f"Failed to get page {url} after {max_retries} attempts: {e}")
|
||||||
|
# Should not reach here
|
||||||
|
raise SiteDownloaderError(f"Failed to get page {url} after {max_retries} attempts")
|
||||||
|
|||||||
@@ -24,8 +24,15 @@ from bdfr.site_downloaders.youtube import Youtube
|
|||||||
class DownloadFactory:
|
class DownloadFactory:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def pull_lever(url: str) -> type[BaseDownloader]:
|
def pull_lever(url: str) -> type[BaseDownloader]:
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
sanitised_url = DownloadFactory.sanitise_url(url).lower()
|
sanitised_url = DownloadFactory.sanitise_url(url).lower()
|
||||||
|
logger.debug(f"Selecting downloader for URL: {url} (sanitized: {sanitised_url})")
|
||||||
|
|
||||||
if re.match(r"(i\.|m\.|o\.)?imgur", sanitised_url):
|
if re.match(r"(i\.|m\.|o\.)?imgur", sanitised_url):
|
||||||
|
logger.debug("Using Imgur downloader")
|
||||||
return Imgur
|
return Imgur
|
||||||
elif re.match(r"(i\.|thumbs\d\.|v\d\.)?(redgifs|gifdeliverynetwork)", sanitised_url):
|
elif re.match(r"(i\.|thumbs\d\.|v\d\.)?(redgifs|gifdeliverynetwork)", sanitised_url):
|
||||||
return Redgifs
|
return Redgifs
|
||||||
|
|||||||
@@ -20,12 +20,19 @@ class YtdlpFallback(BaseFallbackDownloader, Youtube):
|
|||||||
super(YtdlpFallback, self).__init__(post)
|
super(YtdlpFallback, self).__init__(post)
|
||||||
|
|
||||||
def find_resources(self, authenticator: Optional[SiteAuthenticator] = None) -> list[Resource]:
|
def find_resources(self, authenticator: Optional[SiteAuthenticator] = None) -> list[Resource]:
|
||||||
|
logger.debug(f"YtdlpFallback processing URL: {self.post.url}")
|
||||||
|
video_attrs = super().get_video_attributes(self.post.url)
|
||||||
|
logger.debug(f"Video attributes: {video_attrs}")
|
||||||
|
extension = video_attrs.get("ext", None)
|
||||||
|
logger.debug(f"Using extension: {extension}")
|
||||||
|
|
||||||
out = Resource(
|
out = Resource(
|
||||||
self.post,
|
self.post,
|
||||||
self.post.url,
|
self.post.url,
|
||||||
super()._download_video({}),
|
super()._download_video({}),
|
||||||
super().get_video_attributes(self.post.url)["ext"],
|
extension,
|
||||||
)
|
)
|
||||||
|
logger.debug(f"Created resource with extension: {out.extension}")
|
||||||
return [out]
|
return [out]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[DEFAULT]
|
||||||
|
client_id = U-6gk4ZCh3IeNQ
|
||||||
|
client_secret = 7CZHY6AmKweZME5s50SfDGylaPg
|
||||||
|
scopes = identity, history, read, save, mysubreddits
|
||||||
|
backup_log_count = 3
|
||||||
|
max_wait_time = 120
|
||||||
|
time_format = ISO
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[DEFAULT]
|
||||||
|
client_id = U-6gk4ZCh3IeNQ
|
||||||
|
client_secret = 7CZHY6AmKweZME5s50SfDGylaPg
|
||||||
|
scopes = identity, history, read, save, mysubreddits
|
||||||
|
backup_log_count = 3
|
||||||
|
max_wait_time = 120
|
||||||
|
time_format = ISO
|
||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,61 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
bdfr-web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: bdfr-web-interface
|
||||||
|
# Note: user directive removed - container runs as user specified by orchestrator
|
||||||
|
# For TrueNAS: Set 'Custom User' in container settings
|
||||||
|
# For docker-compose: Uncomment and set user: "UID:GID" if needed
|
||||||
|
# user: "${PUID:-1000}:${PGID:-1000}"
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
# Downloads directory - all Reddit content goes here
|
||||||
|
- ./downloads:/app/downloads
|
||||||
|
# Database persistence - SQLite databases, scheduled tasks, and BDFR config
|
||||||
|
- ./data:/app/data
|
||||||
|
environment:
|
||||||
|
# Reddit OAuth Configuration
|
||||||
|
# IMPORTANT: Set these in a .env file or directly here
|
||||||
|
- BDFR_CLIENT_ID=${BDFR_CLIENT_ID:-}
|
||||||
|
- BDFR_CLIENT_SECRET=${BDFR_CLIENT_SECRET:-}
|
||||||
|
- BDFR_REDIRECT_URI=${BDFR_REDIRECT_URI:-http://localhost:8000/auth/callback}
|
||||||
|
|
||||||
|
# Server Configuration
|
||||||
|
- HOST=${HOST:-0.0.0.0}
|
||||||
|
- PORT=${PORT:-8000}
|
||||||
|
- DEBUG=${DEBUG:-false}
|
||||||
|
|
||||||
|
# Download Configuration (using /app/downloads for simplicity)
|
||||||
|
- BDFR_DOWNLOAD_DIR=/app/downloads
|
||||||
|
- BDFR_DATA_DIR=/app/data
|
||||||
|
|
||||||
|
# BDFR Configuration - stored in mounted data directory
|
||||||
|
- BDFR_CONFIG_DIR=/app/data/bdfr-config
|
||||||
|
- APPDATA=/app/data/bdfr-config
|
||||||
|
- XDG_CONFIG_HOME=/app/data/bdfr-config
|
||||||
|
|
||||||
|
# Python Configuration
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
networks:
|
||||||
|
- bdfr-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
bdfr-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
# Named volumes configuration (optional)
|
||||||
|
# Uncomment to use named volumes instead of bind mounts
|
||||||
|
# volumes:
|
||||||
|
# downloads:
|
||||||
|
# data:
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "=== BDFR Docker Container Startup Diagnostics ==="
|
||||||
|
echo "Current user: $(id)"
|
||||||
|
echo "Current user name: $(whoami)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check Python package directory permissions
|
||||||
|
echo "Checking Python package directory permissions:"
|
||||||
|
if [ -d "/usr/local/lib/python3.11/site-packages/bdfr" ]; then
|
||||||
|
ls -la /usr/local/lib/python3.11/site-packages/bdfr/ | head -20
|
||||||
|
echo ""
|
||||||
|
echo "Checking default_config.cfg specifically:"
|
||||||
|
ls -l /usr/local/lib/python3.11/site-packages/bdfr/default_config.cfg || echo "File not found!"
|
||||||
|
echo "Can read default_config.cfg: $(test -r /usr/local/lib/python3.11/site-packages/bdfr/default_config.cfg && echo 'YES' || echo 'NO')"
|
||||||
|
else
|
||||||
|
echo "ERROR: BDFR package directory not found!"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check temp config
|
||||||
|
echo "Checking temp config:"
|
||||||
|
ls -l /tmp/bdfr_default_config.cfg 2>/dev/null || echo "Temp config not found!"
|
||||||
|
echo "Can read temp config: $(test -r /tmp/bdfr_default_config.cfg && echo 'YES' || echo 'NO')"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Create BDFR config directory if it doesn't exist
|
||||||
|
# This needs to be done at runtime because /app/data is a volume mount
|
||||||
|
echo "Creating BDFR config directory..."
|
||||||
|
mkdir -p /app/data/bdfr-config
|
||||||
|
chmod 755 /app/data/bdfr-config
|
||||||
|
|
||||||
|
echo "Checking /app/data permissions:"
|
||||||
|
ls -la /app/data/ 2>/dev/null || echo "Cannot list /app/data"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Copy default config if it doesn't exist
|
||||||
|
# Use the temp copy created during build to avoid permission issues with importlib.resources
|
||||||
|
if [ ! -f /app/data/bdfr-config/default_config.cfg ]; then
|
||||||
|
echo "Copying default config to /app/data/bdfr-config/..."
|
||||||
|
cp /tmp/bdfr_default_config.cfg /app/data/bdfr-config/default_config.cfg
|
||||||
|
chmod 644 /app/data/bdfr-config/default_config.cfg
|
||||||
|
echo "Config copied successfully"
|
||||||
|
else
|
||||||
|
echo "Config already exists in /app/data/bdfr-config/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Final config check:"
|
||||||
|
ls -l /app/data/bdfr-config/default_config.cfg 2>/dev/null || echo "Config not found in data dir!"
|
||||||
|
echo ""
|
||||||
|
echo "=== End of diagnostics ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Execute the main command
|
||||||
|
exec "$@"
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
@echo off
|
||||||
|
REM Quick start script for BDFR Web Interface with Docker (Windows)
|
||||||
|
REM This script sets up and starts the Docker environment
|
||||||
|
|
||||||
|
echo ======================================
|
||||||
|
echo BDFR Web Interface - Docker Quick Start
|
||||||
|
echo ======================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM Check if Docker is installed
|
||||||
|
docker --version >nul 2>&1
|
||||||
|
if %errorlevel% neq 0 (
|
||||||
|
echo Error: Docker is not installed
|
||||||
|
echo Please install Docker Desktop from: https://docs.docker.com/desktop/install/windows-install/
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo Docker is installed
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM Check if Docker Compose is installed
|
||||||
|
docker-compose --version >nul 2>&1
|
||||||
|
if %errorlevel% neq 0 (
|
||||||
|
docker compose version >nul 2>&1
|
||||||
|
if %errorlevel% neq 0 (
|
||||||
|
echo Error: Docker Compose is not installed
|
||||||
|
echo Please ensure Docker Desktop is properly installed
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
echo Docker Compose is installed
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM Create required directories
|
||||||
|
echo Creating required directories...
|
||||||
|
if not exist "downloads" mkdir downloads
|
||||||
|
if not exist "data" mkdir data
|
||||||
|
if not exist "config" mkdir config
|
||||||
|
if not exist "logs" mkdir logs
|
||||||
|
|
||||||
|
echo Directories created
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM Check if .env file exists
|
||||||
|
if not exist ".env" (
|
||||||
|
echo Creating .env file from template...
|
||||||
|
if exist ".env.example" (
|
||||||
|
copy .env.example .env >nul
|
||||||
|
echo .env file created
|
||||||
|
echo.
|
||||||
|
echo IMPORTANT: Edit .env file with your Reddit OAuth credentials
|
||||||
|
echo Get credentials from: https://www.reddit.com/prefs/apps
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
) else (
|
||||||
|
echo Warning: .env.example not found
|
||||||
|
)
|
||||||
|
) else (
|
||||||
|
echo .env file already exists
|
||||||
|
)
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM Build and start containers
|
||||||
|
echo Building Docker image...
|
||||||
|
echo This may take a few minutes on first run...
|
||||||
|
echo.
|
||||||
|
|
||||||
|
docker-compose --version >nul 2>&1
|
||||||
|
if %errorlevel% equ 0 (
|
||||||
|
docker-compose build
|
||||||
|
) else (
|
||||||
|
docker compose build
|
||||||
|
)
|
||||||
|
|
||||||
|
if %errorlevel% neq 0 (
|
||||||
|
echo.
|
||||||
|
echo Error: Failed to build Docker image
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Build complete!
|
||||||
|
echo.
|
||||||
|
|
||||||
|
echo Starting containers...
|
||||||
|
docker-compose --version >nul 2>&1
|
||||||
|
if %errorlevel% equ 0 (
|
||||||
|
docker-compose up -d
|
||||||
|
) else (
|
||||||
|
docker compose up -d
|
||||||
|
)
|
||||||
|
|
||||||
|
if %errorlevel% neq 0 (
|
||||||
|
echo.
|
||||||
|
echo Error: Failed to start containers
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ======================================
|
||||||
|
echo BDFR Web Interface is starting!
|
||||||
|
echo ======================================
|
||||||
|
echo.
|
||||||
|
echo Web Interface: http://localhost:8000
|
||||||
|
echo API Documentation: http://localhost:8000/docs
|
||||||
|
echo.
|
||||||
|
echo Useful commands:
|
||||||
|
echo View logs: docker-compose logs -f
|
||||||
|
echo Stop container: docker-compose down
|
||||||
|
echo Restart: docker-compose restart
|
||||||
|
echo.
|
||||||
|
echo For more information, see DOCKER.md
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM Wait a moment and check if container is running
|
||||||
|
timeout /t 3 >nul
|
||||||
|
|
||||||
|
docker ps | findstr "bdfr-web-interface" >nul 2>&1
|
||||||
|
if %errorlevel% equ 0 (
|
||||||
|
echo Container is running successfully!
|
||||||
|
) else (
|
||||||
|
echo Warning: Container may not be running. Check logs with:
|
||||||
|
echo docker-compose logs
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Quick start script for BDFR Web Interface with Docker
|
||||||
|
# This script sets up and starts the Docker environment
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "======================================"
|
||||||
|
echo "BDFR Web Interface - Docker Quick Start"
|
||||||
|
echo "======================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check if Docker is installed
|
||||||
|
if ! command -v docker &> /dev/null; then
|
||||||
|
echo "❌ Error: Docker is not installed"
|
||||||
|
echo "Please install Docker from: https://docs.docker.com/get-docker/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if Docker Compose is installed
|
||||||
|
if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then
|
||||||
|
echo "❌ Error: Docker Compose is not installed"
|
||||||
|
echo "Please install Docker Compose from: https://docs.docker.com/compose/install/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Docker is installed"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Create required directories
|
||||||
|
echo "Creating required directories..."
|
||||||
|
mkdir -p downloads data config logs
|
||||||
|
|
||||||
|
echo "✅ Directories created"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check if .env file exists
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
echo "📝 Creating .env file from template..."
|
||||||
|
if [ -f .env.example ]; then
|
||||||
|
cp .env.example .env
|
||||||
|
echo "✅ Created .env file"
|
||||||
|
echo ""
|
||||||
|
echo "⚠️ IMPORTANT: Edit .env file with your Reddit OAuth credentials"
|
||||||
|
echo " Get credentials from: https://www.reddit.com/prefs/apps"
|
||||||
|
echo ""
|
||||||
|
read -p "Press Enter to continue or Ctrl+C to exit and configure .env first..."
|
||||||
|
else
|
||||||
|
echo "⚠️ Warning: .env.example not found"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "✅ .env file already exists"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Build and start containers
|
||||||
|
echo "Building Docker image..."
|
||||||
|
echo "This may take a few minutes on first run..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if command -v docker-compose &> /dev/null; then
|
||||||
|
docker-compose build
|
||||||
|
else
|
||||||
|
docker compose build
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Build complete!"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "Starting containers..."
|
||||||
|
if command -v docker-compose &> /dev/null; then
|
||||||
|
docker-compose up -d
|
||||||
|
else
|
||||||
|
docker compose up -d
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "======================================"
|
||||||
|
echo "✅ BDFR Web Interface is starting!"
|
||||||
|
echo "======================================"
|
||||||
|
echo ""
|
||||||
|
echo "🌐 Web Interface: http://localhost:8000"
|
||||||
|
echo "📚 API Documentation: http://localhost:8000/docs"
|
||||||
|
echo ""
|
||||||
|
echo "Useful commands:"
|
||||||
|
echo " View logs: docker-compose logs -f"
|
||||||
|
echo " Stop container: docker-compose down"
|
||||||
|
echo " Restart: docker-compose restart"
|
||||||
|
echo ""
|
||||||
|
echo "For more information, see DOCKER.md"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Wait a moment and check if container is running
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
if command -v docker-compose &> /dev/null; then
|
||||||
|
if docker-compose ps | grep -q "Up"; then
|
||||||
|
echo "✅ Container is running successfully!"
|
||||||
|
else
|
||||||
|
echo "⚠️ Container may not be running. Check logs with:"
|
||||||
|
echo " docker-compose logs"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if docker compose ps | grep -q "Up"; then
|
||||||
|
echo "✅ Container is running successfully!"
|
||||||
|
else
|
||||||
|
echo "⚠️ Container may not be running. Check logs with:"
|
||||||
|
echo " docker compose logs"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# BDFR Web Interface
|
||||||
|
|
||||||
|
A complete web-based interface for the Bulk Downloader for Reddit (BDFR) with real-time progress tracking and scheduled downloads.
|
||||||
|
|
||||||
|
This tool is a frontend for the BDFR tool found here: https://github.com/Serene-Arc/bulk-downloader-for-reddit
|
||||||
|
Although I've made a few changes to allow for better repeat download persistence to avoid redownloading previously downloaded content.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### 🎯 **Three Download Modes**
|
||||||
|
- **Download**: Media files only (images, videos, gifs) from Reddit posts
|
||||||
|
- **Archive**: Post metadata (title, author, comments) in JSON/XML format
|
||||||
|
- **Clone**: Complete backup combining media files and metadata
|
||||||
|
|
||||||
|
### 📍 **Multiple Source Types**
|
||||||
|
- **Subreddit Downloads**: Download content from any subreddit with customizable filters
|
||||||
|
- **User Downloads**: Download posts from specific users (submitted, upvoted, or saved content)
|
||||||
|
|
||||||
|
### ⚡ **Real-Time Progress Tracking**
|
||||||
|
- WebSocket-based live progress updates
|
||||||
|
- Visual progress bars and status indicators
|
||||||
|
- Download queue management with cancel functionality
|
||||||
|
- Phase tracking (downloading, processing, completing)
|
||||||
|
|
||||||
|
### 🔐 **Reddit OAuth2 Authentication**
|
||||||
|
- Secure authentication with Reddit API
|
||||||
|
- Access to private content (saved/upvoted posts)
|
||||||
|
- Session management and token refresh
|
||||||
|
|
||||||
|
### ⚙️ **Advanced Options**
|
||||||
|
- **Duplicate Detection**: Avoid re-downloading existing files
|
||||||
|
- **Hard Link Creation**: Save disk space with file links
|
||||||
|
- **Scheduled Downloads**: Set up daily automatic downloads
|
||||||
|
- **Content Filtering**: Filter by score, time range, and sort order
|
||||||
|
- **Simple Check**: Fast URL-based duplicate detection
|
||||||
|
|
||||||
|
### 📊 **Management Dashboard**
|
||||||
|
- Active download monitoring
|
||||||
|
- Scheduled task management
|
||||||
|
- System status indicators
|
||||||
|
- Download history and statistics
|
||||||
|
|
||||||
|
## Quick Start with Docker Compose
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
services:
|
||||||
|
bdfr-web:
|
||||||
|
image: moderatewinguy/bdfr-web:latest
|
||||||
|
# Uncomment and set user if needed (optional for most setups)
|
||||||
|
# user: "1000:1000" # Set to your user's UID:GID (run 'id' to find yours)
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- ./downloads:/app/downloads # Reddit content storage
|
||||||
|
- ./data:/app/data # Database persistence
|
||||||
|
environment:
|
||||||
|
- BDFR_CLIENT_ID=your_reddit_client_id
|
||||||
|
- BDFR_CLIENT_SECRET=your_reddit_client_secret
|
||||||
|
- BDFR_REDIRECT_URI=http://localhost:8000/auth/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
Run with:
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Access at: http://localhost:8000
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### User Permissions
|
||||||
|
|
||||||
|
The container is designed to run as any user, allowing it to match your system's permissions.
|
||||||
|
|
||||||
|
**For TrueNAS/NAS Systems:**
|
||||||
|
- Use the "Custom User" setting in your container configuration
|
||||||
|
- Set it to the UID of the user that owns your mounted shares
|
||||||
|
- The container will automatically run as that user
|
||||||
|
|
||||||
|
**For Docker Compose:**
|
||||||
|
If needed, uncomment the `user:` line in docker-compose.yml:
|
||||||
|
```yaml
|
||||||
|
user: "1000:1000" # Set to your user's UID:GID
|
||||||
|
```
|
||||||
|
|
||||||
|
Find your UID/GID with: `id` (Linux/Mac) or `wsl id` (Windows WSL)
|
||||||
|
|
||||||
|
**The container will work with any UID/GID** - just ensure the mounted volumes are owned by the same user.
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description | Required |
|
||||||
|
|----------|-------------|----------|
|
||||||
|
| `BDFR_CLIENT_ID` | Reddit OAuth2 client ID | Yes |
|
||||||
|
| `BDFR_CLIENT_SECRET` | Reddit OAuth2 client secret | Yes |
|
||||||
|
| `BDFR_REDIRECT_URI` | OAuth2 redirect URI | Yes |
|
||||||
|
| `HOST` | Server host (default: 0.0.0.0) | No |
|
||||||
|
| `PORT` | Server port (default: 8000) | No |
|
||||||
|
| `DEBUG` | Enable debug mode | No |
|
||||||
|
|
||||||
|
## Volume Mounts
|
||||||
|
|
||||||
|
- **`/app/downloads`**: All Reddit content is stored here
|
||||||
|
- **`/app/data`**: SQLite databases for scheduled tasks and application data
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
- `GET /` - Web interface
|
||||||
|
- `POST /api/download/subreddit` - Start subreddit download
|
||||||
|
- `POST /api/download/user` - Start user download
|
||||||
|
- `GET /api/downloads` - List active downloads
|
||||||
|
- `DELETE /api/downloads/{id}` - Cancel download
|
||||||
|
- `GET /health` - Health check
|
||||||
|
- `WS /ws/progress` - Real-time progress updates
|
||||||
|
|
||||||
|
## Getting Reddit OAuth Credentials
|
||||||
|
|
||||||
|
1. Go to https://www.reddit.com/prefs/apps
|
||||||
|
2. Click "Create App" or "Create Another App"
|
||||||
|
3. Select "web app" and fill in the details
|
||||||
|
4. Set redirect URI to: `http://localhost:8000/auth/callback`
|
||||||
|
5. Copy the client ID and client secret to your `.env` file
|
||||||
|
|
||||||
|
## Use Cases
|
||||||
|
|
||||||
|
- **Content Archiving**: Save Reddit posts and media for offline access
|
||||||
|
- **Research Data Collection**: Gather post metadata for analysis
|
||||||
|
- **Media Backup**: Download images/videos from favorite subreddits
|
||||||
|
- **Automated Downloads**: Schedule daily content collection
|
||||||
|
- **Personal Archive**: Backup your own posts and saved content
|
||||||
|
|
||||||
|
## Supported Content Sources
|
||||||
|
|
||||||
|
- Direct image/video links
|
||||||
|
- Imgur albums and images
|
||||||
|
- Reddit native media (images, videos, gifs)
|
||||||
|
- YouTube videos
|
||||||
|
- Gfycat animations
|
||||||
|
- And many more via yt-dlp fallback
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
- **Base Image**: Python 3.11-slim
|
||||||
|
- **Web Framework**: FastAPI with WebSocket support
|
||||||
|
- **Frontend**: Vanilla JavaScript with modern CSS
|
||||||
|
- **Database**: SQLite for task scheduling
|
||||||
|
- **Authentication**: OAuth2 with Reddit API
|
||||||
|
- **File Processing**: Multi-threaded downloads with progress tracking
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Note**: This image includes the complete BDFR tool, so you can also run CLI commands directly:
|
||||||
|
```bash
|
||||||
|
docker exec bdfr-web bdfr download /app/downloads --subreddit python -L 50
|
||||||
|
```
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test script to verify that the duplicate folder creation fix works correctly.
|
||||||
|
This script simulates the scenario where duplicate posts would previously create empty folders.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
# Add the bdfr module to the path
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
sys.path.insert(0, ".")
|
||||||
|
|
||||||
|
from bdfr.configuration import Configuration
|
||||||
|
from bdfr.connector import RedditConnector
|
||||||
|
from bdfr.downloader import RedditDownloader
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_folder_creation_fix():
|
||||||
|
"""Test that folders are not created for duplicate posts when no_dupes is enabled."""
|
||||||
|
|
||||||
|
# Create a temporary directory for testing
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
temp_path = Path(temp_dir)
|
||||||
|
|
||||||
|
# Create test configuration
|
||||||
|
args = Configuration()
|
||||||
|
args.no_dupes = True
|
||||||
|
args.folder_scheme = ""
|
||||||
|
args.file_scheme = "{POSTID}"
|
||||||
|
|
||||||
|
# Create downloader instance
|
||||||
|
downloader = RedditDownloader(args)
|
||||||
|
downloader.download_directory = temp_path
|
||||||
|
downloader.file_name_formatter = RedditConnector.create_file_name_formatter(downloader)
|
||||||
|
|
||||||
|
# Mock a submission
|
||||||
|
submission = MagicMock()
|
||||||
|
submission.id = "test123"
|
||||||
|
submission.subreddit.display_name = "testsubreddit"
|
||||||
|
submission.author.name = "testuser"
|
||||||
|
submission.score = 100
|
||||||
|
submission.upvote_ratio = 0.8
|
||||||
|
submission.created_utc = 1640995200 # Jan 1, 2022
|
||||||
|
submission.url = "https://example.com/image.jpg"
|
||||||
|
submission.title = "Test Post"
|
||||||
|
|
||||||
|
# Mock the downloader chain
|
||||||
|
mock_downloader_class = MagicMock()
|
||||||
|
mock_downloader_class.__name__ = "MockDownloader"
|
||||||
|
|
||||||
|
mock_downloader = MagicMock()
|
||||||
|
mock_resource = MagicMock()
|
||||||
|
mock_resource.url = "https://example.com/image.jpg"
|
||||||
|
mock_resource.extension = "jpg"
|
||||||
|
mock_resource.hash.hexdigest.return_value = "duplicate_hash_12345"
|
||||||
|
mock_resource.content = b"fake image content"
|
||||||
|
|
||||||
|
mock_downloader.find_resources.return_value = [mock_resource]
|
||||||
|
|
||||||
|
# Set up the master hash list to contain our "duplicate" hash
|
||||||
|
test_hash = "duplicate_hash_12345"
|
||||||
|
existing_file = temp_path / "existing_file.jpg"
|
||||||
|
existing_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
existing_file.touch()
|
||||||
|
downloader.master_hash_list = {test_hash: existing_file}
|
||||||
|
|
||||||
|
# Mock the download factory
|
||||||
|
import bdfr.site_downloaders.download_factory as df
|
||||||
|
|
||||||
|
original_pull_lever = df.DownloadFactory.pull_lever
|
||||||
|
df.DownloadFactory.pull_lever = MagicMock(return_value=mock_downloader_class)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Call the download submission method
|
||||||
|
downloader._download_submission(submission)
|
||||||
|
|
||||||
|
# Check that no new directories were created (the fix)
|
||||||
|
subdirs = [d for d in temp_path.rglob("*") if d.is_dir() and d != temp_path]
|
||||||
|
print(f"Number of subdirectories created: {len(subdirs)}")
|
||||||
|
|
||||||
|
# With the fix, no new directories should be created for duplicates
|
||||||
|
# The only directory that might exist is the one we created for the existing file
|
||||||
|
assert len(subdirs) <= 1, f"Expected 0 or 1 subdirectories, but found {len(subdirs)}"
|
||||||
|
|
||||||
|
print("Test passed: No empty folders created for duplicate posts!")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Restore original function
|
||||||
|
df.DownloadFactory.pull_lever = original_pull_lever
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_duplicate_folder_creation_fix()
|
||||||
|
print("All tests passed! The duplicate folder creation fix is working correctly.")
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test extension case normalization functionality
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from bdfr.resource import Resource
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtensionNormalization:
|
||||||
|
"""Test that extensions are properly normalized to lowercase"""
|
||||||
|
|
||||||
|
def test_url_extensions_normalized(self):
|
||||||
|
"""Test that extensions from URLs are normalized to lowercase"""
|
||||||
|
test_cases = [
|
||||||
|
("https://example.com/image.JPG", ".jpg"),
|
||||||
|
("https://example.com/image.jpeg", ".jpeg"),
|
||||||
|
("https://example.com/image.JPEG", ".jpeg"),
|
||||||
|
("https://example.com/image.jpg", ".jpg"),
|
||||||
|
("https://example.com/image.PNG", ".png"),
|
||||||
|
("https://example.com/image.GIF", ".gif"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for url, expected in test_cases:
|
||||||
|
mock_submission = MagicMock()
|
||||||
|
mock_submission.id = "test123"
|
||||||
|
|
||||||
|
resource = Resource(mock_submission, url, lambda: None)
|
||||||
|
assert resource.extension == expected, f"URL {url} should normalize to {expected}, got {resource.extension}"
|
||||||
|
|
||||||
|
def test_reddit_media_urls_normalized(self):
|
||||||
|
"""Test that Reddit media URLs are properly normalized"""
|
||||||
|
test_cases = [
|
||||||
|
("https://www.reddit.com/media?url=https%3A%2F%2Fi.redd.it%2Fr2mv10i4vkfd1.JPG", ".jpg"),
|
||||||
|
("https://www.reddit.com/media?url=https%3A%2F%2Fi.redd.it%2Fr2mv10i4vkfd1.jpeg", ".jpeg"),
|
||||||
|
("https://www.reddit.com/media?url=https%3A%2F%2Fi.redd.it%2Fr2mv10i4vkfd1.JPEG", ".jpeg"),
|
||||||
|
("https://www.reddit.com/media?url=https%3A%2F%2Fi.redd.it%2Fr2mv10i4vkfd1.PNG", ".png"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for url, expected in test_cases:
|
||||||
|
mock_submission = MagicMock()
|
||||||
|
mock_submission.id = "test123"
|
||||||
|
|
||||||
|
resource = Resource(mock_submission, url, lambda: None)
|
||||||
|
assert (
|
||||||
|
resource.extension == expected
|
||||||
|
), f"Reddit media URL {url} should normalize to {expected}, got {resource.extension}"
|
||||||
|
|
||||||
|
def test_constructor_extensions_normalized(self):
|
||||||
|
"""Test that extensions passed to constructor are normalized"""
|
||||||
|
test_cases = [
|
||||||
|
(".JPG", ".jpg"),
|
||||||
|
(".JPEG", ".jpeg"),
|
||||||
|
(".PNG", ".png"),
|
||||||
|
(".GIF", ".gif"),
|
||||||
|
# Test extensions without dots (common from yt-dlp)
|
||||||
|
("JPG", ".jpg"),
|
||||||
|
("JPEG", ".jpeg"),
|
||||||
|
("MP4", ".mp4"),
|
||||||
|
("WEBM", ".webm"),
|
||||||
|
("mp4", ".mp4"),
|
||||||
|
("gif", ".gif"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for input_ext, expected in test_cases:
|
||||||
|
mock_submission = MagicMock()
|
||||||
|
mock_submission.id = "test123"
|
||||||
|
|
||||||
|
resource = Resource(mock_submission, "https://example.com/test", lambda: None, input_ext)
|
||||||
|
assert (
|
||||||
|
resource.extension == expected
|
||||||
|
), f"Constructor extension {input_ext} should normalize to {expected}, got {resource.extension}"
|
||||||
|
|
||||||
|
def test_magic_number_detection_normalized(self):
|
||||||
|
"""Test that magic number detection returns normalized extensions"""
|
||||||
|
mock_submission = MagicMock()
|
||||||
|
mock_submission.id = "test123"
|
||||||
|
|
||||||
|
# Test JPEG magic number detection
|
||||||
|
jpeg_content = b"\xff\xd8\xff" + b"0" * 100 # JPEG magic number
|
||||||
|
resource = Resource(mock_submission, "https://example.com/no-extension", lambda params: jpeg_content)
|
||||||
|
resource.download() # Trigger content-based detection
|
||||||
|
assert resource.extension == ".jpg", f"Magic number detection should return .jpg, got {resource.extension}"
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test script to debug file extension detection issues
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from bdfr.resource import Resource
|
||||||
|
|
||||||
|
|
||||||
|
def test_extension_detection():
|
||||||
|
"""Test extension detection with various URL patterns"""
|
||||||
|
|
||||||
|
test_cases = [
|
||||||
|
# Standard URLs with extensions
|
||||||
|
("https://example.com/image.jpg", ".jpg"),
|
||||||
|
("https://example.com/video.mp4", ".mp4"),
|
||||||
|
("https://files.example.com/document.pdf", ".pdf"),
|
||||||
|
# URLs without extensions
|
||||||
|
("https://example.com/api/data", None),
|
||||||
|
("https://example.com/path/without/extension", None),
|
||||||
|
# URLs with query parameters
|
||||||
|
("https://example.com/image.jpg?size=large", ".jpg"),
|
||||||
|
("https://example.com/video.mp4?utm_source=test", ".mp4"),
|
||||||
|
# URLs with fragments
|
||||||
|
("https://example.com/image.png#section", ".png"),
|
||||||
|
# Complex paths
|
||||||
|
("https://imgur.com/a/gallery123", None),
|
||||||
|
("https://reddit.com/r/test/abc123_def456_789", None),
|
||||||
|
# Edge cases that might cause weird names
|
||||||
|
("https://example.com/L7SW9E~G", None),
|
||||||
|
("https://example.com/temp/file", None),
|
||||||
|
# Reddit media URLs (the actual issue)
|
||||||
|
("https://www.reddit.com/media?url=https%3A%2F%2Fi.redd.it%2Fr2mv10i4vkfd1.jpeg", ".jpeg"),
|
||||||
|
("https://i.redd.it/r2mv10i4vkfd1.jpeg", ".jpeg"),
|
||||||
|
]
|
||||||
|
|
||||||
|
print("Testing extension detection with various URLs:")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
for url, expected in test_cases:
|
||||||
|
# Create a mock submission
|
||||||
|
mock_submission = MagicMock()
|
||||||
|
mock_submission.id = "test123"
|
||||||
|
|
||||||
|
# Create resource and test extension detection
|
||||||
|
resource = Resource(mock_submission, url, lambda: None)
|
||||||
|
|
||||||
|
print(f"URL: {url}")
|
||||||
|
print(f"Expected: {expected}")
|
||||||
|
print(f"Detected: {resource.extension}")
|
||||||
|
print(f"Match: {'YES' if resource.extension == expected else 'NO'}")
|
||||||
|
print("-" * 40)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_extension_detection()
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test script to verify that the file locking issue is fixed.
|
||||||
|
This script simulates the scenario where a download fails and then tries to redownload.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Add the bdfr module to the path
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
|
from bdfr.api import BDFRManager, DownloadType, LoggingCallback, ProgressEvent
|
||||||
|
|
||||||
|
|
||||||
|
class TestProgressCallback(LoggingCallback):
|
||||||
|
"""Test callback that simulates a failure"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__("test_logger")
|
||||||
|
self.events = []
|
||||||
|
|
||||||
|
async def on_progress(self, event: ProgressEvent):
|
||||||
|
self.events.append(event)
|
||||||
|
await super().on_progress(event)
|
||||||
|
|
||||||
|
async def on_error(self, event: ProgressEvent):
|
||||||
|
self.events.append(event)
|
||||||
|
await super().on_error(event)
|
||||||
|
|
||||||
|
async def on_completed(self, event: ProgressEvent):
|
||||||
|
self.events.append(event)
|
||||||
|
await super().on_completed(event)
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_locking_fix():
|
||||||
|
"""Test that the file locking issue is resolved"""
|
||||||
|
print("Testing file locking fix...")
|
||||||
|
|
||||||
|
# Create a temporary directory for testing
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
temp_path = Path(temp_dir)
|
||||||
|
print(f"Using temporary directory: {temp_path}")
|
||||||
|
|
||||||
|
# Create BDFR manager
|
||||||
|
manager = BDFRManager(temp_path)
|
||||||
|
|
||||||
|
# Test 1: Create a download that will fail
|
||||||
|
print("\n1. Creating first download (will fail)...")
|
||||||
|
download_id1 = manager.create_download(
|
||||||
|
DownloadType.USER,
|
||||||
|
"test_user_12345", # This user doesn't exist, should fail
|
||||||
|
progress_callbacks=[TestProgressCallback()],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start the download (it should fail)
|
||||||
|
manager.start_download(download_id1)
|
||||||
|
|
||||||
|
# Wait a bit for the download to start and fail
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
status1 = manager.get_download_status(download_id1)
|
||||||
|
print(f"First download status: {status1['status'] if status1 else 'Not found'}")
|
||||||
|
|
||||||
|
# Test 2: Try to create a second download immediately after
|
||||||
|
print("\n2. Creating second download (should work without file locking error)...")
|
||||||
|
download_id2 = manager.create_download(
|
||||||
|
DownloadType.USER,
|
||||||
|
"test_user_67890", # This user also doesn't exist, should fail
|
||||||
|
progress_callbacks=[TestProgressCallback()],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start the second download
|
||||||
|
success = manager.start_download(download_id2)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print("SUCCESS: Second download started successfully (no file locking error)")
|
||||||
|
else:
|
||||||
|
print("FAILED: Failed to start second download")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Wait for second download to fail
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
status2 = manager.get_download_status(download_id2)
|
||||||
|
print(f"Second download status: {status2['status'] if status2 else 'Not found'}")
|
||||||
|
|
||||||
|
# Test 3: Check that log files are unique
|
||||||
|
print("\n3. Checking for unique log files...")
|
||||||
|
logs_dir = temp_path / "logs"
|
||||||
|
if logs_dir.exists():
|
||||||
|
log_files = list(logs_dir.glob("*.log"))
|
||||||
|
print(f"Found {len(log_files)} log files:")
|
||||||
|
for log_file in log_files:
|
||||||
|
print(f" - {log_file.name}")
|
||||||
|
# Check if file is accessible (not locked)
|
||||||
|
try:
|
||||||
|
with open(log_file, "r") as f:
|
||||||
|
content = f.read()
|
||||||
|
print(f" SUCCESS: Log file is accessible ({len(content)} characters)")
|
||||||
|
except PermissionError:
|
||||||
|
print(f" FAILED: Log file is still locked!")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
print("No logs directory found")
|
||||||
|
|
||||||
|
# Test 4: Try to create a third download to ensure cleanup worked
|
||||||
|
print("\n4. Creating third download to verify cleanup...")
|
||||||
|
download_id3 = manager.create_download(
|
||||||
|
DownloadType.USER, "test_user_cleanup", progress_callbacks=[TestProgressCallback()]
|
||||||
|
)
|
||||||
|
|
||||||
|
success3 = manager.start_download(download_id3)
|
||||||
|
if success3:
|
||||||
|
print("SUCCESS: Third download started successfully (cleanup worked)")
|
||||||
|
else:
|
||||||
|
print("FAILED: Third download failed to start")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Wait and check final status
|
||||||
|
time.sleep(2)
|
||||||
|
status3 = manager.get_download_status(download_id3)
|
||||||
|
print(f"Third download status: {status3['status'] if status3 else 'Not found'}")
|
||||||
|
|
||||||
|
print("\nSUCCESS: All tests passed! File locking issue appears to be fixed.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
success = test_file_locking_fix()
|
||||||
|
if success:
|
||||||
|
print("\nTest completed successfully!")
|
||||||
|
sys.exit(0)
|
||||||
|
else:
|
||||||
|
print("\nTest failed!")
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\nTest failed with exception: {e}")
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
sys.exit(1)
|
||||||
@@ -519,3 +519,36 @@ def test_name_submission(
|
|||||||
results = test_formatter.format_resource_paths(test_resources, Path())
|
results = test_formatter.format_resource_paths(test_resources, Path())
|
||||||
results = set([r[0].name for r in results])
|
results = set([r[0].name for r in results])
|
||||||
assert results == expected_names
|
assert results == expected_names
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("input_string", "expected"),
|
||||||
|
(
|
||||||
|
("Test 💕 emoji", "Test emoji"),
|
||||||
|
("Normal text", "Normal text"),
|
||||||
|
("Kirsty-Blue's post", "Kirsty-Blue's post"),
|
||||||
|
("Hello 😀 world 🌍", "Hello world "),
|
||||||
|
("No emoji here", "No emoji here"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_strip_unicode_chars(input_string: str, expected: str):
|
||||||
|
result = FileNameFormatter._strip_unicode_chars(input_string)
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_unicode_stripping_enabled(submission: MagicMock):
|
||||||
|
"""Test that Unicode stripping is applied when enabled"""
|
||||||
|
submission.title = "Test 💕 emoji"
|
||||||
|
formatter = FileNameFormatter("{TITLE}", "", "", strip_unicode=True)
|
||||||
|
result = formatter._format_name(submission, "{TITLE}")
|
||||||
|
assert "💕" not in result
|
||||||
|
assert result == "Test emoji"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unicode_stripping_disabled(submission: MagicMock):
|
||||||
|
"""Test that Unicode stripping is not applied when disabled"""
|
||||||
|
submission.title = "Test 💕 emoji"
|
||||||
|
formatter = FileNameFormatter("{TITLE}", "", "", strip_unicode=False)
|
||||||
|
result = formatter._format_name(submission, "{TITLE}")
|
||||||
|
assert "💕" in result
|
||||||
|
assert result == "Test 💕 emoji"
|
||||||
|
|||||||
@@ -3,14 +3,15 @@
|
|||||||
Test script to verify hash persistence functionality.
|
Test script to verify hash persistence functionality.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import tempfile
|
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import Mock
|
|
||||||
|
|
||||||
# Import the necessary modules
|
# Import the necessary modules
|
||||||
import sys
|
import sys
|
||||||
sys.path.insert(0, '/Users/Daniel/Documents/GitHub/bulk-downloader-for-reddit')
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
sys.path.insert(0, "/Users/Daniel/Documents/GitHub/bulk-downloader-for-reddit")
|
||||||
|
|
||||||
from bdfr.configuration import Configuration
|
from bdfr.configuration import Configuration
|
||||||
from bdfr.downloader import RedditDownloader
|
from bdfr.downloader import RedditDownloader
|
||||||
@@ -59,7 +60,7 @@ def test_hash_persistence():
|
|||||||
# Test 2: Save empty hash list
|
# Test 2: Save empty hash list
|
||||||
print("Test 2: Saving empty hash list")
|
print("Test 2: Saving empty hash list")
|
||||||
downloader._save_hash_list()
|
downloader._save_hash_list()
|
||||||
hash_file = temp_path / '.bdfr_hashes.json'
|
hash_file = temp_path / ".bdfr_hashes.json"
|
||||||
assert hash_file.exists(), "Hash file should be created even when empty"
|
assert hash_file.exists(), "Hash file should be created even when empty"
|
||||||
print("PASS Passed")
|
print("PASS Passed")
|
||||||
|
|
||||||
@@ -71,18 +72,18 @@ def test_hash_persistence():
|
|||||||
|
|
||||||
# Test 4: Add some test data and save
|
# Test 4: Add some test data and save
|
||||||
print("Test 4: Adding test data and saving")
|
print("Test 4: Adding test data and saving")
|
||||||
test_file = temp_path / 'test.txt'
|
test_file = temp_path / "test.txt"
|
||||||
test_file.write_text("test content")
|
test_file.write_text("test content")
|
||||||
downloader.master_hash_list['test_hash_123'] = test_file
|
downloader.master_hash_list["test_hash_123"] = test_file
|
||||||
|
|
||||||
downloader._save_hash_list()
|
downloader._save_hash_list()
|
||||||
|
|
||||||
# Verify the saved JSON structure
|
# Verify the saved JSON structure
|
||||||
with open(hash_file, 'r') as f:
|
with open(hash_file, "r") as f:
|
||||||
saved_data = json.load(f)
|
saved_data = json.load(f)
|
||||||
|
|
||||||
assert 'test_hash_123' in saved_data, "Test hash should be in saved data"
|
assert "test_hash_123" in saved_data, "Test hash should be in saved data"
|
||||||
assert saved_data['test_hash_123'] == 'test.txt', f"Expected 'test.txt', got {saved_data['test_hash_123']}"
|
assert saved_data["test_hash_123"] == "test.txt", f"Expected 'test.txt', got {saved_data['test_hash_123']}"
|
||||||
print("PASS Passed")
|
print("PASS Passed")
|
||||||
|
|
||||||
# Test 5: Load hash list and verify data is restored
|
# Test 5: Load hash list and verify data is restored
|
||||||
@@ -100,13 +101,15 @@ def test_hash_persistence():
|
|||||||
|
|
||||||
loaded_hash_list = new_downloader._load_hash_list()
|
loaded_hash_list = new_downloader._load_hash_list()
|
||||||
assert len(loaded_hash_list) == 1, f"Expected 1 hash, got {len(loaded_hash_list)}"
|
assert len(loaded_hash_list) == 1, f"Expected 1 hash, got {len(loaded_hash_list)}"
|
||||||
assert 'test_hash_123' in loaded_hash_list, "Test hash should be loaded"
|
assert "test_hash_123" in loaded_hash_list, "Test hash should be loaded"
|
||||||
assert loaded_hash_list['test_hash_123'] == test_file, f"File path should match: {loaded_hash_list['test_hash_123']} != {test_file}"
|
assert (
|
||||||
|
loaded_hash_list["test_hash_123"] == test_file
|
||||||
|
), f"File path should match: {loaded_hash_list['test_hash_123']} != {test_file}"
|
||||||
print("PASS Passed")
|
print("PASS Passed")
|
||||||
|
|
||||||
# Test 6: Test corrupted hash file handling
|
# Test 6: Test corrupted hash file handling
|
||||||
print("Test 6: Testing corrupted hash file handling")
|
print("Test 6: Testing corrupted hash file handling")
|
||||||
with open(hash_file, 'w') as f:
|
with open(hash_file, "w") as f:
|
||||||
f.write("invalid json content")
|
f.write("invalid json content")
|
||||||
|
|
||||||
corrupted_downloader = RedditDownloader.__new__(RedditDownloader)
|
corrupted_downloader = RedditDownloader.__new__(RedditDownloader)
|
||||||
@@ -122,7 +125,9 @@ def test_hash_persistence():
|
|||||||
|
|
||||||
# Should handle corrupted file gracefully and return empty dict
|
# Should handle corrupted file gracefully and return empty dict
|
||||||
corrupted_hash_list = corrupted_downloader._load_hash_list()
|
corrupted_hash_list = corrupted_downloader._load_hash_list()
|
||||||
assert len(corrupted_hash_list) == 0, f"Expected empty hash list for corrupted file, got {len(corrupted_hash_list)}"
|
assert (
|
||||||
|
len(corrupted_hash_list) == 0
|
||||||
|
), f"Expected empty hash list for corrupted file, got {len(corrupted_hash_list)}"
|
||||||
print("PASS Passed")
|
print("PASS Passed")
|
||||||
|
|
||||||
print("\nAll tests passed! Hash persistence functionality is working correctly.")
|
print("\nAll tests passed! Hash persistence functionality is working correctly.")
|
||||||
@@ -174,7 +179,7 @@ def test_simple_check_functionality():
|
|||||||
|
|
||||||
# Test 2: Add test data and save with simple_check format
|
# Test 2: Add test data and save with simple_check format
|
||||||
print("Test 2: Adding test data and saving with simple_check format")
|
print("Test 2: Adding test data and saving with simple_check format")
|
||||||
test_file = temp_path / 'test.txt'
|
test_file = temp_path / "test.txt"
|
||||||
test_file.write_text("test content")
|
test_file.write_text("test content")
|
||||||
test_url = "https://example.com/test.txt"
|
test_url = "https://example.com/test.txt"
|
||||||
test_hash = "test_hash_123"
|
test_hash = "test_hash_123"
|
||||||
@@ -185,16 +190,16 @@ def test_simple_check_functionality():
|
|||||||
downloader._save_hash_list()
|
downloader._save_hash_list()
|
||||||
|
|
||||||
# Verify the saved JSON structure has enhanced format
|
# Verify the saved JSON structure has enhanced format
|
||||||
with open(temp_path / '.bdfr_hashes.json', 'r') as f:
|
with open(temp_path / ".bdfr_hashes.json", "r") as f:
|
||||||
saved_data = json.load(f)
|
saved_data = json.load(f)
|
||||||
|
|
||||||
assert 'files' in saved_data, "Enhanced format should have 'files' section"
|
assert "files" in saved_data, "Enhanced format should have 'files' section"
|
||||||
assert 'urls' in saved_data, "Enhanced format should have 'urls' section"
|
assert "urls" in saved_data, "Enhanced format should have 'urls' section"
|
||||||
assert 'metadata' in saved_data, "Enhanced format should have 'metadata' section"
|
assert "metadata" in saved_data, "Enhanced format should have 'metadata' section"
|
||||||
assert test_hash in saved_data['files'], "Test hash should be in files section"
|
assert test_hash in saved_data["files"], "Test hash should be in files section"
|
||||||
assert test_url in saved_data['urls'], "Test URL should be in urls section"
|
assert test_url in saved_data["urls"], "Test URL should be in urls section"
|
||||||
assert saved_data['metadata']['version'] == '2.0', "Version should be 2.0"
|
assert saved_data["metadata"]["version"] == "2.0", "Version should be 2.0"
|
||||||
assert saved_data['metadata']['created_with'] == 'simple_check', "Should be created with simple_check"
|
assert saved_data["metadata"]["created_with"] == "simple_check", "Should be created with simple_check"
|
||||||
print("PASS")
|
print("PASS")
|
||||||
|
|
||||||
# Test 3: Load hash list and verify URL mapping is restored
|
# Test 3: Load hash list and verify URL mapping is restored
|
||||||
@@ -228,7 +233,7 @@ def test_simple_check_functionality():
|
|||||||
mock_resource.hash.hexdigest.return_value = test_hash
|
mock_resource.hash.hexdigest.return_value = test_hash
|
||||||
|
|
||||||
# Create a mock destination that exists
|
# Create a mock destination that exists
|
||||||
mock_destination = temp_path / 'existing_file.txt'
|
mock_destination = temp_path / "existing_file.txt"
|
||||||
mock_destination.parent.mkdir(parents=True, exist_ok=True)
|
mock_destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
mock_destination.write_text("existing content")
|
mock_destination.write_text("existing content")
|
||||||
|
|
||||||
@@ -260,17 +265,14 @@ def test_backward_compatibility():
|
|||||||
temp_path = Path(temp_dir)
|
temp_path = Path(temp_dir)
|
||||||
|
|
||||||
# Create old-format hash file manually
|
# Create old-format hash file manually
|
||||||
(temp_path / 'relative' / 'path').mkdir(parents=True, exist_ok=True)
|
(temp_path / "relative" / "path").mkdir(parents=True, exist_ok=True)
|
||||||
(temp_path / 'relative' / 'path' / 'file1.txt').write_text("content1")
|
(temp_path / "relative" / "path" / "file1.txt").write_text("content1")
|
||||||
(temp_path / 'relative' / 'path' / 'file2.txt').write_text("content2")
|
(temp_path / "relative" / "path" / "file2.txt").write_text("content2")
|
||||||
|
|
||||||
old_hash_data = {
|
old_hash_data = {"hash1": "relative/path/file1.txt", "hash2": "relative/path/file2.txt"}
|
||||||
"hash1": "relative/path/file1.txt",
|
|
||||||
"hash2": "relative/path/file2.txt"
|
|
||||||
}
|
|
||||||
|
|
||||||
hash_file = temp_path / '.bdfr_hashes.json'
|
hash_file = temp_path / ".bdfr_hashes.json"
|
||||||
with open(hash_file, 'w') as f:
|
with open(hash_file, "w") as f:
|
||||||
json.dump(old_hash_data, f)
|
json.dump(old_hash_data, f)
|
||||||
|
|
||||||
# Create downloader and load old format
|
# Create downloader and load old format
|
||||||
@@ -294,21 +296,21 @@ def test_backward_compatibility():
|
|||||||
print("PASS - Old format loaded correctly")
|
print("PASS - Old format loaded correctly")
|
||||||
|
|
||||||
# Test saving in new format
|
# Test saving in new format
|
||||||
(temp_path / 'another').mkdir(parents=True, exist_ok=True)
|
(temp_path / "another").mkdir(parents=True, exist_ok=True)
|
||||||
test_file = temp_path / 'another' / 'new_file.txt'
|
test_file = temp_path / "another" / "new_file.txt"
|
||||||
test_file.write_text("new content")
|
test_file.write_text("new content")
|
||||||
downloader.master_hash_list["new_hash"] = test_file
|
downloader.master_hash_list["new_hash"] = test_file
|
||||||
|
|
||||||
downloader._save_hash_list()
|
downloader._save_hash_list()
|
||||||
|
|
||||||
# Verify new format was created
|
# Verify new format was created
|
||||||
with open(hash_file, 'r') as f:
|
with open(hash_file, "r") as f:
|
||||||
new_data = json.load(f)
|
new_data = json.load(f)
|
||||||
|
|
||||||
assert 'files' in new_data, "New format should have 'files' section"
|
assert "files" in new_data, "New format should have 'files' section"
|
||||||
assert 'urls' in new_data, "New format should have 'urls' section"
|
assert "urls" in new_data, "New format should have 'urls' section"
|
||||||
assert 'metadata' in new_data, "New format should have 'metadata' section"
|
assert "metadata" in new_data, "New format should have 'metadata' section"
|
||||||
assert new_data['metadata']['version'] == '2.0', "Should be version 2.0"
|
assert new_data["metadata"]["version"] == "2.0", "Should be version 2.0"
|
||||||
|
|
||||||
print("PASS - Old format upgraded to new format correctly")
|
print("PASS - Old format upgraded to new format correctly")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
"""
|
||||||
|
Test script to verify user folder structure changes.
|
||||||
|
|
||||||
|
This script tests that:
|
||||||
|
1. Subreddit downloads go to: downloads/subreddit_name/files
|
||||||
|
2. User downloads go to: downloads/username/subreddit_name/files
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Set UTF-8 encoding for Windows console
|
||||||
|
if sys.platform == "win32":
|
||||||
|
import codecs
|
||||||
|
|
||||||
|
sys.stdout = codecs.getwriter("utf-8")(sys.stdout.buffer, "strict")
|
||||||
|
sys.stderr = codecs.getwriter("utf-8")(sys.stderr.buffer, "strict")
|
||||||
|
|
||||||
|
from bdfr.api import BDFRManager, DownloadType
|
||||||
|
from bdfr.configuration import Configuration
|
||||||
|
|
||||||
|
|
||||||
|
def test_subreddit_directory_structure():
|
||||||
|
"""Test that subreddit downloads use correct directory structure"""
|
||||||
|
print("\n=== Testing Subreddit Directory Structure ===")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
manager = BDFRManager(download_directory=tmpdir)
|
||||||
|
|
||||||
|
# Create a download for a subreddit
|
||||||
|
download_id = manager.create_download(DownloadType.SUBREDDIT, "test_subreddit")
|
||||||
|
|
||||||
|
download_info = manager.get_download_status(download_id)
|
||||||
|
config = download_info["config"]
|
||||||
|
|
||||||
|
expected_dir = str(Path(tmpdir))
|
||||||
|
actual_dir = config.directory
|
||||||
|
|
||||||
|
print(f"Expected directory: {expected_dir}")
|
||||||
|
print(f"Actual directory: {actual_dir}")
|
||||||
|
print(f"Subreddit config: {config.subreddit}")
|
||||||
|
print(f"Folder scheme: {config.folder_scheme}")
|
||||||
|
|
||||||
|
assert actual_dir == expected_dir, f"Subreddit directory mismatch!"
|
||||||
|
print("[OK] Subreddit directory structure is correct")
|
||||||
|
print(f" Files will be saved to: {actual_dir}/{{SUBREDDIT}}/{{files}}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_directory_structure():
|
||||||
|
"""Test that user downloads use correct directory structure"""
|
||||||
|
print("\n=== Testing User Directory Structure ===")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
manager = BDFRManager(download_directory=tmpdir)
|
||||||
|
|
||||||
|
# Create a download for a user
|
||||||
|
username = "test_user"
|
||||||
|
download_id = manager.create_download(DownloadType.USER, username)
|
||||||
|
|
||||||
|
download_info = manager.get_download_status(download_id)
|
||||||
|
config = download_info["config"]
|
||||||
|
|
||||||
|
expected_dir = str(Path(tmpdir) / username)
|
||||||
|
actual_dir = config.directory
|
||||||
|
|
||||||
|
print(f"Expected directory: {expected_dir}")
|
||||||
|
print(f"Actual directory: {actual_dir}")
|
||||||
|
print(f"User config: {config.user}")
|
||||||
|
print(f"Folder scheme: {config.folder_scheme}")
|
||||||
|
|
||||||
|
assert actual_dir == expected_dir, f"User directory mismatch!"
|
||||||
|
print("[OK] User directory structure is correct")
|
||||||
|
print(f" Files will be saved to: {actual_dir}/{{SUBREDDIT}}/{{files}}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_convenience_method():
|
||||||
|
"""Test the convenience method download_user()"""
|
||||||
|
print("\n=== Testing download_user() Convenience Method ===")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
manager = BDFRManager(download_directory=tmpdir)
|
||||||
|
|
||||||
|
# Don't actually start the download, just check the config
|
||||||
|
username = "convenience_test_user"
|
||||||
|
|
||||||
|
# Create config manually like the convenience method does
|
||||||
|
config = Configuration()
|
||||||
|
user_directory = Path(tmpdir) / username
|
||||||
|
config.directory = str(user_directory)
|
||||||
|
config.user = [username]
|
||||||
|
|
||||||
|
expected_dir = str(Path(tmpdir) / username)
|
||||||
|
actual_dir = config.directory
|
||||||
|
|
||||||
|
print(f"Expected directory: {expected_dir}")
|
||||||
|
print(f"Actual directory: {actual_dir}")
|
||||||
|
print(f"User config: {config.user}")
|
||||||
|
|
||||||
|
assert actual_dir == expected_dir, f"Convenience method directory mismatch!"
|
||||||
|
print("[OK] Convenience method directory structure is correct")
|
||||||
|
|
||||||
|
|
||||||
|
def demonstrate_folder_structure():
|
||||||
|
"""Demonstrate the folder structure for both download types"""
|
||||||
|
print("\n=== Folder Structure Demonstration ===")
|
||||||
|
print("\nWhen downloading from a SUBREDDIT 'python':")
|
||||||
|
print(" downloads/")
|
||||||
|
print(" └── python/")
|
||||||
|
print(" ├── file1.jpg")
|
||||||
|
print(" ├── file2.png")
|
||||||
|
print(" └── file3.mp4")
|
||||||
|
|
||||||
|
print("\nWhen downloading from a USER 'spez' who posts to multiple subreddits:")
|
||||||
|
print(" downloads/")
|
||||||
|
print(" └── spez/")
|
||||||
|
print(" ├── python/")
|
||||||
|
print(" │ ├── file1.jpg")
|
||||||
|
print(" │ └── file2.png")
|
||||||
|
print(" ├── announcements/")
|
||||||
|
print(" │ └── file3.jpg")
|
||||||
|
print(" └── pics/")
|
||||||
|
print(" └── file4.png")
|
||||||
|
|
||||||
|
print("\n[OK] This structure allows:")
|
||||||
|
print(" 1. Easy identification of user-specific downloads")
|
||||||
|
print(" 2. Organization by subreddit within each user folder")
|
||||||
|
print(" 3. No conflicts between subreddit and user downloads")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("=" * 60)
|
||||||
|
print("Testing User Folder Structure Changes")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
test_subreddit_directory_structure()
|
||||||
|
test_user_directory_structure()
|
||||||
|
test_convenience_method()
|
||||||
|
demonstrate_folder_structure()
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("[SUCCESS] All tests passed!")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
except AssertionError as e:
|
||||||
|
print(f"\n[FAIL] Test failed: {e}")
|
||||||
|
exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n[ERROR] Unexpected error: {e}")
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
exit(1)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
env
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
.tox
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.pytest_cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.log
|
||||||
|
.git
|
||||||
|
.mypy_cache
|
||||||
|
.pytest_cache
|
||||||
|
.hypothesis
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy requirements first for better caching
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY app/ ./app/
|
||||||
|
COPY templates/ ./templates/
|
||||||
|
COPY static/ ./static/
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN useradd --create-home --shell /bin/bash app \
|
||||||
|
&& chown -R app:app /app
|
||||||
|
USER app
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8000/health || exit 1
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
CMD ["python", "/app/app/main.py"]
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
# BDFR Web Interface
|
||||||
|
|
||||||
|
A modern web interface for the Bulk Downloader for Reddit (BDFR) built with FastAPI, WebSockets, and vanilla JavaScript.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Modern UI**: Clean, responsive design with gradient backgrounds and smooth animations
|
||||||
|
- **Real-time Progress**: WebSocket-based progress updates for active downloads
|
||||||
|
- **Subreddit Downloads**: Download posts from any subreddit with customizable limits and sorting
|
||||||
|
- **User Downloads**: Download posts from specific users
|
||||||
|
- **Status Monitoring**: Real-time system status and connection monitoring
|
||||||
|
- **Form Validation**: Client-side validation with visual feedback
|
||||||
|
- **Error Handling**: Comprehensive error handling with user-friendly notifications
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
web_interface/
|
||||||
|
├── app/
|
||||||
|
│ └── main.py # FastAPI application
|
||||||
|
├── static/
|
||||||
|
│ ├── css/
|
||||||
|
│ │ └── style.css # Modern CSS styling
|
||||||
|
│ └── js/
|
||||||
|
│ └── app.js # WebSocket client and form handling
|
||||||
|
├── templates/
|
||||||
|
│ └── index.html # Main web interface
|
||||||
|
└── requirements.txt # Python dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. **Install Dependencies**:
|
||||||
|
```bash
|
||||||
|
cd web_interface
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Run the Application**:
|
||||||
|
```bash
|
||||||
|
cd app
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Access the Interface**:
|
||||||
|
Open your browser and navigate to `http://localhost:8000`
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Download Endpoints
|
||||||
|
- `POST /api/download/subreddit` - Start subreddit download
|
||||||
|
- `POST /api/download/user` - Start user download
|
||||||
|
- `GET /api/downloads` - List all active downloads
|
||||||
|
- `GET /api/downloads/{download_id}` - Get specific download status
|
||||||
|
- `DELETE /api/downloads/{download_id}` - Cancel download
|
||||||
|
|
||||||
|
### WebSocket
|
||||||
|
- `ws://localhost:8000/ws/progress` - Real-time progress updates
|
||||||
|
|
||||||
|
### Status Endpoints
|
||||||
|
- `GET /` - Main web interface
|
||||||
|
- `GET /health` - Health check
|
||||||
|
- `GET /api/bdfr/status` - BDFR system status
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The application uses the following default settings:
|
||||||
|
- **Host**: `0.0.0.0`
|
||||||
|
- **Port**: `8000`
|
||||||
|
- **WebSocket Path**: `/ws/progress`
|
||||||
|
- **Static Files**: Served from `/static`
|
||||||
|
|
||||||
|
## Docker Support
|
||||||
|
|
||||||
|
To run with Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build the image
|
||||||
|
docker build -t bdfr-web-interface .
|
||||||
|
|
||||||
|
# Run the container
|
||||||
|
docker run -p 8000:8000 bdfr-web-interface
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Adding New Features
|
||||||
|
|
||||||
|
1. **Backend Changes**: Modify `app/main.py` to add new endpoints
|
||||||
|
2. **Frontend Changes**: Update `templates/index.html` for UI changes
|
||||||
|
3. **Styling**: Modify `static/css/style.css` for visual changes
|
||||||
|
4. **JavaScript**: Update `static/js/app.js` for client-side functionality
|
||||||
|
|
||||||
|
### WebSocket Integration
|
||||||
|
|
||||||
|
The WebSocket connection automatically handles:
|
||||||
|
- Connection establishment and reconnection
|
||||||
|
- Progress updates from the server
|
||||||
|
- Error handling and user notifications
|
||||||
|
- Real-time UI updates
|
||||||
|
|
||||||
|
### Form Handling
|
||||||
|
|
||||||
|
Both download forms include:
|
||||||
|
- Input validation
|
||||||
|
- Loading states
|
||||||
|
- Success/error notifications
|
||||||
|
- Automatic form reset on success
|
||||||
|
|
||||||
|
## Integration with BDFR
|
||||||
|
|
||||||
|
### Direct BDFR API Integration
|
||||||
|
|
||||||
|
This interface uses the direct BDFR API integration, eliminating the need for subprocess console parsing:
|
||||||
|
|
||||||
|
- `/api/download/subreddit` - Downloads from subreddits using `BDFRManager.download_subreddit()`
|
||||||
|
- `/api/download/user` - Downloads from users using `BDFRManager.download_user()`
|
||||||
|
- `/api/bdfr/status` - Returns BDFR system status and capabilities
|
||||||
|
- `/ws/progress` - Provides real-time progress updates via WebSocket
|
||||||
|
|
||||||
|
The web interface imports `BDFRManager` directly from `bdfr.api` and uses structured progress callbacks for seamless integration.
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
|
||||||
|
**Previous Approach (Subprocess-based)**:
|
||||||
|
- Used `subprocess.Popen` to start BDFR CLI
|
||||||
|
- Parsed console output with regex for progress updates
|
||||||
|
- Required `BDFRRunner` class for process management
|
||||||
|
- Used `threading.Thread` and `queue.Queue` for coordination
|
||||||
|
|
||||||
|
**Current Approach (Direct API)**:
|
||||||
|
- Direct integration with `BDFRManager` from `bdfr.api`
|
||||||
|
- Structured `ProgressEvent` callbacks instead of console parsing
|
||||||
|
- Thread-safe progress tracking with `ProgressCallback` interface
|
||||||
|
- No subprocess overhead or console output parsing required
|
||||||
|
|
||||||
|
The migration provides better error handling, structured progress events, and eliminates console parsing complexity.
|
||||||
|
|
||||||
|
## Browser Support
|
||||||
|
|
||||||
|
- Modern browsers with WebSocket support
|
||||||
|
- Chrome 60+
|
||||||
|
- Firefox 55+
|
||||||
|
- Safari 11+
|
||||||
|
- Edge 79+
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- CORS is enabled for all origins (configure for production)
|
||||||
|
- Input validation on both client and server
|
||||||
|
- No authentication implemented (add as needed)
|
||||||
|
- WebSocket connections are not secured (use WSS in production)
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
For production deployment:
|
||||||
|
|
||||||
|
1. Configure CORS for specific origins
|
||||||
|
2. Add authentication/authorization
|
||||||
|
3. Use HTTPS/WSS for secure connections
|
||||||
|
4. Configure proper logging
|
||||||
|
5. Set up reverse proxy (nginx recommended)
|
||||||
|
6. Add rate limiting
|
||||||
|
7. Configure environment variables
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **WebSocket Connection Failed**:
|
||||||
|
- Check if the server is running
|
||||||
|
- Verify firewall settings
|
||||||
|
- Check browser console for errors
|
||||||
|
|
||||||
|
2. **Downloads Not Starting**:
|
||||||
|
- Verify BDFR integration is configured
|
||||||
|
- Check server logs for errors
|
||||||
|
- Ensure form data is valid
|
||||||
|
|
||||||
|
3. **Static Files Not Loading**:
|
||||||
|
- Verify static file paths
|
||||||
|
- Check file permissions
|
||||||
|
- Ensure proper MIME types
|
||||||
|
|
||||||
|
### Debug Mode
|
||||||
|
|
||||||
|
Run with debug logging:
|
||||||
|
```bash
|
||||||
|
python main.py --log-level debug
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch
|
||||||
|
3. Make your changes
|
||||||
|
4. Test thoroughly
|
||||||
|
5. Submit a pull request
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is part of the BDFR ecosystem. See the main project license for details.
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
# Scheduled Downloads Feature
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The BDFR Web Interface now supports scheduled downloads, allowing users to configure downloads that run automatically on a daily basis. This feature is designed to work seamlessly in Docker containers and ensures sequential execution to prevent system overload.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### 1. **Daily Scheduling**
|
||||||
|
- Tasks run once per day at a user-specified time
|
||||||
|
- Time is specified in the user's local timezone and automatically converted to UTC for container execution
|
||||||
|
- Automatically sets `time_filter="day"` to only download content from the last 24 hours
|
||||||
|
- Always enables `no_dupes=True` to avoid re-downloading existing content
|
||||||
|
|
||||||
|
### 2. **Sequential Execution**
|
||||||
|
- Tasks are processed one at a time through a queue system
|
||||||
|
- Manual "Run Now" tasks have priority over scheduled tasks
|
||||||
|
- Queue status is displayed in real-time
|
||||||
|
|
||||||
|
### 3. **Persistent Storage**
|
||||||
|
- SQLite database stores task configurations
|
||||||
|
- APScheduler job store ensures tasks persist across container restarts
|
||||||
|
- Execution history tracked for each task
|
||||||
|
|
||||||
|
### 4. **Full Task Management**
|
||||||
|
- Create, enable/disable, and delete scheduled tasks
|
||||||
|
- Run tasks manually on-demand
|
||||||
|
- View last run and next scheduled run times
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Backend Components
|
||||||
|
|
||||||
|
#### 1. **Database Models** (`web_interface/app/models.py`)
|
||||||
|
- `ScheduledTask`: Stores task configuration
|
||||||
|
- Fields: name, source (subreddit/user), schedule, timezone, etc.
|
||||||
|
- Automatically sets time_filter="day" and no_dupes=True
|
||||||
|
- `TaskExecutionHistory`: Tracks each execution
|
||||||
|
- Fields: task_id, status, items downloaded, errors, etc.
|
||||||
|
|
||||||
|
#### 2. **Task Queue** (`web_interface/app/task_queue.py`)
|
||||||
|
- `TaskQueue` class manages sequential execution
|
||||||
|
- Priority queue: 0=scheduled, 1=manual
|
||||||
|
- Blocks until each download completes before starting the next
|
||||||
|
- Thread-safe using asyncio
|
||||||
|
|
||||||
|
#### 3. **Scheduler Service** (`web_interface/app/scheduler.py`)
|
||||||
|
- APScheduler with SQLAlchemy job store for persistence
|
||||||
|
- Functions:
|
||||||
|
- `schedule_task()`: Creates cron job
|
||||||
|
- `queue_scheduled_task()`: Adds task to queue (called by scheduler)
|
||||||
|
- `execute_scheduled_task()`: Executes download and waits for completion
|
||||||
|
- `wait_for_download_completion()`: Polls every 5 seconds until done
|
||||||
|
|
||||||
|
#### 4. **API Endpoints** (`web_interface/app/scheduled_tasks.py`)
|
||||||
|
```
|
||||||
|
POST /api/scheduled-tasks - Create task
|
||||||
|
GET /api/scheduled-tasks - List all tasks
|
||||||
|
GET /api/scheduled-tasks/{id} - Get specific task
|
||||||
|
PUT /api/scheduled-tasks/{id} - Update task
|
||||||
|
DELETE /api/scheduled-tasks/{id} - Delete task
|
||||||
|
POST /api/scheduled-tasks/{id}/toggle - Enable/disable
|
||||||
|
POST /api/scheduled-tasks/{id}/run-now - Queue immediately
|
||||||
|
GET /api/scheduled-tasks/{id}/history - Execution history
|
||||||
|
GET /api/scheduled-tasks/queue/status - Queue status
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Components
|
||||||
|
|
||||||
|
#### 1. **HTML** (`web_interface/templates/index.html`)
|
||||||
|
- "Run Daily" checkbox in Advanced Options
|
||||||
|
- Schedule configuration fields (task name, run time)
|
||||||
|
- Scheduled Downloads section with task cards
|
||||||
|
- Queue status badge
|
||||||
|
|
||||||
|
#### 2. **JavaScript** (`web_interface/static/js/app.js`)
|
||||||
|
- `loadScheduledTasks()`: Fetches and renders tasks
|
||||||
|
- `createScheduledTask()`: Creates new scheduled task
|
||||||
|
- `toggleTask()`, `deleteTask()`, `runTaskNow()`: Task management
|
||||||
|
- `updateQueueStatus()`: Polls queue every 10 seconds
|
||||||
|
- Auto-detects browser timezone via `Intl.DateTimeFormat()`
|
||||||
|
|
||||||
|
#### 3. **CSS** (`web_interface/static/css/style.css`)
|
||||||
|
- Task card styling with hover effects
|
||||||
|
- Status badges (enabled/disabled)
|
||||||
|
- Schedule options panel
|
||||||
|
- Queue status badge
|
||||||
|
|
||||||
|
## Usage Guide
|
||||||
|
|
||||||
|
### Creating a Scheduled Download
|
||||||
|
|
||||||
|
1. **Configure Download Settings**
|
||||||
|
- Select download mode (Download/Archive/Clone)
|
||||||
|
- Choose source type (Subreddit/User)
|
||||||
|
- Enter source name
|
||||||
|
- Set limit, sort, and other options
|
||||||
|
|
||||||
|
2. **Enable Scheduling**
|
||||||
|
- Check "Run Daily" in Advanced Options
|
||||||
|
- Enter a task name (e.g., "Daily Python Posts")
|
||||||
|
- Select run time (24-hour format, in your local timezone)
|
||||||
|
|
||||||
|
3. **Submit**
|
||||||
|
- Click "Start Download" to create the scheduled task
|
||||||
|
- Task appears in the Scheduled Downloads section
|
||||||
|
- First run scheduled for the specified time
|
||||||
|
|
||||||
|
### Managing Scheduled Tasks
|
||||||
|
|
||||||
|
Each task card shows:
|
||||||
|
- Task name and source
|
||||||
|
- Download mode
|
||||||
|
- Schedule (daily at X time)
|
||||||
|
- Last run and next run times
|
||||||
|
- Status (Enabled/Disabled)
|
||||||
|
|
||||||
|
**Actions:**
|
||||||
|
- **Disable/Enable**: Toggle task on/off without deleting
|
||||||
|
- **Run Now**: Add task to queue immediately (higher priority)
|
||||||
|
- **Delete**: Remove task permanently
|
||||||
|
|
||||||
|
### Queue System
|
||||||
|
|
||||||
|
- Queue status badge shows number of tasks waiting
|
||||||
|
- Tasks execute one at a time to prevent overload
|
||||||
|
- Manual "Run Now" tasks have priority over scheduled tasks
|
||||||
|
- Download progress appears in Progress section
|
||||||
|
|
||||||
|
## Docker Deployment
|
||||||
|
|
||||||
|
### Volume Mounts Required
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- ./downloads:/downloads # Downloaded files
|
||||||
|
- ./data:/app/data # Database and job store
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Location
|
||||||
|
- SQLite: `/app/data/scheduled_tasks.db`
|
||||||
|
- APScheduler job store: Same database
|
||||||
|
|
||||||
|
### Timezone Handling
|
||||||
|
- User specifies time in their local timezone
|
||||||
|
- Frontend auto-detects timezone via JavaScript
|
||||||
|
- Backend converts to UTC for container execution
|
||||||
|
- Cron jobs run at correct local time regardless of container timezone
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### Sequential Execution Flow
|
||||||
|
|
||||||
|
1. APScheduler triggers at scheduled time
|
||||||
|
2. Scheduler calls `queue_scheduled_task(task_id)`
|
||||||
|
3. Task added to queue with priority 0
|
||||||
|
4. Queue worker picks up task
|
||||||
|
5. `execute_scheduled_task()` called
|
||||||
|
6. Downloads via existing BDFR API
|
||||||
|
7. `wait_for_download_completion()` polls every 5s
|
||||||
|
8. Once complete, queue processes next task
|
||||||
|
9. Execution history recorded
|
||||||
|
|
||||||
|
### Time Filter Logic
|
||||||
|
|
||||||
|
For scheduled tasks:
|
||||||
|
- `time_filter` is automatically set to "day"
|
||||||
|
- This filters Reddit API to only return posts from last 24 hours
|
||||||
|
- Combined with daily scheduling, ensures only new content downloaded
|
||||||
|
- Prevents re-downloading old content
|
||||||
|
|
||||||
|
### Duplicate Prevention
|
||||||
|
|
||||||
|
For scheduled tasks:
|
||||||
|
- `no_dupes` is automatically enabled
|
||||||
|
- Uses existing BDFR duplicate detection
|
||||||
|
- Checks file hashes or URLs (depending on simple_check setting)
|
||||||
|
- Skips files that already exist
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
### Basic Functionality
|
||||||
|
- [ ] Create scheduled task via "Run Daily" checkbox
|
||||||
|
- [ ] Task appears in Scheduled Downloads section
|
||||||
|
- [ ] Task name, source, and schedule displayed correctly
|
||||||
|
- [ ] Enable/disable toggle works
|
||||||
|
- [ ] Delete removes task
|
||||||
|
|
||||||
|
### Execution
|
||||||
|
- [ ] Manual "Run Now" triggers download immediately
|
||||||
|
- [ ] Download progress appears in Progress section
|
||||||
|
- [ ] Task completes successfully
|
||||||
|
- [ ] Execution history recorded
|
||||||
|
|
||||||
|
### Sequential Processing
|
||||||
|
- [ ] Queue multiple tasks via "Run Now"
|
||||||
|
- [ ] Tasks execute one at a time (not concurrent)
|
||||||
|
- [ ] Queue badge shows correct count
|
||||||
|
- [ ] Manual tasks execute before scheduled tasks
|
||||||
|
|
||||||
|
### Persistence
|
||||||
|
- [ ] Restart container/server
|
||||||
|
- [ ] Tasks still present after restart
|
||||||
|
- [ ] Scheduled jobs still execute at correct time
|
||||||
|
- [ ] Execution history preserved
|
||||||
|
|
||||||
|
### Timezone Handling
|
||||||
|
- [ ] Create task with different timezone
|
||||||
|
- [ ] Task runs at correct local time
|
||||||
|
- [ ] Next run time displays in user's timezone
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
- [ ] Create task with invalid source name
|
||||||
|
- [ ] Disable task, verify it doesn't run
|
||||||
|
- [ ] Enable disabled task
|
||||||
|
- [ ] Delete task while it's running
|
||||||
|
- [ ] Run same task multiple times quickly
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Tasks Not Running
|
||||||
|
1. Check container logs for scheduler errors
|
||||||
|
2. Verify `/app/data` volume is mounted
|
||||||
|
3. Check database file permissions
|
||||||
|
4. Verify APScheduler is running (`scheduler.running()`)
|
||||||
|
|
||||||
|
### Queue Stuck
|
||||||
|
1. Check task_queue status in logs
|
||||||
|
2. Verify WebSocket connection for progress updates
|
||||||
|
3. Restart container to reset queue
|
||||||
|
|
||||||
|
### Timezone Issues
|
||||||
|
1. Verify browser timezone detection in DevTools
|
||||||
|
2. Check conversion in scheduler logs
|
||||||
|
3. Ensure container has correct UTC time
|
||||||
|
|
||||||
|
### Database Issues
|
||||||
|
1. Check `/app/data/scheduled_tasks.db` exists
|
||||||
|
2. Verify write permissions
|
||||||
|
3. Use SQLite browser to inspect tables
|
||||||
|
4. Check for migration errors in logs
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Potential improvements:
|
||||||
|
- [ ] Weekly scheduling option
|
||||||
|
- [ ] Custom time filters (last 3 days, last week, etc.)
|
||||||
|
- [ ] Email notifications on completion/failure
|
||||||
|
- [ ] Retry logic for failed tasks
|
||||||
|
- [ ] Task templates for quick setup
|
||||||
|
- [ ] Bulk operations (enable/disable multiple tasks)
|
||||||
|
- [ ] Advanced schedule expressions (cron syntax)
|
||||||
|
- [ ] Export/import task configurations
|
||||||
|
- [ ] Task execution statistics and charts
|
||||||
|
- [ ] Pause/resume queue
|
||||||
|
|
||||||
|
## API Examples
|
||||||
|
|
||||||
|
### Create Task
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/scheduled-tasks \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"name": "Daily Python Posts",
|
||||||
|
"source_type": "subreddit",
|
||||||
|
"source_name": "python",
|
||||||
|
"download_mode": "download",
|
||||||
|
"limit": 50,
|
||||||
|
"sort": "hot",
|
||||||
|
"run_time": "02:00",
|
||||||
|
"timezone": "Pacific/Auckland",
|
||||||
|
"enabled": true
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### List Tasks
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/api/scheduled-tasks
|
||||||
|
```
|
||||||
|
|
||||||
|
### Toggle Task
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/scheduled-tasks/1/toggle
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Task Now
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/scheduled-tasks/1/run-now
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Queue Status
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/api/scheduled-tasks/queue/status
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files Modified/Created
|
||||||
|
|
||||||
|
### Created:
|
||||||
|
- `web_interface/app/database.py` - Database configuration
|
||||||
|
- `web_interface/app/models.py` - ORM models
|
||||||
|
- `web_interface/app/task_queue.py` - Queue manager
|
||||||
|
- `web_interface/app/scheduler.py` - Scheduler service
|
||||||
|
- `web_interface/app/scheduled_tasks.py` - API endpoints
|
||||||
|
- `web_interface/SCHEDULED_DOWNLOADS.md` - This file
|
||||||
|
|
||||||
|
### Modified:
|
||||||
|
- `web_interface/requirements.txt` - Added dependencies
|
||||||
|
- `web_interface/app/main.py` - Integrated scheduler
|
||||||
|
- `web_interface/templates/index.html` - Added UI elements
|
||||||
|
- `web_interface/static/js/app.js` - Added JavaScript functions
|
||||||
|
- `web_interface/static/css/style.css` - Added styles
|
||||||
|
|
||||||
|
## Dependencies Added
|
||||||
|
|
||||||
|
```
|
||||||
|
sqlalchemy>=2.0.0
|
||||||
|
alembic>=1.12.0
|
||||||
|
apscheduler>=3.10.0
|
||||||
|
pytz>=2023.3
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
# BDFR Web Interface Startup Scripts
|
||||||
|
|
||||||
|
This directory contains simple startup scripts to easily run the BDFR web interface application.
|
||||||
|
|
||||||
|
## Available Scripts
|
||||||
|
|
||||||
|
### 🚀 Quick Start
|
||||||
|
|
||||||
|
Choose the appropriate script for your operating system:
|
||||||
|
|
||||||
|
- **`start.py`** - Cross-platform Python script (recommended)
|
||||||
|
- **`start.sh`** - Unix/Linux/macOS shell script
|
||||||
|
- **`start.bat`** - Windows batch script
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Option 1: Python Script (Cross-platform)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Navigate to the web_interface directory
|
||||||
|
cd web_interface
|
||||||
|
|
||||||
|
# Run the startup script
|
||||||
|
python start.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Shell Script (Unix/Linux/macOS)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Navigate to the web_interface directory
|
||||||
|
cd web_interface
|
||||||
|
|
||||||
|
# Make sure the script is executable
|
||||||
|
chmod +x start.sh
|
||||||
|
|
||||||
|
# Run the startup script
|
||||||
|
./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 3: Batch Script (Windows)
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
REM Navigate to the web_interface directory
|
||||||
|
cd web_interface
|
||||||
|
|
||||||
|
REM Run the startup script
|
||||||
|
start.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
## What the Scripts Do
|
||||||
|
|
||||||
|
1. **Check Python version** - Ensures Python 3.8+ is installed
|
||||||
|
2. **Install dependencies** - Automatically installs required packages from `requirements.txt`
|
||||||
|
3. **Verify BDFR module** - Checks if the BDFR module is available
|
||||||
|
4. **Start the server** - Launches the FastAPI application with uvicorn
|
||||||
|
|
||||||
|
## Server Information
|
||||||
|
|
||||||
|
Once started, the web interface will be available at:
|
||||||
|
- **Main interface**: http://localhost:8000
|
||||||
|
- **API documentation**: http://localhost:8000/docs
|
||||||
|
- **Health check**: http://localhost:8000/health
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- ✅ Automatic dependency management
|
||||||
|
- ✅ Cross-platform compatibility
|
||||||
|
- ✅ Colored output for better user experience
|
||||||
|
- ✅ Error handling and informative messages
|
||||||
|
- ✅ Graceful server shutdown
|
||||||
|
- ✅ BDFR module availability checking
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Python 3.8 or higher
|
||||||
|
- Internet connection (for installing dependencies)
|
||||||
|
- BDFR module in Python path (parent directory should contain the BDFR package)
|
||||||
|
- Reddit OAuth application (for authentication features)
|
||||||
|
|
||||||
|
## Reddit OAuth Setup
|
||||||
|
|
||||||
|
To use the authentication features, you need to:
|
||||||
|
|
||||||
|
1. **Create a Reddit OAuth Application**:
|
||||||
|
- Go to [Reddit App Preferences](https://www.reddit.com/prefs/apps)
|
||||||
|
- Click "Create App" or "Create Another App"
|
||||||
|
- Choose "web app" as the application type
|
||||||
|
- Set a name (e.g., "BDFR Web Interface")
|
||||||
|
- Set redirect URI to: `http://localhost:8000/auth/callback`
|
||||||
|
|
||||||
|
2. **Configure the Redirect URI** (if using a different port or domain):
|
||||||
|
- Run the OAuth setup helper: `python setup_oauth.py`
|
||||||
|
- Or manually copy `.env.example` to `.env`
|
||||||
|
- Update the following in the `.env` file:
|
||||||
|
- `BDFR_REDIRECT_URI` - Your OAuth redirect URI
|
||||||
|
- `BDFR_CLIENT_ID` - Your OAuth client ID (from Reddit app)
|
||||||
|
- `BDFR_CLIENT_SECRET` - Your OAuth client secret (from Reddit app)
|
||||||
|
- Make sure the redirect URI matches exactly what you set in your Reddit OAuth app
|
||||||
|
|
||||||
|
3. **Update BDFR Configuration**:
|
||||||
|
- The web interface uses the same OAuth credentials as BDFR
|
||||||
|
- You need to either update your existing Reddit OAuth app or create a new one
|
||||||
|
|
||||||
|
## Option A: Update Existing Reddit OAuth App
|
||||||
|
|
||||||
|
If you want to use the same OAuth app for both BDFR CLI and web interface:
|
||||||
|
|
||||||
|
1. Go to [Reddit App Preferences](https://www.reddit.com/prefs/apps)
|
||||||
|
2. Find your existing app (the one with client ID `U-6gk4ZCh3IeNQ`)
|
||||||
|
3. Click "edit" and add your redirect URI to the "redirect uris" field:
|
||||||
|
- `http://localhost:8000/auth/callback`
|
||||||
|
4. Save the changes
|
||||||
|
|
||||||
|
## Option B: Create a New Reddit OAuth App (Recommended)
|
||||||
|
|
||||||
|
For better separation between CLI and web interface:
|
||||||
|
|
||||||
|
1. Go to [Reddit App Preferences](https://www.reddit.com/prefs/apps)
|
||||||
|
2. Click "Create App" or "Create Another App"
|
||||||
|
3. Fill in the details:
|
||||||
|
- **Name**: `BDFR Web Interface` (or your preferred name)
|
||||||
|
- **App type**: `web app`
|
||||||
|
- **Description**: `Web interface for BDFR (Bulk Downloader for Reddit)`
|
||||||
|
- **About URL**: (optional)
|
||||||
|
- **Redirect URI**: `http://localhost:8000/auth/callback`
|
||||||
|
4. Click "Create app"
|
||||||
|
5. Copy the client ID and client secret
|
||||||
|
6. Update `bdfr/default_config.cfg` with the new credentials:
|
||||||
|
```
|
||||||
|
client_id = YOUR_NEW_CLIENT_ID
|
||||||
|
client_secret = YOUR_NEW_CLIENT_SECRET
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "BDFR module not found"
|
||||||
|
Make sure you're running the script from the correct directory, or ensure the parent directory containing the BDFR package is in your Python path.
|
||||||
|
|
||||||
|
### "Python 3.8+ required"
|
||||||
|
Install Python 3.8 or higher from the official Python website.
|
||||||
|
|
||||||
|
### "Permission denied" (Unix/Linux/macOS)
|
||||||
|
Make sure the shell script has execute permissions:
|
||||||
|
```bash
|
||||||
|
chmod +x start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### "invalid redirect_uri parameter" (OAuth Error)
|
||||||
|
This error occurs when the redirect URI doesn't match what you configured in your Reddit OAuth app:
|
||||||
|
|
||||||
|
1. **Verify your Reddit OAuth app settings**:
|
||||||
|
- Go to [Reddit App Preferences](https://www.reddit.com/prefs/apps)
|
||||||
|
- Find your app and check the redirect URI
|
||||||
|
- Make sure it exactly matches what you're using
|
||||||
|
|
||||||
|
2. **Update the redirect URI**:
|
||||||
|
- Copy `.env.example` to `.env`
|
||||||
|
- Set `BDFR_REDIRECT_URI` to match your Reddit OAuth app
|
||||||
|
- Example: `BDFR_REDIRECT_URI=http://localhost:8000/auth/callback`
|
||||||
|
|
||||||
|
3. **Common redirect URI formats**:
|
||||||
|
- Local development: `http://localhost:8000/auth/callback`
|
||||||
|
- With custom port: `http://localhost:3000/auth/callback`
|
||||||
|
- Production: `https://yourdomain.com/auth/callback`
|
||||||
|
|
||||||
|
4. **Recreate your OAuth app if needed**:
|
||||||
|
- Delete the existing app in Reddit
|
||||||
|
- Create a new one with the correct redirect URI
|
||||||
|
|
||||||
|
## Manual Alternative
|
||||||
|
|
||||||
|
If you prefer to run the server manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web_interface
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
"""
|
||||||
|
OAuth2 Authentication module for BDFR Web Interface
|
||||||
|
|
||||||
|
This module handles OAuth2 authentication flow for the web interface,
|
||||||
|
integrating with BDFR's existing OAuth2 system.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Dict, Optional, Any
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
# Try to import BDFR modules, but handle gracefully if not available
|
||||||
|
try:
|
||||||
|
from bdfr.oauth2 import OAuth2Authenticator, OAuth2TokenManager
|
||||||
|
from bdfr.exceptions import RedditAuthenticationError
|
||||||
|
BDFR_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
BDFR_AVAILABLE = False
|
||||||
|
# Create mock classes for when BDFR is not available
|
||||||
|
class OAuth2Authenticator:
|
||||||
|
pass
|
||||||
|
class OAuth2TokenManager:
|
||||||
|
pass
|
||||||
|
class RedditAuthenticationError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class WebOAuth2Manager:
|
||||||
|
"""OAuth2 manager for web interface authentication"""
|
||||||
|
|
||||||
|
def __init__(self, client_id: str, client_secret: str, scopes: list = None):
|
||||||
|
self.client_id = client_id
|
||||||
|
self.client_secret = client_secret
|
||||||
|
self.scopes = scopes or ["identity", "history", "read", "save", "mysubreddits"]
|
||||||
|
|
||||||
|
# In-memory storage for OAuth2 states and tokens
|
||||||
|
# In production, this should be replaced with a proper database
|
||||||
|
self.oauth_states = {}
|
||||||
|
self.refresh_tokens = {}
|
||||||
|
self.access_tokens = {}
|
||||||
|
# Store Reddit usernames per session state
|
||||||
|
self.usernames = {}
|
||||||
|
|
||||||
|
# Reddit OAuth2 endpoints
|
||||||
|
self.reddit_auth_url = "https://www.reddit.com/api/v1/authorize"
|
||||||
|
self.reddit_token_url = "https://www.reddit.com/api/v1/access_token"
|
||||||
|
self.reddit_user_info_url = "https://oauth.reddit.com/api/v1/me"
|
||||||
|
|
||||||
|
# Token expiration tracking
|
||||||
|
self.token_expiry = {}
|
||||||
|
|
||||||
|
def generate_state(self) -> str:
|
||||||
|
"""Generate a secure random state for OAuth2"""
|
||||||
|
state = secrets.token_urlsafe(32)
|
||||||
|
self.oauth_states[state] = {
|
||||||
|
"created_at": time.time(),
|
||||||
|
"used": False
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
|
||||||
|
def validate_state(self, state: str) -> bool:
|
||||||
|
"""Validate OAuth2 state parameter"""
|
||||||
|
if state not in self.oauth_states:
|
||||||
|
return False
|
||||||
|
|
||||||
|
state_data = self.oauth_states[state]
|
||||||
|
if state_data["used"]:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# States expire after 10 minutes
|
||||||
|
if time.time() - state_data["created_at"] > 600:
|
||||||
|
del self.oauth_states[state]
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def mark_state_used(self, state: str):
|
||||||
|
"""Mark OAuth2 state as used"""
|
||||||
|
if state in self.oauth_states:
|
||||||
|
self.oauth_states[state]["used"] = True
|
||||||
|
|
||||||
|
def get_authorization_url(self, redirect_uri: str) -> Dict[str, str]:
|
||||||
|
"""Generate OAuth2 authorization URL"""
|
||||||
|
state = self.generate_state()
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"client_id": self.client_id,
|
||||||
|
"response_type": "code",
|
||||||
|
"state": state,
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
"scope": " ".join(self.scopes),
|
||||||
|
"duration": "permanent"
|
||||||
|
}
|
||||||
|
|
||||||
|
auth_url = f"{self.reddit_auth_url}?{urlencode(params)}"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"authorization_url": auth_url,
|
||||||
|
"state": state
|
||||||
|
}
|
||||||
|
|
||||||
|
async def exchange_code_for_token(self, code: str, state: str, redirect_uri: str) -> Dict[str, Any]:
|
||||||
|
"""Exchange authorization code for access token"""
|
||||||
|
if not self.validate_state(state):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid or expired state parameter"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.mark_state_used(state)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": redirect_uri
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"User-Agent": "BDFR-Web-Interface/1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Use HTTP Basic Auth for client credentials
|
||||||
|
auth = (self.client_id, self.client_secret)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
try:
|
||||||
|
response = await client.post(
|
||||||
|
self.reddit_token_url,
|
||||||
|
data=data,
|
||||||
|
auth=auth,
|
||||||
|
headers=headers,
|
||||||
|
timeout=30.0
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
error_detail = response.json() if response.headers.get("content-type", "").startswith("application/json") else response.text
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Token exchange failed: {error_detail}"
|
||||||
|
)
|
||||||
|
|
||||||
|
token_data = response.json()
|
||||||
|
|
||||||
|
# Store tokens
|
||||||
|
access_token = token_data["access_token"]
|
||||||
|
refresh_token = token_data.get("refresh_token")
|
||||||
|
|
||||||
|
if refresh_token:
|
||||||
|
self.refresh_tokens[state] = refresh_token
|
||||||
|
self.access_tokens[state] = access_token
|
||||||
|
|
||||||
|
# Set expiry (Reddit tokens typically last 1 hour)
|
||||||
|
self.token_expiry[state] = time.time() + token_data.get("expires_in", 3600)
|
||||||
|
|
||||||
|
# Attempt to fetch and store the Reddit username for this session
|
||||||
|
username = None
|
||||||
|
try:
|
||||||
|
user_info = await self.get_user_info(access_token)
|
||||||
|
username = user_info.get("name")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to fetch user info during token exchange: {e}")
|
||||||
|
|
||||||
|
if username:
|
||||||
|
self.usernames[state] = username
|
||||||
|
|
||||||
|
return {
|
||||||
|
"access_token": access_token,
|
||||||
|
"refresh_token": refresh_token,
|
||||||
|
"expires_in": token_data.get("expires_in", 3600),
|
||||||
|
"token_type": token_data.get("token_type", "bearer"),
|
||||||
|
"state": state,
|
||||||
|
"username": username
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="No refresh token received"
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_408_REQUEST_TIMEOUT,
|
||||||
|
detail="Token exchange timed out"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Token exchange error: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Internal server error during token exchange"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_user_info(self, access_token: str) -> Dict[str, Any]:
|
||||||
|
"""Get user information using access token"""
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {access_token}",
|
||||||
|
"User-Agent": "BDFR-Web-Interface/1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
try:
|
||||||
|
response = await client.get(
|
||||||
|
self.reddit_user_info_url,
|
||||||
|
headers=headers,
|
||||||
|
timeout=30.0
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid access token"
|
||||||
|
)
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_408_REQUEST_TIMEOUT,
|
||||||
|
detail="User info request timed out"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"User info error: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Error retrieving user information"
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_token_expired(self, state: str) -> bool:
|
||||||
|
"""Check if access token is expired"""
|
||||||
|
if state not in self.token_expiry:
|
||||||
|
return True
|
||||||
|
return time.time() > self.token_expiry[state]
|
||||||
|
|
||||||
|
def get_valid_token(self, state: str) -> Optional[str]:
|
||||||
|
"""Get valid access token, refreshing if necessary"""
|
||||||
|
if state not in self.access_tokens:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if self.is_token_expired(state):
|
||||||
|
# Token expired, would need refresh logic here
|
||||||
|
# For now, just return None to indicate re-auth needed
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self.access_tokens[state]
|
||||||
|
|
||||||
|
def revoke_session(self, state: str):
|
||||||
|
"""Revoke OAuth2 session"""
|
||||||
|
if state in self.oauth_states:
|
||||||
|
del self.oauth_states[state]
|
||||||
|
if state in self.refresh_tokens:
|
||||||
|
del self.refresh_tokens[state]
|
||||||
|
if state in self.access_tokens:
|
||||||
|
del self.access_tokens[state]
|
||||||
|
if state in self.token_expiry:
|
||||||
|
del self.token_expiry[state]
|
||||||
|
if state in self.usernames:
|
||||||
|
del self.usernames[state]
|
||||||
|
|
||||||
|
def get_auth_status(self, state: str = None) -> Dict[str, Any]:
|
||||||
|
"""Get authentication status"""
|
||||||
|
if not state:
|
||||||
|
return {
|
||||||
|
"authenticated": False,
|
||||||
|
"message": "No active session"
|
||||||
|
}
|
||||||
|
|
||||||
|
if state not in self.access_tokens:
|
||||||
|
return {
|
||||||
|
"authenticated": False,
|
||||||
|
"message": "No tokens found for session"
|
||||||
|
}
|
||||||
|
|
||||||
|
access_token = self.get_valid_token(state)
|
||||||
|
if not access_token:
|
||||||
|
return {
|
||||||
|
"authenticated": False,
|
||||||
|
"message": "Token expired or invalid"
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"authenticated": True,
|
||||||
|
"expires_at": self.token_expiry.get(state, 0),
|
||||||
|
"scopes": self.scopes,
|
||||||
|
"username": self.usernames.get(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Global OAuth2 manager instance
|
||||||
|
oauth_manager = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_oauth_manager(client_id: str, client_secret: str, scopes: list = None):
|
||||||
|
"""Initialize the global OAuth2 manager"""
|
||||||
|
global oauth_manager
|
||||||
|
oauth_manager = WebOAuth2Manager(client_id, client_secret, scopes)
|
||||||
|
|
||||||
|
|
||||||
|
def get_oauth_manager() -> WebOAuth2Manager:
|
||||||
|
"""Get the global OAuth2 manager instance"""
|
||||||
|
if oauth_manager is None:
|
||||||
|
raise RuntimeError("OAuth2 manager not initialized")
|
||||||
|
return oauth_manager
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"""
|
||||||
|
Database configuration for scheduled downloads.
|
||||||
|
Uses SQLite with SQLAlchemy ORM for persistent storage.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, event
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from pathlib import Path
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Database file location - uses /app/data in Docker container
|
||||||
|
# This directory should be mounted as a volume for persistence
|
||||||
|
DATA_DIR = Path(__file__).parent.parent / "data"
|
||||||
|
DB_PATH = DATA_DIR / "scheduled_tasks.db"
|
||||||
|
|
||||||
|
# Ensure data directory exists (will be mounted volume in Docker)
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Create engine with proper settings for SQLite in Docker
|
||||||
|
engine = create_engine(
|
||||||
|
f"sqlite:///{DB_PATH}",
|
||||||
|
echo=False,
|
||||||
|
connect_args={
|
||||||
|
"check_same_thread": False, # Allow multi-threaded access
|
||||||
|
"timeout": 30 # Longer timeout for container I/O
|
||||||
|
},
|
||||||
|
pool_pre_ping=True, # Verify connections before using
|
||||||
|
)
|
||||||
|
|
||||||
|
# Enable foreign keys for SQLite
|
||||||
|
@event.listens_for(engine, "connect")
|
||||||
|
def set_sqlite_pragma(dbapi_conn, connection_record):
|
||||||
|
cursor = dbapi_conn.cursor()
|
||||||
|
cursor.execute("PRAGMA foreign_keys=ON")
|
||||||
|
cursor.close()
|
||||||
|
|
||||||
|
# Create session factory
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
# Base class for models
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
def init_database():
|
||||||
|
"""Initialize database - called on container startup"""
|
||||||
|
try:
|
||||||
|
# Check if database file exists
|
||||||
|
if DB_PATH.exists():
|
||||||
|
logger.info(f"Database file exists at {DB_PATH}, checking for schema upgrades")
|
||||||
|
upgrade_database_schema()
|
||||||
|
else:
|
||||||
|
logger.info(f"Creating new database at {DB_PATH}")
|
||||||
|
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
logger.info(f"Database initialized at {DB_PATH}")
|
||||||
|
|
||||||
|
# Verify the schema after creation/upgrade
|
||||||
|
verify_database_schema()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to initialize database: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def verify_database_schema():
|
||||||
|
"""Verify that the database schema has all required columns"""
|
||||||
|
try:
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
with engine.connect() as conn:
|
||||||
|
result = conn.execute(text("PRAGMA table_info(scheduled_tasks)"))
|
||||||
|
columns = [row[1] for row in result.fetchall()]
|
||||||
|
|
||||||
|
required_columns = ['upvoted', 'saved']
|
||||||
|
missing_columns = [col for col in required_columns if col not in columns]
|
||||||
|
|
||||||
|
if missing_columns:
|
||||||
|
logger.error(f"Database schema verification failed. Missing columns: {missing_columns}")
|
||||||
|
logger.error(f"Available columns: {columns}")
|
||||||
|
else:
|
||||||
|
logger.info("Database schema verification passed")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to verify database schema: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade_database_schema():
|
||||||
|
"""Check and upgrade database schema for new columns"""
|
||||||
|
try:
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
logger.info("Starting database schema upgrade check...")
|
||||||
|
|
||||||
|
# Check if scheduled_tasks table exists first
|
||||||
|
with engine.connect() as conn:
|
||||||
|
# Check if table exists
|
||||||
|
result = conn.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name='scheduled_tasks'"))
|
||||||
|
table_exists = result.fetchone() is not None
|
||||||
|
|
||||||
|
if not table_exists:
|
||||||
|
logger.info("scheduled_tasks table does not exist, will be created by create_all")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("scheduled_tasks table exists, checking columns...")
|
||||||
|
|
||||||
|
# Get table info
|
||||||
|
result = conn.execute(text("PRAGMA table_info(scheduled_tasks)"))
|
||||||
|
columns = [row[1] for row in result.fetchall()]
|
||||||
|
|
||||||
|
logger.info(f"Existing columns in scheduled_tasks: {columns}")
|
||||||
|
|
||||||
|
# Check for missing columns
|
||||||
|
missing_columns = []
|
||||||
|
if 'upvoted' not in columns:
|
||||||
|
missing_columns.append('upvoted BOOLEAN DEFAULT 0')
|
||||||
|
if 'saved' not in columns:
|
||||||
|
missing_columns.append('saved BOOLEAN DEFAULT 0')
|
||||||
|
|
||||||
|
# Add missing columns
|
||||||
|
for column in missing_columns:
|
||||||
|
logger.info(f"Adding missing column: {column}")
|
||||||
|
try:
|
||||||
|
conn.execute(text(f"ALTER TABLE scheduled_tasks ADD COLUMN {column}"))
|
||||||
|
conn.commit()
|
||||||
|
logger.info(f"Successfully added column: {column}")
|
||||||
|
except Exception as alter_error:
|
||||||
|
logger.error(f"Failed to add column {column}: {alter_error}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
if missing_columns:
|
||||||
|
logger.info(f"Database schema upgraded with columns: {missing_columns}")
|
||||||
|
else:
|
||||||
|
logger.info("Database schema is up to date")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to upgrade database schema: {e}")
|
||||||
|
logger.error(f"Error type: {type(e)}")
|
||||||
|
import traceback
|
||||||
|
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||||
|
# Don't raise here - let the app continue with create_all
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
"""
|
||||||
|
Dependency for FastAPI to get database session.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
@app.get("/endpoint")
|
||||||
|
def endpoint(db: Session = Depends(get_db)):
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,136 @@
|
|||||||
|
"""
|
||||||
|
Database models for scheduled downloads.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Column, String, Integer, Boolean, DateTime, Time, ForeignKey, Text
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime, time as time_type
|
||||||
|
import uuid
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
from .database import Base
|
||||||
|
|
||||||
|
|
||||||
|
def generate_uuid():
|
||||||
|
"""Generate a UUID string"""
|
||||||
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduledTask(Base):
|
||||||
|
"""
|
||||||
|
Represents a scheduled download task.
|
||||||
|
Tasks run at a specified time each day.
|
||||||
|
"""
|
||||||
|
__tablename__ = "scheduled_tasks"
|
||||||
|
|
||||||
|
# Primary key
|
||||||
|
id = Column(String(36), primary_key=True, default=generate_uuid)
|
||||||
|
|
||||||
|
# Task identification
|
||||||
|
name = Column(String(255), nullable=False, index=True)
|
||||||
|
enabled = Column(Boolean, default=True, nullable=False, index=True)
|
||||||
|
|
||||||
|
# Download configuration
|
||||||
|
source_type = Column(String(20), nullable=False) # "subreddit" or "user"
|
||||||
|
source_name = Column(String(255), nullable=False, index=True)
|
||||||
|
download_mode = Column(String(20), nullable=False) # "download", "archive", "clone"
|
||||||
|
|
||||||
|
# Filter options
|
||||||
|
limit = Column(Integer, default=25, nullable=False)
|
||||||
|
sort = Column(String(20), default="hot", nullable=False)
|
||||||
|
time_filter = Column(String(20), default="day", nullable=False) # Always "day" for daily tasks
|
||||||
|
min_score = Column(Integer, nullable=True)
|
||||||
|
no_dupes = Column(Boolean, default=True, nullable=False) # Always true for scheduled
|
||||||
|
simple_check = Column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
# Scheduling
|
||||||
|
schedule_frequency = Column(String(20), default="daily", nullable=False)
|
||||||
|
run_time = Column(Time, nullable=False) # Time of day to run
|
||||||
|
timezone = Column(String(50), default="UTC", nullable=False)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
created_at = Column(DateTime, default=lambda: datetime.now(pytz.UTC), nullable=False)
|
||||||
|
updated_at = Column(DateTime, default=lambda: datetime.now(pytz.UTC), onupdate=lambda: datetime.now(pytz.UTC), nullable=False)
|
||||||
|
last_run_at = Column(DateTime, nullable=True, index=True)
|
||||||
|
next_run_at = Column(DateTime, nullable=True, index=True)
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
auth_state = Column(String(255), nullable=True)
|
||||||
|
|
||||||
|
# User-specific download options
|
||||||
|
upvoted = Column(Boolean, default=False, nullable=False)
|
||||||
|
saved = Column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
executions = relationship("TaskExecutionHistory", back_populates="task", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""Convert to dictionary for JSON serialization"""
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"name": self.name,
|
||||||
|
"enabled": self.enabled,
|
||||||
|
"source_type": self.source_type,
|
||||||
|
"source_name": self.source_name,
|
||||||
|
"download_mode": self.download_mode,
|
||||||
|
"limit": self.limit,
|
||||||
|
"sort": self.sort,
|
||||||
|
"time_filter": self.time_filter,
|
||||||
|
"min_score": self.min_score,
|
||||||
|
"no_dupes": self.no_dupes,
|
||||||
|
"simple_check": self.simple_check,
|
||||||
|
"schedule_frequency": self.schedule_frequency,
|
||||||
|
"run_time": self.run_time.isoformat() if self.run_time else None,
|
||||||
|
"timezone": self.timezone,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||||
|
"last_run_at": self.last_run_at.isoformat() if self.last_run_at else None,
|
||||||
|
"next_run_at": self.next_run_at.isoformat() if self.next_run_at else None,
|
||||||
|
"auth_state": self.auth_state,
|
||||||
|
"upvoted": getattr(self, 'upvoted', False),
|
||||||
|
"saved": getattr(self, 'saved', False)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TaskExecutionHistory(Base):
|
||||||
|
"""
|
||||||
|
Records each execution of a scheduled task.
|
||||||
|
Tracks success/failure and metrics.
|
||||||
|
"""
|
||||||
|
__tablename__ = "task_execution_history"
|
||||||
|
|
||||||
|
# Primary key
|
||||||
|
id = Column(String(36), primary_key=True, default=generate_uuid)
|
||||||
|
|
||||||
|
# Foreign key to task
|
||||||
|
task_id = Column(String(36), ForeignKey("scheduled_tasks.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
|
||||||
|
# Execution details
|
||||||
|
started_at = Column(DateTime, default=lambda: datetime.now(pytz.UTC), nullable=False, index=True)
|
||||||
|
completed_at = Column(DateTime, nullable=True)
|
||||||
|
status = Column(String(20), default="queued", nullable=False, index=True) # queued, running, success, failed, skipped
|
||||||
|
|
||||||
|
# Results
|
||||||
|
items_found = Column(Integer, default=0, nullable=False)
|
||||||
|
items_downloaded = Column(Integer, default=0, nullable=False)
|
||||||
|
error_message = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Link to download
|
||||||
|
download_id = Column(String(255), nullable=True, index=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
task = relationship("ScheduledTask", back_populates="executions")
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""Convert to dictionary for JSON serialization"""
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"task_id": self.task_id,
|
||||||
|
"started_at": self.started_at.isoformat() if self.started_at else None,
|
||||||
|
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
|
||||||
|
"status": self.status,
|
||||||
|
"items_found": self.items_found,
|
||||||
|
"items_downloaded": self.items_downloaded,
|
||||||
|
"error_message": self.error_message,
|
||||||
|
"download_id": self.download_id
|
||||||
|
}
|
||||||
@@ -0,0 +1,676 @@
|
|||||||
|
"""
|
||||||
|
API endpoints and business logic for scheduled tasks management.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime, time as time_type
|
||||||
|
import pytz
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from .database import get_db
|
||||||
|
from .models import ScheduledTask, TaskExecutionHistory
|
||||||
|
from .scheduler import schedule_task, unschedule_task, calculate_next_run
|
||||||
|
from .task_queue import task_queue
|
||||||
|
from .auth import get_oauth_manager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Create router
|
||||||
|
router = APIRouter(prefix="/api/scheduled-tasks", tags=["scheduled-tasks"])
|
||||||
|
|
||||||
|
|
||||||
|
# Pydantic models for API
|
||||||
|
class ScheduledTaskCreate(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
|
source_type: str = Field(..., pattern="^(subreddit|user)$")
|
||||||
|
source_name: str = Field(..., min_length=1, max_length=255)
|
||||||
|
download_mode: str = Field(..., pattern="^(download|archive|clone)$")
|
||||||
|
limit: int = Field(default=25, ge=1, le=1000)
|
||||||
|
sort: str = Field(default="hot")
|
||||||
|
min_score: Optional[int] = None
|
||||||
|
simple_check: bool = False
|
||||||
|
run_time: str = Field(..., pattern="^([01]?[0-9]|2[0-3]):[0-5][0-9]$") # HH:MM format
|
||||||
|
timezone: str = Field(default="UTC")
|
||||||
|
auth_state: Optional[str] = None
|
||||||
|
upvoted: bool = False
|
||||||
|
saved: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduledTaskUpdate(BaseModel):
|
||||||
|
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
|
limit: Optional[int] = Field(None, ge=1, le=1000)
|
||||||
|
sort: Optional[str] = None
|
||||||
|
min_score: Optional[int] = None
|
||||||
|
simple_check: Optional[bool] = None
|
||||||
|
run_time: Optional[str] = Field(None, pattern="^([01]?[0-9]|2[0-3]):[0-5][0-9]$")
|
||||||
|
timezone: Optional[str] = None
|
||||||
|
enabled: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduledTaskResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
enabled: bool
|
||||||
|
source_type: str
|
||||||
|
source_name: str
|
||||||
|
download_mode: str
|
||||||
|
limit: int
|
||||||
|
sort: str
|
||||||
|
time_filter: str
|
||||||
|
min_score: Optional[int]
|
||||||
|
no_dupes: bool
|
||||||
|
simple_check: bool
|
||||||
|
schedule_frequency: str
|
||||||
|
run_time: str
|
||||||
|
timezone: str
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
last_run_at: Optional[str]
|
||||||
|
next_run_at: Optional[str]
|
||||||
|
auth_state: Optional[str]
|
||||||
|
upvoted: bool
|
||||||
|
saved: bool
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class TaskExecutionResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
task_id: str
|
||||||
|
started_at: str
|
||||||
|
completed_at: Optional[str]
|
||||||
|
status: str
|
||||||
|
items_found: int
|
||||||
|
items_downloaded: int
|
||||||
|
error_message: Optional[str]
|
||||||
|
download_id: Optional[str]
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# API Endpoints
|
||||||
|
|
||||||
|
@router.post("", response_model=ScheduledTaskResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_scheduled_task(task_data: ScheduledTaskCreate, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Create a new scheduled download task.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Validate timezone
|
||||||
|
try:
|
||||||
|
pytz.timezone(task_data.timezone)
|
||||||
|
except pytz.exceptions.UnknownTimeZoneError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid timezone: {task_data.timezone}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse run_time
|
||||||
|
hour, minute = map(int, task_data.run_time.split(':'))
|
||||||
|
run_time_obj = time_type(hour=hour, minute=minute)
|
||||||
|
|
||||||
|
# Create task
|
||||||
|
task = ScheduledTask(
|
||||||
|
name=task_data.name,
|
||||||
|
enabled=True,
|
||||||
|
source_type=task_data.source_type,
|
||||||
|
source_name=task_data.source_name,
|
||||||
|
download_mode=task_data.download_mode,
|
||||||
|
limit=task_data.limit,
|
||||||
|
sort=task_data.sort,
|
||||||
|
time_filter="day", # Always "day" for daily scheduled tasks
|
||||||
|
min_score=task_data.min_score,
|
||||||
|
no_dupes=True, # Always true for scheduled tasks
|
||||||
|
simple_check=task_data.simple_check,
|
||||||
|
schedule_frequency="daily",
|
||||||
|
run_time=run_time_obj,
|
||||||
|
timezone=task_data.timezone,
|
||||||
|
auth_state=task_data.auth_state,
|
||||||
|
upvoted=task_data.upvoted,
|
||||||
|
saved=task_data.saved
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate next run time
|
||||||
|
task.next_run_at = calculate_next_run(task)
|
||||||
|
|
||||||
|
# Save to database
|
||||||
|
db.add(task)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(task)
|
||||||
|
|
||||||
|
# Schedule the task
|
||||||
|
schedule_task(task)
|
||||||
|
|
||||||
|
logger.info(f"Created scheduled task {task.id}: {task.name}")
|
||||||
|
|
||||||
|
return task.to_dict()
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create scheduled task: {e}", exc_info=True)
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to create scheduled task: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=List[ScheduledTaskResponse])
|
||||||
|
async def list_scheduled_tasks(db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Get all scheduled tasks.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
tasks = db.query(ScheduledTask).order_by(ScheduledTask.created_at.desc()).all()
|
||||||
|
return [task.to_dict() for task in tasks]
|
||||||
|
except Exception as e:
|
||||||
|
if "no such column" in str(e):
|
||||||
|
logger.warning(f"Database schema is outdated: {e}")
|
||||||
|
# Return empty list if schema is outdated
|
||||||
|
return []
|
||||||
|
logger.error(f"Failed to list scheduled tasks: {e}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to list scheduled tasks"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{task_id}", response_model=ScheduledTaskResponse)
|
||||||
|
async def get_scheduled_task(task_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Get a specific scheduled task by ID.
|
||||||
|
"""
|
||||||
|
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
|
||||||
|
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Task {task_id} not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
return task.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{task_id}", response_model=ScheduledTaskResponse)
|
||||||
|
async def update_scheduled_task(
|
||||||
|
task_id: str,
|
||||||
|
task_data: ScheduledTaskUpdate,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update a scheduled task.
|
||||||
|
"""
|
||||||
|
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
|
||||||
|
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Task {task_id} not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Update fields
|
||||||
|
if task_data.name is not None:
|
||||||
|
task.name = task_data.name
|
||||||
|
if task_data.limit is not None:
|
||||||
|
task.limit = task_data.limit
|
||||||
|
if task_data.sort is not None:
|
||||||
|
task.sort = task_data.sort
|
||||||
|
if task_data.min_score is not None:
|
||||||
|
task.min_score = task_data.min_score
|
||||||
|
if task_data.simple_check is not None:
|
||||||
|
task.simple_check = task_data.simple_check
|
||||||
|
|
||||||
|
# Handle run_time update
|
||||||
|
reschedule_needed = False
|
||||||
|
if task_data.run_time is not None:
|
||||||
|
hour, minute = map(int, task_data.run_time.split(':'))
|
||||||
|
task.run_time = time_type(hour=hour, minute=minute)
|
||||||
|
reschedule_needed = True
|
||||||
|
|
||||||
|
# Handle timezone update
|
||||||
|
if task_data.timezone is not None:
|
||||||
|
try:
|
||||||
|
pytz.timezone(task_data.timezone)
|
||||||
|
task.timezone = task_data.timezone
|
||||||
|
reschedule_needed = True
|
||||||
|
except pytz.exceptions.UnknownTimeZoneError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid timezone: {task_data.timezone}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handle enabled status
|
||||||
|
if task_data.enabled is not None and task_data.enabled != task.enabled:
|
||||||
|
task.enabled = task_data.enabled
|
||||||
|
reschedule_needed = True
|
||||||
|
|
||||||
|
# Update next_run_at if needed
|
||||||
|
if reschedule_needed:
|
||||||
|
if task.enabled:
|
||||||
|
task.next_run_at = calculate_next_run(task)
|
||||||
|
# Reschedule
|
||||||
|
unschedule_task(task_id)
|
||||||
|
schedule_task(task)
|
||||||
|
else:
|
||||||
|
# Unschedule if disabled
|
||||||
|
unschedule_task(task_id)
|
||||||
|
task.next_run_at = None
|
||||||
|
|
||||||
|
task.updated_at = datetime.now(pytz.UTC)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(task)
|
||||||
|
|
||||||
|
logger.info(f"Updated scheduled task {task_id}")
|
||||||
|
|
||||||
|
return task.to_dict()
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update task {task_id}: {e}", exc_info=True)
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to update task: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_scheduled_task(task_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Delete a scheduled task.
|
||||||
|
"""
|
||||||
|
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
|
||||||
|
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Task {task_id} not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Unschedule
|
||||||
|
unschedule_task(task_id)
|
||||||
|
|
||||||
|
# Delete from database (cascade will delete execution history)
|
||||||
|
db.delete(task)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Deleted scheduled task {task_id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to delete task {task_id}: {e}", exc_info=True)
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to delete task: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{task_id}/toggle")
|
||||||
|
async def toggle_scheduled_task(task_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Toggle a task's enabled status.
|
||||||
|
"""
|
||||||
|
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
|
||||||
|
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Task {task_id} not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Toggle enabled
|
||||||
|
task.enabled = not task.enabled
|
||||||
|
task.updated_at = datetime.now(pytz.UTC)
|
||||||
|
|
||||||
|
if task.enabled:
|
||||||
|
# Re-enable: schedule and calculate next run
|
||||||
|
task.next_run_at = calculate_next_run(task)
|
||||||
|
schedule_task(task)
|
||||||
|
message = "Task enabled"
|
||||||
|
else:
|
||||||
|
# Disable: unschedule
|
||||||
|
unschedule_task(task_id)
|
||||||
|
task.next_run_at = None
|
||||||
|
message = "Task disabled"
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Toggled task {task_id}: {message}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": message,
|
||||||
|
"enabled": task.enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to toggle task {task_id}: {e}", exc_info=True)
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to toggle task: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{task_id}/run-now")
|
||||||
|
async def run_task_now(task_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Manually trigger a scheduled task to run now.
|
||||||
|
Adds it to the queue with high priority.
|
||||||
|
"""
|
||||||
|
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
|
||||||
|
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Task {task_id} not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Add to queue with priority (goes ahead of scheduled tasks)
|
||||||
|
await task_queue.add_task(task_id, priority=1)
|
||||||
|
|
||||||
|
queue_status = task_queue.get_queue_status()
|
||||||
|
|
||||||
|
logger.info(f"Manually queued task {task_id} for immediate execution")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": f"Task queued for execution",
|
||||||
|
"task_id": task_id,
|
||||||
|
"queue_position": queue_status['queue_size'],
|
||||||
|
"currently_running": queue_status['current_task'],
|
||||||
|
"status": "queued" if queue_status['current_task'] else "starting"
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to queue task {task_id}: {e}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to queue task: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{task_id}/history", response_model=List[TaskExecutionResponse])
|
||||||
|
async def get_task_history(task_id: str, limit: int = 10, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Get execution history for a specific task.
|
||||||
|
"""
|
||||||
|
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
|
||||||
|
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Task {task_id} not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
executions = db.query(TaskExecutionHistory)\
|
||||||
|
.filter(TaskExecutionHistory.task_id == task_id)\
|
||||||
|
.order_by(TaskExecutionHistory.started_at.desc())\
|
||||||
|
.limit(limit)\
|
||||||
|
.all()
|
||||||
|
|
||||||
|
return [execution.to_dict() for execution in executions]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get history for task {task_id}: {e}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to get task history"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history/recent", response_model=List[TaskExecutionResponse])
|
||||||
|
async def get_recent_history(limit: int = 20, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Get recent execution history across all tasks.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
executions = db.query(TaskExecutionHistory)\
|
||||||
|
.order_by(TaskExecutionHistory.started_at.desc())\
|
||||||
|
.limit(limit)\
|
||||||
|
.all()
|
||||||
|
|
||||||
|
return [execution.to_dict() for execution in executions]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get recent history: {e}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to get execution history"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/queue/status")
|
||||||
|
async def get_queue_status():
|
||||||
|
"""Get current task queue status"""
|
||||||
|
try:
|
||||||
|
status = task_queue.get_queue_status()
|
||||||
|
|
||||||
|
# Get details of current task if any
|
||||||
|
current_task_info = None
|
||||||
|
if status['current_task']:
|
||||||
|
db = next(get_db())
|
||||||
|
try:
|
||||||
|
task = db.query(ScheduledTask).filter(ScheduledTask.id == status['current_task']).first()
|
||||||
|
if task:
|
||||||
|
current_task_info = {
|
||||||
|
'id': task.id,
|
||||||
|
'name': task.name,
|
||||||
|
'source': f"{task.source_type}/{task.source_name}"
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'queue_size': status['queue_size'],
|
||||||
|
'is_processing': status['is_processing'],
|
||||||
|
'current_task': current_task_info
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get queue status: {e}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to get queue status"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UserDownloadRequest(BaseModel):
|
||||||
|
limit: int = Field(default=25, ge=1, le=1000)
|
||||||
|
sort: str = Field(default="hot")
|
||||||
|
download_mode: str = Field(default="download", pattern="^(download|archive|clone)$")
|
||||||
|
run_now: bool = Field(default=False)
|
||||||
|
run_time: str = Field(default="02:00", pattern="^([01]?[0-9]|2[0-3]):[0-5][0-9]$")
|
||||||
|
timezone: str = Field(default="UTC")
|
||||||
|
auth_state: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/create-likes", response_model=ScheduledTaskResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_likes_task(
|
||||||
|
request: UserDownloadRequest,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Create a scheduled task to download the user's liked posts.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get current username from auth
|
||||||
|
if not request.auth_state:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Authentication required"
|
||||||
|
)
|
||||||
|
|
||||||
|
oauth_manager = get_oauth_manager()
|
||||||
|
auth_status = oauth_manager.get_auth_status(request.auth_state)
|
||||||
|
if not auth_status["authenticated"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid authentication"
|
||||||
|
)
|
||||||
|
|
||||||
|
username = auth_status["username"]
|
||||||
|
task_name = f"{username} - Liked posts"
|
||||||
|
|
||||||
|
# Validate timezone
|
||||||
|
try:
|
||||||
|
pytz.timezone(request.timezone)
|
||||||
|
except pytz.exceptions.UnknownTimeZoneError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid timezone: {request.timezone}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse run_time
|
||||||
|
hour, minute = map(int, request.run_time.split(':'))
|
||||||
|
run_time_obj = time_type(hour=hour, minute=minute)
|
||||||
|
|
||||||
|
# Create task
|
||||||
|
task = ScheduledTask(
|
||||||
|
name=task_name,
|
||||||
|
enabled=True,
|
||||||
|
source_type="user",
|
||||||
|
source_name=username,
|
||||||
|
download_mode=request.download_mode,
|
||||||
|
limit=request.limit,
|
||||||
|
sort=request.sort,
|
||||||
|
time_filter="day",
|
||||||
|
no_dupes=True,
|
||||||
|
simple_check=False,
|
||||||
|
schedule_frequency="daily",
|
||||||
|
run_time=run_time_obj,
|
||||||
|
timezone=request.timezone,
|
||||||
|
auth_state=request.auth_state,
|
||||||
|
upvoted=True,
|
||||||
|
saved=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate next run time
|
||||||
|
task.next_run_at = calculate_next_run(task)
|
||||||
|
|
||||||
|
# Save to database
|
||||||
|
db.add(task)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(task)
|
||||||
|
|
||||||
|
# Schedule the task
|
||||||
|
schedule_task(task)
|
||||||
|
|
||||||
|
# If run_now is True, queue it immediately
|
||||||
|
if request.run_now:
|
||||||
|
await task_queue.add_task(task.id, priority=1)
|
||||||
|
|
||||||
|
logger.info(f"Created likes task {task.id}: {task.name}")
|
||||||
|
|
||||||
|
return task.to_dict()
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create likes task: {e}", exc_info=True)
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to create likes task: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/create-saved", response_model=ScheduledTaskResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_saved_task(
|
||||||
|
request: UserDownloadRequest,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Create a scheduled task to download the user's saved posts.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get current username from auth
|
||||||
|
if not request.auth_state:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Authentication required"
|
||||||
|
)
|
||||||
|
|
||||||
|
oauth_manager = get_oauth_manager()
|
||||||
|
auth_status = oauth_manager.get_auth_status(request.auth_state)
|
||||||
|
if not auth_status["authenticated"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid authentication"
|
||||||
|
)
|
||||||
|
|
||||||
|
username = auth_status["username"]
|
||||||
|
task_name = f"{username} - Saved posts"
|
||||||
|
|
||||||
|
# Validate timezone
|
||||||
|
try:
|
||||||
|
pytz.timezone(request.timezone)
|
||||||
|
except pytz.exceptions.UnknownTimeZoneError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid timezone: {request.timezone}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse run_time
|
||||||
|
hour, minute = map(int, request.run_time.split(':'))
|
||||||
|
run_time_obj = time_type(hour=hour, minute=minute)
|
||||||
|
|
||||||
|
# Create task
|
||||||
|
task = ScheduledTask(
|
||||||
|
name=task_name,
|
||||||
|
enabled=True,
|
||||||
|
source_type="user",
|
||||||
|
source_name=username,
|
||||||
|
download_mode=request.download_mode,
|
||||||
|
limit=request.limit,
|
||||||
|
sort=request.sort,
|
||||||
|
time_filter="day",
|
||||||
|
no_dupes=True,
|
||||||
|
simple_check=False,
|
||||||
|
schedule_frequency="daily",
|
||||||
|
run_time=run_time_obj,
|
||||||
|
timezone=request.timezone,
|
||||||
|
auth_state=request.auth_state,
|
||||||
|
upvoted=False,
|
||||||
|
saved=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate next run time
|
||||||
|
task.next_run_at = calculate_next_run(task)
|
||||||
|
|
||||||
|
# Save to database
|
||||||
|
db.add(task)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(task)
|
||||||
|
|
||||||
|
# Schedule the task
|
||||||
|
schedule_task(task)
|
||||||
|
|
||||||
|
# If run_now is True, queue it immediately
|
||||||
|
if request.run_now:
|
||||||
|
await task_queue.add_task(task.id, priority=1)
|
||||||
|
|
||||||
|
logger.info(f"Created saved task {task.id}: {task.name}")
|
||||||
|
|
||||||
|
return task.to_dict()
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create saved task: {e}", exc_info=True)
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to create saved task: {str(e)}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
"""
|
||||||
|
Scheduler service for managing scheduled download tasks.
|
||||||
|
Uses APScheduler with task queue for sequential execution.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
|
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
|
||||||
|
from datetime import datetime, time as time_type, timedelta
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
import pytz
|
||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from .database import SessionLocal, DATA_DIR
|
||||||
|
from .models import ScheduledTask, TaskExecutionHistory
|
||||||
|
from .task_queue import task_queue
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Scheduler job store database (separate from main DB)
|
||||||
|
SCHEDULER_DB_PATH = DATA_DIR / "scheduler_jobs.db"
|
||||||
|
|
||||||
|
# Configure job stores
|
||||||
|
jobstores = {
|
||||||
|
'default': SQLAlchemyJobStore(url=f'sqlite:///{SCHEDULER_DB_PATH}')
|
||||||
|
}
|
||||||
|
|
||||||
|
# Configure scheduler for Docker container
|
||||||
|
scheduler = AsyncIOScheduler(
|
||||||
|
jobstores=jobstores,
|
||||||
|
timezone=pytz.UTC, # Container runs in UTC
|
||||||
|
job_defaults={
|
||||||
|
'coalesce': True, # Combine multiple missed executions into one
|
||||||
|
'max_instances': 1, # Only one instance of each job at a time
|
||||||
|
'misfire_grace_time': 3600 # Allow up to 1 hour late execution
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def queue_scheduled_task(task_id: str):
|
||||||
|
"""
|
||||||
|
Called by scheduler at the configured time.
|
||||||
|
Adds task to queue rather than executing immediately.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: UUID of the scheduled task
|
||||||
|
"""
|
||||||
|
logger.info(f"Scheduler triggered for task {task_id}, adding to queue")
|
||||||
|
try:
|
||||||
|
await task_queue.add_task(task_id, priority=0) # Normal priority for scheduled tasks
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to queue scheduled task {task_id}: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_task(task: ScheduledTask):
|
||||||
|
"""
|
||||||
|
Schedule a task to be added to the queue at specified time.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: ScheduledTask model instance
|
||||||
|
"""
|
||||||
|
if not task.enabled:
|
||||||
|
logger.info(f"Skipping scheduling for disabled task {task.id}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Convert user's timezone to UTC for container execution
|
||||||
|
user_tz = pytz.timezone(task.timezone)
|
||||||
|
|
||||||
|
# Parse time
|
||||||
|
if isinstance(task.run_time, str):
|
||||||
|
hour, minute = map(int, task.run_time.split(':')[:2])
|
||||||
|
else:
|
||||||
|
hour = task.run_time.hour
|
||||||
|
minute = task.run_time.minute
|
||||||
|
|
||||||
|
# Create cron trigger with user's timezone
|
||||||
|
trigger = CronTrigger(
|
||||||
|
hour=hour,
|
||||||
|
minute=minute,
|
||||||
|
timezone=user_tz
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add job to scheduler
|
||||||
|
scheduler.add_job(
|
||||||
|
func=queue_scheduled_task,
|
||||||
|
trigger=trigger,
|
||||||
|
args=[task.id],
|
||||||
|
id=str(task.id),
|
||||||
|
replace_existing=True,
|
||||||
|
name=f"{task.name} ({task.source_type}/{task.source_name})"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Scheduled task {task.id} '{task.name}' for {hour:02d}:{minute:02d} {task.timezone}")
|
||||||
|
|
||||||
|
# Update next_run_at
|
||||||
|
next_run = trigger.get_next_fire_time(None, datetime.now(user_tz))
|
||||||
|
if next_run:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
db_task = db.query(ScheduledTask).filter(ScheduledTask.id == task.id).first()
|
||||||
|
if db_task:
|
||||||
|
db_task.next_run_at = next_run
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to schedule task {task.id}: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def unschedule_task(task_id: str):
|
||||||
|
"""
|
||||||
|
Remove a task from the scheduler.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: UUID of the scheduled task
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if scheduler.get_job(task_id):
|
||||||
|
scheduler.remove_job(task_id)
|
||||||
|
logger.info(f"Unscheduled task {task_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to unschedule task {task_id}: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def load_scheduled_tasks():
|
||||||
|
"""
|
||||||
|
Load all enabled scheduled tasks from database and schedule them.
|
||||||
|
Called on application startup.
|
||||||
|
"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
# Check if the required columns exist before querying
|
||||||
|
try:
|
||||||
|
# First, check if we can access the table at all
|
||||||
|
db.query(ScheduledTask).first()
|
||||||
|
|
||||||
|
# Try to query with the new columns - this will fail if columns don't exist
|
||||||
|
tasks = db.query(ScheduledTask).filter(ScheduledTask.enabled == True).all()
|
||||||
|
except Exception as column_error:
|
||||||
|
if "no such column" in str(column_error):
|
||||||
|
logger.warning(f"Database schema is outdated. Required columns missing: {column_error}")
|
||||||
|
logger.info("Attempting to upgrade database schema...")
|
||||||
|
|
||||||
|
# Try to upgrade the database
|
||||||
|
try:
|
||||||
|
from .database import upgrade_database_schema
|
||||||
|
upgrade_database_schema()
|
||||||
|
|
||||||
|
# Now try again to load tasks
|
||||||
|
tasks = db.query(ScheduledTask).filter(ScheduledTask.enabled == True).all()
|
||||||
|
logger.info("Database upgraded successfully, continuing with task loading")
|
||||||
|
except Exception as upgrade_error:
|
||||||
|
logger.error(f"Failed to upgrade database: {upgrade_error}")
|
||||||
|
logger.info("Skipping scheduled task loading until database is manually upgraded")
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
logger.info(f"Loading {len(tasks)} enabled scheduled tasks")
|
||||||
|
|
||||||
|
for task in tasks:
|
||||||
|
schedule_task(task)
|
||||||
|
|
||||||
|
logger.info(f"Loaded {len(tasks)} scheduled tasks")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load scheduled tasks: {e}", exc_info=True)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_scheduled_task(task_id: str):
|
||||||
|
"""
|
||||||
|
Execute a scheduled download task.
|
||||||
|
This function BLOCKS until the download is complete,
|
||||||
|
ensuring sequential execution.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: UUID of the scheduled task
|
||||||
|
"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
# Load task from database
|
||||||
|
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
|
||||||
|
|
||||||
|
if not task:
|
||||||
|
logger.error(f"Task {task_id} not found in database")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not task.enabled:
|
||||||
|
logger.info(f"Skipping disabled task {task_id}")
|
||||||
|
# Still record in history that it was skipped
|
||||||
|
execution = TaskExecutionHistory(
|
||||||
|
task_id=task_id,
|
||||||
|
status='skipped',
|
||||||
|
completed_at=datetime.now(pytz.UTC)
|
||||||
|
)
|
||||||
|
db.add(execution)
|
||||||
|
db.commit()
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Executing scheduled task {task_id}: {task.source_type}/{task.source_name}")
|
||||||
|
|
||||||
|
# Create execution history record
|
||||||
|
execution = TaskExecutionHistory(
|
||||||
|
task_id=task_id,
|
||||||
|
status='running',
|
||||||
|
started_at=datetime.now(pytz.UTC)
|
||||||
|
)
|
||||||
|
db.add(execution)
|
||||||
|
db.commit()
|
||||||
|
execution_id = execution.id
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Import here to avoid circular imports
|
||||||
|
from .main import create_download_with_bdfr_api, bdfr_manager
|
||||||
|
|
||||||
|
# Build download parameters
|
||||||
|
kwargs = {
|
||||||
|
'limit': task.limit,
|
||||||
|
'sort': task.sort,
|
||||||
|
'time_filter': 'day', # Always "day" for daily scheduled tasks
|
||||||
|
'no_dupes': True, # Always enabled for scheduled tasks
|
||||||
|
'simple_check': task.simple_check,
|
||||||
|
'auth_state': task.auth_state,
|
||||||
|
'upvoted': task.upvoted,
|
||||||
|
'saved': task.saved
|
||||||
|
}
|
||||||
|
|
||||||
|
# For user downloads, set submitted appropriately
|
||||||
|
if task.source_type == 'user':
|
||||||
|
# For likes or saved, still need authentication but don't download submitted posts
|
||||||
|
# Set submitted=False when downloading upvoted or saved posts
|
||||||
|
kwargs['submitted'] = not (task.upvoted or task.saved)
|
||||||
|
|
||||||
|
# Ensure auth_state is passed for user downloads requiring authentication
|
||||||
|
if task.upvoted or task.saved:
|
||||||
|
logger.info(f"User download task {task_id} requires authentication for {'likes' if task.upvoted else 'saved'} posts")
|
||||||
|
if not task.auth_state:
|
||||||
|
logger.warning(f"Task {task_id} needs authentication but no auth_state provided")
|
||||||
|
|
||||||
|
# Create download using existing API
|
||||||
|
download_id = await create_download_with_bdfr_api(
|
||||||
|
download_type=task.source_type,
|
||||||
|
name=task.source_name,
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Task {task_id} started as download {download_id}")
|
||||||
|
|
||||||
|
# Update execution record with download_id
|
||||||
|
execution = db.query(TaskExecutionHistory).filter(TaskExecutionHistory.id == execution_id).first()
|
||||||
|
if execution:
|
||||||
|
execution.download_id = download_id
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# **CRITICAL: Wait for download to complete before returning**
|
||||||
|
await wait_for_download_completion(download_id, bdfr_manager)
|
||||||
|
|
||||||
|
# Check final status
|
||||||
|
download_status = bdfr_manager.get_download_status(download_id)
|
||||||
|
|
||||||
|
# Update execution history
|
||||||
|
execution = db.query(TaskExecutionHistory).filter(TaskExecutionHistory.id == execution_id).first()
|
||||||
|
if execution:
|
||||||
|
if download_status and download_status['status'] == 'completed':
|
||||||
|
execution.status = 'success'
|
||||||
|
execution.items_found = download_status.get('items_found', 0)
|
||||||
|
execution.items_downloaded = download_status.get('items_processed', 0)
|
||||||
|
logger.info(f"Task {task_id} completed successfully")
|
||||||
|
else:
|
||||||
|
execution.status = 'failed'
|
||||||
|
execution.error_message = download_status.get('error', 'Unknown error') if download_status else 'Download status not found'
|
||||||
|
logger.error(f"Task {task_id} failed: {execution.error_message}")
|
||||||
|
|
||||||
|
execution.completed_at = datetime.now(pytz.UTC)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Update task timestamps
|
||||||
|
task.last_run_at = datetime.now(pytz.UTC)
|
||||||
|
task.next_run_at = calculate_next_run(task)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Task {task_id} execution error: {e}", exc_info=True)
|
||||||
|
|
||||||
|
# Update execution history with error
|
||||||
|
execution = db.query(TaskExecutionHistory).filter(TaskExecutionHistory.id == execution_id).first()
|
||||||
|
if execution:
|
||||||
|
execution.status = 'failed'
|
||||||
|
execution.error_message = str(e)
|
||||||
|
execution.completed_at = datetime.now(pytz.UTC)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
raise
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_for_download_completion(download_id: str, bdfr_manager, timeout: int = 3600):
|
||||||
|
"""
|
||||||
|
Wait for a download to complete.
|
||||||
|
Polls the download status until it's no longer running.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
download_id: The download to wait for
|
||||||
|
bdfr_manager: BDFRManager instance
|
||||||
|
timeout: Maximum seconds to wait (default 1 hour)
|
||||||
|
"""
|
||||||
|
start_time = datetime.now()
|
||||||
|
check_interval = 5 # Check every 5 seconds
|
||||||
|
|
||||||
|
logger.info(f"Waiting for download {download_id} to complete...")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# Check if timeout exceeded
|
||||||
|
elapsed = (datetime.now() - start_time).total_seconds()
|
||||||
|
if elapsed > timeout:
|
||||||
|
logger.error(f"Download {download_id} timed out after {timeout}s")
|
||||||
|
raise TimeoutError(f"Download exceeded timeout of {timeout}s")
|
||||||
|
|
||||||
|
# Check download status
|
||||||
|
status = bdfr_manager.get_download_status(download_id)
|
||||||
|
|
||||||
|
if not status:
|
||||||
|
logger.warning(f"Download {download_id} status not found, assuming complete")
|
||||||
|
break
|
||||||
|
|
||||||
|
download_status = status.get('status', 'unknown')
|
||||||
|
|
||||||
|
# Check if download is finished (completed, failed, or cancelled)
|
||||||
|
if download_status in ['completed', 'failed', 'cancelled']:
|
||||||
|
logger.info(f"Download {download_id} finished with status: {download_status}")
|
||||||
|
break
|
||||||
|
|
||||||
|
# Still running, wait before checking again
|
||||||
|
await asyncio.sleep(check_interval)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_next_run(task: ScheduledTask) -> datetime:
|
||||||
|
"""
|
||||||
|
Calculate the next run time for a task based on its schedule.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: ScheduledTask instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Next run datetime in UTC
|
||||||
|
"""
|
||||||
|
user_tz = pytz.timezone(task.timezone)
|
||||||
|
now = datetime.now(user_tz)
|
||||||
|
|
||||||
|
# Parse run time
|
||||||
|
if isinstance(task.run_time, str):
|
||||||
|
hour, minute = map(int, task.run_time.split(':')[:2])
|
||||||
|
else:
|
||||||
|
hour = task.run_time.hour
|
||||||
|
minute = task.run_time.minute
|
||||||
|
|
||||||
|
# Calculate next run
|
||||||
|
next_run = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||||
|
|
||||||
|
# If time has passed today, schedule for tomorrow
|
||||||
|
if next_run <= now:
|
||||||
|
next_run += timedelta(days=1)
|
||||||
|
|
||||||
|
# Convert to UTC
|
||||||
|
return next_run.astimezone(pytz.UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def start_scheduler():
|
||||||
|
"""Start the scheduler. Called on application startup."""
|
||||||
|
try:
|
||||||
|
if not scheduler.running:
|
||||||
|
scheduler.start()
|
||||||
|
logger.info("Scheduler started")
|
||||||
|
|
||||||
|
# Set execute callback for task queue
|
||||||
|
task_queue.set_execute_callback(execute_scheduled_task)
|
||||||
|
|
||||||
|
# Load existing tasks
|
||||||
|
load_scheduled_tasks()
|
||||||
|
else:
|
||||||
|
logger.info("Scheduler already running")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to start scheduler: {e}", exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def stop_scheduler():
|
||||||
|
"""Stop the scheduler. Called on application shutdown."""
|
||||||
|
try:
|
||||||
|
if scheduler.running:
|
||||||
|
scheduler.shutdown(wait=True)
|
||||||
|
logger.info("Scheduler stopped")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to stop scheduler: {e}", exc_info=True)
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""
|
||||||
|
Task Queue Manager for sequential execution of scheduled downloads.
|
||||||
|
Ensures only one task runs at a time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Optional, Dict
|
||||||
|
from datetime import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskQueue:
|
||||||
|
"""
|
||||||
|
Manages sequential execution of scheduled download tasks.
|
||||||
|
Ensures only one task runs at a time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.queue: asyncio.Queue = asyncio.Queue()
|
||||||
|
self.current_task: Optional[str] = None # Current task_id being executed
|
||||||
|
self.is_processing: bool = False
|
||||||
|
self.worker_task: Optional[asyncio.Task] = None
|
||||||
|
self._execute_callback = None
|
||||||
|
|
||||||
|
def set_execute_callback(self, callback):
|
||||||
|
"""
|
||||||
|
Set the callback function to execute tasks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
callback: Async function that takes task_id and executes it
|
||||||
|
"""
|
||||||
|
self._execute_callback = callback
|
||||||
|
|
||||||
|
async def add_task(self, task_id: str, priority: int = 0):
|
||||||
|
"""
|
||||||
|
Add a task to the queue.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: UUID of the scheduled task
|
||||||
|
priority: 0 = scheduled (normal), 1 = manual "Run Now" (higher priority)
|
||||||
|
"""
|
||||||
|
task_info = {
|
||||||
|
'task_id': task_id,
|
||||||
|
'priority': priority,
|
||||||
|
'queued_at': datetime.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
# For priority tasks, we need to reorder the queue
|
||||||
|
if priority > 0:
|
||||||
|
# Get all items from queue
|
||||||
|
items = []
|
||||||
|
while not self.queue.empty():
|
||||||
|
try:
|
||||||
|
items.append(await asyncio.wait_for(self.queue.get(), timeout=0.1))
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Add new priority task
|
||||||
|
await self.queue.put(task_info)
|
||||||
|
|
||||||
|
# Re-add other items
|
||||||
|
for item in items:
|
||||||
|
await self.queue.put(item)
|
||||||
|
|
||||||
|
logger.info(f"Priority task {task_id} added to front of queue (queue_size={self.queue.qsize()})")
|
||||||
|
else:
|
||||||
|
await self.queue.put(task_info)
|
||||||
|
logger.info(f"Task {task_id} added to queue (priority={priority}, queue_size={self.queue.qsize()})")
|
||||||
|
|
||||||
|
# Start worker if not already running
|
||||||
|
if not self.is_processing:
|
||||||
|
await self.start_worker()
|
||||||
|
|
||||||
|
async def start_worker(self):
|
||||||
|
"""Start the queue worker if not already running"""
|
||||||
|
if self.worker_task is None or self.worker_task.done():
|
||||||
|
self.worker_task = asyncio.create_task(self._process_queue())
|
||||||
|
logger.info("Queue worker started")
|
||||||
|
|
||||||
|
async def _process_queue(self):
|
||||||
|
"""Process tasks from queue sequentially"""
|
||||||
|
self.is_processing = True
|
||||||
|
logger.info("Queue worker processing started")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
# Wait for next task (with timeout to allow graceful shutdown)
|
||||||
|
try:
|
||||||
|
task_info = await asyncio.wait_for(
|
||||||
|
self.queue.get(),
|
||||||
|
timeout=60.0
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
# Check if queue is empty
|
||||||
|
if self.queue.empty():
|
||||||
|
logger.info("Queue empty, worker stopping")
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
|
||||||
|
task_id = task_info['task_id']
|
||||||
|
self.current_task = task_id
|
||||||
|
|
||||||
|
logger.info(f"Executing task {task_id} from queue (queue_size={self.queue.qsize()})")
|
||||||
|
|
||||||
|
# Execute the task (this will block until download completes)
|
||||||
|
try:
|
||||||
|
if self._execute_callback:
|
||||||
|
await self._execute_callback(task_id)
|
||||||
|
logger.info(f"Task {task_id} completed successfully")
|
||||||
|
else:
|
||||||
|
logger.error(f"No execute callback set, cannot run task {task_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Task {task_id} failed: {e}", exc_info=True)
|
||||||
|
finally:
|
||||||
|
self.current_task = None
|
||||||
|
self.queue.task_done()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Queue worker error: {e}", exc_info=True)
|
||||||
|
|
||||||
|
self.is_processing = False
|
||||||
|
logger.info("Queue worker stopped")
|
||||||
|
|
||||||
|
def get_queue_status(self) -> Dict:
|
||||||
|
"""Get current queue status"""
|
||||||
|
return {
|
||||||
|
'current_task': self.current_task,
|
||||||
|
'queue_size': self.queue.qsize(),
|
||||||
|
'is_processing': self.is_processing
|
||||||
|
}
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
"""Stop the queue worker gracefully"""
|
||||||
|
logger.info("Stopping queue worker...")
|
||||||
|
if self.worker_task and not self.worker_task.done():
|
||||||
|
# Wait for current task to complete
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self.worker_task, timeout=300) # 5 minute timeout
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning("Queue worker did not stop within timeout, cancelling")
|
||||||
|
self.worker_task.cancel()
|
||||||
|
logger.info("Queue worker stopped")
|
||||||
|
|
||||||
|
|
||||||
|
# Global queue instance
|
||||||
|
task_queue = TaskQueue()
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,15 @@
|
|||||||
|
fastapi>=0.100.0
|
||||||
|
uvicorn[standard]>=0.20.0
|
||||||
|
websockets>=10.0
|
||||||
|
jinja2>=3.1.0
|
||||||
|
python-multipart>=0.0.6
|
||||||
|
aiofiles>=0.23.0
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
pydantic>=2.0.0
|
||||||
|
pydantic-settings>=2.0.0
|
||||||
|
requests>=2.25.0
|
||||||
|
httpx>=0.24.0
|
||||||
|
sqlalchemy>=2.0.0
|
||||||
|
alembic>=1.12.0
|
||||||
|
apscheduler>=3.10.0
|
||||||
|
pytz>=2023.3
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
BDFR Web Interface OAuth Setup Helper
|
||||||
|
|
||||||
|
This script helps you set up Reddit OAuth for the BDFR web interface.
|
||||||
|
Run this script to configure your OAuth credentials and redirect URI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def create_env_file():
|
||||||
|
"""Create .env file from template"""
|
||||||
|
env_example = Path(__file__).parent / ".env.example"
|
||||||
|
env_file = Path(__file__).parent / ".env"
|
||||||
|
|
||||||
|
if not env_example.exists():
|
||||||
|
print("❌ Error: .env.example not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if env_file.exists():
|
||||||
|
print("⚠️ .env file already exists")
|
||||||
|
response = input("Do you want to overwrite it? (y/N): ").lower().strip()
|
||||||
|
if response != 'y':
|
||||||
|
print("Setup cancelled")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Copy .env.example to .env
|
||||||
|
with open(env_example, 'r') as src, open(env_file, 'w') as dst:
|
||||||
|
dst.write(src.read())
|
||||||
|
|
||||||
|
print("✅ Created .env file from template")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_oauth_instructions():
|
||||||
|
"""Display OAuth setup instructions"""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("🔐 REDDIT OAUTH SETUP INSTRUCTIONS")
|
||||||
|
print("="*60)
|
||||||
|
print()
|
||||||
|
print("To use the BDFR Web Interface authentication features, you need to:")
|
||||||
|
print()
|
||||||
|
print("1. 📱 CREATE OR UPDATE REDDIT OAUTH APP:")
|
||||||
|
print(" • Go to: https://www.reddit.com/prefs/apps")
|
||||||
|
print(" • Find your app or click 'Create App'")
|
||||||
|
print(" • Set the redirect URI to: http://localhost:8000/auth/callback")
|
||||||
|
print()
|
||||||
|
print("2. 📝 COPY YOUR CREDENTIALS:")
|
||||||
|
print(" • After creating/editing the app, copy the client ID and secret")
|
||||||
|
print(" • These are the values that look like: 7CZHY6AmKweZME5s50SfDGylaPg")
|
||||||
|
print()
|
||||||
|
print("3. ✏️ EDIT YOUR CONFIGURATION:")
|
||||||
|
print(" • Open the .env file that was just created")
|
||||||
|
print(" • Update BDFR_REDIRECT_URI if using a different port/domain")
|
||||||
|
print(" • Update BDFR_CLIENT_ID with your OAuth client ID")
|
||||||
|
print(" • Update BDFR_CLIENT_SECRET with your OAuth client secret")
|
||||||
|
print(" • OR update bdfr/default_config.cfg with your OAuth credentials")
|
||||||
|
print()
|
||||||
|
print("💡 TIP: Use the .env file for web interface configuration")
|
||||||
|
print(" and bdfr/default_config.cfg for CLI tool configuration")
|
||||||
|
print()
|
||||||
|
print("="*60)
|
||||||
|
print()
|
||||||
|
|
||||||
|
input("Press Enter to open the .env file for editing...")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def open_env_file():
|
||||||
|
"""Open .env file in default editor"""
|
||||||
|
env_file = Path(__file__).parent / ".env"
|
||||||
|
|
||||||
|
if not env_file.exists():
|
||||||
|
print("❌ Error: .env file not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f"📝 Opening {env_file} for editing...")
|
||||||
|
|
||||||
|
# Try to open with default editor
|
||||||
|
editor = os.getenv('EDITOR', 'notepad' if os.name == 'nt' else 'nano')
|
||||||
|
|
||||||
|
try:
|
||||||
|
if os.name == 'nt': # Windows
|
||||||
|
os.startfile(env_file)
|
||||||
|
else: # Unix-like
|
||||||
|
os.system(f"{editor} {env_file}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error opening editor: {e}")
|
||||||
|
print(f"📍 Please manually edit the file: {env_file}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main setup function"""
|
||||||
|
print("🚀 BDFR Web Interface OAuth Setup")
|
||||||
|
print("=" * 40)
|
||||||
|
|
||||||
|
# Create .env file
|
||||||
|
if not create_env_file():
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Show instructions
|
||||||
|
if not get_oauth_instructions():
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Open .env file for editing
|
||||||
|
if not open_env_file():
|
||||||
|
print("📝 Please manually edit the .env file with your OAuth settings")
|
||||||
|
print("📍 File location:", Path(__file__).parent / ".env")
|
||||||
|
|
||||||
|
print("\n✅ OAuth setup initiated!")
|
||||||
|
print("📖 Check STARTUP.md for detailed setup instructions")
|
||||||
|
print("🚀 Run 'python start.py' to start the web interface after configuration")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
@echo off
|
||||||
|
REM BDFR Web Interface Startup Script for Windows
|
||||||
|
REM This script provides an easy way to start the BDFR web interface on Windows
|
||||||
|
|
||||||
|
setlocal enabledelayedexpansion
|
||||||
|
|
||||||
|
REM Colors for output (Windows 10+)
|
||||||
|
set "RED=[91m"
|
||||||
|
set "GREEN=[92m"
|
||||||
|
set "YELLOW=[93m"
|
||||||
|
set "BLUE=[94m"
|
||||||
|
set "NC=[0m"
|
||||||
|
|
||||||
|
REM Function to print colored output (simplified for Windows)
|
||||||
|
echo 🌟 BDFR Web Interface Startup
|
||||||
|
echo ==================================================
|
||||||
|
|
||||||
|
REM Check if we're in the right directory
|
||||||
|
if not exist "requirements.txt" (
|
||||||
|
echo ❌ Error: Please run this script from the web_interface directory
|
||||||
|
echo Usage: start.bat
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if not exist "app" (
|
||||||
|
echo ❌ Error: app directory not found
|
||||||
|
echo Please make sure you're in the web_interface directory
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo ℹ️ Checking Python version...
|
||||||
|
|
||||||
|
REM Check Python version
|
||||||
|
python --version > temp_python_version.txt 2>&1
|
||||||
|
set /p PYTHON_VERSION=<temp_python_version.txt
|
||||||
|
del temp_python_version.txt
|
||||||
|
|
||||||
|
echo ✅ Python version: %PYTHON_VERSION%
|
||||||
|
|
||||||
|
REM Check if Python 3.8+ is available (simplified check)
|
||||||
|
echo %PYTHON_VERSION% | findstr /C:"Python 3." >nul
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo ❌ Error: Python 3 is required
|
||||||
|
echo Current version: %PYTHON_VERSION%
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
REM Install dependencies
|
||||||
|
echo ℹ️ Checking and installing dependencies...
|
||||||
|
if exist requirements.txt (
|
||||||
|
echo ℹ️ Installing Python dependencies...
|
||||||
|
python -m pip install -r requirements.txt
|
||||||
|
if !errorlevel! neq 0 (
|
||||||
|
echo ❌ Failed to install dependencies
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
echo ✅ Dependencies installed successfully
|
||||||
|
) else (
|
||||||
|
echo ❌ requirements.txt not found
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
REM Check if BDFR module is available
|
||||||
|
echo ℹ️ Checking BDFR module availability...
|
||||||
|
python -c "import sys; sys.path.insert(0, '../bdfr'); import bdfr.api; print('BDFR API imported successfully')" >nul 2>&1
|
||||||
|
if !errorlevel! neq 0 (
|
||||||
|
echo ⚠️ Warning: BDFR module not found in Python path
|
||||||
|
echo Make sure the parent directory is in your Python path
|
||||||
|
echo Or run this script from the project root directory
|
||||||
|
echo Attempting to install BDFR...
|
||||||
|
cd ..
|
||||||
|
python -m pip install -e .
|
||||||
|
cd web_interface
|
||||||
|
if !errorlevel! neq 0 (
|
||||||
|
echo ❌ Failed to install BDFR
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
echo ✅ BDFR installed successfully
|
||||||
|
) else (
|
||||||
|
echo ✅ BDFR module found
|
||||||
|
)
|
||||||
|
|
||||||
|
REM Start the server
|
||||||
|
echo.
|
||||||
|
echo ==================================================
|
||||||
|
echo ℹ️ Starting BDFR Web Interface...
|
||||||
|
echo ℹ️ Server will be available at: http://localhost:8000
|
||||||
|
echo ℹ️ API documentation at: http://localhost:8000/docs
|
||||||
|
echo ℹ️ Press Ctrl+C to stop the server
|
||||||
|
echo ==================================================
|
||||||
|
|
||||||
|
REM Start uvicorn server
|
||||||
|
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
|
||||||
|
REM This code runs when the server is stopped
|
||||||
|
echo.
|
||||||
|
echo ℹ️ BDFR Web Interface stopped
|
||||||
|
|
||||||
|
pause
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
BDFR Web Interface Startup Script
|
||||||
|
|
||||||
|
This script provides an easy way to start the BDFR web interface with
|
||||||
|
proper dependency management and error handling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import subprocess
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def check_python_version():
|
||||||
|
"""Check if Python version is compatible (3.8+)"""
|
||||||
|
if sys.version_info < (3, 8):
|
||||||
|
print("ERROR: Python 3.8 or higher is required")
|
||||||
|
print(f"Current version: {sys.version}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def install_dependencies():
|
||||||
|
"""Install required dependencies if missing"""
|
||||||
|
requirements_path = Path(__file__).parent / "requirements.txt"
|
||||||
|
|
||||||
|
if not requirements_path.exists():
|
||||||
|
print("❌ Error: requirements.txt not found")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print("Checking and installing dependencies...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try to import required modules first
|
||||||
|
required_modules = [
|
||||||
|
'fastapi',
|
||||||
|
'uvicorn',
|
||||||
|
'websockets',
|
||||||
|
'jinja2'
|
||||||
|
]
|
||||||
|
|
||||||
|
missing_modules = []
|
||||||
|
for module in required_modules:
|
||||||
|
if not importlib.util.find_spec(module):
|
||||||
|
missing_modules.append(module)
|
||||||
|
|
||||||
|
if missing_modules:
|
||||||
|
print(f"Installing missing modules: {', '.join(missing_modules)}")
|
||||||
|
subprocess.check_call([
|
||||||
|
sys.executable, '-m', 'pip', 'install', '-r', str(requirements_path)
|
||||||
|
])
|
||||||
|
else:
|
||||||
|
print("All dependencies are already installed")
|
||||||
|
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"❌ Error installing dependencies: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error checking dependencies: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def check_bdfr_module():
|
||||||
|
"""Check if BDFR module is available"""
|
||||||
|
try:
|
||||||
|
importlib.util.find_spec('bdfr')
|
||||||
|
print("BDFR module found")
|
||||||
|
except ImportError:
|
||||||
|
print("⚠️ Warning: BDFR module not found in Python path")
|
||||||
|
print("Make sure the parent directory is in your Python path or run from project root")
|
||||||
|
|
||||||
|
|
||||||
|
def start_server():
|
||||||
|
"""Start the FastAPI server"""
|
||||||
|
print("Starting BDFR Web Interface...")
|
||||||
|
print("Server will be available at: http://localhost:8000")
|
||||||
|
print("API documentation at: http://localhost:8000/docs")
|
||||||
|
print("Press Ctrl+C to stop the server")
|
||||||
|
print("-" * 50)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Start uvicorn server
|
||||||
|
subprocess.call([
|
||||||
|
sys.executable, '-m', 'uvicorn',
|
||||||
|
'app.main:app',
|
||||||
|
'--host', '0.0.0.0',
|
||||||
|
'--port', '8000',
|
||||||
|
'--reload'
|
||||||
|
])
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n🛑 Server stopped by user")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error starting server: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main startup function"""
|
||||||
|
print("BDFR Web Interface Startup")
|
||||||
|
print("=" * 40)
|
||||||
|
|
||||||
|
# Change to web_interface directory
|
||||||
|
web_interface_dir = Path(__file__).parent
|
||||||
|
os.chdir(web_interface_dir)
|
||||||
|
|
||||||
|
# Pre-flight checks
|
||||||
|
check_python_version()
|
||||||
|
install_dependencies()
|
||||||
|
check_bdfr_module()
|
||||||
|
|
||||||
|
print("\n" + "=" * 40)
|
||||||
|
|
||||||
|
# Start the server
|
||||||
|
start_server()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# BDFR Web Interface Startup Script
|
||||||
|
# Compatible with Linux, macOS, and other Unix-like systems
|
||||||
|
|
||||||
|
set -e # Exit on any error
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Function to print colored output
|
||||||
|
print_info() {
|
||||||
|
echo -e "${BLUE}ℹ️ $1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_success() {
|
||||||
|
echo -e "${GREEN}✅ $1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_warning() {
|
||||||
|
echo -e "${YELLOW}⚠️ $1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_error() {
|
||||||
|
echo -e "${RED}❌ $1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if we're in the right directory
|
||||||
|
if [[ ! -f "requirements.txt" ]] || [[ ! -d "app" ]]; then
|
||||||
|
print_error "Error: Please run this script from the web_interface directory"
|
||||||
|
echo "Usage: ./start.sh"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
print_info "BDFR Web Interface Startup"
|
||||||
|
echo "=================================================="
|
||||||
|
|
||||||
|
# Check Python version
|
||||||
|
print_info "Checking Python version..."
|
||||||
|
PYTHON_VERSION=$(python3 --version 2>&1 | awk '{print $2}')
|
||||||
|
print_success "Python version: $PYTHON_VERSION"
|
||||||
|
|
||||||
|
# Check if Python 3.8+ is available
|
||||||
|
PYTHON_MAJOR=$(echo $PYTHON_VERSION | cut -d. -f1)
|
||||||
|
PYTHON_MINOR=$(echo $PYTHON_VERSION | cut -d. -f2)
|
||||||
|
|
||||||
|
if [[ $PYTHON_MAJOR -lt 3 ]] || [[ $PYTHON_MAJOR -eq 3 && $PYTHON_MINOR -lt 8 ]]; then
|
||||||
|
print_error "Python 3.8 or higher is required"
|
||||||
|
print_error "Current version: $PYTHON_VERSION"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
print_info "Checking and installing dependencies..."
|
||||||
|
if [[ -f "requirements.txt" ]]; then
|
||||||
|
# Check if pip is available
|
||||||
|
if ! command -v pip3 &> /dev/null; then
|
||||||
|
print_error "pip3 is not installed. Please install Python 3 and pip first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install/update requirements
|
||||||
|
print_info "Installing Python dependencies..."
|
||||||
|
pip3 install -r requirements.txt
|
||||||
|
|
||||||
|
if [[ $? -eq 0 ]]; then
|
||||||
|
print_success "Dependencies installed successfully"
|
||||||
|
else
|
||||||
|
print_error "Failed to install dependencies"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
print_error "requirements.txt not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if BDFR module is available
|
||||||
|
print_info "Checking BDFR module availability..."
|
||||||
|
if python3 -c "import bdfr" 2>/dev/null; then
|
||||||
|
print_success "BDFR module found"
|
||||||
|
else
|
||||||
|
print_warning "BDFR module not found in Python path"
|
||||||
|
print_warning "Make sure the parent directory is in your Python path"
|
||||||
|
print_warning "Or run this script from the project root directory"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start the server
|
||||||
|
echo ""
|
||||||
|
echo "=================================================="
|
||||||
|
print_info "Starting BDFR Web Interface..."
|
||||||
|
print_info "Server will be available at: http://localhost:8000"
|
||||||
|
print_info "API documentation at: http://localhost:8000/docs"
|
||||||
|
print_info "Press Ctrl+C to stop the server"
|
||||||
|
echo "=================================================="
|
||||||
|
|
||||||
|
# Start uvicorn server with proper error handling
|
||||||
|
python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
|
||||||
|
# This code runs when the server is stopped
|
||||||
|
echo ""
|
||||||
|
print_info "BDFR Web Interface stopped"
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Authentication Failed - BDFR Web Interface</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.error-container {
|
||||||
|
background: white;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
|
||||||
|
text-align: center;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
.error-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
color: #f44336;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.error-title {
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
.error-message {
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.error-details {
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
text-align: left;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.retry-button {
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 1rem;
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
.retry-button:hover {
|
||||||
|
background: #5a67d8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="error-container">
|
||||||
|
<div class="error-icon">❌</div>
|
||||||
|
<h1 class="error-title">Authentication Failed</h1>
|
||||||
|
<p class="error-message">
|
||||||
|
There was an error during the authentication process. This might be due to:
|
||||||
|
</p>
|
||||||
|
<ul style="text-align: left; color: #666; margin: 1rem 0;">
|
||||||
|
<li>Invalid or expired authorization code</li>
|
||||||
|
<li>Mismatched redirect URI configuration</li>
|
||||||
|
<li>Reddit OAuth app not properly configured</li>
|
||||||
|
</ul>
|
||||||
|
<div class="error-details">{{ error }}</div>
|
||||||
|
<a href="/?auth_error=true" class="retry-button">Return to Main Page</a>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Authentication Successful - BDFR Web Interface</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.success-container {
|
||||||
|
background: white;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
|
||||||
|
text-align: center;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
.success-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
color: #4CAF50;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.success-title {
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
.success-message {
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.redirect-message {
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.spinner {
|
||||||
|
border: 3px solid #f3f3f3;
|
||||||
|
border-top: 3px solid #667eea;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="success-container">
|
||||||
|
<div class="success-icon">✅</div>
|
||||||
|
<h1 class="success-title">Authentication Successful!</h1>
|
||||||
|
<p class="success-message">
|
||||||
|
You have successfully authenticated with Reddit. You can now use all features of the BDFR Web Interface.
|
||||||
|
</p>
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p class="redirect-message">
|
||||||
|
Redirecting you back to the main interface...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Redirect to main page after 3 seconds
|
||||||
|
setTimeout(function() {
|
||||||
|
window.location.href = '/?authenticated=true';
|
||||||
|
}, 3000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>BDFR Web Interface</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>Bulk Downloader for Reddit</h1>
|
||||||
|
<p class="subtitle">Web Interface</p>
|
||||||
|
|
||||||
|
<!-- Authentication Status -->
|
||||||
|
<div id="authSection" class="auth-section" style="display: none;">
|
||||||
|
<div class="auth-status">
|
||||||
|
<div class="auth-info">
|
||||||
|
<span class="auth-label">Reddit Account:</span>
|
||||||
|
<span id="authUser" class="auth-user">-</span>
|
||||||
|
<span id="authStatus" class="auth-status-indicator">🔴 Not Connected</span>
|
||||||
|
</div>
|
||||||
|
<div class="auth-actions">
|
||||||
|
<button id="loginBtn" class="btn btn-small btn-outline">🔐 Login with Reddit</button>
|
||||||
|
<button id="logoutBtn" class="btn btn-small btn-outline" style="display: none;">🚪
|
||||||
|
Logout</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<!-- User Downloads Section -->
|
||||||
|
<section class="user-downloads-section" id="userDownloadsSection" style="display: none;">
|
||||||
|
<div class="form-container-unified">
|
||||||
|
<div class="form-card-unified">
|
||||||
|
<h2>📥 My Downloads</h2>
|
||||||
|
<p>Download your liked and saved posts from Reddit.</p>
|
||||||
|
|
||||||
|
<!-- Download Mode Selection -->
|
||||||
|
<div class="form-section">
|
||||||
|
<h4>🎯 Download Mode</h4>
|
||||||
|
<div class="radio-group mode-radio-group">
|
||||||
|
<label class="radio-label mode-option"
|
||||||
|
data-tooltip="Download media files (images, videos, gifs) from posts">
|
||||||
|
<input type="radio" name="user_download_mode" value="download" checked>
|
||||||
|
<span class="radio-custom"></span>
|
||||||
|
<span class="mode-label">
|
||||||
|
<strong>Download</strong>
|
||||||
|
<small>Media files only</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="radio-label mode-option"
|
||||||
|
data-tooltip="Save post metadata (title, author, comments) as JSON/XML without downloading media">
|
||||||
|
<input type="radio" name="user_download_mode" value="archive">
|
||||||
|
<span class="radio-custom"></span>
|
||||||
|
<span class="mode-label">
|
||||||
|
<strong>Archive</strong>
|
||||||
|
<small>Metadata only</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="radio-label mode-option"
|
||||||
|
data-tooltip="Download media files AND save metadata - complete backup of posts">
|
||||||
|
<input type="radio" name="user_download_mode" value="clone">
|
||||||
|
<span class="radio-custom"></span>
|
||||||
|
<span class="mode-label">
|
||||||
|
<strong>Clone</strong>
|
||||||
|
<small>Media + Metadata</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scheduling Options -->
|
||||||
|
<div class="form-section">
|
||||||
|
<h4>⏰ Scheduling</h4>
|
||||||
|
<div class="radio-group">
|
||||||
|
<label class="radio-label">
|
||||||
|
<input type="radio" name="user_schedule_type" value="now" checked>
|
||||||
|
<span class="radio-custom"></span>
|
||||||
|
Run Now
|
||||||
|
</label>
|
||||||
|
<label class="radio-label">
|
||||||
|
<input type="radio" name="user_schedule_type" value="scheduled">
|
||||||
|
<span class="radio-custom"></span>
|
||||||
|
Schedule for Later
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scheduled Options (shown when Schedule for Later is selected) -->
|
||||||
|
<div id="userScheduleOptions" class="schedule-options" style="display: none;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="userRunTime">Run Time (24-hour format):</label>
|
||||||
|
<input type="time" id="userRunTime" name="user_run_time" value="02:00">
|
||||||
|
<small class="form-help">Time to run the download daily (in your local
|
||||||
|
timezone)</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="form-section">
|
||||||
|
<h4>🚀 Actions</h4>
|
||||||
|
<div class="user-actions">
|
||||||
|
<button id="downloadLikesBtn" class="btn btn-primary">❤️ Download My Likes</button>
|
||||||
|
<button id="downloadSavedBtn" class="btn btn-primary">⭐ Download My Saved Posts</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<!-- Unified Download Form Section -->
|
||||||
|
<section class="download-section">
|
||||||
|
<div class="form-container-unified">
|
||||||
|
<div class="form-card-unified">
|
||||||
|
<h2>📥 Download Reddit Content</h2>
|
||||||
|
<form id="unifiedForm" class="download-form">
|
||||||
|
<!-- Mode Selection -->
|
||||||
|
<div class="form-section mode-section">
|
||||||
|
<h4>🎯 Download Mode</h4>
|
||||||
|
<div class="radio-group mode-radio-group">
|
||||||
|
<label class="radio-label mode-option"
|
||||||
|
data-tooltip="Download media files (images, videos, gifs) from posts">
|
||||||
|
<input type="radio" name="download_mode" value="download" checked>
|
||||||
|
<span class="radio-custom"></span>
|
||||||
|
<span class="mode-label">
|
||||||
|
<strong>Download</strong>
|
||||||
|
<small>Media files only</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="radio-label mode-option"
|
||||||
|
data-tooltip="Save post metadata (title, author, comments) as JSON/XML without downloading media">
|
||||||
|
<input type="radio" name="download_mode" value="archive">
|
||||||
|
<span class="radio-custom"></span>
|
||||||
|
<span class="mode-label">
|
||||||
|
<strong>Archive</strong>
|
||||||
|
<small>Metadata only</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="radio-label mode-option"
|
||||||
|
data-tooltip="Download media files AND save metadata - complete backup of posts">
|
||||||
|
<input type="radio" name="download_mode" value="clone">
|
||||||
|
<span class="radio-custom"></span>
|
||||||
|
<span class="mode-label">
|
||||||
|
<strong>Clone</strong>
|
||||||
|
<small>Media + Metadata</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Source Type Selection -->
|
||||||
|
<div class="form-section">
|
||||||
|
<h4>📍 Source Type</h4>
|
||||||
|
<div class="radio-group">
|
||||||
|
<label class="radio-label">
|
||||||
|
<input type="radio" name="source_type" value="subreddit" checked>
|
||||||
|
<span class="radio-custom"></span>
|
||||||
|
Subreddit
|
||||||
|
</label>
|
||||||
|
<label class="radio-label">
|
||||||
|
<input type="radio" name="source_type" value="user">
|
||||||
|
<span class="radio-custom"></span>
|
||||||
|
User
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Source Name Input -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="sourceName" id="sourceNameLabel">Subreddit Name:</label>
|
||||||
|
<input type="text" id="sourceName" name="source_name"
|
||||||
|
placeholder="e.g., python, machinelearning" required>
|
||||||
|
<small class="form-help" id="sourceNameHelp">Enter subreddit name without 'r/'</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter Options -->
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="limit">Limit:</label>
|
||||||
|
<input type="number" id="limit" name="limit" value="25" min="1" max="1000">
|
||||||
|
<small class="form-help">Max posts to process (1-1000)</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="sort">Sort by:</label>
|
||||||
|
<select id="sort" name="sort">
|
||||||
|
<option value="hot">Hot</option>
|
||||||
|
<option value="top" selected>Top</option>
|
||||||
|
<option value="new">New</option>
|
||||||
|
<option value="rising">Rising</option>
|
||||||
|
<option value="controversial">Controversial</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="timeFilter">Time Filter:</label>
|
||||||
|
<select id="timeFilter" name="time_filter">
|
||||||
|
<option value="">All Time</option>
|
||||||
|
<option value="hour">Past Hour</option>
|
||||||
|
<option value="day">Past Day</option>
|
||||||
|
<option value="week">Past Week</option>
|
||||||
|
<option value="month">Past Month</option>
|
||||||
|
<option value="year">Past Year</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="minScore">Min Score:</label>
|
||||||
|
<input type="number" id="minScore" name="min_score" value="" placeholder="0">
|
||||||
|
<small class="form-help">Minimum upvotes</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Advanced Options -->
|
||||||
|
<div class="form-section">
|
||||||
|
<h4>⚙️ Advanced Options</h4>
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" id="noDupes" name="no_dupes">
|
||||||
|
<span class="checkmark"></span>
|
||||||
|
Avoid Duplicates
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="checkbox-label" style="margin-left: 30px; font-size: 0.9em;">
|
||||||
|
<input type="checkbox" id="simpleCheck" name="simple_check">
|
||||||
|
<span class="checkmark"></span>
|
||||||
|
Use Simple Check (faster URL-based detection)
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" id="makeHardLinks" name="make_hard_links">
|
||||||
|
<span class="checkmark"></span>
|
||||||
|
Create Hard Links
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" id="runDaily" name="run_daily">
|
||||||
|
<span class="checkmark"></span>
|
||||||
|
Run Daily (Scheduled Download)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scheduled Download Options (shown when Run Daily is checked) -->
|
||||||
|
<div id="scheduleOptions" class="schedule-options" style="display: none;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="taskName">Task Name:</label>
|
||||||
|
<input type="text" id="taskName" name="task_name"
|
||||||
|
placeholder="e.g., Daily Python Posts">
|
||||||
|
<small class="form-help">A friendly name to identify this scheduled task</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="runTime">Run Time (24-hour format):</label>
|
||||||
|
<input type="time" id="runTime" name="run_time" value="02:00">
|
||||||
|
<small class="form-help">Time to run the download daily (in your local
|
||||||
|
timezone)</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="hidden" id="authState" name="auth_state" value="">
|
||||||
|
<button type="submit" class="btn btn-primary">🚀 Start Download</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Progress Section -->
|
||||||
|
<section class="progress-section">
|
||||||
|
<h2>📊 Download Progress</h2>
|
||||||
|
<div id="progressContainer" class="progress-container">
|
||||||
|
<div class="no-downloads">
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">📥</div>
|
||||||
|
<p>No active downloads</p>
|
||||||
|
<p>Start a download above to see progress here.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Downloads List -->
|
||||||
|
<div id="downloadsList" class="downloads-list" style="display: none;">
|
||||||
|
<div class="downloads-header">
|
||||||
|
<h3>Active Downloads</h3>
|
||||||
|
</div>
|
||||||
|
<div id="downloadsItems" class="downloads-items"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Scheduled Downloads Section -->
|
||||||
|
<section class="scheduled-section">
|
||||||
|
<div class="scheduled-header">
|
||||||
|
<h2>📅 Scheduled Downloads</h2>
|
||||||
|
<div class="queue-status" id="queueStatus" style="display: none;">
|
||||||
|
<span class="queue-badge">⏳ Queue: <span id="queueCount">0</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="scheduledContainer" class="scheduled-container">
|
||||||
|
<div class="no-tasks">
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">📅</div>
|
||||||
|
<p>No scheduled downloads</p>
|
||||||
|
<p>Check "Run Daily" above to schedule automatic downloads.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scheduled Tasks List -->
|
||||||
|
<div id="scheduledList" class="scheduled-list" style="display: none;">
|
||||||
|
<div id="scheduledItems" class="scheduled-items"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Status Section -->
|
||||||
|
<section class="status-section">
|
||||||
|
<div class="status-card">
|
||||||
|
<h3>System Status</h3>
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="status-label">BDFR Status:</span>
|
||||||
|
<span id="bdfrStatus" class="status-value">Checking...</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="status-label">WebSocket:</span>
|
||||||
|
<span id="wsStatus" class="status-value">Disconnected</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>© 2025 BDFR Web Interface. Powered by FastAPI.</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user