commit
40b48d0b5d
23 changed files with 33584 additions and 9945 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
|
||||
469
README.md
469
README.md
|
|
@ -1,421 +1,126 @@
|
|||
|
||||
<p align="center">
|
||||
<img src="./assets/trans.png" alt="Logo">
|
||||
<img src="./assets/trans.png" alt="SoulSync Logo">
|
||||
</p>
|
||||
|
||||
# 🎵 SoulSync - Automated Music Discovery and Collection Manager
|
||||
# 🎵 SoulSync - Automated Music Discovery & Collection Manager
|
||||
|
||||
SoulSync is a powerful desktop application designed to bridge the gap between your music streaming habits on Spotify and your personal, high-quality music library in Plex or Jellyfin. It automates the process of discovering new music, finding missing tracks from your favorite playlists, and sourcing them from the Soulseek network via slskd.
|
||||
|
||||
The core philosophy of SoulSync is to let you enjoy music discovery on Spotify while it handles the tedious work of building and maintaining a pristine, locally-hosted music collection for you in your media server. Both Plex and Jellyfin are supported as media servers (though neither is required for the app to function), while slskd and Spotify API are required.
|
||||
|
||||
## ☕ Support Development
|
||||
|
||||
If you find SoulSync useful, consider supporting its development:
|
||||
Bridge the gap between streaming services and your local music library. Automatically sync Spotify/Tidal/YouTube playlists to Plex/Jellyfin via Soulseek.
|
||||
|
||||
[](https://ko-fi.com/boulderbadgedad)
|
||||
|
||||

|
||||
## ✨ What It Does
|
||||
|
||||
## ⚠️ Docker Support
|
||||
Docker is unlikely since this is a fully GUI based app. The unique setup would be difficult for most users and my knowledge of docker is sad.
|
||||
- **Auto-sync playlists** from Spotify/Tidal/YouTube to your media server
|
||||
- **Smart matching** finds what you're missing vs what you own
|
||||
- **Download missing tracks** from Soulseek with FLAC priority
|
||||
- **Metadata enhancement** adds proper tags and album art
|
||||
- **File organization** creates clean folder structures
|
||||
- **Artist discovery** browse complete discographies
|
||||
- **Wishlist system** saves failed downloads for automatic retry
|
||||
- **Artist watchlist** monitors for new releases and adds missing tracks
|
||||
- **Background automation** retries failed downloads every hour
|
||||
|
||||
## ✨ Core Features
|
||||
## 🚀 Three Ways to Run
|
||||
|
||||
### 🤖 **Automation Engine**
|
||||
SoulSync handles everything automatically once you set it up. You can sync multiple Spotify / Youtube / Tidal playlists at the same time, and it'll prioritize FLAC files and reliable sources. When downloads finish, it organizes them into clean folder structures and updates your Plex library automatically.
|
||||
|
||||
The app runs a background process every 60 minutes to retry failed downloads - so if a track wasn't available earlier, it'll keep trying until it finds it. It also auto-detects your Plex or Jellyfin server and slskd on your network, backs up your playlists before making changes, and reconnects to services if they go down.
|
||||
|
||||
Once it's running, SoulSync basically acts like a personal music librarian that works in the background.
|
||||
|
||||
### 🎬 **Spotify & YouTube & Tidal Integration**
|
||||
Works with both Spotify / Youtube / Tidal playlists. For YouTube, it extracts clean track names by removing stuff like "(Official Music Video)" and other junk from titles. For Spotify, it tracks playlist changes so it only downloads new tracks instead of re-scanning everything.
|
||||
|
||||
Both get the same smart matching system with color-coded confidence scores, and you can bulk download all missing tracks with progress tracking.
|
||||
|
||||
### 🎯 **Artist Discovery**
|
||||
Search for any artist and see their complete discography with indicators showing what you already own vs what's missing. You can download entire missing discographies with one click, or just grab specific albums/tracks. It shows releases chronologically and highlights gaps in your collection.
|
||||
|
||||
### 🔍 **Search & Download**
|
||||
The search page lets you manually hunt for specific albums or singles. Every result has a preview button so you can stream before downloading. It keeps your search history and has detailed progress tracking for downloads. Failed downloads automatically go to a wishlist for retry later.
|
||||
|
||||
### 🧠 **Smart Matching**
|
||||
The matching engine is pretty sophisticated - it prioritizes original versions over remixes, handles weird characters (like КоЯn → Korn), and removes album names from track titles for cleaner matching. It generates multiple search variations per track to find more results and scores each match so you know how confident it is.
|
||||
|
||||
### 🗄️ **Local Database**
|
||||
Keeps a complete SQLite database of your media server library (Plex or Jellyfin) locally, so matching is instant instead of making slow API calls. Updates automatically when files change and handles thousands of songs without slowing down.
|
||||
|
||||
### 📁 **File Organization**
|
||||
Downloads get organized automatically based on whether they're album tracks or singles. Creates clean folder structures like `Transfer/Artist/Artist - Album/01 - Track.flac`. Supports all common audio formats and automatically tags everything with proper metadata and album art from Spotify.
|
||||
|
||||
### 🎵 **Built-in Player**
|
||||
You can stream tracks directly from Soulseek before downloading to make sure they're the right ones. Supports all common audio formats and the player works across all pages in the app.
|
||||
|
||||
### 📋 **Wishlist System**
|
||||
Failed downloads automatically get saved to a wishlist with context about where they came from. The app tries to download wishlist items every hour automatically. You can also manually retry or bulk manage failed downloads.
|
||||
|
||||
### 👀 **Watchlist Monitoring**
|
||||
Track your favorite artists automatically by adding them to your watchlist. SoulSync monitors watched artists for new releases and automatically scans their latest albums and singles against your library. When new tracks are found missing, they're added to your wishlist for download. The system runs comprehensive scans with intelligent rate limiting to avoid API bans, and you can view real-time progress with detailed status updates for each artist being scanned.
|
||||
|
||||
### 📊 **Dashboard & Monitoring**
|
||||
Real-time status for all your connections (Spotify, Plex/Jellyfin, Soulseek), download statistics, and system performance. Activity feed shows everything that's happening with timestamps.
|
||||
|
||||
### 🎯 **Five Main Pages**
|
||||
|
||||
**Downloads**: Search for music manually, preview before downloading, see progress in real-time.
|
||||
|
||||
**Sync**: Load Spotify/YouTube playlists, see what's missing with confidence scores, bulk download missing tracks.
|
||||
|
||||
**Artists**: Browse complete artist catalogs, see what you own vs missing, bulk download entire discographies.
|
||||
|
||||
**Dashboard**: Overview of all connections and activity, quick access to common functions.
|
||||
|
||||
**Settings**: Configure all your API keys and preferences, database management, performance tuning.
|
||||
|
||||
### 🚀 **Performance**
|
||||
Multi-threaded so it stays responsive during heavy operations. Automatically manages resources, prevents Soulseek bans with rate limiting, and handles errors gracefully with automatic recovery.
|
||||
|
||||
## ⚙️ How It Works
|
||||
|
||||
The application follows a clear, automated workflow to enhance and expand your music library:
|
||||
|
||||
1. **Connect Services**: First, you authenticate with your Spotify account, connect to your media server (Plex or Jellyfin), and connect to your running slskd instance through the settings panel. This gives SoulSync the access it needs to work its magic.
|
||||
|
||||
2. **Analyze**: Navigate to the Sync page and select a Spotify playlist. SoulSync fetches all tracks and compares them against your media server library. This comparison uses a sophisticated matching engine that looks at track title, artist, album, and duration to make an accurate assessment.
|
||||
|
||||
3. **Identify Missing**: After the analysis, the application generates a clear, actionable list of tracks that are present in the Spotify playlist but are not found in your media server library.
|
||||
|
||||
4. **Search & Download**: For each missing track, SoulSync generates multiple optimized search queries to increase the likelihood of finding a high-quality match. It then uses the slskd API to search the Soulseek network, prioritizing FLAC files and reliable users, and automatically queues them for download.
|
||||
|
||||
5. **Organize & Enhance**: Once a download is complete, SoulSync automatically organizes the file from the download directory into the transfer directory, creating a clean folder structure based on the artist and album (`/Transfer/Artist Name/Artist Name - Album Name/Track.flac`). Immediately after organization, the metadata enhancement system enriches the file with accurate Spotify data including proper artist/album names, track numbers, release dates, genres, and high-quality embedded album art. This ensures every file emerges perfectly tagged and ready for your media server, requiring no manual metadata editing.
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
Follow these steps to get SoulSync up and running on your system.
|
||||
|
||||
### ⚠️ Important Soulseek Sharing Requirement
|
||||
|
||||
**CRITICAL**: Before using SoulSync, you MUST set up a shared folder in slskd. Users who only download without sharing are typically banned by other Soulseek users, which will severely limit your ability to find and download music.
|
||||
|
||||
1. In your slskd web interface (`http://localhost:5030`), go to the **Shares** section
|
||||
2. Add at least one folder containing music files to share with the network
|
||||
3. The more you share, the better your reputation and download success rate will be
|
||||
4. Consider sharing your organized music collection from your Plex library
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Before you begin, ensure you have the following installed and configured:
|
||||
|
||||
- **Python 3.8+**: The core runtime for the application.
|
||||
- **Media Server (OPTIONAL)**: Either Plex Media Server or Jellyfin is optional but recommended. If connected, SoulSync will scan your existing music library to identify missing tracks. Without a media server, all tracks will be considered "missing" and automatically downloaded.
|
||||
- **slskd**: A headless Soulseek client. This is the engine that powers the downloading feature. See detailed setup instructions below.
|
||||
- **Spotify Account**: A regular or premium Spotify account is required to access your playlists and artist data.
|
||||
|
||||
### Setting Up slskd
|
||||
|
||||
This application requires **slskd**, a web-based Soulseek client, to handle music downloads. Here's how to set it up:
|
||||
|
||||
#### Installing slskd
|
||||
|
||||
**Option 1: Manual Installation (RECOMMENDED)**
|
||||
1. Download the latest release from [slskd GitHub releases](https://github.com/slskd/slskd/releases)
|
||||
2. Extract and run the executable
|
||||
3. Default web interface will be available at `http://localhost:5030`
|
||||
|
||||
**Option 2: Docker (MAYBE? UNTESTED)**
|
||||
### 1. Desktop GUI (Original)
|
||||
Full PyQt6 desktop application with all features.
|
||||
```bash
|
||||
# Create directories for slskd
|
||||
mkdir -p ~/slskd/{config,downloads,incomplete}
|
||||
|
||||
# Run slskd container
|
||||
docker run -d \
|
||||
--name slskd \
|
||||
-p 5030:5030 \
|
||||
-p 50300:50300 \
|
||||
-v ~/slskd/config:/app/config \
|
||||
-v ~/slskd/downloads:/app/downloads \
|
||||
-v ~/slskd/incomplete:/app/incomplete \
|
||||
slskd/slskd:latest
|
||||
git clone https://github.com/Nezreka/SoulSync
|
||||
cd SoulSync
|
||||
pip install -r requirements.txt
|
||||
python main.py
|
||||
```
|
||||
|
||||
#### Configuring slskd
|
||||
### 2. Web UI (New!)
|
||||
Browser-based interface - same features, runs anywhere.
|
||||
```bash
|
||||
python web_server.py
|
||||
# Open http://localhost:8008
|
||||
```
|
||||
|
||||
1. **Initial Setup**: Open `http://localhost:5030` in your browser
|
||||
2. **Create Account**: Set up your admin username and password
|
||||
3. **Soulseek Credentials**: Enter your Soulseek username and password
|
||||
4. **API Key**: Create a random 16-character API key:
|
||||
- Generate a random string (letters and numbers) like `abc123def456ghi7`
|
||||
- Add this to your slskd configuration file as the API key
|
||||
- Use the same key in SoulSync configuration
|
||||
5. **CONFIG SETUP**: An application directory will be created in either ~/.local/share/slskd (on Linux and macOS) or %localappdata%/slskd (on Windows). In the root of this directory the file slskd.yml will be created the first time the application runs. Edit this file to enter your credentials for the Soulseek network, and tweak any additional settings using the
|
||||
### 3. Docker (New!)
|
||||
Containerized web UI with persistent database.
|
||||
```bash
|
||||
docker-compose up -d
|
||||
# Open http://localhost:8008
|
||||
```
|
||||
|
||||
**Important Notes:**
|
||||
- slskd must be running before starting SoulSync
|
||||
- Make sure your Soulseek account has sharing enabled to avoid connection issues
|
||||
- The default port 5030 can be changed in slskd settings if needed
|
||||
## ⚡ Quick Setup
|
||||
|
||||
### Installation
|
||||
### Prerequisites
|
||||
- **slskd**: Download from [GitHub](https://github.com/slskd/slskd/releases), run on port 5030
|
||||
- **Spotify API**: Create app at [Spotify Dashboard](https://developer.spotify.com/dashboard)
|
||||
- **Media Server**: Plex or Jellyfin (optional but recommended)
|
||||
|
||||
1. **Clone the repository**:
|
||||
```bash
|
||||
git clone https://github.com/Nezreka/SoulSync
|
||||
cd soulsync-app
|
||||
```
|
||||
### Essential Config
|
||||
1. Get Spotify Client ID/Secret from developer dashboard
|
||||
2. Set up slskd with downloads folder and API key
|
||||
3. Launch SoulSync, go to Settings, enter your credentials
|
||||
4. **Important**: Share music in slskd to avoid bans
|
||||
|
||||
2. **Install dependencies**:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
### Docker Notes
|
||||
- Database persists between rebuilds via named volume
|
||||
- Mount drives containing your download/transfer folders:
|
||||
```yaml
|
||||
volumes:
|
||||
- /mnt/c:/host/mnt/c:rw # For C: drive paths
|
||||
- /mnt/d:/host/mnt/d:rw # For D: drive paths
|
||||
```
|
||||
- Uses separate database from GUI/WebUI versions
|
||||
|
||||
### ⚠️ First-Time Setup: A Critical Step
|
||||
## 🎯 Core Features
|
||||
|
||||
**IMPORTANT**: SoulSync will not function until you provide your API keys and service details. You must do this before you start using the app's features. You have two options for this initial setup:
|
||||
**Search & Download**: Manual track search with preview streaming
|
||||
**Playlist Sync**: Spotify/Tidal/YouTube playlist analysis and batch downloads
|
||||
**Artist Explorer**: Complete discography browsing with missing indicators
|
||||
**Smart Matching**: Advanced algorithm prioritizes originals over remixes
|
||||
**Wishlist Management**: Failed downloads automatically saved and retried hourly
|
||||
**Artist Watchlist**: Add favorite artists to monitor for new releases automatically
|
||||
**Automation**: Hourly retry of failed downloads, metadata enhancement
|
||||
**Activity Feed**: Real-time status and progress tracking
|
||||
|
||||
#### Option 1 (Recommended): Use the In-App Settings Page
|
||||
## 📁 File Flow
|
||||
|
||||
1. Launch the application (`python main.py`).
|
||||
2. The very first thing you should do is navigate to the **Settings** page using the sidebar.
|
||||
3. Fill in the required fields for Spotify and Soulseek. Media server configuration (Plex or Jellyfin) is optional but recommended.
|
||||
4. Click "Save Settings". The app is now ready to use.
|
||||
5. Restart the app for good luck.
|
||||
1. **Search**: Query Soulseek via slskd API
|
||||
2. **Download**: Files saved to configured download folder
|
||||
3. **Process**: Auto-organize to transfer folder with metadata
|
||||
4. **Structure**: `Transfer/Artist/Artist - Album/01 - Track.flac`
|
||||
5. **Import**: Media server picks up organized files
|
||||
|
||||
#### Option 2: Edit the config.json File Manually
|
||||
|
||||
1. **Locate the Configuration File**: Before launching the app, find the `config.json` file in the `config/` directory of the project.
|
||||
2. **Configure API Keys and URLs**: Open the file and fill in the details as described below.
|
||||
|
||||
### Configuration Details
|
||||
|
||||
Open the `config.json` file and fill in the details for Spotify, your media server (Plex or Jellyfin), and Soulseek.
|
||||
|
||||
#### 🔑 Obtaining Required API Credentials
|
||||
|
||||
Before configuring SoulSync, you'll need to obtain API credentials from Spotify and your media server. Here's how:
|
||||
|
||||
##### Spotify Client ID and Secret
|
||||
|
||||
**Step 1: Create a Spotify App**
|
||||
1. Go to the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard)
|
||||
2. Log in with your Spotify account
|
||||
3. Click "Create App"
|
||||
4. Fill in the required information:
|
||||
- **App name**: "SoulSync" (or any name you prefer)
|
||||
- **App description**: "Music library sync application"
|
||||
- **Redirect URI**: `http://127.0.0.1:8888/callback`
|
||||
- Check the boxes to agree to the Terms of Service
|
||||
5. Click "Save"
|
||||
|
||||
**Step 2: Get Your Credentials**
|
||||
1. In your newly created app, click "Settings"
|
||||
2. Copy the **Client ID** - this is your `client_id`
|
||||
3. Click "View client secret" to reveal and copy the **Client Secret** - this is your `client_secret`
|
||||
|
||||
##### Tidal ID and Secret
|
||||
|
||||
**Step 1: Create a Tidal App**
|
||||
1. Go to the [Tidal Developer Dashboard](https://developer.tidal.com/dashboard)
|
||||
2. Log in with your Tidal account
|
||||
3. Click "Create New App"
|
||||
4. Fill in the required information:
|
||||
- **App name**: "SoulSync" (or any name you prefer)
|
||||
- **App description**: "Music library sync application"
|
||||
- **Redirect URI**: `http://127.0.0.1:8889/tidal/callback`
|
||||
- **Required Scopes:**
|
||||
- user.read (Read access to a user's account information, such as country and email address.)
|
||||
- playlists.read (Required to list playlists created by a user.)
|
||||
5. Click "Save"
|
||||
|
||||
**Step 2: Get Your Credentials**
|
||||
1. In your newly created app, click "Settings"
|
||||
2. Copy the **Client ID** - this is your `client_id`
|
||||
3. Click "View client secret" to reveal and copy the **Client Secret** - this is your `client_secret`
|
||||
|
||||
##### Plex Token
|
||||
|
||||
**Method 1: Through Plex Web Interface (Recommended)**
|
||||
1. Open Plex in your web browser and sign in
|
||||
2. Right-click anywhere on the page and select "Inspect" or press F12
|
||||
3. Go to the **Network** tab in Developer Tools
|
||||
4. Reload the page
|
||||
5. Look for requests to `plex.tv` or your Plex server
|
||||
6. In the request headers, find `X-Plex-Token` - copy this value
|
||||
|
||||
**Method 2: Using Browser Console**
|
||||
1. Go to [plex.tv](https://plex.tv) and sign in
|
||||
2. Open Developer Tools (F12) and go to the **Console** tab
|
||||
3. Type: `localStorage.myPlexAccessToken` and press Enter
|
||||
4. Copy the returned token value (without quotes)
|
||||
|
||||
**Method 3: Through Media Item XML (Easy)**
|
||||
1. Open Plex in your web browser and navigate to any media item
|
||||
2. Click on the item to view its details
|
||||
3. Click "View XML" or right-click and select "View XML"
|
||||
4. In the URL bar, you'll see a URL like: `http://your-server:32400/library/metadata/12345?X-Plex-Token=YOUR_TOKEN_HERE`
|
||||
5. Copy the token from the `X-Plex-Token=` parameter in the URL
|
||||
|
||||
**Method 4: Using Plex API**
|
||||
1. Make a POST request to `https://plex.tv/users/sign_in.xml`
|
||||
2. Include your Plex username and password in the request
|
||||
3. Extract the authentication token from the XML response
|
||||
|
||||
**Finding Your Plex Server URL:**
|
||||
- Local network: `http://[YOUR_PLEX_SERVER_IP]:32400` (e.g., `http://192.168.1.100:32400`)
|
||||
- Same machine: `http://localhost:32400`
|
||||
- To find your server IP, check your Plex server settings or use your router's admin panel
|
||||
|
||||
##### Jellyfin API Key
|
||||
|
||||
**Method 1: Through Jellyfin Dashboard (Recommended)**
|
||||
1. Open your Jellyfin web interface and sign in as an administrator
|
||||
2. Go to **Dashboard** → **API Keys**
|
||||
3. Click **New API Key**
|
||||
4. Enter an **App Name** (e.g., "SoulSync")
|
||||
5. Click **Create**
|
||||
6. Copy the generated API key immediately (it won't be shown again)
|
||||
|
||||
**Method 2: Through User Settings**
|
||||
1. Go to **Dashboard** → **Users**
|
||||
2. Click on your user account
|
||||
3. Go to the **API Keys** tab
|
||||
4. Create a new API key as described above
|
||||
|
||||
**Finding Your Jellyfin Server URL:**
|
||||
- Local network: `http://[YOUR_JELLYFIN_SERVER_IP]:8096` (e.g., `http://192.168.1.100:8096`)
|
||||
- Same machine: `http://localhost:8096`
|
||||
- HTTPS: `https://[YOUR_JELLYFIN_SERVER_IP]:8920` (if configured)
|
||||
|
||||
**Note**: SoulSync uses Jellyfin's REST API for library access. Make sure your Jellyfin user has permissions to access the music library.
|
||||
|
||||
#### 📁 Important: Understanding Download vs Transfer Folders
|
||||
|
||||
- **download_path**: This should be the exact same folder where slskd saves its downloads (e.g., the downloads folder you configured in slskd). SoulSync monitors this folder for completed downloads.
|
||||
- **transfer_path**: This is where SoulSync moves and organizes the processed files. Typically, this should be your main media server music library folder, so the files are immediately available to your media server after processing.
|
||||
|
||||
#### ❗ Important: slskd API Key Setup
|
||||
|
||||
The slskd API key is crucial for the application to communicate with your Soulseek client.
|
||||
|
||||
1. **Find your slskd config file**: This is typically a `slskd.yml` or `slskd.json` file located where you installed slskd.
|
||||
2. **Locate the API key**: Inside the slskd configuration, find the `api_key` value you have set. It will look something like this:
|
||||
```yaml
|
||||
# slskd.yml example
|
||||
api:
|
||||
key: "your-secret-api-key-goes-here"
|
||||
```
|
||||
3. **Copy and Paste**: Copy the exact API key from your slskd configuration.
|
||||
4. **Update config.json**: Paste the key into the `api_key` field under the `soulseek` section in the SoulSync app's `config.json` file.
|
||||
|
||||
Alternatively, you can paste this key directly into the API Key field in the Settings menu within the application after launching it.
|
||||
## 🔧 Config Example
|
||||
|
||||
```json
|
||||
{
|
||||
"active_media_server": "plex",
|
||||
"spotify": {
|
||||
"client_id": "CLIENT ID",
|
||||
"client_secret": "CLIENT SECRET"
|
||||
"client_id": "your_client_id",
|
||||
"client_secret": "your_client_secret"
|
||||
},
|
||||
"plex": {
|
||||
"base_url": "http://192.168.86.36:32400",
|
||||
"token": "PLEX TOKEN",
|
||||
"auto_detect": true
|
||||
},
|
||||
"jellyfin": {
|
||||
"base_url": "http://localhost:8096",
|
||||
"api_key": "JELLYFIN API KEY",
|
||||
"auto_detect": true
|
||||
"base_url": "http://localhost:32400",
|
||||
"token": "your_plex_token"
|
||||
},
|
||||
"soulseek": {
|
||||
"slskd_url": "http://localhost:5030",
|
||||
"api_key": "SLSKD API KEY",
|
||||
"download_path": "./downloads",
|
||||
"transfer_path": "./transfers"
|
||||
},
|
||||
"logging": {
|
||||
"path": "logs/app.log",
|
||||
"level": "INFO"
|
||||
},
|
||||
"settings": {
|
||||
"audio_quality": "flac"
|
||||
},
|
||||
"database": {
|
||||
"path": "database/music_library.db",
|
||||
"max_workers": 5
|
||||
},
|
||||
"metadata_enhancement": {
|
||||
"enabled": true,
|
||||
"embed_album_art": true
|
||||
},
|
||||
"playlist_sync": {
|
||||
"create_backup": true
|
||||
"slskd_url": "http://localhost:5030",
|
||||
"api_key": "your_api_key",
|
||||
"download_path": "/path/to/downloads",
|
||||
"transfer_path": "/path/to/music/library"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: Set `"active_media_server"` to either `"plex"` or `"jellyfin"` depending on which media server you want to use. You only need to configure the server you plan to use.
|
||||
## ⚠️ Important Notes
|
||||
|
||||
## 🖥️ Usage
|
||||
- **Must share files in slskd** - downloaders without shares get banned
|
||||
- **Docker uses separate database** from GUI/WebUI versions
|
||||
- **Transfer path** should point to your media server music library
|
||||
- **FLAC preferred** but supports all common formats
|
||||
|
||||
Run the main application file to launch the GUI:
|
||||
## 🏗️ Architecture
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
or for mac:
|
||||
```bash
|
||||
python3 main.py
|
||||
```
|
||||
- **Core**: Service clients for Spotify, Plex, Jellyfin, Soulseek
|
||||
- **Database**: SQLite with full media library cache
|
||||
- **UI**: PyQt6 desktop + Flask web interface
|
||||
- **Matching**: Advanced text normalization and scoring
|
||||
- **Background**: Multi-threaded with automatic retry logic
|
||||
|
||||
### Application Pages
|
||||
|
||||
- **Dashboard**: Real-time system overview with service connection matrix (Spotify/Plex/Jellyfin/Soulseek status), live download statistics (active transfers, speeds, queue status), database health metrics (size, sync status, last update), chronological activity feed of all application events, and quick action buttons for common operations.
|
||||
|
||||
- **Sync**: Advanced playlist management featuring Spotify playlist loading with snapshot-based change detection, confidence-scored track matching with color-coded indicators, bulk "Download Missing Tracks" with progress tracking, intelligent retry logic for failed downloads, and detailed match analysis showing why tracks were or weren't found in your media server library.
|
||||
|
||||
- **Downloads**: Comprehensive download management with unified Albums/Singles search interface, stream-before-download capability for every result, matched download system with artist/album selection modals, real-time progress monitoring with queue positions, failed download recovery via integrated wishlist access, and persistent search history across sessions.
|
||||
|
||||
- **Artists**: Complete discography explorer with full artist catalog browsing, ownership status indicators for every album, chronological release timeline with media server library overlay, bulk download operations for entire discographies, album-level missing track downloads, and integration with matched download system for accurate metadata assignment.
|
||||
|
||||
- **Settings**: Service configuration hub for Spotify/Plex/Jellyfin/Soulseek credentials, media server selection, download/transfer path management, metadata enhancement controls (enable/disable automatic tagging and album art embedding), database operations (update, rebuild, health check), performance tuning options (thread limits, cache settings), notification preferences, and application logging controls.
|
||||
|
||||
## 🐍 Key Components
|
||||
|
||||
The application is built on a modern, layered architecture with distinct separation of concerns:
|
||||
|
||||
- **main.py**: PyQt6 application entry point with main window management, service initialization, media player signal routing, and application lifecycle management.
|
||||
|
||||
- **core/**: Business logic and service integration layer
|
||||
- `spotify_client.py`: Spotify Web API integration with OAuth2 authentication, playlist/artist data retrieval, and metadata normalization.
|
||||
- `plex_client.py`: Plex Media Server API client with library scanning, metadata retrieval, and server status monitoring.
|
||||
- `jellyfin_client.py`: Jellyfin Media Server API client with library scanning, metadata retrieval, and server status monitoring.
|
||||
- `soulseek_client.py`: slskd API communication handling search operations, download management, and queue monitoring.
|
||||
- `matching_engine.py`: Advanced metadata comparison engine with version-aware scoring, text normalization, and confidence calculation.
|
||||
- `wishlist_service.py`: Failed download management with retry mechanisms and source context preservation.
|
||||
- `media_scan_manager.py`: Intelligent media server library scan coordination with debouncing and periodic updates.
|
||||
|
||||
- **database/**: Data persistence and management layer
|
||||
- `music_database.py`: SQLite database operations with thread-safe connections, WAL mode, and metadata synchronization.
|
||||
- `music_library.db`: Local database storing complete media server library metadata for instant access.
|
||||
|
||||
- **ui/**: Modern PyQt6 user interface with responsive design
|
||||
- `sidebar.py`: Navigation sidebar with integrated media player, service status indicators, and scrolling track info.
|
||||
- `components/`: Reusable UI elements including toast notifications, loading animations, and database status widgets.
|
||||
- `pages/`: Application pages (`dashboard.py`, `sync.py`, `downloads.py`, `artists.py`, `settings.py`) with specialized workflows.
|
||||
|
||||
- **services/**: Background service layer
|
||||
- `sync_service.py`: High-level sync orchestration with playlist analysis and download coordination.
|
||||
|
||||
- **config/**: Configuration management
|
||||
- `config.json`: Service credentials, paths, and application settings.
|
||||
- `settings.py`: Configuration file handling and validation.
|
||||
|
||||
- **utils/**: Shared utilities
|
||||
- `logging_config.py`: Centralized logging configuration with file and console output.
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit a pull request or open an issue for any bugs or feature requests.
|
||||
|
||||
## 📜 License
|
||||
|
||||
This project is licensed under the MIT License. See the LICENSE file for details.
|
||||
Modern, clean, automated. Set it up once, let it manage your music library.
|
||||
45
config/config.example.json
Normal file
45
config/config.example.json
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"active_media_server": "plex",
|
||||
"spotify": {
|
||||
"client_id": "SpotifyClientID",
|
||||
"client_secret": "SpotifyClientSecret"
|
||||
},
|
||||
"tidal": {
|
||||
"client_id": "TidalClientID",
|
||||
"client_secret": "TidalClientSecret"
|
||||
},
|
||||
"plex": {
|
||||
"base_url": "http://192.168.86.36:32400",
|
||||
"token": "PLEX_API_TOKEN",
|
||||
"auto_detect": true
|
||||
},
|
||||
"jellyfin": {
|
||||
"base_url": "http://localhost:8096",
|
||||
"api_key": "JELLYFIN_API_KEY",
|
||||
"auto_detect": true
|
||||
},
|
||||
"soulseek": {
|
||||
"slskd_url": "http://localhost:5030",
|
||||
"api_key": "SoulseekAPIKey",
|
||||
"download_path": "./Downloads",
|
||||
"transfer_path": "./Transfer"
|
||||
},
|
||||
"logging": {
|
||||
"path": "logs/app.log",
|
||||
"level": "INFO"
|
||||
},
|
||||
"settings": {
|
||||
"audio_quality": "flac"
|
||||
},
|
||||
"database": {
|
||||
"path": "database/music_library.db",
|
||||
"max_workers": 5
|
||||
},
|
||||
"metadata_enhancement": {
|
||||
"enabled": true,
|
||||
"embed_album_art": true
|
||||
},
|
||||
"playlist_sync": {
|
||||
"create_backup": true
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import requests
|
||||
import asyncio
|
||||
import aiohttp
|
||||
import os
|
||||
from typing import List, Optional, Dict, Any
|
||||
from dataclasses import dataclass
|
||||
import time
|
||||
|
|
@ -219,9 +220,26 @@ class SoulseekClient:
|
|||
logger.warning("Soulseek slskd URL not configured")
|
||||
return
|
||||
|
||||
self.base_url = config['slskd_url'].rstrip('/')
|
||||
# Apply Docker URL resolution if running in container
|
||||
slskd_url = config.get('slskd_url')
|
||||
import os
|
||||
if os.path.exists('/.dockerenv') and 'localhost' in slskd_url:
|
||||
slskd_url = slskd_url.replace('localhost', 'host.docker.internal')
|
||||
logger.info(f"Docker detected, using {slskd_url} for slskd connection")
|
||||
|
||||
self.base_url = slskd_url.rstrip('/')
|
||||
self.api_key = config.get('api_key', '')
|
||||
self.download_path = Path(config.get('download_path', './downloads'))
|
||||
|
||||
# Handle download path with Docker translation
|
||||
download_path_str = config.get('download_path', './downloads')
|
||||
if os.path.exists('/.dockerenv') and len(download_path_str) >= 3 and download_path_str[1] == ':' and download_path_str[0].isalpha():
|
||||
# Convert Windows path (E:/path) to WSL mount path (/mnt/e/path)
|
||||
drive_letter = download_path_str[0].lower()
|
||||
rest_of_path = download_path_str[2:].replace('\\', '/') # Remove E: and convert backslashes
|
||||
download_path_str = f"/host/mnt/{drive_letter}{rest_of_path}"
|
||||
logger.info(f"Docker detected, using {download_path_str} for downloads")
|
||||
|
||||
self.download_path = Path(download_path_str)
|
||||
self.download_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger.info(f"Soulseek client configured with slskd at {self.base_url}")
|
||||
|
|
@ -308,7 +326,16 @@ class SoulseekClient:
|
|||
else:
|
||||
return {}
|
||||
else:
|
||||
logger.error(f"API request failed: {response.status} - {response_text}")
|
||||
# Enhanced error logging for better debugging
|
||||
error_detail = response_text if response_text.strip() else "No error details provided"
|
||||
|
||||
# Reduce noise for expected 404s during search cleanup
|
||||
if response.status == 404 and 'searches/' in url and method == 'DELETE':
|
||||
logger.debug(f"Search not found for deletion (expected): {url}")
|
||||
else:
|
||||
logger.error(f"API request failed: HTTP {response.status} ({response.reason}) - {error_detail}")
|
||||
logger.debug(f"Failed request: {method} {url}")
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -907,14 +934,29 @@ class SoulseekClient:
|
|||
return False
|
||||
|
||||
try:
|
||||
# Use correct slskd API format: DELETE /transfers/downloads/{username}/{download_id}?remove={true/false}
|
||||
# remove=false: Cancel download but keep in transfer list
|
||||
# remove=true: Remove download completely from transfer list
|
||||
endpoint = f'transfers/downloads/{username}/{download_id}?remove={str(remove).lower()}'
|
||||
# Try multiple API formats as slskd API may vary between versions
|
||||
endpoints_to_try = [
|
||||
# Format 1: With username and remove parameter (original format)
|
||||
f'transfers/downloads/{username}/{download_id}?remove={str(remove).lower()}',
|
||||
# Format 2: Simple format with just download_id (used in sync.py)
|
||||
f'transfers/downloads/{download_id}',
|
||||
# Format 3: Alternative format without remove parameter
|
||||
f'transfers/downloads/{username}/{download_id}'
|
||||
]
|
||||
|
||||
action = "Removing" if remove else "Cancelling"
|
||||
logger.debug(f"{action} download with endpoint: {endpoint}")
|
||||
response = await self._make_request('DELETE', endpoint)
|
||||
return response is not None
|
||||
|
||||
for i, endpoint in enumerate(endpoints_to_try):
|
||||
logger.debug(f"{action} download (attempt {i+1}/3) with endpoint: {endpoint}")
|
||||
response = await self._make_request('DELETE', endpoint)
|
||||
if response is not None:
|
||||
logger.info(f"✅ Successfully cancelled download using endpoint format {i+1}")
|
||||
return True
|
||||
else:
|
||||
logger.debug(f"❌ Endpoint format {i+1} failed: {endpoint}")
|
||||
|
||||
logger.error(f"❌ All cancel endpoint formats failed for download_id: {download_id}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error cancelling download: {e}")
|
||||
|
|
@ -1034,7 +1076,8 @@ class SoulseekClient:
|
|||
if success:
|
||||
logger.debug(f"Successfully deleted search {search_id}")
|
||||
else:
|
||||
logger.warning(f"Failed to delete search {search_id}")
|
||||
# Don't log warnings for failed deletions - they're often just 404s for already-removed searches
|
||||
logger.debug(f"Search deletion returned false (likely already removed): {search_id}")
|
||||
|
||||
return success
|
||||
|
||||
|
|
|
|||
|
|
@ -174,7 +174,7 @@ class SpotifyClient:
|
|||
client_secret=config['client_secret'],
|
||||
redirect_uri="http://127.0.0.1:8888/callback",
|
||||
scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email",
|
||||
cache_path='.spotify_cache'
|
||||
cache_path='config/.spotify_cache'
|
||||
)
|
||||
|
||||
self.sp = spotipy.Spotify(auth_manager=auth_manager)
|
||||
|
|
|
|||
|
|
@ -272,6 +272,12 @@ class TidalClient:
|
|||
|
||||
def _start_callback_server(self):
|
||||
"""Start HTTP server to receive OAuth callback"""
|
||||
# Skip starting server in Docker/production mode - web server handles callbacks
|
||||
import os
|
||||
if os.getenv('FLASK_ENV') == 'production' or os.path.exists('/.dockerenv'):
|
||||
logger.info("Docker/WebUI mode detected - skipping TidalClient callback server (web server handles callbacks)")
|
||||
return
|
||||
|
||||
# Store reference to self for the callback handler
|
||||
tidal_client_ref = self
|
||||
|
||||
|
|
@ -412,6 +418,28 @@ class TidalClient:
|
|||
logger.error(f"Error refreshing Tidal token: {e}")
|
||||
return False
|
||||
|
||||
def fetch_token_from_code(self, auth_code: str) -> bool:
|
||||
"""Exchange authorization code for access tokens (for web server callback)"""
|
||||
try:
|
||||
logger.info(f"Starting token exchange with code: {auth_code[:20]}...")
|
||||
logger.info(f"Using code_verifier: {self.code_verifier[:20] if self.code_verifier else 'None'}...")
|
||||
logger.info(f"Using redirect_uri: {self.redirect_uri}")
|
||||
|
||||
self.auth_code = auth_code
|
||||
result = self._exchange_code_for_tokens()
|
||||
|
||||
if result:
|
||||
logger.info("✅ Token exchange successful")
|
||||
else:
|
||||
logger.error("❌ Token exchange failed")
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error in fetch_token_from_code: {e}")
|
||||
import traceback
|
||||
logger.error(f"Full traceback: {traceback.format_exc()}")
|
||||
return False
|
||||
|
||||
def _ensure_valid_token(self):
|
||||
"""Ensure we have a valid access token"""
|
||||
if not self.access_token:
|
||||
|
|
|
|||
|
|
@ -165,6 +165,52 @@ class WishlistService:
|
|||
"""Clear all tracks from the wishlist"""
|
||||
return self.database.clear_wishlist()
|
||||
|
||||
def check_track_in_wishlist(self, spotify_track_id: str) -> bool:
|
||||
"""Check if a track exists in the wishlist by Spotify track ID"""
|
||||
try:
|
||||
wishlist_tracks = self.get_wishlist_tracks_for_download()
|
||||
for track in wishlist_tracks:
|
||||
if track.get('spotify_track_id') == spotify_track_id or track.get('id') == spotify_track_id:
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking track in wishlist: {e}")
|
||||
return False
|
||||
|
||||
def find_matching_wishlist_track(self, track_name: str, artist_name: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Find a matching track in the wishlist using fuzzy matching on name and artist.
|
||||
Returns the first matching wishlist track or None if no match found.
|
||||
"""
|
||||
try:
|
||||
wishlist_tracks = self.get_wishlist_tracks_for_download()
|
||||
|
||||
# Normalize input for comparison
|
||||
normalized_track_name = track_name.lower().strip()
|
||||
normalized_artist_name = artist_name.lower().strip()
|
||||
|
||||
for wl_track in wishlist_tracks:
|
||||
wl_name = wl_track.get('name', '').lower().strip()
|
||||
wl_artists = wl_track.get('artists', [])
|
||||
|
||||
# Extract artist name from wishlist track
|
||||
wl_artist_name = ''
|
||||
if wl_artists:
|
||||
if isinstance(wl_artists[0], dict):
|
||||
wl_artist_name = wl_artists[0].get('name', '').lower().strip()
|
||||
else:
|
||||
wl_artist_name = str(wl_artists[0]).lower().strip()
|
||||
|
||||
# Simple exact matching (could be enhanced with fuzzy matching algorithms)
|
||||
if wl_name == normalized_track_name and wl_artist_name == normalized_artist_name:
|
||||
return wl_track
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error finding matching wishlist track: {e}")
|
||||
return None
|
||||
|
||||
def get_wishlist_summary(self) -> Dict[str, Any]:
|
||||
"""Get a summary of the wishlist for dashboard display"""
|
||||
try:
|
||||
|
|
|
|||
64
docker-compose.yml
Normal file
64
docker-compose.yml
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
soulsync:
|
||||
build: .
|
||||
container_name: soulsync-webui
|
||||
ports:
|
||||
- "8008:8008" # Main web app
|
||||
- "8888:8888" # Spotify OAuth callback
|
||||
- "8889:8889" # Tidal OAuth callback
|
||||
volumes:
|
||||
# Persistent data volumes
|
||||
- ./config:/app/config
|
||||
- ./logs:/app/logs
|
||||
- ./downloads:/app/downloads
|
||||
# Use named volume for database persistence (separate from host database)
|
||||
- soulsync_database:/app/database
|
||||
# IMPORTANT: Mount the drives containing your download and transfer folders
|
||||
# If your download/transfer paths are on E: drive, mount E: drive:
|
||||
- /mnt/e:/host/mnt/e:rw
|
||||
# If your download/transfer paths are on C: drive, uncomment this line:
|
||||
# - /mnt/c:/host/mnt/c:rw
|
||||
# If your download/transfer paths are on D: drive, uncomment this line:
|
||||
# - /mnt/d:/host/mnt/d:rw
|
||||
# Add additional drive mounts as needed for your specific setup
|
||||
# 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
|
||||
extra_hosts:
|
||||
# Allow container to reach host services
|
||||
- "host.docker.internal:host-gateway"
|
||||
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
|
||||
|
||||
# Named volumes for persistent data
|
||||
volumes:
|
||||
soulsync_database:
|
||||
driver: local
|
||||
|
||||
# Optional: Add external network for communication with other containers
|
||||
networks:
|
||||
default:
|
||||
name: soulsync-network
|
||||
8923
logs/app.log
8923
logs/app.log
File diff suppressed because it is too large
Load diff
3
main.py
3
main.py
|
|
@ -1,3 +1,4 @@
|
|||
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import sys
|
||||
|
|
@ -433,4 +434,4 @@ def main():
|
|||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
|
|
|
|||
|
|
@ -1,197 +0,0 @@
|
|||
# Multi-Server Database Update Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the plan to extend SoulSync's database update functionality to support both Plex and Jellyfin media servers, moving from the current Plex-only architecture to a unified multi-server approach.
|
||||
|
||||
## Current Architecture Analysis
|
||||
|
||||
### Existing Plex-Centric System
|
||||
|
||||
**DatabaseUpdateWorker** (`core/database_update_worker.py`):
|
||||
- Hardcoded to accept `plex_client` parameter
|
||||
- Uses Plex-specific API methods: `get_all_artists()`, `albums()`, `tracks()`
|
||||
- Relies on Plex `ratingKey` attributes as primary identifiers
|
||||
- Implements smart incremental updates using Plex's `recentlyAdded` and `updatedAt` sorting
|
||||
|
||||
**Database Schema** (`database/music_database.py`):
|
||||
- `insert_or_update_artist(plex_artist)` - expects Plex artist objects with `ratingKey`
|
||||
- `insert_or_update_album(plex_album, artist_id)` - expects Plex album objects
|
||||
- `insert_or_update_track(plex_track, album_id, artist_id)` - expects Plex track objects
|
||||
- All database operations depend on `.ratingKey`, `.title`, `.artist()`, `.album()` methods
|
||||
|
||||
**Dashboard Tools**:
|
||||
- "Update SoulSync Database" button → `DatabaseUpdateWorker` with Plex client
|
||||
- "Plex Metadata Updater" → separate tool for enhancing artist photos via Spotify API
|
||||
- Both tools assume Plex connectivity and data structures
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Recommended Approach: Universal Database Worker
|
||||
|
||||
Extend the existing architecture rather than creating duplicate tools to maintain consistency and user experience.
|
||||
|
||||
#### Key Benefits:
|
||||
- **Unified Database**: Both servers populate same tables for consistent search/sync
|
||||
- **Single User Interface**: One "Update Database" button that works with active server
|
||||
- **Code Reuse**: Share database logic, incremental update strategies, and UI components
|
||||
- **Seamless Switching**: Users don't need to learn different tools for different servers
|
||||
|
||||
#### Architecture Changes:
|
||||
|
||||
1. **Server Abstraction Layer**:
|
||||
```python
|
||||
# Create common interface for both servers
|
||||
class MediaServerClient(ABC):
|
||||
@abstractmethod
|
||||
def get_all_artists(self) -> List[MediaServerArtist]
|
||||
@abstractmethod
|
||||
def get_recently_added_content(self) -> List[MediaServerAlbum]
|
||||
|
||||
class JellyfinClient(MediaServerClient):
|
||||
# Implement Jellyfin-specific API calls
|
||||
|
||||
class PlexClient(MediaServerClient):
|
||||
# Existing implementation
|
||||
```
|
||||
|
||||
2. **Database Worker Updates**:
|
||||
```python
|
||||
class DatabaseUpdateWorker(QThread):
|
||||
def __init__(self, media_client: MediaServerClient, server_type: str, ...):
|
||||
self.media_client = media_client
|
||||
self.server_type = server_type # "plex" or "jellyfin"
|
||||
```
|
||||
|
||||
3. **Dynamic Dashboard Tools**:
|
||||
- "Update SoulSync Database" → detects active server and uses appropriate client
|
||||
- "Metadata Updater" → works with both server types using abstracted artist data
|
||||
|
||||
## Technical Concerns & Design Decisions
|
||||
|
||||
### 1. Database Schema Strategy
|
||||
|
||||
**Option A: Shared Tables with Server Source Column** (Recommended)
|
||||
```sql
|
||||
-- Add server_source column to existing tables
|
||||
ALTER TABLE artists ADD COLUMN server_source TEXT DEFAULT 'plex';
|
||||
ALTER TABLE albums ADD COLUMN server_source TEXT DEFAULT 'plex';
|
||||
ALTER TABLE tracks ADD COLUMN server_source TEXT DEFAULT 'plex';
|
||||
```
|
||||
|
||||
**Pros**:
|
||||
- Single source of truth for all music data
|
||||
- Unified search and sync operations
|
||||
- Easy migration path from current Plex-only setup
|
||||
|
||||
**Cons**:
|
||||
- Potential ID conflicts between servers
|
||||
- Need to handle server-switching scenarios
|
||||
|
||||
**Option B: Separate Tables per Server**
|
||||
```sql
|
||||
-- Create parallel table structures
|
||||
CREATE TABLE jellyfin_artists (...);
|
||||
CREATE TABLE jellyfin_albums (...);
|
||||
CREATE TABLE jellyfin_tracks (...);
|
||||
```
|
||||
|
||||
**Pros**:
|
||||
- Complete isolation between server data
|
||||
- No ID conflicts
|
||||
|
||||
**Cons**:
|
||||
- Duplicate database logic
|
||||
- Complex search/sync operations across multiple tables
|
||||
- User confusion about data sources
|
||||
|
||||
### 2. ID Handling Strategy
|
||||
|
||||
**Challenge**: Plex uses `ratingKey` (e.g., "12345"), Jellyfin uses GUIDs (e.g., "f2a6c4e8-1234-5678-9abc-def012345678")
|
||||
|
||||
**Option A: Native ID Storage** (Recommended)
|
||||
- Store server-specific IDs as-is in existing `id` columns
|
||||
- Add `server_source` column to identify which server the ID belongs to
|
||||
- Update queries to filter by both ID and server source
|
||||
|
||||
**Option B: Universal ID Mapping**
|
||||
- Create internal UUID system that maps to server-specific IDs
|
||||
- Maintain mapping tables for translation
|
||||
- More complex but server-agnostic
|
||||
|
||||
### 3. Incremental Update Strategy
|
||||
|
||||
**Plex Approach**: Uses `recentlyAdded()` and `updatedAt` sorting for smart incremental updates
|
||||
|
||||
**Jellyfin Equivalent**:
|
||||
- `/Users/{userId}/Items?SortBy=DateCreated&SortOrder=Descending` for recently added
|
||||
- `/Users/{userId}/Items?SortBy=DateLastMediaAdded&SortOrder=Descending` for updated content
|
||||
- Similar early-stopping logic when consecutive items are already in database
|
||||
|
||||
### 4. Metadata Enhancement Compatibility
|
||||
|
||||
**Current "Plex Metadata Updater"**:
|
||||
- Scans Plex artists for missing/low-quality photos
|
||||
- Uses Spotify API to find and download better artist images
|
||||
- Updates Plex server with enhanced metadata
|
||||
|
||||
**Jellyfin Compatibility Question**:
|
||||
- Can Jellyfin accept metadata updates via API?
|
||||
- Should this be server-agnostic or Plex-specific?
|
||||
- Recommendation: Make server-agnostic if Jellyfin supports metadata updates
|
||||
|
||||
### 5. Error Handling & Fallbacks
|
||||
|
||||
**Server Switching Scenarios**:
|
||||
- What happens when user switches from Plex to Jellyfin mid-update?
|
||||
- How to handle existing database with Plex data when switching to Jellyfin?
|
||||
- Should we clear database on server switch or maintain both datasets?
|
||||
|
||||
**API Differences**:
|
||||
- Different error types and handling between Plex and Jellyfin APIs
|
||||
- Different rate limiting and authentication mechanisms
|
||||
- Need abstraction layer for consistent error handling
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Foundation
|
||||
1. Create `JellyfinClient` class matching `PlexClient` interface
|
||||
2. Add `server_source` column to database tables
|
||||
3. Update database methods to handle server-agnostic data structures
|
||||
|
||||
### Phase 2: Worker Updates
|
||||
1. Modify `DatabaseUpdateWorker` to accept generic media server client
|
||||
2. Implement Jellyfin-specific data extraction methods
|
||||
3. Update incremental update logic for Jellyfin API patterns
|
||||
|
||||
### Phase 3: UI Integration
|
||||
1. Update dashboard tools to detect active server
|
||||
2. Make tool names dynamic ("Update Database" instead of "Update Plex Database")
|
||||
3. Update progress messages and activity logs to show current server
|
||||
|
||||
### Phase 4: Metadata Enhancement
|
||||
1. Evaluate Jellyfin metadata update capabilities
|
||||
2. Create server-agnostic metadata enhancement workflow
|
||||
3. Update UI to reflect server-specific capabilities
|
||||
|
||||
## Questions Requiring Decision
|
||||
|
||||
1. **Database Strategy**: Shared tables with `server_source` column vs. separate tables?
|
||||
|
||||
2. **ID Handling**: Store native server IDs vs. create universal mapping system?
|
||||
|
||||
3. **Data Isolation**: Should switching servers clear existing data or maintain both?
|
||||
|
||||
4. **Metadata Updates**: Extend metadata enhancement to Jellyfin or keep Plex-specific?
|
||||
|
||||
5. **Migration Strategy**: How to handle existing Plex databases when introducing multi-server support?
|
||||
|
||||
## Recommended Next Steps
|
||||
|
||||
1. **Decide on database schema approach** (shared vs. separate tables)
|
||||
2. **Create basic JellyfinClient class** with artist/album/track enumeration
|
||||
3. **Test Jellyfin API** for incremental update patterns
|
||||
4. **Update database schema** with chosen approach
|
||||
5. **Modify DatabaseUpdateWorker** for server abstraction
|
||||
|
||||
This plan prioritizes maintaining the existing user experience while adding Jellyfin support seamlessly.
|
||||
12
plans.md
12
plans.md
|
|
@ -1,12 +0,0 @@
|
|||
# Plans
|
||||
|
||||
## Additional Media Server Sources
|
||||
- Include Emby support for database building phase
|
||||
- Include Jellyfin support for database building phase
|
||||
|
||||
## Playlist Support
|
||||
- Add Tidal playlist support
|
||||
- Investigate Apple Music playlist support (if possible)
|
||||
|
||||
## Transfer Phase Enhancements
|
||||
- Add lyrics during the transfer phase
|
||||
35
requirements-webui.txt
Normal file
35
requirements-webui.txt
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# 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
|
||||
|
||||
# System monitoring
|
||||
psutil>=6.0.0
|
||||
|
||||
# YouTube support
|
||||
yt-dlp>=2024.12.13
|
||||
|
||||
# Optional: MQTT support (for future features)
|
||||
asyncio-mqtt>=0.16.0
|
||||
|
|
@ -9,4 +9,5 @@ mutagen>=1.47.0
|
|||
Pillow>=10.0.0
|
||||
aiohttp>=3.9.0
|
||||
unidecode>=1.3.8
|
||||
yt-dlp>=2024.12.13
|
||||
yt-dlp>=2024.12.13
|
||||
Flask>=3.0.0
|
||||
370
server-source.md
370
server-source.md
|
|
@ -1,370 +0,0 @@
|
|||
# Multi-Server Support Feature Specification
|
||||
|
||||
## Overview
|
||||
Add Jellyfin support alongside existing Plex functionality to provide users with media server choice and flexibility. This feature will allow SoulSync to work with either Plex or Jellyfin as the source for music library data, with the same level of functionality for both platforms.
|
||||
|
||||
## Goals
|
||||
- **Server Choice**: Allow users to choose between Plex and Jellyfin as their media server
|
||||
- **Feature Parity**: Maintain all existing functionality regardless of server choice
|
||||
- **Future-Proof Architecture**: Design extensible system for additional servers (Emby, etc.)
|
||||
- **Backward Compatibility**: Existing Plex users experience no disruption
|
||||
- **Unified Experience**: Same SoulSync interface and features regardless of backend
|
||||
|
||||
## User Experience
|
||||
|
||||
### Settings Page UI Changes
|
||||
#### Server Selection Interface
|
||||
- **Toggle Buttons**: Two prominent buttons at top of API Configuration section
|
||||
- `[🟦 Plex]` - Plex logo button (active state shown with filled background)
|
||||
- `⬜ Jellyfin` - Jellyfin logo button (inactive state shown with outline)
|
||||
- **Dynamic Settings Container**: Only show settings for selected server
|
||||
- **Smooth Transitions**: Animated transitions when switching between servers
|
||||
- **Clear Visual Feedback**: Active server button is highlighted, inactive is dimmed
|
||||
|
||||
#### Plex Settings Container (Existing)
|
||||
```
|
||||
Plex Configuration
|
||||
├── Server URL: [text input with auto-detect button]
|
||||
├── Token: [password input with help text]
|
||||
└── Auto-detect: [checkbox]
|
||||
```
|
||||
|
||||
#### Jellyfin Settings Container (New)
|
||||
```
|
||||
Jellyfin Configuration
|
||||
├── Server URL: [text input with auto-detect button]
|
||||
├── API Key: [password input with help text]
|
||||
└── Auto-detect: [checkbox]
|
||||
```
|
||||
|
||||
### Default Behavior
|
||||
- **First-time users**: Default to Plex selection (maintains current onboarding)
|
||||
- **Existing users**: Migrate to new system with Plex pre-selected
|
||||
- **Settings validation**: Real-time validation for active server only
|
||||
- **Connection status**: Show connection status for selected server only
|
||||
|
||||
## Configuration Change Behavior
|
||||
|
||||
### Restart Required for Server Changes
|
||||
**Design Decision**: Changing the active media server requires application restart to take effect.
|
||||
|
||||
#### Implementation
|
||||
- **Config Update**: Settings are saved to `config.json` immediately when changed
|
||||
- **Runtime Behavior**: Application continues using current server until restart
|
||||
- **User Feedback**: Clear messaging that restart is required for changes to take effect
|
||||
- **Validation**: New server settings are validated before saving, but not activated
|
||||
|
||||
#### UI Messaging
|
||||
```
|
||||
Settings Page when server is changed:
|
||||
┌─────────────────────────────────────────┐
|
||||
│ ⚠️ Server change requires restart │
|
||||
│ Click "Save Settings" then restart │
|
||||
│ SoulSync to use Jellyfin │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Benefits of Restart Requirement
|
||||
|
||||
**Stability**:
|
||||
- Avoids complex runtime server switching logic
|
||||
- Prevents issues with active connections and workers
|
||||
- Ensures clean initialization of new server client
|
||||
- No risk of mixed server data or state conflicts
|
||||
|
||||
**Data Integrity**:
|
||||
- Clean separation between server data sources
|
||||
- No risk of cross-contamination during switch
|
||||
- Ensures database connections are properly established
|
||||
- Prevents orphaned connections or incomplete switches
|
||||
|
||||
**Development Simplicity**:
|
||||
- Much simpler implementation without runtime switching
|
||||
- Fewer edge cases and error conditions to handle
|
||||
- Cleaner architecture without complex state management
|
||||
- Easier testing and debugging
|
||||
|
||||
**User Experience**:
|
||||
- Clear expectations about when changes take effect
|
||||
- Follows common application patterns for major config changes
|
||||
- Prevents confusion about which server is currently active
|
||||
- Allows users to review settings before committing to switch
|
||||
|
||||
#### User Workflow
|
||||
1. User selects different server (Plex → Jellyfin)
|
||||
2. User configures new server settings
|
||||
3. User clicks "Save Settings"
|
||||
4. Warning appears: "Restart required for server change"
|
||||
5. User restarts SoulSync
|
||||
6. Application loads with new server configuration
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### Database Design
|
||||
#### Shared Database with Server-Specific Tables
|
||||
**File**: `music_library.db` (single database file)
|
||||
|
||||
**Table Structure**:
|
||||
```sql
|
||||
-- Server-specific music data
|
||||
plex_artists, plex_albums, plex_tracks
|
||||
jellyfin_artists, jellyfin_albums, jellyfin_tracks
|
||||
[future: emby_artists, emby_albums, emby_tracks]
|
||||
|
||||
-- Shared functionality tables (unchanged)
|
||||
wishlist_tracks
|
||||
watchlist_artists
|
||||
sync_history
|
||||
download_history
|
||||
settings
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- Clean separation per server type
|
||||
- Easy to query specific server data
|
||||
- Simple to add new servers
|
||||
- Shared functionality works across servers
|
||||
- Single database file for easy backup/migration
|
||||
|
||||
#### Data Consistency Requirements
|
||||
**All servers must provide same core metadata**:
|
||||
|
||||
**Artist Data**:
|
||||
- `id` (server-specific identifier)
|
||||
- `name` (artist name)
|
||||
- `genres` (array/comma-separated)
|
||||
- `image_url` (artist photo)
|
||||
- `server_path` (internal server path)
|
||||
|
||||
**Album Data**:
|
||||
- `id` (server-specific identifier)
|
||||
- `title` (album title)
|
||||
- `artist_id` (reference to artist)
|
||||
- `release_year` (integer)
|
||||
- `track_count` (integer)
|
||||
- `image_url` (album artwork)
|
||||
- `server_path` (internal server path)
|
||||
|
||||
**Track Data**:
|
||||
- `id` (server-specific identifier)
|
||||
- `title` (track title)
|
||||
- `artist_id` (reference to artist)
|
||||
- `album_id` (reference to album)
|
||||
- `track_number` (integer)
|
||||
- `duration_ms` (integer)
|
||||
- `file_path` (absolute file system path)
|
||||
- `server_path` (internal server path)
|
||||
|
||||
**Removed from Scope**:
|
||||
- Artist biographies/summaries (unnecessary complexity)
|
||||
- Extended metadata (focus on core music data)
|
||||
- Social features (ratings, reviews)
|
||||
|
||||
### Configuration System
|
||||
|
||||
#### Config File Structure
|
||||
```json
|
||||
{
|
||||
"active_media_server": "plex",
|
||||
"plex": {
|
||||
"base_url": "http://localhost:32400",
|
||||
"token": "xxx-plex-token-xxx",
|
||||
"auto_detect": true
|
||||
},
|
||||
"jellyfin": {
|
||||
"base_url": "http://localhost:8096",
|
||||
"api_key": "xxx-jellyfin-api-key-xxx",
|
||||
"auto_detect": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Configuration Management
|
||||
- **Active Server Setting**: `active_media_server` determines which client to use
|
||||
- **Server-Specific Configs**: Each server maintains separate configuration
|
||||
- **Validation**: Only validate settings for currently active server
|
||||
- **Migration**: Automatic migration of existing Plex configs to new structure
|
||||
- **Restart Requirement**: Server changes only take effect after application restart
|
||||
|
||||
### API Client Architecture
|
||||
|
||||
#### Abstract Base Class
|
||||
```python
|
||||
class MediaServerClient(ABC):
|
||||
@abstractmethod
|
||||
def is_connected(self) -> bool
|
||||
|
||||
@abstractmethod
|
||||
def get_music_library(self)
|
||||
|
||||
@abstractmethod
|
||||
def get_all_artists(self) -> List[Artist]
|
||||
|
||||
@abstractmethod
|
||||
def get_artist_albums(self, artist_id: str) -> List[Album]
|
||||
|
||||
@abstractmethod
|
||||
def get_album_tracks(self, album_id: str) -> List[Track]
|
||||
|
||||
@abstractmethod
|
||||
def search_tracks(self, query: str) -> List[Track]
|
||||
```
|
||||
|
||||
#### Implementation Classes
|
||||
- **PlexClient**: Existing implementation adapted to new interface
|
||||
- **JellyfinClient**: New implementation following same patterns
|
||||
- **Future**: EmbyClient, KodiClient, etc.
|
||||
|
||||
#### Client Factory Pattern
|
||||
```python
|
||||
def get_media_server_client() -> MediaServerClient:
|
||||
active_server = config_manager.get('active_media_server', 'plex')
|
||||
|
||||
if active_server == 'plex':
|
||||
return PlexClient()
|
||||
elif active_server == 'jellyfin':
|
||||
return JellyfinClient()
|
||||
else:
|
||||
raise ValueError(f"Unsupported server: {active_server}")
|
||||
```
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Phase 1: Foundation (Database & Config)
|
||||
1. **Database Schema Migration**
|
||||
- Create Jellyfin tables mirroring Plex structure
|
||||
- Migrate existing Plex data to new `plex_*` tables
|
||||
- Update database access methods to be server-aware
|
||||
- Test data migration with existing user databases
|
||||
|
||||
2. **Configuration System Updates**
|
||||
- Add `active_media_server` config option
|
||||
- Create Jellyfin configuration section
|
||||
- Update settings validation logic
|
||||
- Implement configuration migration for existing users
|
||||
- Add restart requirement logic and UI messaging
|
||||
|
||||
3. **Settings UI Foundation**
|
||||
- Create server toggle button components
|
||||
- Implement dynamic settings container switching
|
||||
- Add Jellyfin settings form (URL + API Key)
|
||||
- Update settings page layout and styling
|
||||
- Add restart warning system
|
||||
|
||||
### Phase 2: Jellyfin Integration
|
||||
1. **JellyfinClient Development**
|
||||
- Research Jellyfin API endpoints and authentication
|
||||
- Implement MediaServerClient interface
|
||||
- Handle Jellyfin-specific data formats and responses
|
||||
- Add comprehensive error handling and logging
|
||||
|
||||
2. **Database Abstraction Layer**
|
||||
- Create server-agnostic database access methods
|
||||
- Update existing Plex code to use abstraction layer
|
||||
- Implement Jellyfin data persistence
|
||||
- Ensure cross-server functionality (wishlist/watchlist)
|
||||
|
||||
3. **Core Service Updates**
|
||||
- Update DatabaseUpdateWorker to support both servers
|
||||
- Modify matching engine to handle server-specific data
|
||||
- Update download/sync services for multi-server support
|
||||
- Add server-aware logging and error messages
|
||||
|
||||
### Phase 3: Integration & Polish
|
||||
1. **Dashboard Integration**
|
||||
- Update connection status indicators for active server
|
||||
- Modify service health checks for selected server
|
||||
- Update statistics and activity feeds
|
||||
- Add server type indicators in UI
|
||||
|
||||
2. **Feature Testing & Validation**
|
||||
- Comprehensive testing with both server types
|
||||
- Data consistency verification
|
||||
- Performance testing and optimization
|
||||
- User experience testing and refinement
|
||||
|
||||
3. **Documentation & Migration**
|
||||
- Update user documentation for multi-server setup
|
||||
- Create migration guides for existing users
|
||||
- Add troubleshooting guides for each server type
|
||||
- Update installation instructions
|
||||
|
||||
## Jellyfin Integration Requirements
|
||||
|
||||
### API Research Needs
|
||||
- **Authentication**: API key format and usage patterns
|
||||
- **Library Structure**: How Jellyfin organizes music libraries
|
||||
- **Endpoint Discovery**: Artist, album, track retrieval methods
|
||||
- **Search Capabilities**: Track/artist search functionality
|
||||
- **Error Handling**: Common error responses and status codes
|
||||
|
||||
### Data Mapping
|
||||
- **Field Mapping**: Jellyfin → SoulSync data field mappings
|
||||
- **ID Handling**: Jellyfin ID formats and uniqueness
|
||||
- **Path Resolution**: File system path access from Jellyfin
|
||||
- **Image URLs**: Album art and artist image retrieval
|
||||
|
||||
### Connection Management
|
||||
- **Auto-Detection**: Jellyfin server discovery on local network
|
||||
- **Health Checks**: Connection validation and status monitoring
|
||||
- **Rate Limiting**: Jellyfin API rate limits and best practices
|
||||
- **Session Management**: API key validation and renewal
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Existing User Impact
|
||||
- **Zero Disruption**: Existing Plex users see no functional changes
|
||||
- **Opt-in Jellyfin**: New server support is additive, not replacement
|
||||
- **Data Preservation**: All existing data, settings, and functionality preserved
|
||||
- **Seamless Upgrade**: Automatic config migration during app update
|
||||
- **Clear Restart Process**: Users understand when changes take effect
|
||||
|
||||
### New User Onboarding
|
||||
- **Server Choice**: Present server options during initial setup
|
||||
- **Guided Configuration**: Step-by-step setup for chosen server
|
||||
- **Validation Feedback**: Clear error messages and connection testing
|
||||
- **Fallback Options**: Graceful handling of connection failures
|
||||
|
||||
## Future Extensibility
|
||||
|
||||
### Additional Server Support
|
||||
- **Emby**: Natural next addition with similar API patterns
|
||||
- **Kodi**: Potential integration for advanced users
|
||||
- **Subsonic**: API-compatible servers
|
||||
- **Custom Servers**: Plugin architecture for community additions
|
||||
|
||||
### Advanced Features
|
||||
- **Multi-Server Mode**: Theoretical support for multiple active servers
|
||||
- **Server Synchronization**: Cross-server library comparison
|
||||
- **Server Migration**: Tools for moving between server types
|
||||
- **Hybrid Workflows**: Different servers for different purposes
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Functional Requirements
|
||||
- ✅ Users can switch between Plex and Jellyfin servers
|
||||
- ✅ All existing features work with both server types
|
||||
- ✅ Database maintains data for both servers simultaneously
|
||||
- ✅ Settings UI clearly shows active server and appropriate options
|
||||
- ✅ Performance is equivalent between server types
|
||||
- ✅ Server changes require restart and users are clearly informed
|
||||
|
||||
### User Experience Requirements
|
||||
- ✅ Switching servers is intuitive with clear restart messaging
|
||||
- ✅ Error messages are server-specific and helpful
|
||||
- ✅ Connection status is always clear and accurate
|
||||
- ✅ Existing Plex users experience no disruption
|
||||
- ✅ New Jellyfin users have equivalent functionality
|
||||
- ✅ Restart requirement is clearly communicated and expected
|
||||
|
||||
### Technical Requirements
|
||||
- ✅ Clean, extensible architecture for future servers
|
||||
- ✅ Comprehensive error handling and logging
|
||||
- ✅ Backward compatibility with existing configurations
|
||||
- ✅ Efficient database design with clear separation
|
||||
- ✅ Consistent API patterns across server implementations
|
||||
- ✅ Stable configuration management with restart-based activation
|
||||
|
||||
---
|
||||
|
||||
*This specification provides the foundation for implementing multi-server support in SoulSync, starting with Jellyfin integration while maintaining the high-quality user experience and robust functionality that users expect.*
|
||||
11512
web_server.py
Normal file
11512
web_server.py
Normal file
File diff suppressed because it is too large
Load diff
977
webui/index.html
Normal file
977
webui/index.html
Normal file
|
|
@ -0,0 +1,977 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>SoulSync - Music Sync & Manager</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-container">
|
||||
<!-- Sidebar - Always Visible -->
|
||||
<div class="sidebar">
|
||||
<!-- Header Section -->
|
||||
<div class="sidebar-header">
|
||||
<h1 class="app-name">SoulSync</h1>
|
||||
<p class="app-subtitle">Music Sync & Manager</p>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Section -->
|
||||
<nav class="sidebar-nav">
|
||||
<button class="nav-button active" data-page="dashboard">
|
||||
<span class="nav-icon">📊</span>
|
||||
<span class="nav-text">Dashboard</span>
|
||||
</button>
|
||||
<button class="nav-button" data-page="sync">
|
||||
<span class="nav-icon">🔄</span>
|
||||
<span class="nav-text">Sync</span>
|
||||
</button>
|
||||
<button class="nav-button" data-page="downloads">
|
||||
<span class="nav-icon">📥</span>
|
||||
<span class="nav-text">Search</span>
|
||||
</button>
|
||||
<button class="nav-button" data-page="artists">
|
||||
<span class="nav-icon">🎵</span>
|
||||
<span class="nav-text">Artists</span>
|
||||
</button>
|
||||
<button class="nav-button" data-page="settings">
|
||||
<span class="nav-icon">⚙️</span>
|
||||
<span class="nav-text">Settings</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Spacer -->
|
||||
<div class="sidebar-spacer"></div>
|
||||
|
||||
<!-- Media Player Section -->
|
||||
<div class="media-player" id="media-player">
|
||||
<!-- Loading Animation -->
|
||||
<div class="loading-animation hidden" id="loading-animation">
|
||||
<div class="loading-bar">
|
||||
<div class="loading-progress"></div>
|
||||
</div>
|
||||
<div class="loading-text">0%</div>
|
||||
</div>
|
||||
|
||||
<!-- Header (always visible) -->
|
||||
<div class="media-header">
|
||||
<div class="media-info">
|
||||
<div class="track-title" id="track-title">No track</div>
|
||||
<div class="artist-name" id="artist-name">Unknown Artist</div>
|
||||
</div>
|
||||
<button class="play-button" id="play-button" disabled>▷</button>
|
||||
</div>
|
||||
|
||||
<!-- Expanded Content (hidden initially) -->
|
||||
<div class="media-expanded hidden" id="media-expanded">
|
||||
<div class="album-name" id="album-name">Unknown Album</div>
|
||||
|
||||
<!-- Progress Bar and Time Display -->
|
||||
<div class="progress-section">
|
||||
<div class="time-display">
|
||||
<span class="current-time" id="current-time">0:00</span>
|
||||
<span class="time-separator">/</span>
|
||||
<span class="total-time" id="total-time">0:00</span>
|
||||
</div>
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill" id="progress-fill"></div>
|
||||
</div>
|
||||
<input type="range" class="progress-bar" id="progress-bar" min="0" max="100" value="0" step="0.1">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="media-controls">
|
||||
<div class="volume-control">
|
||||
<span class="volume-icon">🔊</span>
|
||||
<input type="range" class="volume-slider" id="volume-slider" min="0" max="100" value="70">
|
||||
</div>
|
||||
<button class="stop-button" id="stop-button" disabled>⏹</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Track Message -->
|
||||
<div class="no-track-message" id="no-track-message">
|
||||
Start playing music to see controls
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Crypto Donation Section -->
|
||||
<div class="crypto-donation">
|
||||
<div class="donation-header">
|
||||
<span class="donation-title">Support Development</span>
|
||||
<button class="toggle-button" id="donation-toggle">Show</button>
|
||||
</div>
|
||||
<div class="donation-addresses hidden" id="donation-addresses">
|
||||
<div class="donation-item" onclick="openKofi()">
|
||||
<span class="donation-name">Ko-fi</span>
|
||||
<span class="donation-link">Click to open</span>
|
||||
</div>
|
||||
<div class="donation-item" onclick="copyAddress('3JVWrRSkozAQSmw5DXYVxYKsM9bndPTqdS', 'Bitcoin')">
|
||||
<span class="donation-name">Bitcoin</span>
|
||||
<span class="donation-address">3JVWrR...dPTqdS</span>
|
||||
</div>
|
||||
<div class="donation-item" onclick="copyAddress('0x343fC48c2cd1C6332b0df9a58F86e6520a026AC5', 'Ethereum')">
|
||||
<span class="donation-name">Ethereum</span>
|
||||
<span class="donation-address">0x343f...026AC5</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Version Section -->
|
||||
<div class="version-section">
|
||||
<button class="version-button" onclick="showVersionInfo()">v1.0</button>
|
||||
</div>
|
||||
|
||||
<!-- Status Section -->
|
||||
<div class="status-section">
|
||||
<h4 class="status-title">Service Status</h4>
|
||||
<div class="status-indicator" id="spotify-indicator">
|
||||
<span class="status-dot disconnected"></span>
|
||||
<span class="status-name">Spotify</span>
|
||||
</div>
|
||||
<div class="status-indicator" id="media-server-indicator">
|
||||
<span class="status-dot disconnected"></span>
|
||||
<span class="status-name" id="media-server-name">Plex</span>
|
||||
</div>
|
||||
<div class="status-indicator" id="soulseek-indicator">
|
||||
<span class="status-dot disconnected"></span>
|
||||
<span class="status-name">Soulseek</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<div class="main-content">
|
||||
<!-- Dashboard Page -->
|
||||
<div class="page active" id="dashboard-page">
|
||||
<div class="dashboard-container">
|
||||
<div class="dashboard-header">
|
||||
<div class="header-text">
|
||||
<h2 class="header-title">System Dashboard</h2>
|
||||
<p class="header-subtitle">Monitor your music system health and manage operations</p>
|
||||
</div>
|
||||
<div class="header-spacer"></div>
|
||||
<div class="header-actions">
|
||||
<button class="header-button watchlist-button" id="watchlist-button">👁️ Watchlist (0)</button>
|
||||
<button class="header-button wishlist-button" id="wishlist-button">🎵 Wishlist (0)</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-section">
|
||||
<h3 class="section-title">Service Status</h3>
|
||||
<div class="service-status-grid">
|
||||
<div class="service-card" id="spotify-service-card">
|
||||
<div class="service-card-header">
|
||||
<span class="service-card-title">Spotify</span>
|
||||
<span class="service-card-indicator disconnected" id="spotify-status-indicator">●</span>
|
||||
</div>
|
||||
<p class="service-card-status-text" id="spotify-status-text">Disconnected</p>
|
||||
<p class="service-card-response-time" id="spotify-response-time">Response: --</p>
|
||||
<div class="service-card-footer">
|
||||
<button class="service-card-button" onclick="testDashboardConnection('spotify')">Test Connection</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="service-card" id="media-server-service-card">
|
||||
<div class="service-card-header">
|
||||
<span class="service-card-title" id="media-server-service-name">Server</span>
|
||||
<span class="service-card-indicator disconnected" id="media-server-status-indicator">●</span>
|
||||
</div>
|
||||
<p class="service-card-status-text" id="media-server-status-text">Disconnected</p>
|
||||
<p class="service-card-response-time" id="media-server-response-time">Response: --</p>
|
||||
<div class="service-card-footer">
|
||||
<button class="service-card-button" onclick="testDashboardConnection('server')">Test Connection</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="service-card" id="soulseek-service-card">
|
||||
<div class="service-card-header">
|
||||
<span class="service-card-title">Soulseek</span>
|
||||
<span class="service-card-indicator disconnected" id="soulseek-status-indicator">●</span>
|
||||
</div>
|
||||
<p class="service-card-status-text" id="soulseek-status-text">Disconnected</p>
|
||||
<p class="service-card-response-time" id="soulseek-response-time">Response: --</p>
|
||||
<div class="service-card-footer">
|
||||
<button class="service-card-button" onclick="testDashboardConnection('soulseek')">Test Connection</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-section">
|
||||
<h3 class="section-title">System Statistics</h3>
|
||||
<div class="stats-grid-dashboard">
|
||||
<div class="stat-card-dashboard" id="active-downloads-card">
|
||||
<p class="stat-card-title">Active Downloads</p>
|
||||
<p class="stat-card-value">0</p>
|
||||
<p class="stat-card-subtitle">Currently downloading</p>
|
||||
</div>
|
||||
<div class="stat-card-dashboard" id="finished-downloads-card">
|
||||
<p class="stat-card-title">Finished Downloads</p>
|
||||
<p class="stat-card-value">0</p>
|
||||
<p class="stat-card-subtitle">Completed this session</p>
|
||||
</div>
|
||||
<div class="stat-card-dashboard" id="download-speed-card">
|
||||
<p class="stat-card-title">Download Speed</p>
|
||||
<p class="stat-card-value">0 KB/s</p>
|
||||
<p class="stat-card-subtitle">Combined speed</p>
|
||||
</div>
|
||||
<div class="stat-card-dashboard" id="active-syncs-card">
|
||||
<p class="stat-card-title">Active Syncs</p>
|
||||
<p class="stat-card-value">0</p>
|
||||
<p class="stat-card-subtitle">Playlists syncing</p>
|
||||
</div>
|
||||
<div class="stat-card-dashboard" id="uptime-card">
|
||||
<p class="stat-card-title">System Uptime</p>
|
||||
<p class="stat-card-value">0m</p>
|
||||
<p class="stat-card-subtitle">Application runtime</p>
|
||||
</div>
|
||||
<div class="stat-card-dashboard" id="memory-card">
|
||||
<p class="stat-card-title">Memory Usage</p>
|
||||
<p class="stat-card-value">--</p>
|
||||
<p class="stat-card-subtitle">Current usage</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-section">
|
||||
<h3 class="section-title">Tools & Operations</h3>
|
||||
<div class="tools-grid">
|
||||
<div class="tool-card" id="db-updater-card">
|
||||
<h4 class="tool-card-title">Database Updater</h4>
|
||||
<p class="tool-card-info">Last Full Refresh: <span id="db-last-refresh">Never</span></p>
|
||||
<div class="tool-card-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-item-label">Artists:</span>
|
||||
<span class="stat-item-value" id="db-stat-artists">0</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-item-label">Albums:</span>
|
||||
<span class="stat-item-value" id="db-stat-albums">0</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-item-label">Tracks:</span>
|
||||
<span class="stat-item-value" id="db-stat-tracks">0</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-item-label">Size:</span>
|
||||
<span class="stat-item-value" id="db-stat-size">0.0 MB</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tool-card-controls">
|
||||
<select id="db-refresh-type">
|
||||
<option value="incremental">Incremental Update</option>
|
||||
<option value="full">Full Refresh</option>
|
||||
</select>
|
||||
<button id="db-update-button">Update Database</button>
|
||||
</div>
|
||||
<div class="tool-card-progress-section">
|
||||
<p class="progress-phase-label" id="db-phase-label">Idle</p>
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar-fill" id="db-progress-bar" style="width: 0%;"></div>
|
||||
</div>
|
||||
<p class="progress-details-label" id="db-progress-label">0 / 0 artists (0.0%)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tool-card" id="metadata-updater-card">
|
||||
<h4 class="tool-card-title">Metadata Updater</h4>
|
||||
<p class="tool-card-info">Updates artist photos, genres, and album art from Spotify.</p>
|
||||
<div class="tool-card-controls">
|
||||
<select id="metadata-refresh-interval">
|
||||
<option value="180">6 months</option>
|
||||
<option value="90">3 months</option>
|
||||
<option value="30" selected>1 month</option>
|
||||
<option value="14">2 weeks</option>
|
||||
<option value="7">1 week</option>
|
||||
<option value="0">Full refresh</option>
|
||||
</select>
|
||||
<button id="metadata-update-button">Begin Update</button>
|
||||
</div>
|
||||
<div class="tool-card-progress-section">
|
||||
<p class="progress-phase-label" id="metadata-phase-label">Current Artist: Not running</p>
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar-fill" id="metadata-progress-bar" style="width: 0%;"></div>
|
||||
</div>
|
||||
<p class="progress-details-label" id="metadata-progress-label">0 / 0 artists (0.0%)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-section">
|
||||
<h3 class="section-title">Recent Activity</h3>
|
||||
<div class="activity-feed-container" id="dashboard-activity-feed">
|
||||
<div class="activity-item">
|
||||
<span class="activity-icon">📊</span>
|
||||
<div class="activity-text-content">
|
||||
<p class="activity-title">System Started</p>
|
||||
<p class="activity-subtitle">Dashboard initialized successfully</p>
|
||||
</div>
|
||||
<p class="activity-time">Now</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main container for the Sync page -->
|
||||
<div class="page" id="sync-page">
|
||||
<!-- Header -->
|
||||
<div class="sync-header">
|
||||
<h2 class="sync-title">Playlist Sync</h2>
|
||||
<p class="sync-subtitle">Synchronize your Spotify, Tidal, and YouTube playlists with your media server</p>
|
||||
</div>
|
||||
|
||||
<!-- Main two-column content area -->
|
||||
<div class="sync-content-area">
|
||||
<!-- Left Panel: Tabbed Playlist Section -->
|
||||
<div class="sync-main-panel">
|
||||
<div class="sync-tabs">
|
||||
<button class="sync-tab-button active" data-tab="spotify">
|
||||
<span class="tab-icon spotify-icon"></span> Spotify
|
||||
</button>
|
||||
<button class="sync-tab-button" data-tab="tidal">
|
||||
<span class="tab-icon tidal-icon"></span> Tidal
|
||||
</button>
|
||||
<button class="sync-tab-button" data-tab="youtube">
|
||||
<span class="tab-icon youtube-icon"></span> YouTube
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Spotify Tab Content -->
|
||||
<div class="sync-tab-content active" id="spotify-tab-content">
|
||||
<div class="playlist-header">
|
||||
<h3>Your Spotify Playlists</h3>
|
||||
<button class="refresh-button" id="spotify-refresh-btn">🔄 Refresh</button>
|
||||
</div>
|
||||
<div class="playlist-scroll-container" id="spotify-playlist-container">
|
||||
<div class="playlist-placeholder">Click 'Refresh' to load your Spotify playlists.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tidal Tab Content -->
|
||||
<div class="sync-tab-content" id="tidal-tab-content">
|
||||
<div class="playlist-header">
|
||||
<h3>Your Tidal Playlists</h3>
|
||||
<button class="refresh-button tidal" id="tidal-refresh-btn">🔄 Refresh</button>
|
||||
</div>
|
||||
<div class="playlist-scroll-container" id="tidal-playlist-container">
|
||||
<div class="playlist-placeholder">Click 'Refresh' to load your Tidal playlists.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- YouTube Tab Content -->
|
||||
<div class="sync-tab-content" id="youtube-tab-content">
|
||||
<div class="youtube-input-section">
|
||||
<input type="text" id="youtube-url-input" placeholder="Paste YouTube Music Playlist URL...">
|
||||
<button id="youtube-parse-btn">Parse Playlist</button>
|
||||
</div>
|
||||
<div class="playlist-scroll-container" id="youtube-playlist-container">
|
||||
<div class="playlist-placeholder">Parsed YouTube playlists will appear here.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Panel: Sidebar with Options & Logging -->
|
||||
<div class="sync-sidebar">
|
||||
<div class="sidebar-section">
|
||||
<h4>Sync Actions</h4>
|
||||
<div id="selection-info">Select playlists to sync</div>
|
||||
<button id="start-sync-btn" class="neo-button" disabled>Start Sync</button>
|
||||
</div>
|
||||
<div class="sidebar-section progress-section">
|
||||
<h4>Sync Progress</h4>
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar-fill" id="sync-progress-bar" style="width: 0%;"></div>
|
||||
</div>
|
||||
<div id="sync-progress-text">Ready to sync...</div>
|
||||
<textarea id="sync-log-area" readonly>Waiting for sync to start...</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Downloads Page -->
|
||||
<div class="page" id="downloads-page">
|
||||
<!--
|
||||
This top-level container replicates the QSplitter from downloads.py,
|
||||
creating the two-panel layout for the page.
|
||||
-->
|
||||
<div class="downloads-content">
|
||||
|
||||
<!-- ======================================================= -->
|
||||
<!-- == LEFT PANEL: Search, Filters, and Results == -->
|
||||
<!-- ======================================================= -->
|
||||
<div class="downloads-main-panel">
|
||||
|
||||
<!-- Header: Replicates create_elegant_header() -->
|
||||
<div class="downloads-header">
|
||||
<h2 class="downloads-title">🎵 Music Downloads</h2>
|
||||
<p class="downloads-subtitle">Search, discover, and download high-quality music</p>
|
||||
</div>
|
||||
|
||||
<!-- Search Bar: Replicates create_elegant_search_bar() -->
|
||||
<div class="search-bar-container">
|
||||
<input type="text" id="downloads-search-input" placeholder="Search for music... (e.g., 'Virtual Mage', 'Queen Bohemian Rhapsody')">
|
||||
<button id="downloads-cancel-btn" class="hidden">✕ Cancel</button>
|
||||
<button id="downloads-search-btn">🔍 Search</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="filters-container" class="filters-container hidden">
|
||||
<div class="filter-toggle-header">
|
||||
<button id="filter-toggle-btn" class="filter-toggle-btn">⏷ Filters</button>
|
||||
</div>
|
||||
|
||||
<div id="filter-content" class="filter-content hidden">
|
||||
<!-- Filter by Type -->
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">Type:</label>
|
||||
<button class="filter-btn active" data-filter-type="type" data-value="all">All</button>
|
||||
<button class="filter-btn" data-filter-type="type" data-value="album">Albums</button>
|
||||
<button class="filter-btn" data-filter-type="type" data-value="track">Singles</button>
|
||||
</div>
|
||||
|
||||
<!-- Filter by Format -->
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">Format:</label>
|
||||
<button class="filter-btn active" data-filter-type="format" data-value="all">All</button>
|
||||
<button class="filter-btn" data-filter-type="format" data-value="flac">FLAC</button>
|
||||
<button class="filter-btn" data-filter-type="format" data-value="mp3">MP3</button>
|
||||
<!-- Added missing format buttons -->
|
||||
<button class="filter-btn" data-filter-type="format" data-value="ogg">OGG</button>
|
||||
<button class="filter-btn" data-filter-type="format" data-value="aac">AAC</button>
|
||||
<button class="filter-btn" data-filter-type="format" data-value="wma">WMA</button>
|
||||
</div>
|
||||
|
||||
<!-- Sort Controls -->
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">Sort by:</label>
|
||||
<button id="sort-order-btn" class="filter-btn sort-order-btn" data-order="desc">↓</button>
|
||||
<!-- Added all sort options from the GUI -->
|
||||
<button class="filter-btn active" data-filter-type="sort" data-value="relevance">Relevance</button>
|
||||
<button class="filter-btn" data-filter-type="sort" data-value="quality_score">Quality</button>
|
||||
<button class="filter-btn" data-filter-type="sort" data-value="size">Size</button>
|
||||
<button class="filter-btn" data-filter-type="sort" data-value="title">Name</button>
|
||||
<button class="filter-btn" data-filter-type="sort" data-value="username">Uploader</button>
|
||||
<button class="filter-btn" data-filter-type="sort" data-value="bitrate">Bitrate</button>
|
||||
<button class="filter-btn" data-filter-type="sort" data-value="duration">Duration</button>
|
||||
<button class="filter-btn" data-filter-type="sort" data-value="availability">Available</button>
|
||||
<button class="filter-btn" data-filter-type="sort" data-value="upload_speed">Speed</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Search Status Bar -->
|
||||
<div class="search-status-container">
|
||||
<div class="spinner-animation hidden"></div>
|
||||
<p id="search-status-text">Ready to search • Enter artist, song, or album name</p>
|
||||
<div class="dots-animation hidden"></div>
|
||||
</div>
|
||||
|
||||
<!-- Search Results Area: Replicates the QScrollArea -->
|
||||
<div class="search-results-container">
|
||||
<div class="search-results-header">
|
||||
<h3>Search Results</h3>
|
||||
</div>
|
||||
<div class="search-results-scroll-area" id="search-results-area">
|
||||
<!--
|
||||
The placeholder search results have been removed.
|
||||
This area will now be populated by JavaScript based on API responses.
|
||||
-->
|
||||
<div class="search-results-placeholder">
|
||||
<p>Your search results will appear here.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ======================================================= -->
|
||||
<!-- == RIGHT PANEL: Controls and Download Queue == -->
|
||||
<!-- ======================================================= -->
|
||||
<div class="downloads-side-panel">
|
||||
|
||||
<!-- Controls Panel: Replicates create_collapsible_controls_panel() -->
|
||||
<div class="controls-panel">
|
||||
<h3 class="controls-panel__header">Download Manager</h3>
|
||||
<div class="controls-panel__stats">
|
||||
<p id="active-downloads-label">• Active Downloads: 0</p>
|
||||
<p id="finished-downloads-label">• Finished Downloads: 0</p>
|
||||
</div>
|
||||
<div class="controls-panel__actions">
|
||||
<button class="controls-panel__clear-btn">🗑️ Clear Completed</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Download Queue: Replicates TabbedDownloadManager -->
|
||||
<div class="download-manager">
|
||||
<div class="download-manager__tabs">
|
||||
<button class="tab-btn active" data-tab="active-queue">Download Queue (0)</button>
|
||||
<button class="tab-btn" data-tab="finished-queue">Finished (0)</button>
|
||||
</div>
|
||||
<div class="download-manager__content">
|
||||
|
||||
<!-- Active Queue -->
|
||||
<div class="download-queue active" id="active-queue">
|
||||
<div class="download-queue__empty-message">No active downloads.</div>
|
||||
</div>
|
||||
|
||||
<!-- Finished Queue -->
|
||||
<div class="download-queue" id="finished-queue">
|
||||
<div class="download-queue__empty-message">No finished downloads.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Artists Page -->
|
||||
<div class="page" id="artists-page">
|
||||
<!-- Initial Search State -->
|
||||
<div class="artists-search-state" id="artists-search-state">
|
||||
<div class="artists-search-container">
|
||||
<div class="artists-welcome-section">
|
||||
<h2 class="artists-welcome-title">🎵 Discover Artists</h2>
|
||||
<p class="artists-welcome-subtitle">Search for your favorite artists and explore their complete discography</p>
|
||||
</div>
|
||||
|
||||
<div class="artists-search-input-container">
|
||||
<input type="text"
|
||||
id="artists-search-input"
|
||||
class="artists-search-input"
|
||||
placeholder="Search for an artist...">
|
||||
<div class="artists-search-icon">🔍</div>
|
||||
</div>
|
||||
|
||||
<div class="artists-search-status" id="artists-search-status">
|
||||
Start typing to search for artists
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Results State -->
|
||||
<div class="artists-results-state hidden" id="artists-results-state">
|
||||
<div class="artists-results-header">
|
||||
<button class="artists-back-button" id="artists-back-button">
|
||||
<span class="back-icon">←</span>
|
||||
<span>Back to Search</span>
|
||||
</button>
|
||||
|
||||
<div class="artists-search-header">
|
||||
<input type="text"
|
||||
id="artists-header-search-input"
|
||||
class="artists-header-search-input"
|
||||
placeholder="Search for an artist...">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="artists-results-content">
|
||||
<div class="artists-results-title">Search Results</div>
|
||||
<div class="artists-cards-container" id="artists-cards-container">
|
||||
<!-- Artist cards will be dynamically populated here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Artist Detail State -->
|
||||
<div class="artist-detail-state hidden" id="artist-detail-state">
|
||||
<div class="artist-detail-header">
|
||||
<div class="artist-detail-header-left">
|
||||
<button class="artist-detail-back-button" id="artist-detail-back-button">
|
||||
<span class="back-icon">←</span>
|
||||
<span>Back to Results</span>
|
||||
</button>
|
||||
|
||||
<div class="artist-detail-info">
|
||||
<div class="artist-detail-image" id="artist-detail-image"></div>
|
||||
<div class="artist-detail-text">
|
||||
<h2 class="artist-detail-name" id="artist-detail-name">Artist Name</h2>
|
||||
<p class="artist-detail-genres" id="artist-detail-genres">Genres</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="artist-detail-watchlist-btn" id="artist-detail-watchlist-btn">
|
||||
<span class="watchlist-icon">👁️</span>
|
||||
<span class="watchlist-text">Add to Watchlist</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="artist-detail-content">
|
||||
<div class="artist-detail-tabs">
|
||||
<button class="artist-tab active" data-tab="albums" id="albums-tab">
|
||||
<span class="tab-icon">💿</span>
|
||||
<span>Albums</span>
|
||||
</button>
|
||||
<button class="artist-tab" data-tab="singles" id="singles-tab">
|
||||
<span class="tab-icon">🎵</span>
|
||||
<span>Singles & EPs</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="artist-detail-discography">
|
||||
<div class="tab-content active" id="albums-content">
|
||||
<div class="album-cards-container" id="album-cards-container">
|
||||
<!-- Album cards will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-content" id="singles-content">
|
||||
<div class="singles-cards-container" id="singles-cards-container">
|
||||
<!-- Singles cards will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Page -->
|
||||
<div class="page" id="settings-page">
|
||||
<div class="page-header">
|
||||
<h2>Settings</h2>
|
||||
</div>
|
||||
<div class="settings-content">
|
||||
<!-- Two Column Layout -->
|
||||
<div class="settings-columns">
|
||||
<!-- Left Column - API Configuration -->
|
||||
<div class="settings-left-column">
|
||||
<!-- API Configuration -->
|
||||
<div class="settings-group">
|
||||
<h3>API Configuration</h3>
|
||||
|
||||
<!-- Spotify Settings -->
|
||||
<div class="api-service-frame">
|
||||
<h4 class="service-title spotify-title">Spotify</h4>
|
||||
<div class="form-group">
|
||||
<label>Client ID:</label>
|
||||
<input type="text" id="spotify-client-id" placeholder="Spotify Client ID">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Client Secret:</label>
|
||||
<input type="password" id="spotify-client-secret" placeholder="Spotify Client Secret">
|
||||
</div>
|
||||
<div class="callback-info">
|
||||
<div class="callback-label">Required Redirect URI:</div>
|
||||
<div class="callback-url">http://127.0.0.1:8888/callback</div>
|
||||
<div class="callback-help">Add this URL to your Spotify app's 'Redirect URIs' in the Spotify Developer Dashboard</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="auth-button" onclick="authenticateSpotify()">🔐 Authenticate</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tidal Settings -->
|
||||
<div class="api-service-frame">
|
||||
<h4 class="service-title tidal-title">Tidal</h4>
|
||||
<div class="form-group">
|
||||
<label>Client ID:</label>
|
||||
<input type="text" id="tidal-client-id" placeholder="Tidal Client ID">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Client Secret:</label>
|
||||
<input type="password" id="tidal-client-secret" placeholder="Tidal Client Secret">
|
||||
</div>
|
||||
<div class="callback-info">
|
||||
<div class="callback-label">Required Redirect URI:</div>
|
||||
<div class="callback-url">http://127.0.0.1:8889/tidal/callback</div>
|
||||
<div class="callback-help">Add this URL to your Tidal app configuration</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="auth-button" onclick="authenticateTidal()">🔐 Authenticate</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Soulseek Settings -->
|
||||
<div class="api-service-frame">
|
||||
<h4 class="service-title soulseek-title">Soulseek</h4>
|
||||
<div class="form-group">
|
||||
<label>slskd URL:</label>
|
||||
<div class="path-input-group">
|
||||
<input type="url" id="soulseek-url" placeholder="http://localhost:5030">
|
||||
<button class="detect-button" onclick="autoDetectSlskd()">Auto-detect</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>API Key:</label>
|
||||
<input type="password" id="soulseek-api-key" placeholder="Slskd API Key">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test Connection Buttons -->
|
||||
<div class="api-test-buttons">
|
||||
<button class="test-button" onclick="testConnection('spotify')">Test Spotify</button>
|
||||
<button class="test-button" onclick="testConnection('tidal')">Test Tidal</button>
|
||||
<button class="test-button" onclick="testConnection('soulseek')">Test Soulseek</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Server Connections -->
|
||||
<div class="settings-group">
|
||||
<h3>Server Connections</h3>
|
||||
|
||||
<!-- Server Toggle Buttons -->
|
||||
<div class="server-toggle-container">
|
||||
<button class="server-toggle-btn active" id="plex-toggle" onclick="toggleServer('plex')">Plex</button>
|
||||
<button class="server-toggle-btn" id="jellyfin-toggle" onclick="toggleServer('jellyfin')">Jellyfin</button>
|
||||
</div>
|
||||
|
||||
<!-- Plex Settings -->
|
||||
<div class="server-config-container" id="plex-container">
|
||||
<div class="form-group">
|
||||
<label>Plex Server URL:</label>
|
||||
<input type="url" id="plex-url" placeholder="http://localhost:32400">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Plex Token:</label>
|
||||
<input type="password" id="plex-token" placeholder="X-Plex-Token">
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="detect-button" onclick="autoDetectPlex()">Auto-detect</button>
|
||||
<button class="test-button" onclick="testConnection('plex')">Test</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Jellyfin Settings -->
|
||||
<div class="server-config-container hidden" id="jellyfin-container">
|
||||
<div class="form-group">
|
||||
<label>Jellyfin Server URL:</label>
|
||||
<input type="url" id="jellyfin-url" placeholder="http://localhost:8096">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>API Key:</label>
|
||||
<input type="password" id="jellyfin-api-key" placeholder="Jellyfin API Key">
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="detect-button" onclick="autoDetectJellyfin()">Auto-detect</button>
|
||||
<button class="test-button" onclick="testConnection('jellyfin')">Test</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Server Test Button -->
|
||||
<div class="server-test-section">
|
||||
<button class="test-button server-test-btn" onclick="testConnection('server')">Test Server</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column - Download Settings, Database, Metadata, Logging -->
|
||||
<div class="settings-right-column">
|
||||
<!-- Download Settings -->
|
||||
<div class="settings-group">
|
||||
<h3>Download Settings</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Preferred Quality:</label>
|
||||
<select id="preferred-quality">
|
||||
<option value="flac">FLAC</option>
|
||||
<option value="mp3_320">320 kbps MP3</option>
|
||||
<option value="mp3_256">256 kbps MP3</option>
|
||||
<option value="mp3_192">192 kbps MP3</option>
|
||||
<option value="any">Any</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Slskd Download Dir:</label>
|
||||
<div class="path-input-group">
|
||||
<input type="text" id="download-path" placeholder="./downloads">
|
||||
<button class="browse-button" onclick="browsePath('download')">Browse</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Matched Transfer Dir (Plex Music Dir?):</label>
|
||||
<div class="path-input-group">
|
||||
<input type="text" id="transfer-path" placeholder="./Transfer">
|
||||
<button class="browse-button" onclick="browsePath('transfer')">Browse</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Database Settings -->
|
||||
<div class="settings-group">
|
||||
<h3>Database Settings</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Concurrent Workers:</label>
|
||||
<select id="max-workers">
|
||||
<option value="3">3</option>
|
||||
<option value="4">4</option>
|
||||
<option value="5" selected>5</option>
|
||||
<option value="6">6</option>
|
||||
<option value="7">7</option>
|
||||
<option value="8">8</option>
|
||||
<option value="9">9</option>
|
||||
<option value="10">10</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="help-text">Number of parallel threads for database updates. Higher values = faster updates but more server load.</div>
|
||||
</div>
|
||||
|
||||
<!-- Metadata Enhancement Settings -->
|
||||
<div class="settings-group">
|
||||
<h3>🎵 Metadata Enhancement</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="metadata-enabled" checked>
|
||||
Enable metadata enhancement with Spotify data
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="embed-album-art" checked>
|
||||
Embed high-quality album art from Spotify
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Supported Formats:</label>
|
||||
<div class="supported-formats">MP3, FLAC, MP4/M4A, OGG</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Playlist Sync Settings -->
|
||||
<div class="settings-group">
|
||||
<h3>Playlist Sync Settings</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="create-backup" checked>
|
||||
Create playlist backups before sync
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logging Information (Read-only) -->
|
||||
<div class="settings-group">
|
||||
<h3>Logging Information</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Log Level:</label>
|
||||
<div class="readonly-field" id="log-level-display">DEBUG</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Log Path:</label>
|
||||
<div class="readonly-field" id="log-path-display">logs/app.log</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<div class="settings-actions">
|
||||
<button class="save-button" id="save-settings">💾 Save Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading Overlay -->
|
||||
<div class="loading-overlay hidden" id="loading-overlay">
|
||||
<div class="loading-spinner"></div>
|
||||
<div class="loading-message">Processing...</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast Notifications -->
|
||||
<div class="toast-container" id="toast-container"></div>
|
||||
|
||||
<!-- Hidden HTML5 Audio Player for Streaming -->
|
||||
<audio id="audio-player" style="display: none;"></audio>
|
||||
|
||||
<!-- Matched Download Modal -->
|
||||
<div class="modal-overlay hidden" id="matching-modal-overlay">
|
||||
<div class="matching-modal" id="matching-modal">
|
||||
<div class="matching-modal-header">
|
||||
<h2 id="matching-modal-title">Match Download to Spotify</h2>
|
||||
<button class="matching-modal-close" onclick="closeMatchingModal()">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="matching-modal-content">
|
||||
<!-- Artist Selection Stage -->
|
||||
<div id="artist-selection-stage" class="selection-stage">
|
||||
<div class="stage-header">
|
||||
<h3 id="artist-stage-title">Step 1: Select the correct Artist</h3>
|
||||
<p class="stage-subtitle">Choose the artist that best matches your download</p>
|
||||
</div>
|
||||
|
||||
<div class="suggestions-section">
|
||||
<h4 class="suggestions-title">Top Suggestions</h4>
|
||||
<div class="suggestions-container" id="artist-suggestions">
|
||||
<!-- Artist suggestion cards will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="manual-search-section">
|
||||
<h4 class="suggestions-title">Or, Search Manually</h4>
|
||||
<input type="text" id="artist-search-input" class="search-input" placeholder="Search for an artist...">
|
||||
<div class="suggestions-container" id="artist-manual-results">
|
||||
<!-- Manual search results will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Album Selection Stage (for album downloads) -->
|
||||
<div id="album-selection-stage" class="selection-stage hidden">
|
||||
<div class="stage-header">
|
||||
<h3 id="album-stage-title">Step 2: Select the correct Album</h3>
|
||||
<p class="stage-subtitle">Choose the album that best matches your download for <span id="selected-artist-name"></span></p>
|
||||
</div>
|
||||
|
||||
<div class="suggestions-section">
|
||||
<h4 class="suggestions-title">Top Suggestions</h4>
|
||||
<div class="suggestions-container" id="album-suggestions">
|
||||
<!-- Album suggestion cards will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="manual-search-section">
|
||||
<h4 class="suggestions-title">Or, Search Manually</h4>
|
||||
<input type="text" id="album-search-input" class="search-input" placeholder="Search for an album...">
|
||||
<div class="suggestions-container" id="album-manual-results">
|
||||
<!-- Manual search results will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="matching-modal-actions">
|
||||
<button id="skip-matching-btn" class="modal-button modal-button--secondary">Skip Matching</button>
|
||||
<button id="cancel-match-btn" class="modal-button modal-button--cancel">Cancel</button>
|
||||
<button id="confirm-match-btn" class="modal-button modal-button--primary" disabled>Confirm Selection</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Version Info Modal -->
|
||||
<div class="version-modal-overlay hidden" id="version-modal-overlay" onclick="closeVersionModal()">
|
||||
<div class="version-modal" onclick="event.stopPropagation()">
|
||||
<!-- Header -->
|
||||
<div class="version-modal-header">
|
||||
<h2 class="version-modal-title">What's New in SoulSync</h2>
|
||||
<div class="version-modal-subtitle">Version 1.0 - Complete WebUI Rebuild</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Area with Scroll -->
|
||||
<div class="version-modal-content">
|
||||
<div class="version-content-container" id="version-content-container">
|
||||
<!-- Content will be populated by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="version-modal-footer">
|
||||
<button class="version-modal-close" onclick="closeVersionModal()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="{{ url_for('static', filename='script.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
12932
webui/static/script.js
Normal file
12932
webui/static/script.js
Normal file
File diff suppressed because it is too large
Load diff
7257
webui/static/style.css
Normal file
7257
webui/static/style.css
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue