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.
pull/15/head
Broque Thomas 7 months ago
parent 4e30e90777
commit 287d2fd2ec

@ -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

@ -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"]

@ -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

@ -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

@ -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/"

File diff suppressed because it is too large Load Diff

@ -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…
Cancel
Save