Add Docker support and improve headless compatibility
Introduces Docker deployment files (.dockerignore, Dockerfile, docker-compose.yml, docker-setup.sh, requirements-webui.txt, and README-Docker.md) for SoulSync WebUI. Refactors core/database_update_worker.py and core/media_scan_manager.py to support headless operation without PyQt6, enabling signal/callback compatibility for both GUI and non-GUI environments. Removes logs/app.log file.
This commit is contained in:
parent
4e30e90777
commit
287d2fd2ec
10 changed files with 722 additions and 8986 deletions
70
.dockerignore
Normal file
70
.dockerignore
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# Docker ignore file for SoulSync WebUI
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# IDE and editor files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# Data directories (will be mounted as volumes)
|
||||
logs/*
|
||||
!logs/.gitkeep
|
||||
database/*.db
|
||||
database/*.db-shm
|
||||
database/*.db-wal
|
||||
config/config.json
|
||||
downloads/*
|
||||
!downloads/.gitkeep
|
||||
Transfer/*
|
||||
!Transfer/.gitkeep
|
||||
Storage/*
|
||||
cache/*
|
||||
Stream/*
|
||||
Incomplete/*
|
||||
|
||||
# Temporary files
|
||||
artist_bubble_snapshots.json
|
||||
.spotify_cache
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
README.md
|
||||
headless.md
|
||||
multi-server-database-plan.md
|
||||
server-source.md
|
||||
plans.md
|
||||
|
||||
# GUI-specific files
|
||||
main.py
|
||||
ui/
|
||||
requirements.txt
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
52
Dockerfile
Normal file
52
Dockerfile
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# SoulSync WebUI Dockerfile
|
||||
# Multi-architecture support for AMD64 and ARM64
|
||||
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
libc6-dev \
|
||||
libffi-dev \
|
||||
libssl-dev \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create non-root user for security
|
||||
RUN useradd --create-home --shell /bin/bash --uid 1000 soulsync
|
||||
|
||||
# Copy requirements and install Python dependencies
|
||||
COPY requirements-webui.txt .
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir -r requirements-webui.txt
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Create necessary directories with proper permissions
|
||||
RUN mkdir -p /app/config /app/database /app/logs /app/downloads /app/Transfer && \
|
||||
chown -R soulsync:soulsync /app
|
||||
|
||||
# Create volume mount points
|
||||
VOLUME ["/app/config", "/app/database", "/app/logs", "/app/downloads", "/app/Transfer"]
|
||||
|
||||
# Switch to non-root user
|
||||
USER soulsync
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8008
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD curl -f http://localhost:8008/ || exit 1
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONPATH=/app
|
||||
ENV FLASK_APP=web_server.py
|
||||
ENV FLASK_ENV=production
|
||||
|
||||
# Run the web server
|
||||
CMD ["python", "web_server.py"]
|
||||
271
README-Docker.md
Normal file
271
README-Docker.md
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
# SoulSync WebUI - Docker Deployment Guide
|
||||
|
||||
## 🐳 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- Docker Engine 20.10+
|
||||
- Docker Compose 1.29+
|
||||
- At least 2GB RAM and 10GB free disk space
|
||||
|
||||
### 1. Setup
|
||||
```bash
|
||||
# Clone or download the repository
|
||||
git clone <your-repo-url>
|
||||
cd newmusic
|
||||
|
||||
# Run setup script
|
||||
chmod +x docker-setup.sh
|
||||
./docker-setup.sh
|
||||
```
|
||||
|
||||
### 2. Configure
|
||||
Edit `config/config.json` with your API keys and server settings:
|
||||
```json
|
||||
{
|
||||
"spotify": {
|
||||
"client_id": "your_spotify_client_id",
|
||||
"client_secret": "your_spotify_client_secret"
|
||||
},
|
||||
"plex": {
|
||||
"url": "http://your-plex-server:32400",
|
||||
"token": "your_plex_token"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Deploy
|
||||
```bash
|
||||
# Start SoulSync
|
||||
docker-compose up -d
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f
|
||||
|
||||
# Access the web interface
|
||||
open http://localhost:8008
|
||||
```
|
||||
|
||||
## 📁 Volume Mounts
|
||||
|
||||
SoulSync requires persistent storage for:
|
||||
|
||||
- **`./config`** → `/app/config` - Configuration files
|
||||
- **`./database`** → `/app/database` - SQLite database files
|
||||
- **`./logs`** → `/app/logs` - Application logs
|
||||
- **`./downloads`** → `/app/downloads` - Downloaded music files
|
||||
- **`./Transfer`** → `/app/Transfer` - Processed/matched music files
|
||||
|
||||
## 🔧 Configuration Options
|
||||
|
||||
### Environment Variables
|
||||
```yaml
|
||||
environment:
|
||||
- FLASK_ENV=production # Flask environment
|
||||
- PYTHONPATH=/app # Python path
|
||||
- SOULSYNC_CONFIG_PATH=/app/config/config.json # Config file location
|
||||
- TZ=America/New_York # Timezone
|
||||
```
|
||||
|
||||
### Port Configuration
|
||||
Default port is `8008`. To change:
|
||||
```yaml
|
||||
ports:
|
||||
- "9999:8008" # Access on port 9999
|
||||
```
|
||||
|
||||
### Resource Limits
|
||||
Adjust based on your system:
|
||||
```yaml
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '4.0' # Max CPU cores
|
||||
memory: 4G # Max RAM
|
||||
reservations:
|
||||
cpus: '1.0' # Minimum CPU
|
||||
memory: 1G # Minimum RAM
|
||||
```
|
||||
|
||||
## 🚀 Advanced Setup
|
||||
|
||||
### Multi-Architecture Support
|
||||
The Docker image supports both AMD64 and ARM64:
|
||||
```bash
|
||||
# Build for specific architecture
|
||||
docker buildx build --platform linux/amd64,linux/arm64 -t soulsync-webui .
|
||||
```
|
||||
|
||||
### Custom Network
|
||||
For integration with other containers:
|
||||
```yaml
|
||||
networks:
|
||||
media:
|
||||
external: true
|
||||
```
|
||||
|
||||
### External Services
|
||||
Connect to external Plex/Jellyfin servers:
|
||||
```yaml
|
||||
extra_hosts:
|
||||
- "plex.local:192.168.1.100"
|
||||
- "jellyfin.local:192.168.1.101"
|
||||
```
|
||||
|
||||
## 🔍 Troubleshooting
|
||||
|
||||
### Check Container Status
|
||||
```bash
|
||||
docker-compose ps
|
||||
docker-compose logs soulsync
|
||||
```
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Permission Denied**
|
||||
```bash
|
||||
sudo chown -R 1000:1000 config database logs downloads Transfer
|
||||
```
|
||||
|
||||
**Port Already in Use**
|
||||
```bash
|
||||
# Check what's using port 8888
|
||||
sudo lsof -i :8888
|
||||
# Change port in docker-compose.yml
|
||||
```
|
||||
|
||||
**Out of Memory**
|
||||
```bash
|
||||
# Increase memory limits in docker-compose.yml
|
||||
# Or free up system memory
|
||||
```
|
||||
|
||||
### Health Check
|
||||
The container includes health checks:
|
||||
```bash
|
||||
docker inspect --format='{{.State.Health.Status}}' soulsync-webui
|
||||
```
|
||||
|
||||
## 📊 Monitoring
|
||||
|
||||
### View Real-time Logs
|
||||
```bash
|
||||
docker-compose logs -f --tail=100
|
||||
```
|
||||
|
||||
### Container Stats
|
||||
```bash
|
||||
docker stats soulsync-webui
|
||||
```
|
||||
|
||||
### Database Size
|
||||
```bash
|
||||
du -sh database/
|
||||
```
|
||||
|
||||
## 🔄 Updates
|
||||
|
||||
### Pull Latest Image
|
||||
```bash
|
||||
docker-compose pull
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Backup Before Update
|
||||
```bash
|
||||
# Backup data
|
||||
tar -czf soulsync-backup-$(date +%Y%m%d).tar.gz config/ database/ logs/
|
||||
|
||||
# Update
|
||||
docker-compose pull && docker-compose up -d
|
||||
```
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
### Build Local Image
|
||||
```bash
|
||||
docker build -t soulsync-webui .
|
||||
```
|
||||
|
||||
### Development Mode
|
||||
```yaml
|
||||
# In docker-compose.yml
|
||||
environment:
|
||||
- FLASK_ENV=development
|
||||
volumes:
|
||||
- .:/app # Mount source code for live reload
|
||||
```
|
||||
|
||||
## 🔐 Security
|
||||
|
||||
### Non-Root User
|
||||
The container runs as user `soulsync` (UID 1000) for security.
|
||||
|
||||
### Network Security
|
||||
```yaml
|
||||
# Restrict to localhost only
|
||||
ports:
|
||||
- "127.0.0.1:8888:8888"
|
||||
```
|
||||
|
||||
### Firewall
|
||||
```bash
|
||||
# Allow only local access
|
||||
sudo ufw allow from 192.168.1.0/24 to any port 8888
|
||||
```
|
||||
|
||||
## 📋 Complete Example
|
||||
|
||||
Here's a complete `docker-compose.yml` for production:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
soulsync:
|
||||
build: .
|
||||
container_name: soulsync-webui
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8888:8888"
|
||||
volumes:
|
||||
- ./config:/app/config
|
||||
- ./database:/app/database
|
||||
- ./logs:/app/logs
|
||||
- ./downloads:/app/downloads
|
||||
- ./Transfer:/app/Transfer
|
||||
- /mnt/music:/music:ro # Your music library
|
||||
environment:
|
||||
- FLASK_ENV=production
|
||||
- TZ=America/New_York
|
||||
- PYTHONPATH=/app
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2.0'
|
||||
memory: 2G
|
||||
reservations:
|
||||
cpus: '0.5'
|
||||
memory: 512M
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8888/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
```
|
||||
|
||||
## 🎯 Production Checklist
|
||||
|
||||
- [ ] Configure proper API keys in `config/config.json`
|
||||
- [ ] Set appropriate resource limits
|
||||
- [ ] Configure proper volume mounts
|
||||
- [ ] Set up log rotation
|
||||
- [ ] Configure firewall rules
|
||||
- [ ] Set up backup strategy
|
||||
- [ ] Test health checks
|
||||
- [ ] Verify external service connectivity
|
||||
|
|
@ -1,9 +1,38 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
from PyQt6.QtCore import QThread, pyqtSignal
|
||||
# Conditional PyQt6 import for backward compatibility with GUI version
|
||||
try:
|
||||
from PyQt6.QtCore import QThread, pyqtSignal
|
||||
QT_AVAILABLE = True
|
||||
except ImportError:
|
||||
QT_AVAILABLE = False
|
||||
# Define dummy classes for headless operation
|
||||
class QThread:
|
||||
def __init__(self):
|
||||
self.callbacks = {}
|
||||
def start(self):
|
||||
import threading
|
||||
self.thread = threading.Thread(target=self.run)
|
||||
self.thread.daemon = True
|
||||
self.thread.start()
|
||||
def wait(self):
|
||||
if hasattr(self, 'thread'):
|
||||
self.thread.join()
|
||||
def emit_signal(self, signal_name, *args):
|
||||
if signal_name in self.callbacks:
|
||||
for callback in self.callbacks[signal_name]:
|
||||
try:
|
||||
callback(*args)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in callback for {signal_name}: {e}")
|
||||
def connect_signal(self, signal_name, callback):
|
||||
if signal_name not in self.callbacks:
|
||||
self.callbacks[signal_name] = []
|
||||
self.callbacks[signal_name].append(callback)
|
||||
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import Optional, List
|
||||
from typing import Optional, List, Callable
|
||||
from datetime import datetime
|
||||
import time
|
||||
|
||||
|
|
@ -16,15 +45,27 @@ logger = get_logger("database_update_worker")
|
|||
class DatabaseUpdateWorker(QThread):
|
||||
"""Worker thread for updating SoulSync database with media server library data (Plex or Jellyfin)"""
|
||||
|
||||
# Signals for progress reporting
|
||||
progress_updated = pyqtSignal(str, int, int, float) # current_item, processed, total, percentage
|
||||
artist_processed = pyqtSignal(str, bool, str, int, int) # artist_name, success, details, albums_count, tracks_count
|
||||
finished = pyqtSignal(int, int, int, int, int) # total_artists, total_albums, total_tracks, successful, failed
|
||||
error = pyqtSignal(str) # error_message
|
||||
phase_changed = pyqtSignal(str) # current_phase (artists, albums, tracks)
|
||||
# Qt signals (only available when PyQt6 is installed)
|
||||
if QT_AVAILABLE:
|
||||
progress_updated = pyqtSignal(str, int, int, float) # current_item, processed, total, percentage
|
||||
artist_processed = pyqtSignal(str, bool, str, int, int) # artist_name, success, details, albums_count, tracks_count
|
||||
finished = pyqtSignal(int, int, int, int, int) # total_artists, total_albums, total_tracks, successful, failed
|
||||
error = pyqtSignal(str) # error_message
|
||||
phase_changed = pyqtSignal(str) # current_phase (artists, albums, tracks)
|
||||
|
||||
def __init__(self, media_client, database_path: str = "database/music_library.db", full_refresh: bool = False, server_type: str = "plex"):
|
||||
super().__init__()
|
||||
|
||||
# Initialize signal callbacks for headless mode
|
||||
if not QT_AVAILABLE:
|
||||
self.callbacks = {
|
||||
'progress_updated': [],
|
||||
'artist_processed': [],
|
||||
'finished': [],
|
||||
'error': [],
|
||||
'phase_changed': []
|
||||
}
|
||||
|
||||
# Support both old plex_client parameter and new media_client parameter for backward compatibility
|
||||
if hasattr(media_client, '__class__') and 'plex' in media_client.__class__.__name__.lower():
|
||||
self.media_client = media_client
|
||||
|
|
@ -68,6 +109,21 @@ class DatabaseUpdateWorker(QThread):
|
|||
# Database instance
|
||||
self.database: Optional[MusicDatabase] = None
|
||||
|
||||
def _emit_signal(self, signal_name: str, *args):
|
||||
"""Emit a signal in both Qt and headless modes"""
|
||||
if QT_AVAILABLE and hasattr(self, signal_name):
|
||||
# Qt mode - use actual signal
|
||||
getattr(self, signal_name).emit(*args)
|
||||
elif not QT_AVAILABLE:
|
||||
# Headless mode - use callback system
|
||||
self.emit_signal(signal_name, *args)
|
||||
|
||||
def connect_callback(self, signal_name: str, callback: Callable):
|
||||
"""Connect a callback for headless mode"""
|
||||
if not QT_AVAILABLE:
|
||||
self.connect_signal(signal_name, callback)
|
||||
# In Qt mode, use the normal signal.connect() method
|
||||
|
||||
def stop(self):
|
||||
"""Stop the database update process"""
|
||||
self.should_stop = True
|
||||
|
|
@ -93,30 +149,30 @@ class DatabaseUpdateWorker(QThread):
|
|||
|
||||
# Show cache preparation phase for Jellyfin and set up progress callback
|
||||
if self.server_type == "jellyfin":
|
||||
self.phase_changed.emit("Preparing Jellyfin cache for fast processing...")
|
||||
self._emit_signal('phase_changed', "Preparing Jellyfin cache for fast processing...")
|
||||
# Connect Jellyfin client progress to UI
|
||||
if hasattr(self.media_client, 'set_progress_callback'):
|
||||
self.media_client.set_progress_callback(lambda msg: self.phase_changed.emit(msg))
|
||||
self.media_client.set_progress_callback(lambda msg: self._emit_signal('phase_changed', msg))
|
||||
|
||||
# For full refresh, get all artists
|
||||
artists_to_process = self._get_all_artists()
|
||||
if not artists_to_process:
|
||||
self.error.emit(f"No artists found in {self.server_type} library or connection failed")
|
||||
self._emit_signal('error', f"No artists found in {self.server_type} library or connection failed")
|
||||
return
|
||||
logger.info(f"Full refresh: Found {len(artists_to_process)} artists in {self.server_type} library")
|
||||
else:
|
||||
logger.info("Performing smart incremental update - checking recently added content")
|
||||
# For incremental, use smart recent-first approach
|
||||
self.phase_changed.emit("Finding recently added content...")
|
||||
self._emit_signal('phase_changed', "Finding recently added content...")
|
||||
artists_to_process = self._get_artists_for_incremental_update()
|
||||
if not artists_to_process:
|
||||
logger.info("No new content found - database is up to date")
|
||||
self.finished.emit(0, 0, 0, 0, 0)
|
||||
self._emit_signal('finished', 0, 0, 0, 0, 0)
|
||||
return
|
||||
logger.info(f"Incremental update: Found {len(artists_to_process)} artists to process")
|
||||
|
||||
# Phase 2: Process artists and their albums/tracks
|
||||
self.phase_changed.emit("Processing artists, albums, and tracks...")
|
||||
self._emit_signal('phase_changed', "Processing artists, albums, and tracks...")
|
||||
|
||||
# FAST PATH: For Jellyfin track-based incremental, process new tracks directly
|
||||
if self.server_type == "jellyfin" and hasattr(self, '_jellyfin_new_tracks'):
|
||||
|
|
@ -158,7 +214,7 @@ class DatabaseUpdateWorker(QThread):
|
|||
logger.warning(f"Could not cleanup orphaned records: {e}")
|
||||
|
||||
# Emit final results
|
||||
self.finished.emit(
|
||||
self._emit_signal('finished',
|
||||
self.processed_artists,
|
||||
self.processed_albums,
|
||||
self.processed_tracks,
|
||||
|
|
@ -172,7 +228,7 @@ class DatabaseUpdateWorker(QThread):
|
|||
|
||||
except Exception as e:
|
||||
logger.error(f"Database update failed: {str(e)}")
|
||||
self.error.emit(f"Database update failed: {str(e)}")
|
||||
self._emit_signal('error', f"Database update failed: {str(e)}")
|
||||
|
||||
def _get_all_artists(self) -> List:
|
||||
"""Get all artists from media server library"""
|
||||
|
|
@ -231,7 +287,7 @@ class DatabaseUpdateWorker(QThread):
|
|||
# For Jellyfin, we need to set up progress callback for potential cache population during incremental
|
||||
if self.server_type == "jellyfin":
|
||||
if hasattr(self.media_client, 'set_progress_callback'):
|
||||
self.media_client.set_progress_callback(lambda msg: self.phase_changed.emit(f"Incremental: {msg}"))
|
||||
self.media_client.set_progress_callback(lambda msg: self._emit_signal('phase_changed', f"Incremental: {msg}"))
|
||||
|
||||
# PERFORMANCE BREAKTHROUGH: For Jellyfin, use track-based incremental (much faster)
|
||||
if self.server_type == "jellyfin":
|
||||
|
|
@ -565,11 +621,11 @@ class DatabaseUpdateWorker(QThread):
|
|||
# Emit progress for this artist
|
||||
artist_albums = len(artist_album_ids)
|
||||
artist_tracks = sum(len(tracks_by_album[aid]) for aid in artist_album_ids if aid in tracks_by_album)
|
||||
self.artist_processed.emit(artist_name, True, f"Processed {artist_albums} albums, {artist_tracks} tracks", artist_albums, artist_tracks)
|
||||
self._emit_signal('artist_processed', artist_name, True, f"Processed {artist_albums} albums, {artist_tracks} tracks", artist_albums, artist_tracks)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing artist '{getattr(artist, 'title', 'Unknown')}': {e}")
|
||||
self.artist_processed.emit(getattr(artist, 'title', 'Unknown'), False, f"Error: {str(e)}", 0, 0)
|
||||
self._emit_signal('artist_processed', getattr(artist, 'title', 'Unknown'), False, f"Error: {str(e)}", 0, 0)
|
||||
|
||||
# Update totals
|
||||
with self.thread_lock:
|
||||
|
|
@ -744,7 +800,7 @@ class DatabaseUpdateWorker(QThread):
|
|||
self.processed_artists += 1
|
||||
progress_percent = (self.processed_artists / total_artists) * 100
|
||||
|
||||
self.progress_updated.emit(
|
||||
self._emit_signal('progress_updated',
|
||||
f"Processing {artist_name}",
|
||||
self.processed_artists,
|
||||
total_artists,
|
||||
|
|
@ -788,7 +844,7 @@ class DatabaseUpdateWorker(QThread):
|
|||
artist_name, success, details, album_count, track_count = result
|
||||
|
||||
# Emit progress signal
|
||||
self.artist_processed.emit(artist_name, success, details, album_count, track_count)
|
||||
self._emit_signal('artist_processed', artist_name, success, details, album_count, track_count)
|
||||
|
||||
def _process_artist_with_content(self, media_artist) -> tuple[bool, str, int, int]:
|
||||
"""Process an artist and all their albums and tracks with optimized API usage"""
|
||||
|
|
@ -867,17 +923,40 @@ class DatabaseUpdateWorker(QThread):
|
|||
class DatabaseStatsWorker(QThread):
|
||||
"""Simple worker for getting database statistics without blocking UI"""
|
||||
|
||||
stats_updated = pyqtSignal(dict) # Database statistics
|
||||
# Qt signals (only available when PyQt6 is installed)
|
||||
if QT_AVAILABLE:
|
||||
stats_updated = pyqtSignal(dict) # Database statistics
|
||||
|
||||
def __init__(self, database_path: str = "database/music_library.db"):
|
||||
super().__init__()
|
||||
self.database_path = database_path
|
||||
self.should_stop = False
|
||||
|
||||
# Initialize signal callbacks for headless mode
|
||||
if not QT_AVAILABLE:
|
||||
self.callbacks = {
|
||||
'stats_updated': []
|
||||
}
|
||||
|
||||
def stop(self):
|
||||
"""Stop the worker"""
|
||||
self.should_stop = True
|
||||
|
||||
def _emit_signal(self, signal_name: str, *args):
|
||||
"""Emit a signal in both Qt and headless modes"""
|
||||
if QT_AVAILABLE and hasattr(self, signal_name):
|
||||
# Qt mode - use actual signal
|
||||
getattr(self, signal_name).emit(*args)
|
||||
elif not QT_AVAILABLE:
|
||||
# Headless mode - use callback system
|
||||
self.emit_signal(signal_name, *args)
|
||||
|
||||
def connect_callback(self, signal_name: str, callback: Callable):
|
||||
"""Connect a callback for headless mode"""
|
||||
if not QT_AVAILABLE:
|
||||
self.connect_signal(signal_name, callback)
|
||||
# In Qt mode, use the normal signal.connect() method
|
||||
|
||||
def run(self):
|
||||
"""Get database statistics and full info including last refresh"""
|
||||
try:
|
||||
|
|
@ -891,7 +970,7 @@ class DatabaseStatsWorker(QThread):
|
|||
# Get database info for active server (server-aware statistics)
|
||||
info = database.get_database_info_for_server()
|
||||
if not self.should_stop:
|
||||
self.stats_updated.emit(info)
|
||||
self._emit_signal('stats_updated', info)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting database stats: {e}")
|
||||
if not self.should_stop:
|
||||
|
|
@ -899,7 +978,7 @@ class DatabaseStatsWorker(QThread):
|
|||
from config.settings import config_manager
|
||||
active_server = config_manager.get_active_media_server()
|
||||
|
||||
self.stats_updated.emit({
|
||||
self._emit_signal('stats_updated', {
|
||||
'artists': 0,
|
||||
'albums': 0,
|
||||
'tracks': 0,
|
||||
|
|
|
|||
|
|
@ -50,33 +50,56 @@ class MediaScanManager:
|
|||
|
||||
# Try to get client instances from app
|
||||
try:
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
app = QApplication.instance()
|
||||
|
||||
# Try to find the main window from top-level widgets
|
||||
main_window = None
|
||||
for widget in app.topLevelWidgets():
|
||||
if hasattr(widget, 'plex_client') and hasattr(widget, 'jellyfin_client'):
|
||||
main_window = widget
|
||||
break
|
||||
|
||||
if main_window:
|
||||
if active_server == "jellyfin":
|
||||
client = getattr(main_window, 'jellyfin_client', None)
|
||||
if client and client.is_connected():
|
||||
return client, "jellyfin"
|
||||
# Try PyQt6 first (GUI mode)
|
||||
try:
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
app = QApplication.instance()
|
||||
|
||||
if app:
|
||||
# Try to find the main window from top-level widgets
|
||||
main_window = None
|
||||
for widget in app.topLevelWidgets():
|
||||
if hasattr(widget, 'plex_client') and hasattr(widget, 'jellyfin_client'):
|
||||
main_window = widget
|
||||
break
|
||||
|
||||
if main_window:
|
||||
if active_server == "jellyfin":
|
||||
client = getattr(main_window, 'jellyfin_client', None)
|
||||
if client and client.is_connected():
|
||||
return client, "jellyfin"
|
||||
else:
|
||||
logger.warning("Jellyfin client not connected, falling back to Plex")
|
||||
|
||||
# Default to Plex or fallback
|
||||
client = getattr(main_window, 'plex_client', None)
|
||||
if client and client.is_connected():
|
||||
return client, "plex"
|
||||
else:
|
||||
logger.debug(f"Plex client not connected or not found")
|
||||
else:
|
||||
logger.warning("Jellyfin client not connected, falling back to Plex")
|
||||
|
||||
# Default to Plex or fallback
|
||||
client = getattr(main_window, 'plex_client', None)
|
||||
if client and client.is_connected():
|
||||
return client, "plex"
|
||||
logger.debug("No main window found in Qt application")
|
||||
else:
|
||||
logger.debug(f"Plex client not connected or not found")
|
||||
logger.debug("No QApplication instance found")
|
||||
|
||||
except ImportError:
|
||||
logger.debug("PyQt6 not available, trying headless mode")
|
||||
|
||||
# Headless mode - try to get clients from global instances
|
||||
import sys
|
||||
for module_name, module in sys.modules.items():
|
||||
if hasattr(module, 'plex_client') and hasattr(module, 'jellyfin_client'):
|
||||
if active_server == "jellyfin":
|
||||
client = getattr(module, 'jellyfin_client', None)
|
||||
if client and hasattr(client, 'is_connected') and client.is_connected():
|
||||
return client, "jellyfin"
|
||||
|
||||
client = getattr(module, 'plex_client', None)
|
||||
if client and hasattr(client, 'is_connected') and client.is_connected():
|
||||
return client, "plex"
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not access clients from main window: {e}")
|
||||
logger.debug(f"Could not access clients: {e}")
|
||||
|
||||
logger.error("No active media client available")
|
||||
return None, None
|
||||
|
|
|
|||
46
docker-compose.yml
Normal file
46
docker-compose.yml
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
soulsync:
|
||||
build: .
|
||||
container_name: soulsync-webui
|
||||
ports:
|
||||
- "8008:8008"
|
||||
volumes:
|
||||
# Persistent data volumes
|
||||
- ./config:/app/config
|
||||
- ./database:/app/database
|
||||
- ./logs:/app/logs
|
||||
- ./downloads:/app/downloads
|
||||
- ./Transfer:/app/Transfer
|
||||
# Optional: Mount your music library for Plex/Jellyfin access
|
||||
- /path/to/your/music:/music:ro
|
||||
environment:
|
||||
# Web server configuration
|
||||
- FLASK_ENV=production
|
||||
- PYTHONPATH=/app
|
||||
# Optional: Configure through environment variables
|
||||
- SOULSYNC_CONFIG_PATH=/app/config/config.json
|
||||
# Set timezone
|
||||
- TZ=America/New_York
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8888/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
# Resource limits (adjust as needed)
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2.0'
|
||||
memory: 2G
|
||||
reservations:
|
||||
cpus: '0.5'
|
||||
memory: 512M
|
||||
|
||||
# Optional: Add external network for communication with other containers
|
||||
networks:
|
||||
default:
|
||||
name: soulsync-network
|
||||
67
docker-setup.sh
Normal file
67
docker-setup.sh
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
#!/bin/bash
|
||||
|
||||
# SoulSync Docker Setup Script
|
||||
# This script helps set up the Docker environment for SoulSync WebUI
|
||||
|
||||
set -e
|
||||
|
||||
echo "🎵 SoulSync WebUI Docker Setup"
|
||||
echo "==============================="
|
||||
|
||||
# Create necessary directories
|
||||
echo "📁 Creating directory structure..."
|
||||
mkdir -p config database logs downloads Transfer
|
||||
|
||||
# Create .gitkeep files for empty directories
|
||||
touch downloads/.gitkeep Transfer/.gitkeep logs/.gitkeep
|
||||
|
||||
# Copy example config if config.json doesn't exist
|
||||
if [ ! -f "config/config.json" ]; then
|
||||
if [ -f "config/config.example.json" ]; then
|
||||
echo "📋 Copying example configuration..."
|
||||
cp config/config.example.json config/config.json
|
||||
echo "⚙️ Please edit config/config.json with your API keys and settings"
|
||||
else
|
||||
echo "⚠️ Warning: No example config found. You'll need to create config/config.json manually"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Set proper permissions
|
||||
echo "🔐 Setting permissions..."
|
||||
chmod -R 755 config database logs downloads Transfer
|
||||
chown -R $USER:$USER config database logs downloads Transfer
|
||||
|
||||
# Check if Docker is installed
|
||||
if ! command -v docker &> /dev/null; then
|
||||
echo "❌ Docker is not installed. Please install Docker first."
|
||||
echo " Visit: https://docs.docker.com/get-docker/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if Docker Compose is installed
|
||||
if ! command -v docker-compose &> /dev/null; then
|
||||
echo "❌ Docker Compose is not installed. Please install Docker Compose first."
|
||||
echo " Visit: https://docs.docker.com/compose/install/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Setup complete!"
|
||||
echo ""
|
||||
echo "📝 Next steps:"
|
||||
echo "1. Edit config/config.json with your API keys and server settings"
|
||||
echo "2. Run: docker-compose up -d"
|
||||
echo "3. Access SoulSync at http://localhost:8888"
|
||||
echo ""
|
||||
echo "🔧 Useful commands:"
|
||||
echo " docker-compose up -d # Start in background"
|
||||
echo " docker-compose logs -f # View logs"
|
||||
echo " docker-compose down # Stop container"
|
||||
echo " docker-compose pull # Update image"
|
||||
echo " docker-compose restart # Restart container"
|
||||
echo ""
|
||||
echo "📂 Data locations:"
|
||||
echo " - Configuration: ./config/"
|
||||
echo " - Database: ./database/"
|
||||
echo " - Logs: ./logs/"
|
||||
echo " - Downloads: ./downloads/"
|
||||
echo " - Transfer: ./Transfer/"
|
||||
8923
logs/app.log
8923
logs/app.log
File diff suppressed because it is too large
Load diff
32
requirements-webui.txt
Normal file
32
requirements-webui.txt
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# SoulSync WebUI Requirements
|
||||
# Docker-compatible requirements without PyQt6 dependencies
|
||||
|
||||
# Core web framework
|
||||
Flask>=3.0.0
|
||||
|
||||
# Music service APIs
|
||||
spotipy>=2.23.0
|
||||
PlexAPI>=4.17.0
|
||||
|
||||
# HTTP and async support
|
||||
requests>=2.31.0
|
||||
aiohttp>=3.9.0
|
||||
|
||||
# Configuration management
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# Security and encryption
|
||||
cryptography>=41.0.0
|
||||
|
||||
# Media metadata handling
|
||||
mutagen>=1.47.0
|
||||
Pillow>=10.0.0
|
||||
|
||||
# Text processing
|
||||
unidecode>=1.3.8
|
||||
|
||||
# YouTube support
|
||||
yt-dlp>=2024.12.13
|
||||
|
||||
# Optional: MQTT support (for future features)
|
||||
asyncio-mqtt>=0.16.0
|
||||
|
|
@ -5447,11 +5447,19 @@ def _run_db_update_task(full_refresh, server_type):
|
|||
full_refresh=full_refresh,
|
||||
server_type=server_type
|
||||
)
|
||||
# Connect signals to callbacks
|
||||
db_update_worker.progress_updated.connect(_db_update_progress_callback)
|
||||
db_update_worker.phase_changed.connect(_db_update_phase_callback)
|
||||
db_update_worker.finished.connect(_db_update_finished_callback)
|
||||
db_update_worker.error.connect(_db_update_error_callback)
|
||||
# Connect signals to callbacks (handle both Qt and headless modes)
|
||||
try:
|
||||
# Try Qt signal connection first
|
||||
db_update_worker.progress_updated.connect(_db_update_progress_callback)
|
||||
db_update_worker.phase_changed.connect(_db_update_phase_callback)
|
||||
db_update_worker.finished.connect(_db_update_finished_callback)
|
||||
db_update_worker.error.connect(_db_update_error_callback)
|
||||
except AttributeError:
|
||||
# Headless mode - use callback system
|
||||
db_update_worker.connect_callback('progress_updated', _db_update_progress_callback)
|
||||
db_update_worker.connect_callback('phase_changed', _db_update_phase_callback)
|
||||
db_update_worker.connect_callback('finished', _db_update_finished_callback)
|
||||
db_update_worker.connect_callback('error', _db_update_error_callback)
|
||||
|
||||
# This is a blocking call that runs the QThread's logic
|
||||
db_update_worker.run()
|
||||
|
|
@ -6218,6 +6226,10 @@ def _on_download_completed(batch_id, task_id, success=True):
|
|||
|
||||
print(f"🔄 [Batch Manager] Task {task_id} completed ({'success' if success else 'failed/cancelled'}). Active workers: {old_active} → {new_active}/{download_batches[batch_id]['max_concurrent']}")
|
||||
|
||||
# ENHANCED: Always check batch completion after any task completes
|
||||
# This ensures completion is detected even when mixing normal downloads with cancelled tasks
|
||||
print(f"🔍 [Batch Manager] Checking batch completion after task {task_id} completed")
|
||||
|
||||
# FIXED: Check if batch is truly complete (all tasks finished, not just workers freed)
|
||||
batch = download_batches[batch_id]
|
||||
all_tasks_started = batch['queue_index'] >= len(batch['queue'])
|
||||
|
|
@ -6253,8 +6265,10 @@ def _on_download_completed(batch_id, task_id, success=True):
|
|||
# Check if this is an auto-initiated batch
|
||||
is_auto_batch = batch.get('auto_initiated', False)
|
||||
|
||||
# Mark batch as complete and process wishlist outside of lock to prevent deadlocks
|
||||
batch['phase'] = 'complete'
|
||||
# FIXED: Ensure batch is not already marked as complete to prevent duplicate processing
|
||||
if batch.get('phase') != 'complete':
|
||||
# Mark batch as complete and process wishlist outside of lock to prevent deadlocks
|
||||
batch['phase'] = 'complete'
|
||||
|
||||
# Add activity for batch completion
|
||||
playlist_name = batch.get('playlist_name', 'Unknown Playlist')
|
||||
|
|
@ -7912,13 +7926,18 @@ def _check_batch_completion_v2(batch_id):
|
|||
print(f"🔍 [Completion Check V2] Batch {batch_id}: tasks_started={all_tasks_started}, workers={no_active_workers}, finished={finished_count}/{len(queue)}, retrying={retrying_count}")
|
||||
|
||||
if all_tasks_started and no_active_workers and all_tasks_truly_finished and not has_retrying_tasks:
|
||||
print(f"🎉 [Completion Check V2] Batch {batch_id} is complete - marking as finished")
|
||||
|
||||
# Check if this is an auto-initiated batch
|
||||
is_auto_batch = batch.get('auto_initiated', False)
|
||||
|
||||
# Mark batch as complete
|
||||
batch['phase'] = 'complete'
|
||||
# FIXED: Ensure batch is not already marked as complete to prevent duplicate processing
|
||||
if batch.get('phase') != 'complete':
|
||||
print(f"🎉 [Completion Check V2] Batch {batch_id} is complete - marking as finished")
|
||||
|
||||
# Check if this is an auto-initiated batch
|
||||
is_auto_batch = batch.get('auto_initiated', False)
|
||||
|
||||
# Mark batch as complete
|
||||
batch['phase'] = 'complete'
|
||||
else:
|
||||
print(f"✅ [Completion Check V2] Batch {batch_id} already marked complete - skipping duplicate processing")
|
||||
return True # Already complete
|
||||
|
||||
# Update YouTube playlist phase to 'download_complete' if this is a YouTube playlist
|
||||
playlist_id = batch.get('playlist_id')
|
||||
|
|
@ -11177,7 +11196,7 @@ class WebMetadataUpdateWorker:
|
|||
|
||||
if __name__ == '__main__':
|
||||
print("🚀 Starting SoulSync Web UI Server...")
|
||||
print("Open your browser and navigate to http://127.0.0.1:5001")
|
||||
print("Open your browser and navigate to http://127.0.0.1:8008")
|
||||
|
||||
# Start simple background monitor when server starts
|
||||
print("🔧 Starting simple background monitor...")
|
||||
|
|
@ -11204,4 +11223,4 @@ if __name__ == '__main__':
|
|||
# Add a test activity to verify the system is working
|
||||
add_activity_item("🔧", "Debug Test", "Activity feed system test", "Now")
|
||||
|
||||
app.run(host='0.0.0.0', port=5001, debug=True)
|
||||
app.run(host='0.0.0.0', port=8008, debug=True)
|
||||
|
|
|
|||
Loading…
Reference in a new issue