feat(UI): initial working frontend UI
This commit is contained in:
@@ -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
|
||||||
+1290
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -386,6 +386,18 @@ 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
|
||||||
|
elif (hasattr(e, "response") and getattr(e.response, "status_code", None) == 429) or "429" in str(e):
|
||||||
|
is_rate_limited = True
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
+1
-1
@@ -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)
|
||||||
|
|||||||
@@ -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: {event.progress:.1f}%")
|
||||||
|
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: {status['progress']:.1f}%")
|
||||||
|
|
||||||
|
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']} ({status['progress']:.1f}%)")
|
||||||
|
|
||||||
|
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: {status['progress']:.1f}%")
|
||||||
|
|
||||||
|
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: {status['progress']:.1f}%")
|
||||||
|
|
||||||
|
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']} ({download['progress']:.1f}%)")
|
||||||
|
|
||||||
|
# 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())
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
env
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
.tox
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.pytest_cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.log
|
||||||
|
.git
|
||||||
|
.mypy_cache
|
||||||
|
.pytest_cache
|
||||||
|
.hypothesis
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# BDFR Web Interface Configuration
|
||||||
|
# Copy this file to .env and update the values as needed
|
||||||
|
|
||||||
|
# Reddit OAuth Configuration
|
||||||
|
# You MUST set this to match your Reddit OAuth app settings
|
||||||
|
# Go to https://www.reddit.com/prefs/apps, create/edit your app, and use the exact redirect URI
|
||||||
|
BDFR_REDIRECT_URI=http://localhost:8000/auth/callback
|
||||||
|
|
||||||
|
# OAuth Credentials (from your Reddit OAuth app)
|
||||||
|
# Get these from: https://www.reddit.com/prefs/apps
|
||||||
|
BDFR_CLIENT_ID=your_client_id_here
|
||||||
|
BDFR_CLIENT_SECRET=your_client_secret_here
|
||||||
|
|
||||||
|
# Server Configuration (optional)
|
||||||
|
# HOST=0.0.0.0
|
||||||
|
# PORT=8000
|
||||||
|
# DEBUG=true
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy requirements first for better caching
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY app/ ./app/
|
||||||
|
COPY templates/ ./templates/
|
||||||
|
COPY static/ ./static/
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN useradd --create-home --shell /bin/bash app \
|
||||||
|
&& chown -R app:app /app
|
||||||
|
USER app
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8000/health || exit 1
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
CMD ["python", "/app/app/main.py"]
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
# BDFR Web Interface
|
||||||
|
|
||||||
|
A modern web interface for the Bulk Downloader for Reddit (BDFR) built with FastAPI, WebSockets, and vanilla JavaScript.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Modern UI**: Clean, responsive design with gradient backgrounds and smooth animations
|
||||||
|
- **Real-time Progress**: WebSocket-based progress updates for active downloads
|
||||||
|
- **Subreddit Downloads**: Download posts from any subreddit with customizable limits and sorting
|
||||||
|
- **User Downloads**: Download posts from specific users
|
||||||
|
- **Status Monitoring**: Real-time system status and connection monitoring
|
||||||
|
- **Form Validation**: Client-side validation with visual feedback
|
||||||
|
- **Error Handling**: Comprehensive error handling with user-friendly notifications
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
web_interface/
|
||||||
|
├── app/
|
||||||
|
│ └── main.py # FastAPI application
|
||||||
|
├── static/
|
||||||
|
│ ├── css/
|
||||||
|
│ │ └── style.css # Modern CSS styling
|
||||||
|
│ └── js/
|
||||||
|
│ └── app.js # WebSocket client and form handling
|
||||||
|
├── templates/
|
||||||
|
│ └── index.html # Main web interface
|
||||||
|
└── requirements.txt # Python dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. **Install Dependencies**:
|
||||||
|
```bash
|
||||||
|
cd web_interface
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Run the Application**:
|
||||||
|
```bash
|
||||||
|
cd app
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Access the Interface**:
|
||||||
|
Open your browser and navigate to `http://localhost:8000`
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Download Endpoints
|
||||||
|
- `POST /api/download/subreddit` - Start subreddit download
|
||||||
|
- `POST /api/download/user` - Start user download
|
||||||
|
- `GET /api/downloads` - List all active downloads
|
||||||
|
- `GET /api/downloads/{download_id}` - Get specific download status
|
||||||
|
- `DELETE /api/downloads/{download_id}` - Cancel download
|
||||||
|
|
||||||
|
### WebSocket
|
||||||
|
- `ws://localhost:8000/ws/progress` - Real-time progress updates
|
||||||
|
|
||||||
|
### Status Endpoints
|
||||||
|
- `GET /` - Main web interface
|
||||||
|
- `GET /health` - Health check
|
||||||
|
- `GET /api/bdfr/status` - BDFR system status
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The application uses the following default settings:
|
||||||
|
- **Host**: `0.0.0.0`
|
||||||
|
- **Port**: `8000`
|
||||||
|
- **WebSocket Path**: `/ws/progress`
|
||||||
|
- **Static Files**: Served from `/static`
|
||||||
|
|
||||||
|
## Docker Support
|
||||||
|
|
||||||
|
To run with Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build the image
|
||||||
|
docker build -t bdfr-web-interface .
|
||||||
|
|
||||||
|
# Run the container
|
||||||
|
docker run -p 8000:8000 bdfr-web-interface
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Adding New Features
|
||||||
|
|
||||||
|
1. **Backend Changes**: Modify `app/main.py` to add new endpoints
|
||||||
|
2. **Frontend Changes**: Update `templates/index.html` for UI changes
|
||||||
|
3. **Styling**: Modify `static/css/style.css` for visual changes
|
||||||
|
4. **JavaScript**: Update `static/js/app.js` for client-side functionality
|
||||||
|
|
||||||
|
### WebSocket Integration
|
||||||
|
|
||||||
|
The WebSocket connection automatically handles:
|
||||||
|
- Connection establishment and reconnection
|
||||||
|
- Progress updates from the server
|
||||||
|
- Error handling and user notifications
|
||||||
|
- Real-time UI updates
|
||||||
|
|
||||||
|
### Form Handling
|
||||||
|
|
||||||
|
Both download forms include:
|
||||||
|
- Input validation
|
||||||
|
- Loading states
|
||||||
|
- Success/error notifications
|
||||||
|
- Automatic form reset on success
|
||||||
|
|
||||||
|
## Integration with BDFR
|
||||||
|
|
||||||
|
### Direct BDFR API Integration
|
||||||
|
|
||||||
|
This interface uses the direct BDFR API integration, eliminating the need for subprocess console parsing:
|
||||||
|
|
||||||
|
- `/api/download/subreddit` - Downloads from subreddits using `BDFRManager.download_subreddit()`
|
||||||
|
- `/api/download/user` - Downloads from users using `BDFRManager.download_user()`
|
||||||
|
- `/api/bdfr/status` - Returns BDFR system status and capabilities
|
||||||
|
- `/ws/progress` - Provides real-time progress updates via WebSocket
|
||||||
|
|
||||||
|
The web interface imports `BDFRManager` directly from `bdfr.api` and uses structured progress callbacks for seamless integration.
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
|
||||||
|
**Previous Approach (Subprocess-based)**:
|
||||||
|
- Used `subprocess.Popen` to start BDFR CLI
|
||||||
|
- Parsed console output with regex for progress updates
|
||||||
|
- Required `BDFRRunner` class for process management
|
||||||
|
- Used `threading.Thread` and `queue.Queue` for coordination
|
||||||
|
|
||||||
|
**Current Approach (Direct API)**:
|
||||||
|
- Direct integration with `BDFRManager` from `bdfr.api`
|
||||||
|
- Structured `ProgressEvent` callbacks instead of console parsing
|
||||||
|
- Thread-safe progress tracking with `ProgressCallback` interface
|
||||||
|
- No subprocess overhead or console output parsing required
|
||||||
|
|
||||||
|
The migration provides better error handling, structured progress events, and eliminates console parsing complexity.
|
||||||
|
|
||||||
|
## Browser Support
|
||||||
|
|
||||||
|
- Modern browsers with WebSocket support
|
||||||
|
- Chrome 60+
|
||||||
|
- Firefox 55+
|
||||||
|
- Safari 11+
|
||||||
|
- Edge 79+
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- CORS is enabled for all origins (configure for production)
|
||||||
|
- Input validation on both client and server
|
||||||
|
- No authentication implemented (add as needed)
|
||||||
|
- WebSocket connections are not secured (use WSS in production)
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
For production deployment:
|
||||||
|
|
||||||
|
1. Configure CORS for specific origins
|
||||||
|
2. Add authentication/authorization
|
||||||
|
3. Use HTTPS/WSS for secure connections
|
||||||
|
4. Configure proper logging
|
||||||
|
5. Set up reverse proxy (nginx recommended)
|
||||||
|
6. Add rate limiting
|
||||||
|
7. Configure environment variables
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **WebSocket Connection Failed**:
|
||||||
|
- Check if the server is running
|
||||||
|
- Verify firewall settings
|
||||||
|
- Check browser console for errors
|
||||||
|
|
||||||
|
2. **Downloads Not Starting**:
|
||||||
|
- Verify BDFR integration is configured
|
||||||
|
- Check server logs for errors
|
||||||
|
- Ensure form data is valid
|
||||||
|
|
||||||
|
3. **Static Files Not Loading**:
|
||||||
|
- Verify static file paths
|
||||||
|
- Check file permissions
|
||||||
|
- Ensure proper MIME types
|
||||||
|
|
||||||
|
### Debug Mode
|
||||||
|
|
||||||
|
Run with debug logging:
|
||||||
|
```bash
|
||||||
|
python main.py --log-level debug
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch
|
||||||
|
3. Make your changes
|
||||||
|
4. Test thoroughly
|
||||||
|
5. Submit a pull request
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is part of the BDFR ecosystem. See the main project license for details.
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
# BDFR Web Interface Startup Scripts
|
||||||
|
|
||||||
|
This directory contains simple startup scripts to easily run the BDFR web interface application.
|
||||||
|
|
||||||
|
## Available Scripts
|
||||||
|
|
||||||
|
### 🚀 Quick Start
|
||||||
|
|
||||||
|
Choose the appropriate script for your operating system:
|
||||||
|
|
||||||
|
- **`start.py`** - Cross-platform Python script (recommended)
|
||||||
|
- **`start.sh`** - Unix/Linux/macOS shell script
|
||||||
|
- **`start.bat`** - Windows batch script
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Option 1: Python Script (Cross-platform)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Navigate to the web_interface directory
|
||||||
|
cd web_interface
|
||||||
|
|
||||||
|
# Run the startup script
|
||||||
|
python start.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Shell Script (Unix/Linux/macOS)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Navigate to the web_interface directory
|
||||||
|
cd web_interface
|
||||||
|
|
||||||
|
# Make sure the script is executable
|
||||||
|
chmod +x start.sh
|
||||||
|
|
||||||
|
# Run the startup script
|
||||||
|
./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 3: Batch Script (Windows)
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
REM Navigate to the web_interface directory
|
||||||
|
cd web_interface
|
||||||
|
|
||||||
|
REM Run the startup script
|
||||||
|
start.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
## What the Scripts Do
|
||||||
|
|
||||||
|
1. **Check Python version** - Ensures Python 3.8+ is installed
|
||||||
|
2. **Install dependencies** - Automatically installs required packages from `requirements.txt`
|
||||||
|
3. **Verify BDFR module** - Checks if the BDFR module is available
|
||||||
|
4. **Start the server** - Launches the FastAPI application with uvicorn
|
||||||
|
|
||||||
|
## Server Information
|
||||||
|
|
||||||
|
Once started, the web interface will be available at:
|
||||||
|
- **Main interface**: http://localhost:8000
|
||||||
|
- **API documentation**: http://localhost:8000/docs
|
||||||
|
- **Health check**: http://localhost:8000/health
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- ✅ Automatic dependency management
|
||||||
|
- ✅ Cross-platform compatibility
|
||||||
|
- ✅ Colored output for better user experience
|
||||||
|
- ✅ Error handling and informative messages
|
||||||
|
- ✅ Graceful server shutdown
|
||||||
|
- ✅ BDFR module availability checking
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Python 3.8 or higher
|
||||||
|
- Internet connection (for installing dependencies)
|
||||||
|
- BDFR module in Python path (parent directory should contain the BDFR package)
|
||||||
|
- Reddit OAuth application (for authentication features)
|
||||||
|
|
||||||
|
## Reddit OAuth Setup
|
||||||
|
|
||||||
|
To use the authentication features, you need to:
|
||||||
|
|
||||||
|
1. **Create a Reddit OAuth Application**:
|
||||||
|
- Go to [Reddit App Preferences](https://www.reddit.com/prefs/apps)
|
||||||
|
- Click "Create App" or "Create Another App"
|
||||||
|
- Choose "web app" as the application type
|
||||||
|
- Set a name (e.g., "BDFR Web Interface")
|
||||||
|
- Set redirect URI to: `http://localhost:8000/auth/callback`
|
||||||
|
|
||||||
|
2. **Configure the Redirect URI** (if using a different port or domain):
|
||||||
|
- Run the OAuth setup helper: `python setup_oauth.py`
|
||||||
|
- Or manually copy `.env.example` to `.env`
|
||||||
|
- Update the following in the `.env` file:
|
||||||
|
- `BDFR_REDIRECT_URI` - Your OAuth redirect URI
|
||||||
|
- `BDFR_CLIENT_ID` - Your OAuth client ID (from Reddit app)
|
||||||
|
- `BDFR_CLIENT_SECRET` - Your OAuth client secret (from Reddit app)
|
||||||
|
- Make sure the redirect URI matches exactly what you set in your Reddit OAuth app
|
||||||
|
|
||||||
|
3. **Update BDFR Configuration**:
|
||||||
|
- The web interface uses the same OAuth credentials as BDFR
|
||||||
|
- You need to either update your existing Reddit OAuth app or create a new one
|
||||||
|
|
||||||
|
## Option A: Update Existing Reddit OAuth App
|
||||||
|
|
||||||
|
If you want to use the same OAuth app for both BDFR CLI and web interface:
|
||||||
|
|
||||||
|
1. Go to [Reddit App Preferences](https://www.reddit.com/prefs/apps)
|
||||||
|
2. Find your existing app (the one with client ID `U-6gk4ZCh3IeNQ`)
|
||||||
|
3. Click "edit" and add your redirect URI to the "redirect uris" field:
|
||||||
|
- `http://localhost:8000/auth/callback`
|
||||||
|
4. Save the changes
|
||||||
|
|
||||||
|
## Option B: Create a New Reddit OAuth App (Recommended)
|
||||||
|
|
||||||
|
For better separation between CLI and web interface:
|
||||||
|
|
||||||
|
1. Go to [Reddit App Preferences](https://www.reddit.com/prefs/apps)
|
||||||
|
2. Click "Create App" or "Create Another App"
|
||||||
|
3. Fill in the details:
|
||||||
|
- **Name**: `BDFR Web Interface` (or your preferred name)
|
||||||
|
- **App type**: `web app`
|
||||||
|
- **Description**: `Web interface for BDFR (Bulk Downloader for Reddit)`
|
||||||
|
- **About URL**: (optional)
|
||||||
|
- **Redirect URI**: `http://localhost:8000/auth/callback`
|
||||||
|
4. Click "Create app"
|
||||||
|
5. Copy the client ID and client secret
|
||||||
|
6. Update `bdfr/default_config.cfg` with the new credentials:
|
||||||
|
```
|
||||||
|
client_id = YOUR_NEW_CLIENT_ID
|
||||||
|
client_secret = YOUR_NEW_CLIENT_SECRET
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "BDFR module not found"
|
||||||
|
Make sure you're running the script from the correct directory, or ensure the parent directory containing the BDFR package is in your Python path.
|
||||||
|
|
||||||
|
### "Python 3.8+ required"
|
||||||
|
Install Python 3.8 or higher from the official Python website.
|
||||||
|
|
||||||
|
### "Permission denied" (Unix/Linux/macOS)
|
||||||
|
Make sure the shell script has execute permissions:
|
||||||
|
```bash
|
||||||
|
chmod +x start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### "invalid redirect_uri parameter" (OAuth Error)
|
||||||
|
This error occurs when the redirect URI doesn't match what you configured in your Reddit OAuth app:
|
||||||
|
|
||||||
|
1. **Verify your Reddit OAuth app settings**:
|
||||||
|
- Go to [Reddit App Preferences](https://www.reddit.com/prefs/apps)
|
||||||
|
- Find your app and check the redirect URI
|
||||||
|
- Make sure it exactly matches what you're using
|
||||||
|
|
||||||
|
2. **Update the redirect URI**:
|
||||||
|
- Copy `.env.example` to `.env`
|
||||||
|
- Set `BDFR_REDIRECT_URI` to match your Reddit OAuth app
|
||||||
|
- Example: `BDFR_REDIRECT_URI=http://localhost:8000/auth/callback`
|
||||||
|
|
||||||
|
3. **Common redirect URI formats**:
|
||||||
|
- Local development: `http://localhost:8000/auth/callback`
|
||||||
|
- With custom port: `http://localhost:3000/auth/callback`
|
||||||
|
- Production: `https://yourdomain.com/auth/callback`
|
||||||
|
|
||||||
|
4. **Recreate your OAuth app if needed**:
|
||||||
|
- Delete the existing app in Reddit
|
||||||
|
- Create a new one with the correct redirect URI
|
||||||
|
|
||||||
|
## Manual Alternative
|
||||||
|
|
||||||
|
If you prefer to run the server manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web_interface
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
"""
|
||||||
|
OAuth2 Authentication module for BDFR Web Interface
|
||||||
|
|
||||||
|
This module handles OAuth2 authentication flow for the web interface,
|
||||||
|
integrating with BDFR's existing OAuth2 system.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Dict, Optional, Any
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
# Try to import BDFR modules, but handle gracefully if not available
|
||||||
|
try:
|
||||||
|
from bdfr.oauth2 import OAuth2Authenticator, OAuth2TokenManager
|
||||||
|
from bdfr.exceptions import RedditAuthenticationError
|
||||||
|
BDFR_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
BDFR_AVAILABLE = False
|
||||||
|
# Create mock classes for when BDFR is not available
|
||||||
|
class OAuth2Authenticator:
|
||||||
|
pass
|
||||||
|
class OAuth2TokenManager:
|
||||||
|
pass
|
||||||
|
class RedditAuthenticationError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class WebOAuth2Manager:
|
||||||
|
"""OAuth2 manager for web interface authentication"""
|
||||||
|
|
||||||
|
def __init__(self, client_id: str, client_secret: str, scopes: list = None):
|
||||||
|
self.client_id = client_id
|
||||||
|
self.client_secret = client_secret
|
||||||
|
self.scopes = scopes or ["identity", "history", "read", "save", "mysubreddits"]
|
||||||
|
|
||||||
|
# In-memory storage for OAuth2 states and tokens
|
||||||
|
# In production, this should be replaced with a proper database
|
||||||
|
self.oauth_states = {}
|
||||||
|
self.refresh_tokens = {}
|
||||||
|
self.access_tokens = {}
|
||||||
|
# Store Reddit usernames per session state
|
||||||
|
self.usernames = {}
|
||||||
|
|
||||||
|
# Reddit OAuth2 endpoints
|
||||||
|
self.reddit_auth_url = "https://www.reddit.com/api/v1/authorize"
|
||||||
|
self.reddit_token_url = "https://www.reddit.com/api/v1/access_token"
|
||||||
|
self.reddit_user_info_url = "https://oauth.reddit.com/api/v1/me"
|
||||||
|
|
||||||
|
# Token expiration tracking
|
||||||
|
self.token_expiry = {}
|
||||||
|
|
||||||
|
def generate_state(self) -> str:
|
||||||
|
"""Generate a secure random state for OAuth2"""
|
||||||
|
state = secrets.token_urlsafe(32)
|
||||||
|
self.oauth_states[state] = {
|
||||||
|
"created_at": time.time(),
|
||||||
|
"used": False
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
|
||||||
|
def validate_state(self, state: str) -> bool:
|
||||||
|
"""Validate OAuth2 state parameter"""
|
||||||
|
if state not in self.oauth_states:
|
||||||
|
return False
|
||||||
|
|
||||||
|
state_data = self.oauth_states[state]
|
||||||
|
if state_data["used"]:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# States expire after 10 minutes
|
||||||
|
if time.time() - state_data["created_at"] > 600:
|
||||||
|
del self.oauth_states[state]
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def mark_state_used(self, state: str):
|
||||||
|
"""Mark OAuth2 state as used"""
|
||||||
|
if state in self.oauth_states:
|
||||||
|
self.oauth_states[state]["used"] = True
|
||||||
|
|
||||||
|
def get_authorization_url(self, redirect_uri: str) -> Dict[str, str]:
|
||||||
|
"""Generate OAuth2 authorization URL"""
|
||||||
|
state = self.generate_state()
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"client_id": self.client_id,
|
||||||
|
"response_type": "code",
|
||||||
|
"state": state,
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
"scope": " ".join(self.scopes),
|
||||||
|
"duration": "permanent"
|
||||||
|
}
|
||||||
|
|
||||||
|
auth_url = f"{self.reddit_auth_url}?{urlencode(params)}"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"authorization_url": auth_url,
|
||||||
|
"state": state
|
||||||
|
}
|
||||||
|
|
||||||
|
async def exchange_code_for_token(self, code: str, state: str, redirect_uri: str) -> Dict[str, Any]:
|
||||||
|
"""Exchange authorization code for access token"""
|
||||||
|
if not self.validate_state(state):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid or expired state parameter"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.mark_state_used(state)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": redirect_uri
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"User-Agent": "BDFR-Web-Interface/1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Use HTTP Basic Auth for client credentials
|
||||||
|
auth = (self.client_id, self.client_secret)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
try:
|
||||||
|
response = await client.post(
|
||||||
|
self.reddit_token_url,
|
||||||
|
data=data,
|
||||||
|
auth=auth,
|
||||||
|
headers=headers,
|
||||||
|
timeout=30.0
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
error_detail = response.json() if response.headers.get("content-type", "").startswith("application/json") else response.text
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Token exchange failed: {error_detail}"
|
||||||
|
)
|
||||||
|
|
||||||
|
token_data = response.json()
|
||||||
|
|
||||||
|
# Store tokens
|
||||||
|
access_token = token_data["access_token"]
|
||||||
|
refresh_token = token_data.get("refresh_token")
|
||||||
|
|
||||||
|
if refresh_token:
|
||||||
|
self.refresh_tokens[state] = refresh_token
|
||||||
|
self.access_tokens[state] = access_token
|
||||||
|
|
||||||
|
# Set expiry (Reddit tokens typically last 1 hour)
|
||||||
|
self.token_expiry[state] = time.time() + token_data.get("expires_in", 3600)
|
||||||
|
|
||||||
|
# Attempt to fetch and store the Reddit username for this session
|
||||||
|
username = None
|
||||||
|
try:
|
||||||
|
user_info = await self.get_user_info(access_token)
|
||||||
|
username = user_info.get("name")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to fetch user info during token exchange: {e}")
|
||||||
|
|
||||||
|
if username:
|
||||||
|
self.usernames[state] = username
|
||||||
|
|
||||||
|
return {
|
||||||
|
"access_token": access_token,
|
||||||
|
"refresh_token": refresh_token,
|
||||||
|
"expires_in": token_data.get("expires_in", 3600),
|
||||||
|
"token_type": token_data.get("token_type", "bearer"),
|
||||||
|
"state": state,
|
||||||
|
"username": username
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="No refresh token received"
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_408_REQUEST_TIMEOUT,
|
||||||
|
detail="Token exchange timed out"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Token exchange error: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Internal server error during token exchange"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_user_info(self, access_token: str) -> Dict[str, Any]:
|
||||||
|
"""Get user information using access token"""
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {access_token}",
|
||||||
|
"User-Agent": "BDFR-Web-Interface/1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
try:
|
||||||
|
response = await client.get(
|
||||||
|
self.reddit_user_info_url,
|
||||||
|
headers=headers,
|
||||||
|
timeout=30.0
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid access token"
|
||||||
|
)
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_408_REQUEST_TIMEOUT,
|
||||||
|
detail="User info request timed out"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"User info error: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Error retrieving user information"
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_token_expired(self, state: str) -> bool:
|
||||||
|
"""Check if access token is expired"""
|
||||||
|
if state not in self.token_expiry:
|
||||||
|
return True
|
||||||
|
return time.time() > self.token_expiry[state]
|
||||||
|
|
||||||
|
def get_valid_token(self, state: str) -> Optional[str]:
|
||||||
|
"""Get valid access token, refreshing if necessary"""
|
||||||
|
if state not in self.access_tokens:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if self.is_token_expired(state):
|
||||||
|
# Token expired, would need refresh logic here
|
||||||
|
# For now, just return None to indicate re-auth needed
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self.access_tokens[state]
|
||||||
|
|
||||||
|
def revoke_session(self, state: str):
|
||||||
|
"""Revoke OAuth2 session"""
|
||||||
|
if state in self.oauth_states:
|
||||||
|
del self.oauth_states[state]
|
||||||
|
if state in self.refresh_tokens:
|
||||||
|
del self.refresh_tokens[state]
|
||||||
|
if state in self.access_tokens:
|
||||||
|
del self.access_tokens[state]
|
||||||
|
if state in self.token_expiry:
|
||||||
|
del self.token_expiry[state]
|
||||||
|
if state in self.usernames:
|
||||||
|
del self.usernames[state]
|
||||||
|
|
||||||
|
def get_auth_status(self, state: str = None) -> Dict[str, Any]:
|
||||||
|
"""Get authentication status"""
|
||||||
|
if not state:
|
||||||
|
return {
|
||||||
|
"authenticated": False,
|
||||||
|
"message": "No active session"
|
||||||
|
}
|
||||||
|
|
||||||
|
if state not in self.access_tokens:
|
||||||
|
return {
|
||||||
|
"authenticated": False,
|
||||||
|
"message": "No tokens found for session"
|
||||||
|
}
|
||||||
|
|
||||||
|
access_token = self.get_valid_token(state)
|
||||||
|
if not access_token:
|
||||||
|
return {
|
||||||
|
"authenticated": False,
|
||||||
|
"message": "Token expired or invalid"
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"authenticated": True,
|
||||||
|
"expires_at": self.token_expiry.get(state, 0),
|
||||||
|
"scopes": self.scopes,
|
||||||
|
"username": self.usernames.get(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Global OAuth2 manager instance
|
||||||
|
oauth_manager = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_oauth_manager(client_id: str, client_secret: str, scopes: list = None):
|
||||||
|
"""Initialize the global OAuth2 manager"""
|
||||||
|
global oauth_manager
|
||||||
|
oauth_manager = WebOAuth2Manager(client_id, client_secret, scopes)
|
||||||
|
|
||||||
|
|
||||||
|
def get_oauth_manager() -> WebOAuth2Manager:
|
||||||
|
"""Get the global OAuth2 manager instance"""
|
||||||
|
if oauth_manager is None:
|
||||||
|
raise RuntimeError("OAuth2 manager not initialized")
|
||||||
|
return oauth_manager
|
||||||
@@ -0,0 +1,960 @@
|
|||||||
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Form, File, UploadFile, Query, status
|
||||||
|
from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi import Request
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import configparser
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# Load environment variables from .env file if available
|
||||||
|
try:
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
except ImportError:
|
||||||
|
# python-dotenv not installed, use os.environ directly
|
||||||
|
pass
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Import authentication module
|
||||||
|
from .auth import init_oauth_manager, get_oauth_manager
|
||||||
|
|
||||||
|
# Import BDFR API layer
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
# Add the parent directory (BDFR root) to Python path
|
||||||
|
bdfr_root = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'bdfr')
|
||||||
|
sys.path.insert(0, bdfr_root)
|
||||||
|
|
||||||
|
# Also add the current bdfr directory to path
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), ''))
|
||||||
|
|
||||||
|
try:
|
||||||
|
from bdfr.api import BDFRManager, ProgressEvent, ProgressCallback, get_bdfr_manager
|
||||||
|
BDFR_AVAILABLE = True
|
||||||
|
except ImportError as e:
|
||||||
|
logger.warning(f"Failed to import BDFR API: {e}. BDFR modules may not be available.")
|
||||||
|
BDFR_AVAILABLE = False
|
||||||
|
# Create a mock BDFRManager for when BDFR is not available
|
||||||
|
class MockBDFRManager:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
def download_subreddit(self, *args, **kwargs):
|
||||||
|
raise NotImplementedError("BDFR not available")
|
||||||
|
def download_user(self, *args, **kwargs):
|
||||||
|
raise NotImplementedError("BDFR not available")
|
||||||
|
def get_download_status(self, *args, **kwargs):
|
||||||
|
return None
|
||||||
|
def cancel_download(self, *args, **kwargs):
|
||||||
|
return False
|
||||||
|
|
||||||
|
class MockProgressCallback:
|
||||||
|
pass
|
||||||
|
|
||||||
|
class MockProgressEvent:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
BDFRManager = MockBDFRManager
|
||||||
|
ProgressEvent = MockProgressEvent
|
||||||
|
ProgressCallback = MockProgressCallback
|
||||||
|
def get_bdfr_manager(*args, **kwargs):
|
||||||
|
return MockBDFRManager()
|
||||||
|
|
||||||
|
app = FastAPI(title="BDFR Web Interface", version="1.0.0")
|
||||||
|
|
||||||
|
# Initialize OAuth2 manager
|
||||||
|
def init_oauth():
|
||||||
|
"""Initialize OAuth2 manager with credentials from environment or BDFR config"""
|
||||||
|
try:
|
||||||
|
# Try to get credentials from environment variables first
|
||||||
|
client_id = os.getenv("BDFR_CLIENT_ID")
|
||||||
|
client_secret = os.getenv("BDFR_CLIENT_SECRET")
|
||||||
|
|
||||||
|
if client_id and client_secret:
|
||||||
|
# Use environment credentials
|
||||||
|
logger.info("Using OAuth credentials from environment variables")
|
||||||
|
scopes = ["identity", "history", "read", "save", "mysubreddits"]
|
||||||
|
else:
|
||||||
|
# Fall back to BDFR config file
|
||||||
|
logger.info("Using OAuth credentials from BDFR config file")
|
||||||
|
config = configparser.ConfigParser()
|
||||||
|
config.read("../bdfr/default_config.cfg")
|
||||||
|
|
||||||
|
client_id = config.get("DEFAULT", "client_id")
|
||||||
|
client_secret = config.get("DEFAULT", "client_secret")
|
||||||
|
scopes_str = config.get("DEFAULT", "scopes", fallback="identity,read")
|
||||||
|
|
||||||
|
# Parse scopes
|
||||||
|
scopes = [scope.strip() for scope in scopes_str.split(",")]
|
||||||
|
|
||||||
|
init_oauth_manager(client_id, client_secret, scopes)
|
||||||
|
logger.info("OAuth2 manager initialized successfully")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to initialize OAuth2 manager: {e}")
|
||||||
|
# Use default credentials if config fails
|
||||||
|
init_oauth_manager("U-6gk4ZCh3IeNQ", "7CZHY6AmKweZME5s50SfDGylaPg")
|
||||||
|
|
||||||
|
# Initialize OAuth2 on startup
|
||||||
|
# Use a configurable redirect URI - this should match your Reddit OAuth app settings
|
||||||
|
redirect_uri = os.getenv("BDFR_REDIRECT_URI", "http://localhost:8000/auth/callback")
|
||||||
|
init_oauth()
|
||||||
|
|
||||||
|
# Enable CORS
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mount static files
|
||||||
|
import os
|
||||||
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
static_dir = os.path.join(current_dir, "..", "static")
|
||||||
|
template_dir = os.path.join(current_dir, "..", "templates")
|
||||||
|
|
||||||
|
# Ensure directories exist
|
||||||
|
os.makedirs(static_dir, exist_ok=True)
|
||||||
|
os.makedirs(template_dir, exist_ok=True)
|
||||||
|
|
||||||
|
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||||
|
templates = Jinja2Templates(directory=template_dir)
|
||||||
|
|
||||||
|
# WebSocket connection manager
|
||||||
|
class ConnectionManager:
|
||||||
|
def __init__(self):
|
||||||
|
self.active_connections: List[WebSocket] = []
|
||||||
|
|
||||||
|
async def connect(self, websocket: WebSocket):
|
||||||
|
logger.info(f"[WEBSOCKET] WebSocket connection attempt from {websocket.client}")
|
||||||
|
await websocket.accept()
|
||||||
|
self.active_connections.append(websocket)
|
||||||
|
logger.info(f"[WEBSOCKET] New connection established from {websocket.client}. Total connections: {len(self.active_connections)}")
|
||||||
|
|
||||||
|
def disconnect(self, websocket: WebSocket):
|
||||||
|
if websocket in self.active_connections:
|
||||||
|
self.active_connections.remove(websocket)
|
||||||
|
logger.info(f"[WEBSOCKET] Connection disconnected from {websocket.client}. Total connections: {len(self.active_connections)}")
|
||||||
|
|
||||||
|
async def send_personal_message(self, message: str, websocket: WebSocket):
|
||||||
|
try:
|
||||||
|
await websocket.send_text(message)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to send personal message: {e}")
|
||||||
|
self.disconnect(websocket)
|
||||||
|
|
||||||
|
async def broadcast(self, message: str):
|
||||||
|
"""Broadcast message to all active connections - simplified version"""
|
||||||
|
logger.debug(f"[WEBSOCKET] Broadcasting message to {len(self.active_connections)} connections")
|
||||||
|
|
||||||
|
if not self.active_connections:
|
||||||
|
logger.debug("[WEBSOCKET] No active connections to broadcast to!")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Simple approach: try to send to all connections, remove failed ones immediately
|
||||||
|
alive_connections = []
|
||||||
|
|
||||||
|
for connection in self.active_connections:
|
||||||
|
try:
|
||||||
|
await connection.send_text(message)
|
||||||
|
alive_connections.append(connection)
|
||||||
|
logger.debug(f"[WEBSOCKET] Message sent successfully to connection {id(connection)}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[WEBSOCKET] Removing failed connection {id(connection)}: {e}")
|
||||||
|
# Connection failed, don't add it to alive_connections
|
||||||
|
|
||||||
|
# Update active connections to only include successful ones
|
||||||
|
self.active_connections = alive_connections
|
||||||
|
logger.debug(f"[WEBSOCKET] Broadcast complete. Active connections: {len(self.active_connections)}")
|
||||||
|
|
||||||
|
async def is_connection_alive(self, websocket: WebSocket) -> bool:
|
||||||
|
"""Check if a WebSocket connection is still alive"""
|
||||||
|
try:
|
||||||
|
# Try to send a ping frame (this is a WebSocket protocol ping)
|
||||||
|
await websocket.ping()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def cleanup_dead_connections(self):
|
||||||
|
"""Simplified cleanup - just log the current state"""
|
||||||
|
logger.info(f"[WEBSOCKET] Cleanup check: {len(self.active_connections)} active connections")
|
||||||
|
|
||||||
|
manager = ConnectionManager()
|
||||||
|
|
||||||
|
# Initialize BDFR Manager
|
||||||
|
bdfr_manager = get_bdfr_manager("./downloads")
|
||||||
|
|
||||||
|
# Store auth token for BDFR manager if available
|
||||||
|
_bdfr_auth_token = None
|
||||||
|
|
||||||
|
# Active downloads tracking (now managed by BDFRManager, but kept for WebSocket compatibility)
|
||||||
|
active_downloads = {}
|
||||||
|
|
||||||
|
# WebSocket-compatible progress callback
|
||||||
|
class WebSocketProgressCallback(ProgressCallback if BDFR_AVAILABLE else MockProgressCallback):
|
||||||
|
"""Progress callback that sends updates to WebSocket clients"""
|
||||||
|
|
||||||
|
def __init__(self, download_id: str, connection_manager: ConnectionManager):
|
||||||
|
self.download_id = download_id
|
||||||
|
self.connection_manager = connection_manager
|
||||||
|
|
||||||
|
async def on_progress(self, event: ProgressEvent):
|
||||||
|
"""Send progress update to WebSocket clients"""
|
||||||
|
logger.info(f"[WEBSOCKET-PROGRESS] Received progress event for download {event.download_id}: {event.message} ({event.progress}%)")
|
||||||
|
logger.info(f"[WEBSOCKET-PROGRESS] Active connections before broadcast: {len(self.connection_manager.active_connections)}")
|
||||||
|
try:
|
||||||
|
# Convert BDFR API event to web interface format
|
||||||
|
progress_data = {
|
||||||
|
"type": "progress",
|
||||||
|
"id": event.download_id,
|
||||||
|
"download_id": event.download_id,
|
||||||
|
"status": "running",
|
||||||
|
"message": event.message,
|
||||||
|
"progress": event.progress or 0,
|
||||||
|
"data": event.data,
|
||||||
|
"timestamp": event.timestamp.isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update active_downloads for WebSocket compatibility
|
||||||
|
# Need to find the web interface download ID that corresponds to this BDFR download ID
|
||||||
|
web_download_id = None
|
||||||
|
for download_id, download_info in active_downloads.items():
|
||||||
|
if download_info.get("bdfr_download_id") == event.download_id:
|
||||||
|
web_download_id = download_id
|
||||||
|
break
|
||||||
|
|
||||||
|
if web_download_id:
|
||||||
|
# Keep server-side state in sync
|
||||||
|
active_downloads[web_download_id]["status"] = "running"
|
||||||
|
active_downloads[web_download_id]["progress"] = event.progress or 0
|
||||||
|
if "items_processed" in event.data:
|
||||||
|
active_downloads[web_download_id]["items_processed"] = event.data["items_processed"]
|
||||||
|
if "items_found" in event.data:
|
||||||
|
active_downloads[web_download_id]["items_found"] = event.data["items_found"]
|
||||||
|
if "current_item" in event.data:
|
||||||
|
active_downloads[web_download_id]["current_item"] = event.data["current_item"]
|
||||||
|
if "phase" in event.data:
|
||||||
|
active_downloads[web_download_id]["phase"] = event.data["phase"]
|
||||||
|
|
||||||
|
# Unify IDs for frontend to prevent duplicate cards
|
||||||
|
progress_data["id"] = web_download_id
|
||||||
|
progress_data["download_id"] = web_download_id
|
||||||
|
progress_data["bdfr_download_id"] = event.download_id
|
||||||
|
progress_data["web_download_id"] = web_download_id
|
||||||
|
progress_data["subreddit"] = active_downloads[web_download_id].get("subreddit")
|
||||||
|
progress_data["username"] = active_downloads[web_download_id].get("username")
|
||||||
|
progress_data["limit"] = active_downloads[web_download_id].get("limit")
|
||||||
|
else:
|
||||||
|
# No mapping yet; include bdfr id for debugging
|
||||||
|
progress_data["bdfr_download_id"] = event.download_id
|
||||||
|
progress_data["limit"] = (event.data or {}).get("limit")
|
||||||
|
|
||||||
|
logger.info(f"[WEBSOCKET-PROGRESS] Broadcasting progress data: {progress_data}")
|
||||||
|
logger.info(f"[WEBSOCKET-PROGRESS] About to broadcast progress message for download {event.download_id}")
|
||||||
|
await self.connection_manager.broadcast(json.dumps(progress_data))
|
||||||
|
logger.info(f"[WEBSOCKET-PROGRESS] Progress message broadcast completed for download {event.download_id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to send progress update for {event.download_id}: {e}")
|
||||||
|
|
||||||
|
async def on_error(self, event: ProgressEvent):
|
||||||
|
"""Send error update to WebSocket clients"""
|
||||||
|
logger.info(f"[WEBSOCKET-ERROR] Received error event for download {event.download_id}: {event.message}")
|
||||||
|
try:
|
||||||
|
error_data = {
|
||||||
|
"type": "error",
|
||||||
|
"id": event.download_id,
|
||||||
|
"download_id": event.download_id,
|
||||||
|
"status": "failed",
|
||||||
|
"message": event.message,
|
||||||
|
"data": event.data,
|
||||||
|
"timestamp": event.timestamp.isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update active_downloads - need to find the web interface download ID
|
||||||
|
# that corresponds to this BDFR download ID
|
||||||
|
web_download_id = None
|
||||||
|
for download_id, download_info in active_downloads.items():
|
||||||
|
if download_info.get("bdfr_download_id") == event.download_id:
|
||||||
|
web_download_id = download_id
|
||||||
|
break
|
||||||
|
|
||||||
|
if web_download_id:
|
||||||
|
active_downloads[web_download_id]["status"] = "failed"
|
||||||
|
active_downloads[web_download_id]["error"] = event.message
|
||||||
|
active_downloads[web_download_id]["end_time"] = datetime.now().isoformat()
|
||||||
|
# Persist failure phase for status_update broadcasting (e.g., 'rate_limited')
|
||||||
|
try:
|
||||||
|
if isinstance(event.data, dict) and event.data.get("phase"):
|
||||||
|
active_downloads[web_download_id]["phase"] = event.data.get("phase")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
logger.info(f"Updated download {web_download_id} to failed status")
|
||||||
|
|
||||||
|
# Unify IDs for frontend and include mapping
|
||||||
|
error_data["id"] = web_download_id
|
||||||
|
error_data["download_id"] = web_download_id
|
||||||
|
error_data["bdfr_download_id"] = event.download_id
|
||||||
|
error_data["web_download_id"] = web_download_id
|
||||||
|
error_data["subreddit"] = active_downloads[web_download_id].get("subreddit")
|
||||||
|
error_data["username"] = active_downloads[web_download_id].get("username")
|
||||||
|
# Also surface phase at top-level for clients that check data.phase or phase
|
||||||
|
try:
|
||||||
|
if isinstance(event.data, dict) and event.data.get("phase"):
|
||||||
|
error_data["phase"] = event.data.get("phase")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
error_data["bdfr_download_id"] = event.download_id
|
||||||
|
|
||||||
|
await self.connection_manager.broadcast(json.dumps(error_data))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to send error update for {event.download_id}: {e}")
|
||||||
|
logger.warning(f"Error data type: {type(event.data.get('exception'))}")
|
||||||
|
|
||||||
|
async def on_completed(self, event: ProgressEvent):
|
||||||
|
"""Send completion update to WebSocket clients"""
|
||||||
|
logger.info(f"[WEBSOCKET-COMPLETED] Received completion event for download {event.download_id}: {event.message}")
|
||||||
|
try:
|
||||||
|
completed_data = {
|
||||||
|
"type": "completed",
|
||||||
|
"id": event.download_id,
|
||||||
|
"download_id": event.download_id,
|
||||||
|
"status": "completed",
|
||||||
|
"message": event.message,
|
||||||
|
"progress": 100.0,
|
||||||
|
"data": event.data,
|
||||||
|
"timestamp": event.timestamp.isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update active_downloads - need to find the web interface download ID
|
||||||
|
# that corresponds to this BDFR download ID
|
||||||
|
web_download_id = None
|
||||||
|
for download_id, download_info in active_downloads.items():
|
||||||
|
if download_info.get("bdfr_download_id") == event.download_id:
|
||||||
|
web_download_id = download_id
|
||||||
|
break
|
||||||
|
|
||||||
|
if web_download_id:
|
||||||
|
active_downloads[web_download_id]["status"] = "completed"
|
||||||
|
active_downloads[web_download_id]["progress"] = 100.0
|
||||||
|
active_downloads[web_download_id]["end_time"] = datetime.now().isoformat()
|
||||||
|
logger.info(f"Updated download {web_download_id} to completed status")
|
||||||
|
|
||||||
|
# Unify IDs and include mapping data
|
||||||
|
completed_data["id"] = web_download_id
|
||||||
|
completed_data["download_id"] = web_download_id
|
||||||
|
completed_data["bdfr_download_id"] = event.download_id
|
||||||
|
completed_data["web_download_id"] = web_download_id
|
||||||
|
completed_data["subreddit"] = active_downloads[web_download_id].get("subreddit")
|
||||||
|
completed_data["username"] = active_downloads[web_download_id].get("username")
|
||||||
|
else:
|
||||||
|
completed_data["bdfr_download_id"] = event.download_id
|
||||||
|
|
||||||
|
await self.connection_manager.broadcast(json.dumps(completed_data))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to send completion update for {event.download_id}: {e}")
|
||||||
|
|
||||||
|
# Helper function to create download with BDFR API
|
||||||
|
async def create_download_with_bdfr_api(download_type: str, name: str, **kwargs):
|
||||||
|
"""Create a download using the new BDFR API layer"""
|
||||||
|
|
||||||
|
# Check if BDFR is available
|
||||||
|
if not hasattr(bdfr_manager, 'download_subreddit'):
|
||||||
|
# BDFR not available, create a mock failed download
|
||||||
|
download_id = f"{download_type}_{name.replace('/', '_').replace(' ', '_')}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||||
|
active_downloads[download_id] = {
|
||||||
|
"id": download_id,
|
||||||
|
"type": download_type,
|
||||||
|
"status": "failed",
|
||||||
|
"progress": 0,
|
||||||
|
"start_time": datetime.now().isoformat(),
|
||||||
|
"authenticated": False,
|
||||||
|
"items_processed": 0,
|
||||||
|
"items_found": 0,
|
||||||
|
"current_item": None,
|
||||||
|
"current_item_type": None,
|
||||||
|
"phase": "failed",
|
||||||
|
"error": "BDFR API not available"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add type-specific fields
|
||||||
|
if download_type in ["subreddit", "archive", "clone"]:
|
||||||
|
active_downloads[download_id]["subreddit"] = name
|
||||||
|
elif download_type == "user":
|
||||||
|
active_downloads[download_id]["username"] = name
|
||||||
|
|
||||||
|
return download_id
|
||||||
|
|
||||||
|
# Get global auth token if available
|
||||||
|
global _bdfr_auth_token
|
||||||
|
auth_token = _bdfr_auth_token
|
||||||
|
logger.info(f"[DEBUG] Global auth token available: {auth_token is not None}")
|
||||||
|
logger.info(f"[DEBUG] Auth token for BDFR: {auth_token[:10]}..." if auth_token else "None")
|
||||||
|
|
||||||
|
# Create unique download ID
|
||||||
|
download_id = f"{download_type}_{name.replace('/', '_').replace(' ', '_')}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||||
|
|
||||||
|
# Get auth token if provided
|
||||||
|
auth_token = None
|
||||||
|
logger.info(f"[DEBUG] Checking auth_state: {kwargs.get('auth_state')}")
|
||||||
|
if kwargs.get('auth_state'):
|
||||||
|
try:
|
||||||
|
oauth_manager = get_oauth_manager()
|
||||||
|
logger.info(f"[DEBUG] OAuth manager exists: {oauth_manager is not None}")
|
||||||
|
auth_token = oauth_manager.get_valid_token(kwargs['auth_state'])
|
||||||
|
logger.info(f"[DEBUG] Auth token retrieved for state {kwargs['auth_state']}: {auth_token[:10]}..." if auth_token else "None")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to get auth token: {e}")
|
||||||
|
|
||||||
|
# Initialize download tracking for WebSocket compatibility
|
||||||
|
active_downloads[download_id] = {
|
||||||
|
"id": download_id,
|
||||||
|
"type": download_type,
|
||||||
|
"status": "queued",
|
||||||
|
"progress": 0,
|
||||||
|
"start_time": datetime.now().isoformat(),
|
||||||
|
"limit": kwargs.get('limit'),
|
||||||
|
"authenticated": auth_token is not None,
|
||||||
|
"items_processed": 0,
|
||||||
|
"items_found": 0,
|
||||||
|
"current_item": None,
|
||||||
|
"current_item_type": None,
|
||||||
|
"phase": "queued"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add type-specific fields
|
||||||
|
if download_type in ["subreddit", "archive", "clone"]:
|
||||||
|
active_downloads[download_id]["subreddit"] = name
|
||||||
|
elif download_type == "user":
|
||||||
|
active_downloads[download_id]["username"] = name
|
||||||
|
|
||||||
|
# Create progress callback
|
||||||
|
callback = WebSocketProgressCallback(download_id, manager)
|
||||||
|
|
||||||
|
# Start download based on type
|
||||||
|
if download_type == "subreddit":
|
||||||
|
bdfr_download_id = bdfr_manager.download_subreddit(
|
||||||
|
name,
|
||||||
|
limit=kwargs.get('limit'),
|
||||||
|
sort=kwargs.get('sort', 'hot'),
|
||||||
|
time_filter=kwargs.get('time_filter', 'all'),
|
||||||
|
no_dupes=kwargs.get('no_dupes', False),
|
||||||
|
progress_callbacks=[callback]
|
||||||
|
)
|
||||||
|
elif download_type == "archive":
|
||||||
|
# Archive mode - metadata only
|
||||||
|
bdfr_download_id = bdfr_manager.archive_subreddit(
|
||||||
|
name,
|
||||||
|
format_type=kwargs.get('format', 'json'),
|
||||||
|
limit=kwargs.get('limit'),
|
||||||
|
progress_callbacks=[callback]
|
||||||
|
)
|
||||||
|
elif download_type == "clone":
|
||||||
|
# Clone mode - both download and archive
|
||||||
|
bdfr_download_id = bdfr_manager.clone_subreddit(
|
||||||
|
name,
|
||||||
|
limit=kwargs.get('limit'),
|
||||||
|
format_type=kwargs.get('format', 'json'),
|
||||||
|
no_dupes=kwargs.get('no_dupes', False),
|
||||||
|
progress_callbacks=[callback]
|
||||||
|
)
|
||||||
|
elif download_type == "user":
|
||||||
|
bdfr_download_id = bdfr_manager.download_user(
|
||||||
|
name,
|
||||||
|
limit=kwargs.get('limit'),
|
||||||
|
submitted=kwargs.get('submitted', True),
|
||||||
|
upvoted=kwargs.get('upvoted', False),
|
||||||
|
saved=kwargs.get('saved', False),
|
||||||
|
no_dupes=kwargs.get('no_dupes', False),
|
||||||
|
progress_callbacks=[callback]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported download type: {download_type}")
|
||||||
|
|
||||||
|
# Store BDFR download ID for tracking
|
||||||
|
active_downloads[download_id]["bdfr_download_id"] = bdfr_download_id
|
||||||
|
|
||||||
|
return download_id
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
async def home(request: Request):
|
||||||
|
"""Serve the main interface"""
|
||||||
|
return templates.TemplateResponse("index.html", {"request": request})
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
"""Health check endpoint"""
|
||||||
|
return {"status": "healthy", "timestamp": datetime.now().isoformat()}
|
||||||
|
|
||||||
|
# OAuth2 Authentication Endpoints
|
||||||
|
@app.get("/auth/login")
|
||||||
|
async def oauth_login(redirect_uri: str = None):
|
||||||
|
"""Initiate OAuth2 login flow"""
|
||||||
|
try:
|
||||||
|
# Use provided redirect_uri or fall back to configured default
|
||||||
|
if redirect_uri is None:
|
||||||
|
redirect_uri = os.getenv("BDFR_REDIRECT_URI", "http://localhost:8000/auth/callback")
|
||||||
|
|
||||||
|
oauth_manager = get_oauth_manager()
|
||||||
|
auth_data = oauth_manager.get_authorization_url(redirect_uri)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"authorization_url": auth_data["authorization_url"],
|
||||||
|
"state": auth_data["state"],
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
"message": "Redirect user to the authorization URL"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to initiate OAuth2 login: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/auth/callback")
|
||||||
|
async def oauth_callback(
|
||||||
|
request: Request,
|
||||||
|
code: str = Query(..., description="Authorization code from Reddit"),
|
||||||
|
state: str = Query(..., description="State parameter for security"),
|
||||||
|
error: Optional[str] = Query(None, description="Error from OAuth2 provider")
|
||||||
|
):
|
||||||
|
"""Handle OAuth2 callback"""
|
||||||
|
try:
|
||||||
|
if error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"OAuth2 error: {error}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not code or not state:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Missing code or state parameter"
|
||||||
|
)
|
||||||
|
|
||||||
|
oauth_manager = get_oauth_manager()
|
||||||
|
# Use the same redirect URI that was used for authorization
|
||||||
|
redirect_uri = os.getenv("BDFR_REDIRECT_URI", "http://localhost:8000/auth/callback")
|
||||||
|
token_data = await oauth_manager.exchange_code_for_token(code, state, redirect_uri)
|
||||||
|
|
||||||
|
# Get user info
|
||||||
|
user_info = await oauth_manager.get_user_info(token_data["access_token"])
|
||||||
|
|
||||||
|
# Return success page instead of JSON
|
||||||
|
return templates.TemplateResponse("auth_success.html", {"request": request})
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"OAuth2 callback error: {str(e)}")
|
||||||
|
# Return error page for unexpected errors
|
||||||
|
return templates.TemplateResponse("auth_error.html", {
|
||||||
|
"request": request,
|
||||||
|
"error": "Authentication failed",
|
||||||
|
"details": str(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"OAuth2 callback error: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Authentication failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/auth/status")
|
||||||
|
async def auth_status(state: Optional[str] = Query(None)):
|
||||||
|
"""Get current authentication status"""
|
||||||
|
try:
|
||||||
|
oauth_manager = get_oauth_manager()
|
||||||
|
status_data = oauth_manager.get_auth_status(state)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"authenticated": status_data["authenticated"],
|
||||||
|
"message": status_data.get("message", "Authenticated" if status_data["authenticated"] else "Not authenticated"),
|
||||||
|
"expires_at": status_data.get("expires_at"),
|
||||||
|
"scopes": status_data.get("scopes"),
|
||||||
|
"username": status_data.get("username")
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to get auth status: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/api/auth/status")
|
||||||
|
async def api_auth_status(state: Optional[str] = Query(None)):
|
||||||
|
"""Get current authentication status (JSON API)"""
|
||||||
|
try:
|
||||||
|
oauth_manager = get_oauth_manager()
|
||||||
|
|
||||||
|
# If no state provided, check if there are any active sessions
|
||||||
|
if not state and oauth_manager:
|
||||||
|
# Check if there are any stored access tokens
|
||||||
|
if oauth_manager.access_tokens:
|
||||||
|
# Use the first available state for checking
|
||||||
|
state = next(iter(oauth_manager.access_tokens.keys()))
|
||||||
|
logger.info(f"No state provided, using first available: {state}")
|
||||||
|
|
||||||
|
status_data = oauth_manager.get_auth_status(state) if oauth_manager else {"authenticated": False, "message": "No OAuth manager"}
|
||||||
|
|
||||||
|
# Debug logging
|
||||||
|
logger.info(f"Auth status check - State: {state}, Authenticated: {status_data['authenticated']}")
|
||||||
|
|
||||||
|
# Store auth token for BDFR if authenticated
|
||||||
|
global _bdfr_auth_token
|
||||||
|
if status_data['authenticated'] and state:
|
||||||
|
_bdfr_auth_token = oauth_manager.get_valid_token(state)
|
||||||
|
logger.info(f"Stored auth token for BDFR manager: {_bdfr_auth_token is not None}")
|
||||||
|
elif not status_data['authenticated']:
|
||||||
|
_bdfr_auth_token = None
|
||||||
|
logger.info("Cleared auth token for BDFR manager")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"authenticated": status_data["authenticated"],
|
||||||
|
"message": status_data.get("message", "Authenticated" if status_data["authenticated"] else "Not authenticated"),
|
||||||
|
"expires_at": status_data.get("expires_at", 0),
|
||||||
|
"scopes": status_data.get("scopes", []),
|
||||||
|
"username": status_data.get("username"),
|
||||||
|
"debug_info": {
|
||||||
|
"state_provided": state is not None,
|
||||||
|
"state_used": state,
|
||||||
|
"oauth_manager_exists": oauth_manager is not None,
|
||||||
|
"available_states": len(oauth_manager.oauth_states) if oauth_manager else 0,
|
||||||
|
"available_tokens": len(oauth_manager.access_tokens) if oauth_manager else 0,
|
||||||
|
"bdfr_auth_token_set": _bdfr_auth_token is not None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Auth status error: {str(e)}")
|
||||||
|
return {
|
||||||
|
"authenticated": False,
|
||||||
|
"message": f"Error checking auth status: {str(e)}",
|
||||||
|
"error": True,
|
||||||
|
"debug_info": {
|
||||||
|
"error_details": str(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.post("/auth/logout")
|
||||||
|
async def auth_logout(state: str = Form(...)):
|
||||||
|
"""Logout and revoke OAuth2 session"""
|
||||||
|
try:
|
||||||
|
oauth_manager = get_oauth_manager()
|
||||||
|
oauth_manager.revoke_session(state)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Successfully logged out",
|
||||||
|
"state": state
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Logout failed: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.post("/api/download/subreddit")
|
||||||
|
async def download_subreddit(
|
||||||
|
subreddit: str = Form(...),
|
||||||
|
limit: int = Form(10),
|
||||||
|
sort: str = Form("hot"),
|
||||||
|
time_filter: str = Form(""),
|
||||||
|
min_score: str = Form(""),
|
||||||
|
no_dupes: bool = Form(False),
|
||||||
|
simple_check: bool = Form(False),
|
||||||
|
make_hard_links: bool = Form(False),
|
||||||
|
download_mode: str = Form("download"),
|
||||||
|
auth_state: str = Form(None)
|
||||||
|
):
|
||||||
|
"""Download from subreddit using BDFR API"""
|
||||||
|
|
||||||
|
# Map download_mode to appropriate BDFR operation
|
||||||
|
if download_mode == "archive":
|
||||||
|
# Use archive mode
|
||||||
|
download_id = await create_download_with_bdfr_api(
|
||||||
|
"archive",
|
||||||
|
subreddit,
|
||||||
|
limit=limit,
|
||||||
|
sort=sort,
|
||||||
|
time_filter=time_filter or "all",
|
||||||
|
format="json",
|
||||||
|
simple_check=simple_check,
|
||||||
|
auth_state=auth_state
|
||||||
|
)
|
||||||
|
message = f"Starting archive for r/{subreddit}"
|
||||||
|
elif download_mode == "clone":
|
||||||
|
# Use clone mode (download + archive)
|
||||||
|
download_id = await create_download_with_bdfr_api(
|
||||||
|
"clone",
|
||||||
|
subreddit,
|
||||||
|
limit=limit,
|
||||||
|
sort=sort,
|
||||||
|
time_filter=time_filter or "all",
|
||||||
|
no_dupes=no_dupes,
|
||||||
|
simple_check=simple_check,
|
||||||
|
format="json",
|
||||||
|
auth_state=auth_state
|
||||||
|
)
|
||||||
|
message = f"Starting clone for r/{subreddit}"
|
||||||
|
else:
|
||||||
|
# Default: download mode
|
||||||
|
download_id = await create_download_with_bdfr_api(
|
||||||
|
"subreddit",
|
||||||
|
subreddit,
|
||||||
|
limit=limit,
|
||||||
|
sort=sort,
|
||||||
|
time_filter=time_filter or "all",
|
||||||
|
no_dupes=no_dupes,
|
||||||
|
simple_check=simple_check,
|
||||||
|
auth_state=auth_state
|
||||||
|
)
|
||||||
|
message = f"Starting download for r/{subreddit}"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"download_id": download_id,
|
||||||
|
"message": message,
|
||||||
|
"mode": download_mode,
|
||||||
|
"estimated_time": "2-3 minutes"
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.post("/api/download/user")
|
||||||
|
async def download_user(
|
||||||
|
username: str = Form(...),
|
||||||
|
limit: int = Form(10),
|
||||||
|
submitted: bool = Form(True),
|
||||||
|
sort: str = Form("hot"),
|
||||||
|
time_filter: str = Form(""),
|
||||||
|
no_dupes: bool = Form(False),
|
||||||
|
simple_check: bool = Form(False),
|
||||||
|
make_hard_links: bool = Form(False),
|
||||||
|
download_mode: str = Form("download"),
|
||||||
|
auth_state: str = Form(None)
|
||||||
|
):
|
||||||
|
"""Download from user using BDFR API"""
|
||||||
|
|
||||||
|
# Map download_mode to appropriate BDFR operation
|
||||||
|
if download_mode == "archive":
|
||||||
|
# Use archive mode
|
||||||
|
download_id = await create_download_with_bdfr_api(
|
||||||
|
"archive",
|
||||||
|
username,
|
||||||
|
limit=limit,
|
||||||
|
sort=sort,
|
||||||
|
time_filter=time_filter or "all",
|
||||||
|
format="json",
|
||||||
|
submitted=submitted,
|
||||||
|
simple_check=simple_check,
|
||||||
|
auth_state=auth_state
|
||||||
|
)
|
||||||
|
message = f"Starting archive for u/{username}"
|
||||||
|
elif download_mode == "clone":
|
||||||
|
# Use clone mode (download + archive)
|
||||||
|
download_id = await create_download_with_bdfr_api(
|
||||||
|
"clone",
|
||||||
|
username,
|
||||||
|
limit=limit,
|
||||||
|
sort=sort,
|
||||||
|
time_filter=time_filter or "all",
|
||||||
|
no_dupes=no_dupes,
|
||||||
|
simple_check=simple_check,
|
||||||
|
format="json",
|
||||||
|
submitted=submitted,
|
||||||
|
auth_state=auth_state
|
||||||
|
)
|
||||||
|
message = f"Starting clone for u/{username}"
|
||||||
|
else:
|
||||||
|
# Default: download mode
|
||||||
|
download_id = await create_download_with_bdfr_api(
|
||||||
|
"user",
|
||||||
|
username,
|
||||||
|
limit=limit,
|
||||||
|
sort=sort,
|
||||||
|
submitted=submitted,
|
||||||
|
no_dupes=no_dupes,
|
||||||
|
simple_check=simple_check,
|
||||||
|
auth_state=auth_state
|
||||||
|
)
|
||||||
|
message = f"Starting download for u/{username}"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"download_id": download_id,
|
||||||
|
"message": message,
|
||||||
|
"mode": download_mode,
|
||||||
|
"estimated_time": "1-2 minutes"
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get("/api/downloads")
|
||||||
|
async def get_downloads():
|
||||||
|
"""Get all active downloads"""
|
||||||
|
return {"downloads": active_downloads}
|
||||||
|
|
||||||
|
@app.get("/api/downloads/{download_id}")
|
||||||
|
async def get_download_status(download_id: str):
|
||||||
|
"""Get specific download status"""
|
||||||
|
if download_id not in active_downloads:
|
||||||
|
raise HTTPException(status_code=404, detail="Download not found")
|
||||||
|
return {"download": active_downloads[download_id]}
|
||||||
|
|
||||||
|
@app.delete("/api/downloads/{download_id}")
|
||||||
|
async def cancel_download(download_id: str):
|
||||||
|
"""Cancel a download"""
|
||||||
|
if download_id not in active_downloads:
|
||||||
|
raise HTTPException(status_code=404, detail="Download not found")
|
||||||
|
|
||||||
|
# Cancel in BDFR manager if we have the BDFR download ID
|
||||||
|
if "bdfr_download_id" in active_downloads[download_id]:
|
||||||
|
bdfr_manager.cancel_download(active_downloads[download_id]["bdfr_download_id"])
|
||||||
|
|
||||||
|
active_downloads[download_id]["status"] = "cancelled"
|
||||||
|
return {"message": "Download cancelled"}
|
||||||
|
|
||||||
|
@app.websocket("/ws/progress")
|
||||||
|
async def websocket_endpoint(websocket: WebSocket):
|
||||||
|
"""WebSocket endpoint for real-time progress updates"""
|
||||||
|
logger.info(f"[WEBSOCKET-ENDPOINT] New WebSocket connection attempt from {websocket.client}")
|
||||||
|
await manager.connect(websocket)
|
||||||
|
logger.info(f"[WEBSOCKET-ENDPOINT] WebSocket connection established successfully")
|
||||||
|
logger.info(f"[WEBSOCKET-ENDPOINT] Connection count: {len(manager.active_connections)}")
|
||||||
|
|
||||||
|
# Track last broadcast state to avoid redundant messages
|
||||||
|
last_broadcast_state = {}
|
||||||
|
cleanup_counter = 0
|
||||||
|
keepalive_counter = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
# Clean up dead connections periodically (every 10 iterations = 20 seconds)
|
||||||
|
cleanup_counter += 1
|
||||||
|
if cleanup_counter >= 10:
|
||||||
|
await manager.cleanup_dead_connections()
|
||||||
|
cleanup_counter = 0
|
||||||
|
|
||||||
|
# Send keepalive ping every 15 iterations (30 seconds) to prevent timeout
|
||||||
|
keepalive_counter += 1
|
||||||
|
if keepalive_counter >= 15:
|
||||||
|
try:
|
||||||
|
await websocket.send_json({"type": "keepalive", "timestamp": datetime.now().isoformat()})
|
||||||
|
logger.debug(f"[WEBSOCKET-ENDPOINT] Sent keepalive ping")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[WEBSOCKET-ENDPOINT] Failed to send keepalive: {e}")
|
||||||
|
break
|
||||||
|
keepalive_counter = 0
|
||||||
|
|
||||||
|
# Check for changes in download states
|
||||||
|
has_changes = False
|
||||||
|
for download_id, download in list(active_downloads.items()):
|
||||||
|
# Only check active downloads
|
||||||
|
if download.get("status") in ["running", "queued", "failed"]:
|
||||||
|
# Create a state snapshot for comparison
|
||||||
|
current_state = {
|
||||||
|
"status": download.get("status"),
|
||||||
|
"progress": download.get("progress", 0),
|
||||||
|
"phase": download.get("phase", "unknown"),
|
||||||
|
"items_processed": download.get("items_processed", 0),
|
||||||
|
"items_found": download.get("items_found", 0),
|
||||||
|
"current_item": download.get("current_item"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Compare with last broadcast state
|
||||||
|
if download_id not in last_broadcast_state or last_broadcast_state[download_id] != current_state:
|
||||||
|
has_changes = True
|
||||||
|
last_broadcast_state[download_id] = current_state
|
||||||
|
|
||||||
|
logger.debug(f"[WEBSOCKET-ENDPOINT] State change detected for {download_id}: {download.get('status')}")
|
||||||
|
status_message = {
|
||||||
|
"type": "status_update",
|
||||||
|
"id": download_id,
|
||||||
|
"download_id": download_id,
|
||||||
|
"status": download["status"],
|
||||||
|
"progress": download.get("progress", 0),
|
||||||
|
"phase": download.get("phase", "unknown"),
|
||||||
|
"items_processed": download.get("items_processed", 0),
|
||||||
|
"items_found": download.get("items_found", 0),
|
||||||
|
"limit": download.get("limit", 0),
|
||||||
|
"current_item": download.get("current_item"),
|
||||||
|
"current_item_type": download.get("current_item_type"),
|
||||||
|
"current_subreddit": download.get("current_subreddit"),
|
||||||
|
"username": download.get("username"),
|
||||||
|
"subreddit": download.get("subreddit"),
|
||||||
|
"message": f"{download.get('subreddit', download.get('username', 'content'))} - {download['status']}"
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
await manager.broadcast(json.dumps(status_message))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error broadcasting status update: {e}")
|
||||||
|
|
||||||
|
if not has_changes:
|
||||||
|
logger.debug(f"[WEBSOCKET-ENDPOINT] No state changes detected, skipping broadcast")
|
||||||
|
|
||||||
|
await asyncio.sleep(2) # Check every 2 seconds
|
||||||
|
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
logger.info(f"[WEBSOCKET-ENDPOINT] WebSocket disconnected normally from {websocket.client}")
|
||||||
|
manager.disconnect(websocket)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WEBSOCKET-ENDPOINT] WebSocket error from {websocket.client}: {e}")
|
||||||
|
manager.disconnect(websocket)
|
||||||
|
|
||||||
|
@app.get("/api/bdfr/status")
|
||||||
|
async def bdfr_status():
|
||||||
|
"""Get BDFR status and available options"""
|
||||||
|
return {
|
||||||
|
"bdfr_available": BDFR_AVAILABLE,
|
||||||
|
"version": "2.0.0",
|
||||||
|
"supported_operations": [
|
||||||
|
"subreddit_download",
|
||||||
|
"user_download",
|
||||||
|
"custom_filter"
|
||||||
|
],
|
||||||
|
"output_formats": ["json", "csv", "xml"]
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get("/api/websocket/status")
|
||||||
|
async def websocket_status():
|
||||||
|
"""Get WebSocket connection status for debugging"""
|
||||||
|
return {
|
||||||
|
"active_connections": len(manager.active_connections),
|
||||||
|
"connection_failures": manager.connection_failures,
|
||||||
|
"active_downloads": len(active_downloads),
|
||||||
|
"websocket_endpoint": "/ws/progress",
|
||||||
|
"server_host": "0.0.0.0",
|
||||||
|
"server_port": 8000
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get("/api/oauth/debug")
|
||||||
|
async def oauth_debug():
|
||||||
|
"""Debug endpoint to check OAuth state"""
|
||||||
|
try:
|
||||||
|
oauth_manager = get_oauth_manager()
|
||||||
|
return {
|
||||||
|
"oauth_manager_initialized": oauth_manager is not None,
|
||||||
|
"oauth_states_count": len(oauth_manager.oauth_states) if oauth_manager else 0,
|
||||||
|
"access_tokens_count": len(oauth_manager.access_tokens) if oauth_manager else 0,
|
||||||
|
"refresh_tokens_count": len(oauth_manager.refresh_tokens) if oauth_manager else 0,
|
||||||
|
"client_id_configured": oauth_manager.client_id != "U-6gk4ZCh3IeNQ" if oauth_manager else False,
|
||||||
|
"redirect_uri": os.getenv("BDFR_REDIRECT_URI", "http://localhost:8000/auth/callback")
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"error": str(e),
|
||||||
|
"oauth_manager_initialized": False
|
||||||
|
}
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
BDFR Web Interface OAuth Setup Helper
|
||||||
|
|
||||||
|
This script helps you set up Reddit OAuth for the BDFR web interface.
|
||||||
|
Run this script to configure your OAuth credentials and redirect URI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def create_env_file():
|
||||||
|
"""Create .env file from template"""
|
||||||
|
env_example = Path(__file__).parent / ".env.example"
|
||||||
|
env_file = Path(__file__).parent / ".env"
|
||||||
|
|
||||||
|
if not env_example.exists():
|
||||||
|
print("❌ Error: .env.example not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if env_file.exists():
|
||||||
|
print("⚠️ .env file already exists")
|
||||||
|
response = input("Do you want to overwrite it? (y/N): ").lower().strip()
|
||||||
|
if response != 'y':
|
||||||
|
print("Setup cancelled")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Copy .env.example to .env
|
||||||
|
with open(env_example, 'r') as src, open(env_file, 'w') as dst:
|
||||||
|
dst.write(src.read())
|
||||||
|
|
||||||
|
print("✅ Created .env file from template")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_oauth_instructions():
|
||||||
|
"""Display OAuth setup instructions"""
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print("🔐 REDDIT OAUTH SETUP INSTRUCTIONS")
|
||||||
|
print("="*60)
|
||||||
|
print()
|
||||||
|
print("To use the BDFR Web Interface authentication features, you need to:")
|
||||||
|
print()
|
||||||
|
print("1. 📱 CREATE OR UPDATE REDDIT OAUTH APP:")
|
||||||
|
print(" • Go to: https://www.reddit.com/prefs/apps")
|
||||||
|
print(" • Find your app or click 'Create App'")
|
||||||
|
print(" • Set the redirect URI to: http://localhost:8000/auth/callback")
|
||||||
|
print()
|
||||||
|
print("2. 📝 COPY YOUR CREDENTIALS:")
|
||||||
|
print(" • After creating/editing the app, copy the client ID and secret")
|
||||||
|
print(" • These are the values that look like: 7CZHY6AmKweZME5s50SfDGylaPg")
|
||||||
|
print()
|
||||||
|
print("3. ✏️ EDIT YOUR CONFIGURATION:")
|
||||||
|
print(" • Open the .env file that was just created")
|
||||||
|
print(" • Update BDFR_REDIRECT_URI if using a different port/domain")
|
||||||
|
print(" • Update BDFR_CLIENT_ID with your OAuth client ID")
|
||||||
|
print(" • Update BDFR_CLIENT_SECRET with your OAuth client secret")
|
||||||
|
print(" • OR update bdfr/default_config.cfg with your OAuth credentials")
|
||||||
|
print()
|
||||||
|
print("💡 TIP: Use the .env file for web interface configuration")
|
||||||
|
print(" and bdfr/default_config.cfg for CLI tool configuration")
|
||||||
|
print()
|
||||||
|
print("="*60)
|
||||||
|
print()
|
||||||
|
|
||||||
|
input("Press Enter to open the .env file for editing...")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def open_env_file():
|
||||||
|
"""Open .env file in default editor"""
|
||||||
|
env_file = Path(__file__).parent / ".env"
|
||||||
|
|
||||||
|
if not env_file.exists():
|
||||||
|
print("❌ Error: .env file not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f"📝 Opening {env_file} for editing...")
|
||||||
|
|
||||||
|
# Try to open with default editor
|
||||||
|
editor = os.getenv('EDITOR', 'notepad' if os.name == 'nt' else 'nano')
|
||||||
|
|
||||||
|
try:
|
||||||
|
if os.name == 'nt': # Windows
|
||||||
|
os.startfile(env_file)
|
||||||
|
else: # Unix-like
|
||||||
|
os.system(f"{editor} {env_file}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error opening editor: {e}")
|
||||||
|
print(f"📍 Please manually edit the file: {env_file}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main setup function"""
|
||||||
|
print("🚀 BDFR Web Interface OAuth Setup")
|
||||||
|
print("=" * 40)
|
||||||
|
|
||||||
|
# Create .env file
|
||||||
|
if not create_env_file():
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Show instructions
|
||||||
|
if not get_oauth_instructions():
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Open .env file for editing
|
||||||
|
if not open_env_file():
|
||||||
|
print("📝 Please manually edit the .env file with your OAuth settings")
|
||||||
|
print("📍 File location:", Path(__file__).parent / ".env")
|
||||||
|
|
||||||
|
print("\n✅ OAuth setup initiated!")
|
||||||
|
print("📖 Check STARTUP.md for detailed setup instructions")
|
||||||
|
print("🚀 Run 'python start.py' to start the web interface after configuration")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
@echo off
|
||||||
|
REM BDFR Web Interface Startup Script for Windows
|
||||||
|
REM This script provides an easy way to start the BDFR web interface on Windows
|
||||||
|
|
||||||
|
setlocal enabledelayedexpansion
|
||||||
|
|
||||||
|
REM Colors for output (Windows 10+)
|
||||||
|
set "RED=[91m"
|
||||||
|
set "GREEN=[92m"
|
||||||
|
set "YELLOW=[93m"
|
||||||
|
set "BLUE=[94m"
|
||||||
|
set "NC=[0m"
|
||||||
|
|
||||||
|
REM Function to print colored output (simplified for Windows)
|
||||||
|
echo 🌟 BDFR Web Interface Startup
|
||||||
|
echo ==================================================
|
||||||
|
|
||||||
|
REM Check if we're in the right directory
|
||||||
|
if not exist "requirements.txt" (
|
||||||
|
echo ❌ Error: Please run this script from the web_interface directory
|
||||||
|
echo Usage: start.bat
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if not exist "app" (
|
||||||
|
echo ❌ Error: app directory not found
|
||||||
|
echo Please make sure you're in the web_interface directory
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo ℹ️ Checking Python version...
|
||||||
|
|
||||||
|
REM Check Python version
|
||||||
|
python --version > temp_python_version.txt 2>&1
|
||||||
|
set /p PYTHON_VERSION=<temp_python_version.txt
|
||||||
|
del temp_python_version.txt
|
||||||
|
|
||||||
|
echo ✅ Python version: %PYTHON_VERSION%
|
||||||
|
|
||||||
|
REM Check if Python 3.8+ is available (simplified check)
|
||||||
|
echo %PYTHON_VERSION% | findstr /C:"Python 3." >nul
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo ❌ Error: Python 3 is required
|
||||||
|
echo Current version: %PYTHON_VERSION%
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
REM Install dependencies
|
||||||
|
echo ℹ️ Checking and installing dependencies...
|
||||||
|
if exist requirements.txt (
|
||||||
|
echo ℹ️ Installing Python dependencies...
|
||||||
|
python -m pip install -r requirements.txt
|
||||||
|
if !errorlevel! neq 0 (
|
||||||
|
echo ❌ Failed to install dependencies
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
echo ✅ Dependencies installed successfully
|
||||||
|
) else (
|
||||||
|
echo ❌ requirements.txt not found
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
REM Check if BDFR module is available
|
||||||
|
echo ℹ️ Checking BDFR module availability...
|
||||||
|
python -c "import sys; sys.path.insert(0, '../bdfr'); import bdfr.api; print('BDFR API imported successfully')" >nul 2>&1
|
||||||
|
if !errorlevel! neq 0 (
|
||||||
|
echo ⚠️ Warning: BDFR module not found in Python path
|
||||||
|
echo Make sure the parent directory is in your Python path
|
||||||
|
echo Or run this script from the project root directory
|
||||||
|
echo Attempting to install BDFR...
|
||||||
|
cd ..
|
||||||
|
python -m pip install -e .
|
||||||
|
cd web_interface
|
||||||
|
if !errorlevel! neq 0 (
|
||||||
|
echo ❌ Failed to install BDFR
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
echo ✅ BDFR installed successfully
|
||||||
|
) else (
|
||||||
|
echo ✅ BDFR module found
|
||||||
|
)
|
||||||
|
|
||||||
|
REM Start the server
|
||||||
|
echo.
|
||||||
|
echo ==================================================
|
||||||
|
echo ℹ️ Starting BDFR Web Interface...
|
||||||
|
echo ℹ️ Server will be available at: http://localhost:8000
|
||||||
|
echo ℹ️ API documentation at: http://localhost:8000/docs
|
||||||
|
echo ℹ️ Press Ctrl+C to stop the server
|
||||||
|
echo ==================================================
|
||||||
|
|
||||||
|
REM Start uvicorn server
|
||||||
|
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
|
||||||
|
REM This code runs when the server is stopped
|
||||||
|
echo.
|
||||||
|
echo ℹ️ BDFR Web Interface stopped
|
||||||
|
|
||||||
|
pause
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
BDFR Web Interface Startup Script
|
||||||
|
|
||||||
|
This script provides an easy way to start the BDFR web interface with
|
||||||
|
proper dependency management and error handling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import subprocess
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def check_python_version():
|
||||||
|
"""Check if Python version is compatible (3.8+)"""
|
||||||
|
if sys.version_info < (3, 8):
|
||||||
|
print("ERROR: Python 3.8 or higher is required")
|
||||||
|
print(f"Current version: {sys.version}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def install_dependencies():
|
||||||
|
"""Install required dependencies if missing"""
|
||||||
|
requirements_path = Path(__file__).parent / "requirements.txt"
|
||||||
|
|
||||||
|
if not requirements_path.exists():
|
||||||
|
print("❌ Error: requirements.txt not found")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print("Checking and installing dependencies...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try to import required modules first
|
||||||
|
required_modules = [
|
||||||
|
'fastapi',
|
||||||
|
'uvicorn',
|
||||||
|
'websockets',
|
||||||
|
'jinja2'
|
||||||
|
]
|
||||||
|
|
||||||
|
missing_modules = []
|
||||||
|
for module in required_modules:
|
||||||
|
if not importlib.util.find_spec(module):
|
||||||
|
missing_modules.append(module)
|
||||||
|
|
||||||
|
if missing_modules:
|
||||||
|
print(f"Installing missing modules: {', '.join(missing_modules)}")
|
||||||
|
subprocess.check_call([
|
||||||
|
sys.executable, '-m', 'pip', 'install', '-r', str(requirements_path)
|
||||||
|
])
|
||||||
|
else:
|
||||||
|
print("All dependencies are already installed")
|
||||||
|
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"❌ Error installing dependencies: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error checking dependencies: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def check_bdfr_module():
|
||||||
|
"""Check if BDFR module is available"""
|
||||||
|
try:
|
||||||
|
importlib.util.find_spec('bdfr')
|
||||||
|
print("BDFR module found")
|
||||||
|
except ImportError:
|
||||||
|
print("⚠️ Warning: BDFR module not found in Python path")
|
||||||
|
print("Make sure the parent directory is in your Python path or run from project root")
|
||||||
|
|
||||||
|
|
||||||
|
def start_server():
|
||||||
|
"""Start the FastAPI server"""
|
||||||
|
print("Starting BDFR Web Interface...")
|
||||||
|
print("Server will be available at: http://localhost:8000")
|
||||||
|
print("API documentation at: http://localhost:8000/docs")
|
||||||
|
print("Press Ctrl+C to stop the server")
|
||||||
|
print("-" * 50)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Start uvicorn server
|
||||||
|
subprocess.call([
|
||||||
|
sys.executable, '-m', 'uvicorn',
|
||||||
|
'app.main:app',
|
||||||
|
'--host', '0.0.0.0',
|
||||||
|
'--port', '8000',
|
||||||
|
'--reload'
|
||||||
|
])
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n🛑 Server stopped by user")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error starting server: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main startup function"""
|
||||||
|
print("BDFR Web Interface Startup")
|
||||||
|
print("=" * 40)
|
||||||
|
|
||||||
|
# Change to web_interface directory
|
||||||
|
web_interface_dir = Path(__file__).parent
|
||||||
|
os.chdir(web_interface_dir)
|
||||||
|
|
||||||
|
# Pre-flight checks
|
||||||
|
check_python_version()
|
||||||
|
install_dependencies()
|
||||||
|
check_bdfr_module()
|
||||||
|
|
||||||
|
print("\n" + "=" * 40)
|
||||||
|
|
||||||
|
# Start the server
|
||||||
|
start_server()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# BDFR Web Interface Startup Script
|
||||||
|
# Compatible with Linux, macOS, and other Unix-like systems
|
||||||
|
|
||||||
|
set -e # Exit on any error
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Function to print colored output
|
||||||
|
print_info() {
|
||||||
|
echo -e "${BLUE}ℹ️ $1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_success() {
|
||||||
|
echo -e "${GREEN}✅ $1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_warning() {
|
||||||
|
echo -e "${YELLOW}⚠️ $1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_error() {
|
||||||
|
echo -e "${RED}❌ $1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if we're in the right directory
|
||||||
|
if [[ ! -f "requirements.txt" ]] || [[ ! -d "app" ]]; then
|
||||||
|
print_error "Error: Please run this script from the web_interface directory"
|
||||||
|
echo "Usage: ./start.sh"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
print_info "BDFR Web Interface Startup"
|
||||||
|
echo "=================================================="
|
||||||
|
|
||||||
|
# Check Python version
|
||||||
|
print_info "Checking Python version..."
|
||||||
|
PYTHON_VERSION=$(python3 --version 2>&1 | awk '{print $2}')
|
||||||
|
print_success "Python version: $PYTHON_VERSION"
|
||||||
|
|
||||||
|
# Check if Python 3.8+ is available
|
||||||
|
PYTHON_MAJOR=$(echo $PYTHON_VERSION | cut -d. -f1)
|
||||||
|
PYTHON_MINOR=$(echo $PYTHON_VERSION | cut -d. -f2)
|
||||||
|
|
||||||
|
if [[ $PYTHON_MAJOR -lt 3 ]] || [[ $PYTHON_MAJOR -eq 3 && $PYTHON_MINOR -lt 8 ]]; then
|
||||||
|
print_error "Python 3.8 or higher is required"
|
||||||
|
print_error "Current version: $PYTHON_VERSION"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
print_info "Checking and installing dependencies..."
|
||||||
|
if [[ -f "requirements.txt" ]]; then
|
||||||
|
# Check if pip is available
|
||||||
|
if ! command -v pip3 &> /dev/null; then
|
||||||
|
print_error "pip3 is not installed. Please install Python 3 and pip first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install/update requirements
|
||||||
|
print_info "Installing Python dependencies..."
|
||||||
|
pip3 install -r requirements.txt
|
||||||
|
|
||||||
|
if [[ $? -eq 0 ]]; then
|
||||||
|
print_success "Dependencies installed successfully"
|
||||||
|
else
|
||||||
|
print_error "Failed to install dependencies"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
print_error "requirements.txt not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if BDFR module is available
|
||||||
|
print_info "Checking BDFR module availability..."
|
||||||
|
if python3 -c "import bdfr" 2>/dev/null; then
|
||||||
|
print_success "BDFR module found"
|
||||||
|
else
|
||||||
|
print_warning "BDFR module not found in Python path"
|
||||||
|
print_warning "Make sure the parent directory is in your Python path"
|
||||||
|
print_warning "Or run this script from the project root directory"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start the server
|
||||||
|
echo ""
|
||||||
|
echo "=================================================="
|
||||||
|
print_info "Starting BDFR Web Interface..."
|
||||||
|
print_info "Server will be available at: http://localhost:8000"
|
||||||
|
print_info "API documentation at: http://localhost:8000/docs"
|
||||||
|
print_info "Press Ctrl+C to stop the server"
|
||||||
|
echo "=================================================="
|
||||||
|
|
||||||
|
# Start uvicorn server with proper error handling
|
||||||
|
python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
|
||||||
|
# This code runs when the server is stopped
|
||||||
|
echo ""
|
||||||
|
print_info "BDFR Web Interface stopped"
|
||||||
@@ -0,0 +1,817 @@
|
|||||||
|
/* Reset and base styles */
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
color: white;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
text-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Authentication section */
|
||||||
|
.auth-section {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 15px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 10px;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-status {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-user {
|
||||||
|
font-weight: 500;
|
||||||
|
color: white;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-status-indicator {
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-status-indicator.connected {
|
||||||
|
background: rgba(40, 167, 69, 0.2);
|
||||||
|
color: #28a745;
|
||||||
|
border: 1px solid rgba(40, 167, 69, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-status-indicator.disconnected {
|
||||||
|
background: rgba(220, 53, 69, 0.2);
|
||||||
|
color: #dc3545;
|
||||||
|
border: 1px solid rgba(220, 53, 69, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main content */
|
||||||
|
main {
|
||||||
|
background: white;
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 30px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Download section */
|
||||||
|
.download-section {
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||||
|
gap: 30px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-container-unified {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-card {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 25px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border-left: 4px solid #667eea;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-card-unified {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border-left: 4px solid #667eea;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-card h2,
|
||||||
|
.form-card-unified h2 {
|
||||||
|
color: #2c3e50;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-card:nth-child(2) {
|
||||||
|
border-left-color: #764ba2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form styles */
|
||||||
|
.download-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
padding: 12px;
|
||||||
|
border: 2px solid #e1e8ed;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form layout enhancements */
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
margin-top: 25px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section h4 {
|
||||||
|
color: #2c3e50;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-help {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 4px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox styles */
|
||||||
|
.checkbox-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label:hover {
|
||||||
|
background-color: #f0f2f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label input[type="checkbox"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkmark {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-right: 10px;
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label input[type="checkbox"]:checked + .checkmark {
|
||||||
|
background-color: #667eea;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label input[type="checkbox"]:checked + .checkmark::after {
|
||||||
|
content: '✓';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
color: white;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Radio button styles */
|
||||||
|
.radio-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-label:hover {
|
||||||
|
background-color: #f0f2f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-label input[type="radio"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-custom {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid #ddd;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 10px;
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-label input[type="radio"]:checked + .radio-custom {
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-label input[type="radio"]:checked + .radio-custom::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
background-color: #667eea;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mode Selection Styles */
|
||||||
|
.mode-section {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
border: 2px solid #e1e8ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-radio-group {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-option {
|
||||||
|
background: white;
|
||||||
|
border: 2px solid #e1e8ed;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 15px 12px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-option:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-color: #667eea;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-option input[type="radio"]:checked + .radio-custom + .mode-label {
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-option input[type="radio"]:checked {
|
||||||
|
& ~ * {
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-option input[type="radio"]:checked + .radio-custom {
|
||||||
|
border-color: #667eea;
|
||||||
|
background-color: rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-label strong {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-label small {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #666;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip Styles */
|
||||||
|
[data-tooltip] {
|
||||||
|
position: relative;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-tooltip]::before {
|
||||||
|
content: attr(data-tooltip);
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 10px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) scale(0.95);
|
||||||
|
padding: 10px 15px;
|
||||||
|
background: #2c3e50;
|
||||||
|
color: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: normal;
|
||||||
|
width: max-content;
|
||||||
|
max-width: 280px;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
z-index: 1000;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-tooltip]::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 4px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) scale(0.95);
|
||||||
|
border: 6px solid transparent;
|
||||||
|
border-top-color: #2c3e50;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-tooltip]:hover::before,
|
||||||
|
[data-tooltip]:hover::after {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(-50%) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button variations */
|
||||||
|
.btn-small {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline {
|
||||||
|
background: transparent;
|
||||||
|
border: 2px solid #6c757d;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline:hover {
|
||||||
|
background: #6c757d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state styling */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Downloads header */
|
||||||
|
.downloads-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
padding: 12px 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px rgba(118, 75, 162, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress section */
|
||||||
|
.progress-section {
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-section h2 {
|
||||||
|
color: #2c3e50;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-container {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 20px;
|
||||||
|
min-height: 100px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-downloads {
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-downloads p {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Downloads list */
|
||||||
|
.downloads-list {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-list h3 {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress card */
|
||||||
|
.progress-card {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 4px solid #28a745;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-card.downloading {
|
||||||
|
border-left-color: #ffc107;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-card.running {
|
||||||
|
border-left-color: #ffc107;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-card.completed {
|
||||||
|
border-left-color: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-card.failed {
|
||||||
|
border-left-color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-card.error {
|
||||||
|
border-left-color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-info h4 {
|
||||||
|
color: #2c3e50;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-meta {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-status {
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-starting {
|
||||||
|
background: #e7f3ff;
|
||||||
|
color: #0066cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-downloading {
|
||||||
|
background: #fff3cd;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-completed {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-error {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress bar */
|
||||||
|
.progress-bar-container {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
background: #e9ecef;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-details {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-details div {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-phase {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #555;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-item {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #007bff;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background-color: rgba(0, 123, 255, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
border-left: 3px solid #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress controls buttons */
|
||||||
|
.progress-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-retry {
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: #ffc107;
|
||||||
|
color: #000;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-retry:hover {
|
||||||
|
background: #ffca2c;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 8px rgba(255, 193, 7, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-retry:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status section */
|
||||||
|
.status-section {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border-left: 4px solid #17a2b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card h3 {
|
||||||
|
color: #2c3e50;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-value {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-online {
|
||||||
|
color: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-offline {
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
footer {
|
||||||
|
text-align: center;
|
||||||
|
color: white;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes pulse {
|
||||||
|
0% { box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||||
|
50% { box-shadow: 0 4px 16px rgba(255, 193, 7, 0.3); }
|
||||||
|
100% { box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-container {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-card {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-item {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
header h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-card {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Authentication Failed - BDFR Web Interface</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.error-container {
|
||||||
|
background: white;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
|
||||||
|
text-align: center;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
.error-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
color: #f44336;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.error-title {
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
.error-message {
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.error-details {
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
text-align: left;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.retry-button {
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 1rem;
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
.retry-button:hover {
|
||||||
|
background: #5a67d8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="error-container">
|
||||||
|
<div class="error-icon">❌</div>
|
||||||
|
<h1 class="error-title">Authentication Failed</h1>
|
||||||
|
<p class="error-message">
|
||||||
|
There was an error during the authentication process. This might be due to:
|
||||||
|
</p>
|
||||||
|
<ul style="text-align: left; color: #666; margin: 1rem 0;">
|
||||||
|
<li>Invalid or expired authorization code</li>
|
||||||
|
<li>Mismatched redirect URI configuration</li>
|
||||||
|
<li>Reddit OAuth app not properly configured</li>
|
||||||
|
</ul>
|
||||||
|
<div class="error-details">{{ error }}</div>
|
||||||
|
<a href="/?auth_error=true" class="retry-button">Return to Main Page</a>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Authentication Successful - BDFR Web Interface</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.success-container {
|
||||||
|
background: white;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
|
||||||
|
text-align: center;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
.success-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
color: #4CAF50;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.success-title {
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
.success-message {
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.redirect-message {
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.spinner {
|
||||||
|
border: 3px solid #f3f3f3;
|
||||||
|
border-top: 3px solid #667eea;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="success-container">
|
||||||
|
<div class="success-icon">✅</div>
|
||||||
|
<h1 class="success-title">Authentication Successful!</h1>
|
||||||
|
<p class="success-message">
|
||||||
|
You have successfully authenticated with Reddit. You can now use all features of the BDFR Web Interface.
|
||||||
|
</p>
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p class="redirect-message">
|
||||||
|
Redirecting you back to the main interface...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Redirect to main page after 3 seconds
|
||||||
|
setTimeout(function() {
|
||||||
|
window.location.href = '/?authenticated=true';
|
||||||
|
}, 3000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
<!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>
|
||||||
|
<!-- 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>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- 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>© 2024 BDFR Web Interface. Powered by FastAPI.</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user