Compare commits

..
6 Commits
Author SHA1 Message Date
ModerateWinGuy f3dc8e46fd fixes for file naming
formatting_check / formatting_check (push) Failing after 6s
Python Test / test (.sh, ubuntu-latest, 3.9) (push) Failing after 16s
Python Test / test (.ps1, windows-latest, 3.9) (push) Has been cancelled
Python Test / test (.sh, macos-latest, 3.9) (push) Has been cancelled
2026-07-14 21:11:24 +12:00
ModerateWinGuy 8f8e2c744d fixed file naming thingy 2025-11-06 21:53:32 +13:00
ModerateWinGuy 6d9a078656 Fixed issue where file extenions not found and auth timeout 2025-10-24 13:43:03 +13:00
ModerateWinGuy 7580dc3f94 Fixed misc bugs 2025-10-15 13:31:18 +13:00
ModerateWinGuy 9f5a25fcf5 feat(UI): added docker image and config 2025-10-10 11:15:28 +13:00
ModerateWinGuy 9e61d18bf6 feat(UI): initial working frontend UI 2025-10-09 17:12:55 +13:00
60 changed files with 12638 additions and 23 deletions
+112
View File
@@ -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
View File
@@ -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
+514
View File
@@ -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)
+262
View File
@@ -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
View File
@@ -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"]
+25
View File
@@ -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.
+839
View File
@@ -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.
+430
View File
@@ -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
+1
View File
@@ -26,6 +26,7 @@ _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),
+1397
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -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,10 @@ 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:
if submission is not None:
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}")
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)
+4
View File
@@ -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,9 @@ 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:
if submission is not None:
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}")
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)
+1
View File
@@ -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
+38 -2
View File
@@ -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:
# Try to use a fallback location that avoids importlib.resources context manager issues
# when running as non-root user in Docker
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: with importlib.resources.path("bdfr", "default_config.cfg") as path:
self.config_location = path self.config_location = path
shutil.copy(self.config_location, Path(self.config_directory, "default_config.cfg")) 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,20 @@ 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 +441,7 @@ 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:
+10 -4
View File
@@ -84,7 +84,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 +149,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
@@ -166,6 +168,7 @@ class RedditDownloader(RedditConnector):
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,7 +188,6 @@ 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:
@@ -213,10 +215,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)
+409
View File
@@ -0,0 +1,409 @@
#!/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,
DownloadType,
DownloadStatus,
ProgressEvent,
ProgressCallback,
LoggingCallback,
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", # username
limit=25, # 25 posts
submitted=True,
upvoted=False,
saved=False
)
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())
+38 -1
View File
@@ -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,33 @@ 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")
+82 -2
View File
@@ -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,76 @@ 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]:
+16 -4
View File
@@ -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:
max_retries = 3
for attempt in range(1, max_retries + 1):
try: try:
res = requests.get(url, cookies=cookies, headers=headers) res = requests.get(url, cookies=cookies, headers=headers, timeout=10)
except requests.exceptions.RequestException as e:
logger.exception(e)
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}")
if attempt == max_retries:
raise ResourceNotFound(f"Server responded with {res.status_code} to {url}") raise ResourceNotFound(f"Server responded with {res.status_code} to {url}")
else:
return res 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,14 @@ 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
+8
View File
@@ -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
+8
View File
@@ -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.
+61
View File
@@ -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:
+56
View File
@@ -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 "$@"
+132
View File
@@ -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
+110
View File
@@ -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
+157
View File
@@ -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
```
+96
View File
@@ -0,0 +1,96 @@
#!/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 tempfile
import shutil
from pathlib import Path
from unittest.mock import MagicMock
# Add the bdfr module to the path
import sys
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,80 @@
#!/usr/bin/env python3
"""
Test extension case normalization functionality
"""
import pytest
from unittest.mock import MagicMock
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}"
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""
Test script to debug file extension detection issues
"""
import sys
import os
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()
+148
View File
@@ -0,0 +1,148 @@
#!/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
import tempfile
import time
from pathlib import Path
# Add the bdfr module to the path
import sys
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)
+33
View File
@@ -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"
+161
View File
@@ -0,0 +1,161 @@
#!/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)
+35
View File
@@ -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
*~
+35
View File
@@ -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"]
+202
View File
@@ -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.
+319
View File
@@ -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
+177
View File
@@ -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
+311
View File
@@ -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
+159
View File
@@ -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
+136
View File
@@ -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
}
+676
View File
@@ -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)}"
)
+400
View File
@@ -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)
+148
View File
@@ -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.
+15
View File
@@ -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
+123
View File
@@ -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())
+105
View File
@@ -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
+119
View File
@@ -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()
+105
View File
@@ -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
+86
View File
@@ -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>
+82
View File
@@ -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>
+342
View File
@@ -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>&copy; 2025 BDFR Web Interface. Powered by FastAPI.</p>
</footer>
</div>
<script src="/static/js/app.js"></script>
</body>
</html>