Merge pull request #8 from Nezreka/multi-source-support

Multi source support
This commit is contained in:
Broque Thomas 2025-08-19 09:47:50 -07:00 committed by GitHub
commit 5f25bc5cd8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 4668 additions and 586 deletions

View file

@ -5,9 +5,9 @@
# 🎵 SoulSync - Automated Music Discovery and 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. It automates the process of discovering new music, finding missing tracks from your favorite playlists, and sourcing them from the Soulseek network via slskd.
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 Plex. Plex is not required for the app to function but slskd and Spotify API are required.
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
@ -15,7 +15,7 @@ If you find SoulSync useful, consider supporting its development:
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/boulderbadgedad)
![A screenshot of my awesome app in action.](./assets/dashboard.png)
![A screenshot of SoulSync](./assets/dashboard.png)
## ⚠️ 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.
@ -25,7 +25,7 @@ Docker is unlikely since this is a fully GUI based app. The unique setup would b
### 🤖 **Automation Engine**
SoulSync handles everything automatically once you set it up. You can sync multiple Spotify and YouTube 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 server and slskd on your network, backs up your playlists before making changes, and reconnects to services if they go down.
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.
@ -44,7 +44,7 @@ The search page lets you manually hunt for specific albums or singles. Every res
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 Plex library locally, so matching is instant instead of making slow API calls. Updates automatically when files change and handles thousands of songs without slowing down.
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.
@ -59,7 +59,7 @@ Failed downloads automatically get saved to a wishlist with context about where
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, Soulseek), download statistics, and system performance. Activity feed shows everything that's happening with timestamps.
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**
@ -80,15 +80,15 @@ Multi-threaded so it stays responsive during heavy operations. Automatically man
The application follows a clear, automated workflow to enhance and expand your music library:
1. **Connect Services**: First, you authenticate with your Spotify and Plex accounts and connect to your running slskd instance through the settings panel. This gives SoulSync the access it needs to work its magic.
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 Plex library. This comparison uses a sophisticated matching engine that looks at track title, artist, album, and duration to make an accurate assessment.
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 Plex library.
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 Plex, requiring no manual metadata editing.
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
@ -108,7 +108,7 @@ Follow these steps to get SoulSync up and running on your system.
Before you begin, ensure you have the following installed and configured:
- **Python 3.8+**: The core runtime for the application.
- **Plex Media Server (OPTIONAL)**: Optional but recommended. If connected, SoulSync will scan your existing music library to identify missing tracks. Without Plex, all tracks will be considered "missing" and automatically downloaded.
- **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.
@ -176,7 +176,7 @@ docker run -d \
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. Plex configuration is optional but recommended.
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.
@ -187,11 +187,11 @@ docker run -d \
### Configuration Details
Open the `config.json` file and fill in the details for Spotify, Plex, and Soulseek.
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 Plex. Here's how:
Before configuring SoulSync, you'll need to obtain API credentials from Spotify and your media server. Here's how:
##### Spotify Client ID and Secret
@ -244,10 +244,33 @@ Before configuring SoulSync, you'll need to obtain API credentials from Spotify
- 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 Plex music library folder, so the files are immediately available to Plex after processing.
- **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
@ -275,12 +298,20 @@ Alternatively, you can paste this key directly into the API Key field in the Set
"base_url": "<YOUR_PLEX_SERVER_URL>",
"token": "<YOUR_PLEX_TOKEN>"
},
"jellyfin": {
"base_url": "<YOUR_JELLYFIN_SERVER_URL>",
"api_key": "<YOUR_JELLYFIN_API_KEY>",
"user_id": "<YOUR_JELLYFIN_USER_ID>"
},
"soulseek": {
"slskd_url": "<YOUR_SLSKD_URL>",
"api_key": "<YOUR_SLSKD_API_KEY>",
"download_path": "./path/to/slskd/download/folder",
"transfer_path": "./path/to/music/folder"
},
"server": {
"active_media_server": "plex"
},
"logging": {
"path": "logs/app.log",
"level": "INFO"
@ -302,6 +333,8 @@ Alternatively, you can paste this key directly into the API Key field in the Set
}
```
**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.
## 🖥️ Usage
Run the main application file to launch the GUI:
@ -316,15 +349,15 @@ python3 main.py
### Application Pages
- **Dashboard**: Real-time system overview with service connection matrix (Spotify/Plex/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.
- **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 Plex library.
- **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 Plex library overlay, bulk download operations for entire discographies, album-level missing track downloads, and integration with matched download system for accurate metadata assignment.
- **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/Soulseek credentials, 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.
- **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
@ -335,14 +368,15 @@ The application is built on a modern, layered architecture with distinct separat
- **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.
- `plex_scan_manager.py`: Intelligent Plex library scan coordination with debouncing and periodic updates.
- `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 Plex library metadata for instant access.
- `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.

View file

@ -1,17 +1,24 @@
{
"active_media_server": "plex",
"spotify": {
"client_id": "YOUR CLIENT ID",
"client_secret": "YOUR CLIENT SECRET"
"client_id": "CLIENT ID",
"client_secret": "CLIENT SECRET"
},
"plex": {
"base_url": "http://192.168.86.36:32400",
"token": "YOUR PLEX TOKEN"
"token": "PLEX TOKEN",
"auto_detect": true
},
"jellyfin": {
"base_url": "http://localhost:8096",
"api_key": "JELLYFIN TOKEN",
"auto_detect": true
},
"soulseek": {
"slskd_url": "http://localhost:5030",
"api_key": "1234567891234567",
"download_path": "./Downloads",
"transfer_path": "./Music"
"api_key": "SOULSEEK TOKEN",
"download_path": "E:/Broque Projects/newMusic/downloads",
"transfer_path": "H:/Music"
},
"logging": {
"path": "logs/app.log",

View file

@ -14,7 +14,7 @@ from config.settings import config_manager
logger = get_logger("database_update_worker")
class DatabaseUpdateWorker(QThread):
"""Worker thread for updating SoulSync database with Plex library data"""
"""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
@ -23,9 +23,20 @@ class DatabaseUpdateWorker(QThread):
error = pyqtSignal(str) # error_message
phase_changed = pyqtSignal(str) # current_phase (artists, albums, tracks)
def __init__(self, plex_client, database_path: str = "database/music_library.db", full_refresh: bool = False):
def __init__(self, media_client, database_path: str = "database/music_library.db", full_refresh: bool = False, server_type: str = "plex"):
super().__init__()
self.plex_client = plex_client
# 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
self.server_type = "plex"
# Keep old attribute for backward compatibility
self.plex_client = media_client
else:
self.media_client = media_client
self.server_type = server_type
# Keep old attribute for backward compatibility with existing code that expects it
self.plex_client = media_client if server_type == "plex" else None
self.database_path = database_path
self.full_refresh = full_refresh
self.should_stop = False
@ -39,8 +50,19 @@ class DatabaseUpdateWorker(QThread):
# Threading control - get from config or default to 5
database_config = config_manager.get('database', {})
self.max_workers = database_config.get('max_workers', 5)
logger.info(f"Using {self.max_workers} worker threads for database update")
base_max_workers = database_config.get('max_workers', 5)
# Optimize worker count - reduce for database concurrency safety
if self.server_type == "jellyfin":
# Reduce workers to prevent database lock issues with bulk inserts
self.max_workers = min(base_max_workers, 3) # Max 3 workers for database safety
if base_max_workers > 3:
logger.info(f"Reducing worker count from {base_max_workers} to {self.max_workers} for Jellyfin database safety")
else:
# Plex uses standard worker count
self.max_workers = base_max_workers
logger.info(f"Using {self.max_workers} worker threads for {self.server_type} database update")
self.thread_lock = threading.Lock()
# Database instance
@ -49,6 +71,15 @@ class DatabaseUpdateWorker(QThread):
def stop(self):
"""Stop the database update process"""
self.should_stop = True
# Clear Jellyfin cache when user stops scan to free memory
if self.server_type == "jellyfin" and hasattr(self, 'media_client'):
try:
cache_stats = self.media_client.get_cache_stats()
self.media_client.clear_cache()
logger.info(f"🧹 Cleared Jellyfin cache after user stop - freed ~{cache_stats.get('bulk_albums_cached', 0) + cache_stats.get('bulk_tracks_cached', 0)} items from memory")
except Exception as e:
logger.warning(f"Could not clear Jellyfin cache on stop: {e}")
def run(self):
"""Main worker thread execution"""
@ -57,14 +88,22 @@ class DatabaseUpdateWorker(QThread):
self.database = get_database(self.database_path)
if self.full_refresh:
logger.info("Performing full database refresh - clearing existing data")
self.database.clear_all_data()
# For full refresh, use the old method (all artists)
logger.info(f"Performing full database refresh for {self.server_type} - clearing existing {self.server_type} data")
self.database.clear_server_data(self.server_type)
# 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...")
# 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))
# For full refresh, get all artists
artists_to_process = self._get_all_artists()
if not artists_to_process:
self.error.emit("No artists found in Plex library or connection failed")
self.error.emit(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 Plex library")
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
@ -78,7 +117,13 @@ class DatabaseUpdateWorker(QThread):
# Phase 2: Process artists and their albums/tracks
self.phase_changed.emit("Processing artists, albums, and tracks...")
self._process_all_artists(artists_to_process)
# FAST PATH: For Jellyfin track-based incremental, process new tracks directly
if self.server_type == "jellyfin" and hasattr(self, '_jellyfin_new_tracks'):
self._process_jellyfin_new_tracks_directly(artists_to_process)
else:
# Standard artist processing for Plex or full refresh
self._process_all_artists(artists_to_process)
# Record full refresh completion for tracking purposes
if self.full_refresh and self.database:
@ -88,6 +133,15 @@ class DatabaseUpdateWorker(QThread):
except Exception as e:
logger.warning(f"Could not record full refresh completion: {e}")
# Clear Jellyfin cache after full refresh to free memory
if self.full_refresh and self.server_type == "jellyfin":
try:
cache_stats = self.media_client.get_cache_stats()
self.media_client.clear_cache()
logger.info(f"🧹 Cleared Jellyfin cache after full refresh - freed ~{cache_stats.get('bulk_albums_cached', 0) + cache_stats.get('bulk_tracks_cached', 0)} items from memory")
except Exception as e:
logger.warning(f"Could not clear Jellyfin cache: {e}")
# Cleanup orphaned records after incremental updates (catches fixed matches)
if not self.full_refresh and self.database:
try:
@ -121,33 +175,38 @@ class DatabaseUpdateWorker(QThread):
self.error.emit(f"Database update failed: {str(e)}")
def _get_all_artists(self) -> List:
"""Get all artists from Plex library"""
"""Get all artists from media server library"""
try:
if not self.plex_client.ensure_connection():
logger.error("Could not connect to Plex server")
if not self.media_client.ensure_connection():
logger.error(f"Could not connect to {self.server_type} server")
return []
artists = self.plex_client.get_all_artists()
artists = self.media_client.get_all_artists()
return artists
except Exception as e:
logger.error(f"Error getting artists from Plex: {e}")
logger.error(f"Error getting artists from {self.server_type}: {e}")
return []
def _get_artists_for_incremental_update(self) -> List:
"""Get artists that need processing for incremental update using smart early-stopping logic"""
try:
if not self.plex_client.ensure_connection():
logger.error("Could not connect to Plex server")
if not self.media_client.ensure_connection():
logger.error(f"Could not connect to {self.server_type} server")
return []
if not self.plex_client.music_library:
# Check for music library (Plex-specific check)
if self.server_type == "plex" and not self.media_client.music_library:
logger.error("No music library found in Plex")
return []
# Check if database has enough content for incremental updates
# Check if database has enough content for incremental updates (server-specific)
try:
stats = self.database.get_database_info()
# Get stats for the specific server we're updating
if hasattr(self.database, 'get_database_info_for_server'):
stats = self.database.get_database_info_for_server(self.server_type)
else:
stats = self.database.get_database_info()
track_count = stats.get('tracks', 0)
if track_count < 100: # Minimum threshold for meaningful incremental updates
@ -165,105 +224,34 @@ class DatabaseUpdateWorker(QThread):
return self._get_all_artists()
# Enhanced Strategy: Get both recently added AND recently updated content
# This catches both new content and metadata corrections done in Plex
# This catches both new content and metadata corrections done on the server
logger.info("Getting recently added and recently updated content...")
logger.info(f"Getting recently added and recently updated content from {self.server_type}...")
# Get both recently added and recently updated albums
all_recent_content = []
# 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}"))
try:
# Get recently added albums (up to 400 to catch more recent content)
try:
recently_added = self.plex_client.music_library.recentlyAdded(libtype='album', maxresults=400)
all_recent_content.extend(recently_added)
logger.info(f"Found {len(recently_added)} recently added albums")
except:
# Fallback to general recently added
recently_added = self.plex_client.music_library.recentlyAdded(maxresults=400)
all_recent_content.extend(recently_added)
logger.info(f"Found {len(recently_added)} recently added items (mixed types)")
# Get recently updated albums (catches metadata corrections) - increased limit
try:
recently_updated = self.plex_client.music_library.search(sort='updatedAt:desc', libtype='album', limit=400)
# Remove duplicates (items that are both recently added and updated)
added_keys = {getattr(item, 'ratingKey', None) for item in all_recent_content}
unique_updated = [item for item in recently_updated if getattr(item, 'ratingKey', None) not in added_keys]
all_recent_content.extend(unique_updated)
logger.info(f"Found {len(unique_updated)} additional recently updated albums (after deduplication)")
except Exception as e:
logger.warning(f"Could not get recently updated content: {e}")
recent_content = all_recent_content
logger.info(f"Combined total: {len(recent_content)} recent albums (added + updated)")
# Filter to only get Album objects and convert Artist objects to their albums
recent_albums = []
artist_count = 0
album_count = 0
for item in recent_content:
try:
item_type = type(item).__name__
logger.info(f"Processing recently added item: {item_type} - '{getattr(item, 'title', 'Unknown')}'")
if hasattr(item, 'tracks') and hasattr(item, 'artist'):
# This is an Album - add directly
recent_albums.append(item)
album_count += 1
logger.info(f"✅ Added album directly: '{item.title}'")
elif hasattr(item, 'albums'):
# This is an Artist - get their albums
try:
artist_albums = list(item.albums())
if artist_albums:
recent_albums.extend(artist_albums)
artist_count += 1
logger.info(f"✅ Added {len(artist_albums)} albums from artist '{item.title}'")
else:
logger.info(f"⚠️ Artist '{item.title}' has no albums")
except Exception as albums_error:
logger.warning(f"Error getting albums from artist '{getattr(item, 'title', 'Unknown')}': {albums_error}")
else:
# Unknown type - skip
logger.info(f"❌ Skipping unsupported type: {item_type} (has tracks: {hasattr(item, 'tracks')}, has albums: {hasattr(item, 'albums')}, has artist: {hasattr(item, 'artist')})")
except Exception as e:
logger.warning(f"Error processing recently added item: {e}")
continue
logger.info(f"Processed recently added content: {artist_count} artists → albums, {album_count} direct albums")
logger.info(f"Extracted {len(recent_albums)} albums from recently added content")
except Exception as e:
logger.warning(f"Could not get recently added albums: {e}")
# Fallback: get recently added tracks instead
try:
recent_tracks = self.plex_client.music_library.recentlyAdded(libtype='track', maxresults=1000)
logger.info(f"Fallback: Found {len(recent_tracks)} recently added tracks")
# Extract albums from tracks
recent_albums = []
seen_albums = set()
for track in recent_tracks:
try:
album = track.album()
if album and album.ratingKey not in seen_albums:
recent_albums.append(album)
seen_albums.add(album.ratingKey)
except:
continue
logger.info(f"Extracted {len(recent_albums)} unique albums from tracks")
except Exception as e2:
logger.error(f"Could not get recently added content: {e2}")
return []
# PERFORMANCE BREAKTHROUGH: For Jellyfin, use track-based incremental (much faster)
if self.server_type == "jellyfin":
return self._get_artists_for_jellyfin_track_incremental_update()
# Plex uses album-based approach (established and working)
recent_albums = self._get_recent_albums_for_server()
if not recent_albums:
logger.info("No recently added albums found")
return []
# Sort albums by added date (newest first)
# Sort albums by added date (newest first) - handle None dates properly
try:
recent_albums.sort(key=lambda x: getattr(x, 'addedAt', 0), reverse=True)
def get_sort_date(album):
date_val = getattr(album, 'addedAt', None)
if date_val is None:
return 0 # Fallback for albums with no date
return date_val
recent_albums.sort(key=get_sort_date, reverse=True)
logger.info("Sorted albums by recently added date (newest first)")
except Exception as e:
logger.warning(f"Could not sort albums by date: {e}")
@ -315,10 +303,18 @@ class DatabaseUpdateWorker(QThread):
for track in tracks:
total_tracks_checked += 1
try:
track_id = int(track.ratingKey)
# Handle both Plex (integer) and Jellyfin (string GUID) IDs
track_id = str(track.ratingKey)
track_title = getattr(track, 'title', 'Unknown Track')
if not self.database.track_exists(track_id):
# Use server-aware track existence check
if hasattr(self.database, 'track_exists_by_server'):
track_exists = self.database.track_exists_by_server(track_id, self.server_type)
else:
# Fallback to generic check (works for string IDs)
track_exists = self.database.track_exists(track_id)
if not track_exists:
missing_tracks_count += 1
album_has_new_tracks = True
logger.debug(f"📀 Track '{track_title}' is new - album needs processing")
@ -366,7 +362,8 @@ class DatabaseUpdateWorker(QThread):
try:
album_artist = album.artist()
if album_artist:
artist_id = int(album_artist.ratingKey)
# Handle both Plex (integer) and Jellyfin (string GUID) artist IDs
artist_id = str(album_artist.ratingKey)
# Skip if we've already queued this artist
if artist_id not in processed_artist_ids:
@ -398,16 +395,208 @@ class DatabaseUpdateWorker(QThread):
# Fallback to empty list - user can try full refresh
return []
def _check_for_metadata_changes(self, plex_tracks) -> bool:
def _get_artists_for_jellyfin_track_incremental_update(self) -> List:
"""FAST Jellyfin incremental update using recent tracks directly (no caching needed)"""
try:
logger.info("🚀 FAST Jellyfin incremental: getting recent tracks directly...")
# Get recent tracks directly from Jellyfin (FAST - 2 API calls)
recent_added_tracks = self.media_client.get_recently_added_tracks(5000)
recent_updated_tracks = self.media_client.get_recently_updated_tracks(5000)
# Combine and deduplicate
all_recent_tracks = recent_added_tracks[:]
added_ids = {track.ratingKey for track in recent_added_tracks}
unique_updated = [track for track in recent_updated_tracks if track.ratingKey not in added_ids]
all_recent_tracks.extend(unique_updated)
logger.info(f"Found {len(recent_added_tracks)} recent + {len(unique_updated)} updated = {len(all_recent_tracks)} tracks to check")
if not all_recent_tracks:
logger.info("No recent tracks found")
return []
# Check which tracks are actually new (FAST - database lookups only)
new_tracks = []
consecutive_existing_tracks = 0
processed_artists = set()
for i, track in enumerate(all_recent_tracks):
try:
track_id = str(track.ratingKey)
# Check if track exists in database
if hasattr(self.database, 'track_exists_by_server'):
track_exists = self.database.track_exists_by_server(track_id, self.server_type)
else:
track_exists = self.database.track_exists(track_id)
if not track_exists:
new_tracks.append(track)
consecutive_existing_tracks = 0 # Reset counter
logger.debug(f"🎵 New track: {track.title}")
else:
consecutive_existing_tracks += 1
logger.debug(f"✅ Track exists: {track.title}")
# Early stopping: if we find 100 consecutive existing tracks, we're done
if consecutive_existing_tracks >= 100:
logger.info(f"🛑 Found 100 consecutive existing tracks - stopping after checking {i+1} tracks")
break
except Exception as e:
logger.debug(f"Error checking track {getattr(track, 'title', 'Unknown')}: {e}")
continue
logger.info(f"Found {len(new_tracks)} genuinely new tracks (early stopped after {consecutive_existing_tracks} consecutive existing)")
if not new_tracks:
logger.info("All recent tracks already exist - database is up to date")
return []
# Store new tracks for direct processing (avoid slow artist->album->track lookups)
self._jellyfin_new_tracks = new_tracks
# Extract unique artists from new tracks (FAST - no additional API calls needed)
artists_to_process = []
for track in new_tracks:
try:
# Track already has artist info from the API call
track_artist = track.artist() # This will make an API call, but only for new tracks
if track_artist:
artist_id = str(track_artist.ratingKey)
if artist_id not in processed_artists:
processed_artists.add(artist_id)
artists_to_process.append(track_artist)
logger.info(f"✅ Added artist '{track_artist.title}' (from new track '{track.title}')")
except Exception as e:
logger.debug(f"Error getting artist for track {getattr(track, 'title', 'Unknown')}: {e}")
continue
logger.info(f"🚀 FAST incremental complete: {len(artists_to_process)} artists need processing (from {len(new_tracks)} new tracks)")
return artists_to_process
except Exception as e:
logger.error(f"Error in fast Jellyfin incremental update: {e}")
return []
def _process_jellyfin_new_tracks_directly(self, artists_to_process):
"""Process new Jellyfin tracks directly without slow artist->album->track lookups"""
try:
new_tracks = getattr(self, '_jellyfin_new_tracks', [])
if not new_tracks:
logger.warning("No new tracks to process directly")
return
logger.info(f"🚀 FAST PROCESSING: Directly processing {len(new_tracks)} new tracks...")
# Group tracks by album and artist for efficient processing
tracks_by_album = {}
albums_by_artist = {}
for track in new_tracks:
try:
# Track already has album and artist IDs from API response
album_id = str(track._album_id) if track._album_id else "unknown"
artist_id = str(track._artist_ids[0]) if track._artist_ids else "unknown"
if album_id not in tracks_by_album:
tracks_by_album[album_id] = []
tracks_by_album[album_id].append(track)
if artist_id not in albums_by_artist:
albums_by_artist[artist_id] = set()
albums_by_artist[artist_id].add(album_id)
except Exception as e:
logger.debug(f"Error grouping track {getattr(track, 'title', 'Unknown')}: {e}")
continue
total_processed_tracks = 0
total_processed_albums = 0
total_processed_artists = 0
# Process each artist
for artist in artists_to_process:
if self.should_stop:
break
try:
artist_id = str(artist.ratingKey)
artist_name = getattr(artist, 'title', 'Unknown Artist')
# Insert/update the artist
artist_success = self.database.insert_or_update_media_artist(artist, server_source=self.server_type)
if artist_success:
total_processed_artists += 1
# Process albums for this artist
artist_album_ids = albums_by_artist.get(artist_id, set())
for album_id in artist_album_ids:
if self.should_stop:
break
try:
# Get album from the first track (they all have the same album)
album_tracks = tracks_by_album[album_id]
if album_tracks:
album = album_tracks[0].album() # Get album object
if album:
# Insert/update album
album_success = self.database.insert_or_update_media_album(album, artist_id, server_source=self.server_type)
if album_success:
total_processed_albums += 1
# Process all tracks in this album
for track in album_tracks:
if self.should_stop:
break
try:
track_success = self.database.insert_or_update_media_track(track, album_id, artist_id, server_source=self.server_type)
if track_success:
total_processed_tracks += 1
logger.debug(f"✅ Processed new track: {track.title}")
except Exception as e:
logger.warning(f"Failed to process track '{getattr(track, 'title', 'Unknown')}': {e}")
except Exception as e:
logger.warning(f"Failed to process album {album_id}: {e}")
# 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)
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)
# Update totals
with self.thread_lock:
self.processed_artists += total_processed_artists
self.processed_albums += total_processed_albums
self.processed_tracks += total_processed_tracks
self.successful_operations += total_processed_artists # Count successful artists
logger.info(f"🚀 FAST PROCESSING COMPLETE: {total_processed_artists} artists, {total_processed_albums} albums, {total_processed_tracks} tracks")
# Clean up
delattr(self, '_jellyfin_new_tracks')
except Exception as e:
logger.error(f"Error in fast Jellyfin track processing: {e}")
def _check_for_metadata_changes(self, media_tracks) -> bool:
"""Check if any tracks in the list have metadata changes compared to database"""
try:
if not self.database or not plex_tracks:
if not self.database or not media_tracks:
return False
changes_detected = 0
for track in plex_tracks:
for track in media_tracks:
try:
track_id = int(track.ratingKey)
# Handle both Plex (integer) and Jellyfin (string GUID) IDs
track_id = str(track.ratingKey)
# Get current data from database
db_track = self.database.get_track_by_id(track_id)
@ -442,6 +631,102 @@ class DatabaseUpdateWorker(QThread):
logger.debug(f"Error checking for metadata changes: {e}")
return False # Assume no changes if we can't check
def _get_recent_albums_for_server(self) -> List:
"""Get recently added albums using server-specific methods"""
try:
if self.server_type == "plex":
return self._get_recent_albums_plex()
elif self.server_type == "jellyfin":
return self._get_recent_albums_jellyfin()
else:
logger.error(f"Unknown server type: {self.server_type}")
return []
except Exception as e:
logger.error(f"Error getting recent albums for {self.server_type}: {e}")
return []
def _get_recent_albums_plex(self) -> List:
"""Get recently added and updated albums from Plex"""
all_recent_content = []
try:
# Get recently added albums (up to 400 to catch more recent content)
try:
recently_added = self.media_client.music_library.recentlyAdded(libtype='album', maxresults=400)
all_recent_content.extend(recently_added)
logger.info(f"Found {len(recently_added)} recently added albums")
except:
# Fallback to general recently added
recently_added = self.media_client.music_library.recentlyAdded(maxresults=400)
all_recent_content.extend(recently_added)
logger.info(f"Found {len(recently_added)} recently added items (mixed types)")
# Get recently updated albums (catches metadata corrections)
try:
recently_updated = self.media_client.music_library.search(sort='updatedAt:desc', libtype='album', limit=400)
# Remove duplicates (items that are both recently added and updated)
added_keys = {getattr(item, 'ratingKey', None) for item in all_recent_content}
unique_updated = [item for item in recently_updated if getattr(item, 'ratingKey', None) not in added_keys]
all_recent_content.extend(unique_updated)
logger.info(f"Found {len(unique_updated)} additional recently updated albums (after deduplication)")
except Exception as e:
logger.warning(f"Could not get recently updated content: {e}")
# Filter to only get Album objects and convert Artist objects to their albums
recent_albums = []
artist_count = 0
album_count = 0
for item in all_recent_content:
try:
if hasattr(item, 'tracks') and hasattr(item, 'artist'):
# This is an Album - add directly
recent_albums.append(item)
album_count += 1
elif hasattr(item, 'albums'):
# This is an Artist - get their albums
try:
artist_albums = list(item.albums())
if artist_albums:
recent_albums.extend(artist_albums)
artist_count += 1
except Exception as albums_error:
logger.warning(f"Error getting albums from artist '{getattr(item, 'title', 'Unknown')}': {albums_error}")
except Exception as e:
logger.warning(f"Error processing recently added item: {e}")
continue
logger.info(f"Processed {artist_count} artists → albums, {album_count} direct albums")
return recent_albums
except Exception as e:
logger.error(f"Error getting recent Plex albums: {e}")
return []
def _get_recent_albums_jellyfin(self) -> List:
"""Get recently added and updated albums from Jellyfin"""
try:
all_recent_albums = []
# Get recently added albums
recently_added = self.media_client.get_recently_added_albums(400)
all_recent_albums.extend(recently_added)
logger.info(f"Found {len(recently_added)} recently added albums")
# Get recently updated albums
recently_updated = self.media_client.get_recently_updated_albums(400)
# Remove duplicates
added_ids = {album.ratingKey for album in all_recent_albums}
unique_updated = [album for album in recently_updated if album.ratingKey not in added_ids]
all_recent_albums.extend(unique_updated)
logger.info(f"Found {len(unique_updated)} additional recently updated albums (after deduplication)")
return all_recent_albums
except Exception as e:
logger.error(f"Error getting recent Jellyfin albums: {e}")
return []
def _process_all_artists(self, artists: List):
"""Process all artists and their albums/tracks using thread pool"""
total_artists = len(artists)
@ -505,21 +790,21 @@ class DatabaseUpdateWorker(QThread):
# Emit progress signal
self.artist_processed.emit(artist_name, success, details, album_count, track_count)
def _process_artist_with_content(self, plex_artist) -> tuple[bool, str, int, int]:
"""Process an artist and all their albums and tracks"""
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"""
try:
artist_name = getattr(plex_artist, 'title', 'Unknown Artist')
artist_name = getattr(media_artist, 'title', 'Unknown Artist')
# 1. Insert/update the artist
artist_success = self.database.insert_or_update_artist(plex_artist)
# 1. Insert/update the artist using server-agnostic method
artist_success = self.database.insert_or_update_media_artist(media_artist, server_source=self.server_type)
if not artist_success:
return False, "Failed to update artist data", 0, 0
artist_id = int(plex_artist.ratingKey)
artist_id = str(media_artist.ratingKey)
# 2. Get all albums for this artist
# 2. Get all albums for this artist (cached from aggressive pre-population)
try:
albums = list(plex_artist.albums())
albums = list(media_artist.albums())
except Exception as e:
logger.warning(f"Could not get albums for artist '{artist_name}': {e}")
return True, "Artist updated (no albums accessible)", 0, 0
@ -527,44 +812,56 @@ class DatabaseUpdateWorker(QThread):
album_count = 0
track_count = 0
# 3. Process each album
for album in albums:
# 3. Process albums in smaller batches to reduce memory usage
batch_size = 10 # Process 10 albums at a time
for i in range(0, len(albums), batch_size):
if self.should_stop:
break
try:
# Insert/update album
album_success = self.database.insert_or_update_album(album, artist_id)
if album_success:
album_count += 1
album_id = int(album.ratingKey)
# 4. Process tracks in this album
try:
tracks = list(album.tracks())
for track in tracks:
if self.should_stop:
break
try:
track_success = self.database.insert_or_update_track(track, album_id, artist_id)
if track_success:
track_count += 1
except Exception as e:
logger.warning(f"Failed to process track '{getattr(track, 'title', 'Unknown')}': {e}")
except Exception as e:
logger.warning(f"Could not get tracks for album '{getattr(album, 'title', 'Unknown')}': {e}")
except Exception as e:
logger.warning(f"Failed to process album '{getattr(album, 'title', 'Unknown')}': {e}")
album_batch = albums[i:i + batch_size]
for album in album_batch:
if self.should_stop:
break
try:
# Insert/update album using server-agnostic method
album_success = self.database.insert_or_update_media_album(album, artist_id, server_source=self.server_type)
if album_success:
album_count += 1
album_id = str(album.ratingKey)
# 4. Process tracks in this album (cached from aggressive pre-population)
try:
tracks = list(album.tracks())
# Batch insert tracks for better database performance
track_batch = []
for track in tracks:
if self.should_stop:
break
track_batch.append((track, album_id, artist_id))
# Process track batch
for track, alb_id, art_id in track_batch:
try:
track_success = self.database.insert_or_update_media_track(track, alb_id, art_id, server_source=self.server_type)
if track_success:
track_count += 1
except Exception as e:
logger.warning(f"Failed to process track '{getattr(track, 'title', 'Unknown')}': {e}")
except Exception as e:
logger.warning(f"Could not get tracks for album '{getattr(album, 'title', 'Unknown')}': {e}")
except Exception as e:
logger.warning(f"Failed to process album '{getattr(album, 'title', 'Unknown')}': {e}")
details = f"Updated with {album_count} albums, {track_count} tracks"
return True, details, album_count, track_count
except Exception as e:
logger.error(f"Error processing artist '{getattr(plex_artist, 'title', 'Unknown')}': {e}")
logger.error(f"Error processing artist '{getattr(media_artist, 'title', 'Unknown')}': {e}")
return False, f"Processing error: {str(e)}", 0, 0
class DatabaseStatsWorker(QThread):
@ -591,18 +888,23 @@ class DatabaseStatsWorker(QThread):
if self.should_stop:
return
# Get full database info (includes last_full_refresh)
info = database.get_database_info()
# 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)
except Exception as e:
logger.error(f"Error getting database stats: {e}")
if not self.should_stop:
# Import here to avoid circular imports
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
self.stats_updated.emit({
'artists': 0,
'albums': 0,
'tracks': 0,
'database_size_mb': 0.0,
'last_update': None,
'last_full_refresh': None
'last_full_refresh': None,
'server_source': active_server
})

1353
core/jellyfin_client.py Normal file

File diff suppressed because it is too large Load diff

346
core/media_scan_manager.py Normal file
View file

@ -0,0 +1,346 @@
#!/usr/bin/env python3
import threading
import time
from utils.logging_config import get_logger
logger = get_logger("media_scan_manager")
class MediaScanManager:
"""
Smart media library scan manager with debouncing and scan-aware follow-up logic.
Supports both Plex and Jellyfin servers based on active configuration.
Features:
- Debounces multiple scan requests to prevent spam
- Tracks downloads that happen during active scans
- Automatically triggers follow-up scans when needed
- Thread-safe operation
- Works with both Plex and Jellyfin
"""
def __init__(self, delay_seconds: int = 60):
"""
Initialize the scan manager.
Args:
delay_seconds: Debounce delay in seconds (default 60s)
"""
self.delay = delay_seconds
self._timer = None
self._scan_in_progress = False
self._downloads_during_scan = False
self._lock = threading.Lock()
self._scan_completion_callbacks = [] # List of callback functions to call when scan completes
self._scan_start_time = None # Track when scan started for timeout
self._max_scan_time = 1800 # Maximum scan time in seconds (30 minutes)
# New periodic update system
self._periodic_update_timer = None # Timer for 5-minute periodic updates
self._periodic_update_interval = 300 # 5 minutes in seconds
self._is_doing_periodic_updates = False # Track if we're in periodic update mode
logger.info(f"MediaScanManager initialized with {delay_seconds}s debounce delay")
def _get_active_media_client(self):
"""Get the active media client based on config settings"""
try:
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
# 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"
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")
except Exception as e:
logger.debug(f"Could not access clients from main window: {e}")
logger.error("No active media client available")
return None, None
except Exception as e:
logger.error(f"Error determining active media server: {e}")
return None, None
def request_scan(self, reason: str = "Download completed"):
"""
Request a library scan with smart debouncing logic.
Args:
reason: Optional reason for the scan request (for logging)
"""
logger.info(f"DEBUG: Media scan requested - reason: {reason}")
with self._lock:
if self._scan_in_progress:
# Server is currently scanning - mark that we need another scan later
self._downloads_during_scan = True
logger.info(f"📡 Media scan in progress - queueing follow-up scan ({reason})")
return
# Cancel any existing timer and start a new one
if self._timer:
self._timer.cancel()
logger.debug(f"⏳ Resetting scan timer ({reason})")
else:
logger.info(f"⏳ Media scan queued - will execute in {self.delay}s ({reason})")
# Start the debounce timer
self._timer = threading.Timer(self.delay, self._execute_scan)
self._timer.start()
def add_scan_completion_callback(self, callback):
"""
Add a callback function to be called when scan completes.
Args:
callback: Function to call when scan completes (no arguments)
"""
with self._lock:
if callback not in self._scan_completion_callbacks:
self._scan_completion_callbacks.append(callback)
logger.info(f"DEBUG: Added scan completion callback: {callback.__name__}")
logger.info(f"DEBUG: Total callbacks registered: {len(self._scan_completion_callbacks)}")
def remove_scan_completion_callback(self, callback):
"""
Remove a previously registered callback.
Args:
callback: Function to remove from callbacks
"""
with self._lock:
if callback in self._scan_completion_callbacks:
self._scan_completion_callbacks.remove(callback)
logger.debug(f"Removed scan completion callback: {callback.__name__}")
def _execute_scan(self):
"""Execute the actual media library scan"""
with self._lock:
if self._scan_in_progress:
logger.warning("Scan already in progress - skipping duplicate execution")
return
self._scan_in_progress = True
self._downloads_during_scan = False
self._timer = None
self._scan_start_time = time.time()
# Get the active media client
media_client, server_type = self._get_active_media_client()
if not media_client:
logger.error("❌ No active media client available for library scan")
self._reset_scan_state()
return
logger.info(f"🎵 Starting {server_type.upper()} library scan...")
try:
success = media_client.trigger_library_scan()
if success:
logger.info(f"{server_type.upper()} library scan initiated successfully")
# Start new periodic update system instead of completion detection
self._start_periodic_updates()
else:
logger.error(f"❌ Failed to initiate {server_type.upper()} library scan")
self._reset_scan_state()
except Exception as e:
logger.error(f"Exception during {server_type.upper()} library scan: {e}")
self._reset_scan_state()
def _start_periodic_updates(self):
"""Start periodic database updates while media server is scanning"""
try:
with self._lock:
if self._is_doing_periodic_updates:
logger.debug("Periodic updates already in progress")
return
self._is_doing_periodic_updates = True
logger.info(f"🕒 Starting periodic database updates - will check/update every {self._periodic_update_interval//60} minutes")
# Schedule first periodic update after 5 minutes
self._periodic_update_timer = threading.Timer(self._periodic_update_interval, self._do_periodic_update)
self._periodic_update_timer.start()
except Exception as e:
logger.error(f"Error starting periodic updates: {e}")
self._reset_scan_state()
def _do_periodic_update(self):
"""Execute periodic database update and check if scanning continues"""
try:
with self._lock:
if not self._scan_in_progress:
logger.debug("Scan no longer in progress, stopping periodic updates")
return
# Check for timeout
if self._scan_start_time and (time.time() - self._scan_start_time) > self._max_scan_time:
logger.warning(f"Media scan timeout reached ({self._max_scan_time}s), stopping periodic updates")
self._stop_periodic_updates()
return
# Get the active media client
media_client, server_type = self._get_active_media_client()
if not media_client:
logger.warning("No active media client available for scan status check")
self._stop_periodic_updates()
return
# Check if media server is still scanning
is_scanning = media_client.is_library_scanning("Music")
elapsed_time = time.time() - self._scan_start_time if self._scan_start_time else 0
logger.info(f"🕒 PERIODIC UPDATE: After {elapsed_time//60:.0f} minutes - {server_type.upper()} scanning: {is_scanning}")
if is_scanning:
# Still scanning - trigger database update and continue periodic updates
logger.info(f"🔄 {server_type.upper()} still scanning - triggering database update")
self._call_completion_callbacks()
# Schedule next periodic update
logger.info(f"🕒 Scheduling next periodic update in {self._periodic_update_interval//60} minutes")
self._periodic_update_timer = threading.Timer(self._periodic_update_interval, self._do_periodic_update)
self._periodic_update_timer.start()
else:
# Scanning stopped - final update and cleanup
logger.info(f"{server_type.upper()} scanning completed - doing final database update")
self._call_completion_callbacks()
self._stop_periodic_updates()
except Exception as e:
logger.error(f"Error during periodic update: {e}")
self._stop_periodic_updates()
def _stop_periodic_updates(self):
"""Stop periodic updates and clean up"""
try:
with self._lock:
self._is_doing_periodic_updates = False
if self._periodic_update_timer:
self._periodic_update_timer.cancel()
self._periodic_update_timer = None
logger.info("🕒 Stopped periodic database updates")
self._scan_completed()
except Exception as e:
logger.error(f"Error stopping periodic updates: {e}")
def _scan_completed(self):
"""Called when we assume the scan has completed"""
with self._lock:
was_in_progress = self._scan_in_progress
downloads_during_scan = self._downloads_during_scan
# Reset scan state
self._scan_in_progress = False
if not was_in_progress:
logger.debug("Scan completion callback called but scan was not in progress")
return
logger.info("📡 Media library scan completed")
# Call registered completion callbacks
self._call_completion_callbacks()
# Check if we need a follow-up scan
if downloads_during_scan:
logger.info("🔄 Downloads occurred during scan - triggering follow-up scan")
self.request_scan("Follow-up scan for downloads during previous scan")
else:
logger.info("✅ No downloads during scan - scan cycle complete")
def _call_completion_callbacks(self):
"""Call all registered scan completion callbacks"""
with self._lock:
callbacks = self._scan_completion_callbacks.copy() # Copy to avoid lock issues
logger.info(f"DEBUG: Calling {len(callbacks)} scan completion callbacks")
for callback in callbacks:
try:
logger.info(f"DEBUG: Executing callback: {callback.__name__}")
callback()
logger.info(f"DEBUG: Callback {callback.__name__} completed successfully")
except Exception as e:
logger.error(f"Error in scan completion callback {callback.__name__}: {e}")
def _reset_scan_state(self):
"""Reset scan state after an error"""
with self._lock:
self._scan_in_progress = False
self._scan_start_time = None
# Cancel periodic updates if running
if self._periodic_update_timer:
self._periodic_update_timer.cancel()
self._periodic_update_timer = None
self._is_doing_periodic_updates = False
def force_scan(self):
"""
Force an immediate scan, bypassing debouncing.
Use sparingly - mainly for manual/administrative triggers.
"""
with self._lock:
if self._timer:
self._timer.cancel()
self._timer = None
if self._scan_in_progress:
logger.warning("Force scan requested but scan already in progress")
return
logger.info("🚀 Force scan requested - executing immediately")
self._execute_scan()
def get_status(self) -> dict:
"""Get current status of the scan manager"""
with self._lock:
return {
'scan_in_progress': self._scan_in_progress,
'downloads_during_scan': self._downloads_during_scan,
'timer_active': self._timer is not None,
'delay_seconds': self.delay
}
def shutdown(self):
"""Clean shutdown - cancel any pending timers"""
with self._lock:
if self._timer:
self._timer.cancel()
self._timer = None
if self._periodic_update_timer:
self._periodic_update_timer.cancel()
self._periodic_update_timer = None
self._is_doing_periodic_updates = False
logger.info("MediaScanManager shutdown - cancelled all pending timers")

View file

@ -362,8 +362,10 @@ class WatchlistScanner:
for artist_name in artists_to_search:
for query_title in unique_title_variations:
# Use same database check as modals
db_track, confidence = self.database.check_track_exists(query_title, artist_name, confidence_threshold=0.7)
# Use same database check as modals with server awareness
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
db_track, confidence = self.database.check_track_exists(query_title, artist_name, confidence_threshold=0.7, server_source=active_server)
if db_track and confidence >= 0.7:
logger.debug(f"✔️ Track found in library: '{original_title}' by '{artist_name}' (confidence: {confidence:.2f})")

View file

@ -6,6 +6,7 @@ import logging
import os
import re
import threading
import time
from datetime import datetime
from typing import List, Optional, Dict, Any, Tuple
from dataclasses import dataclass
@ -207,6 +208,12 @@ class MusicDatabase:
cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_title ON albums (title)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_title ON tracks (title)")
# Add server_source columns for multi-server support (migration)
self._add_server_source_columns(cursor)
# Migrate ID columns to support both integer (Plex) and string (Jellyfin) IDs
self._migrate_id_columns_to_text(cursor)
conn.commit()
logger.info("Database initialized successfully")
@ -214,13 +221,168 @@ class MusicDatabase:
logger.error(f"Error initializing database: {e}")
raise
def _add_server_source_columns(self, cursor):
"""Add server_source columns to existing tables for multi-server support"""
try:
# Check if server_source column exists in artists table
cursor.execute("PRAGMA table_info(artists)")
artists_columns = [column[1] for column in cursor.fetchall()]
if 'server_source' not in artists_columns:
cursor.execute("ALTER TABLE artists ADD COLUMN server_source TEXT DEFAULT 'plex'")
logger.info("Added server_source column to artists table")
# Check if server_source column exists in albums table
cursor.execute("PRAGMA table_info(albums)")
albums_columns = [column[1] for column in cursor.fetchall()]
if 'server_source' not in albums_columns:
cursor.execute("ALTER TABLE albums ADD COLUMN server_source TEXT DEFAULT 'plex'")
logger.info("Added server_source column to albums table")
# Check if server_source column exists in tracks table
cursor.execute("PRAGMA table_info(tracks)")
tracks_columns = [column[1] for column in cursor.fetchall()]
if 'server_source' not in tracks_columns:
cursor.execute("ALTER TABLE tracks ADD COLUMN server_source TEXT DEFAULT 'plex'")
logger.info("Added server_source column to tracks table")
# Create indexes for server_source columns for performance
cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_server_source ON artists (server_source)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_server_source ON albums (server_source)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_server_source ON tracks (server_source)")
except Exception as e:
logger.error(f"Error adding server_source columns: {e}")
# Don't raise - this is a migration, database can still function without it
def _migrate_id_columns_to_text(self, cursor):
"""Migrate ID columns from INTEGER to TEXT to support both Plex (int) and Jellyfin (GUID) IDs"""
try:
# Check if migration has already been applied by looking for a specific marker
cursor.execute("SELECT value FROM metadata WHERE key = 'id_columns_migrated' LIMIT 1")
migration_done = cursor.fetchone()
if migration_done:
logger.debug("ID columns migration already applied")
return
logger.info("Migrating ID columns to support both integer and string IDs...")
# SQLite doesn't support changing column types directly, so we need to recreate tables
# This is a complex migration - let's do it safely
# Step 1: Create new tables with TEXT IDs
cursor.execute("""
CREATE TABLE IF NOT EXISTS artists_new (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
thumb_url TEXT,
genres TEXT,
summary TEXT,
server_source TEXT DEFAULT 'plex',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS albums_new (
id TEXT PRIMARY KEY,
artist_id TEXT NOT NULL,
title TEXT NOT NULL,
year INTEGER,
thumb_url TEXT,
genres TEXT,
track_count INTEGER,
duration INTEGER,
server_source TEXT DEFAULT 'plex',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (artist_id) REFERENCES artists_new (id) ON DELETE CASCADE
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS tracks_new (
id TEXT PRIMARY KEY,
album_id TEXT NOT NULL,
artist_id TEXT NOT NULL,
title TEXT NOT NULL,
track_number INTEGER,
duration INTEGER,
file_path TEXT,
bitrate INTEGER,
server_source TEXT DEFAULT 'plex',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (album_id) REFERENCES albums_new (id) ON DELETE CASCADE,
FOREIGN KEY (artist_id) REFERENCES artists_new (id) ON DELETE CASCADE
)
""")
# Step 2: Copy existing data (converting INTEGER IDs to TEXT)
cursor.execute("""
INSERT INTO artists_new (id, name, thumb_url, genres, summary, server_source, created_at, updated_at)
SELECT CAST(id AS TEXT), name, thumb_url, genres, summary,
COALESCE(server_source, 'plex'), created_at, updated_at
FROM artists
""")
cursor.execute("""
INSERT INTO albums_new (id, artist_id, title, year, thumb_url, genres, track_count, duration, server_source, created_at, updated_at)
SELECT CAST(id AS TEXT), CAST(artist_id AS TEXT), title, year, thumb_url, genres, track_count, duration,
COALESCE(server_source, 'plex'), created_at, updated_at
FROM albums
""")
cursor.execute("""
INSERT INTO tracks_new (id, album_id, artist_id, title, track_number, duration, file_path, bitrate, server_source, created_at, updated_at)
SELECT CAST(id AS TEXT), CAST(album_id AS TEXT), CAST(artist_id AS TEXT), title, track_number, duration, file_path, bitrate,
COALESCE(server_source, 'plex'), created_at, updated_at
FROM tracks
""")
# Step 3: Drop old tables and rename new ones
cursor.execute("DROP TABLE IF EXISTS tracks")
cursor.execute("DROP TABLE IF EXISTS albums")
cursor.execute("DROP TABLE IF EXISTS artists")
cursor.execute("ALTER TABLE artists_new RENAME TO artists")
cursor.execute("ALTER TABLE albums_new RENAME TO albums")
cursor.execute("ALTER TABLE tracks_new RENAME TO tracks")
# Step 4: Recreate indexes
cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_artist_id ON albums (artist_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_album_id ON tracks (album_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_artist_id ON tracks (artist_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_server_source ON artists (server_source)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_server_source ON albums (server_source)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_server_source ON tracks (server_source)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_name ON artists (name)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_albums_title ON albums (title)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_title ON tracks (title)")
# Step 5: Mark migration as complete
cursor.execute("""
INSERT OR REPLACE INTO metadata (key, value, updated_at)
VALUES ('id_columns_migrated', 'true', CURRENT_TIMESTAMP)
""")
logger.info("ID columns migration completed successfully")
except Exception as e:
logger.error(f"Error migrating ID columns: {e}")
# Don't raise - this is a migration, database can still function
def close(self):
"""Close database connection (no-op since we create connections per operation)"""
# Each operation creates and closes its own connection, so nothing to do here
pass
def get_statistics(self) -> Dict[str, int]:
"""Get database statistics"""
"""Get database statistics for all servers (legacy method)"""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
@ -243,8 +405,44 @@ class MusicDatabase:
logger.error(f"Error getting database statistics: {e}")
return {'artists': 0, 'albums': 0, 'tracks': 0}
def get_statistics_for_server(self, server_source: str = None) -> Dict[str, int]:
"""Get database statistics filtered by server source"""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
if server_source:
# Get counts for specific server
cursor.execute("SELECT COUNT(*) FROM artists WHERE server_source = ?", (server_source,))
artist_count = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM albums WHERE server_source = ?", (server_source,))
album_count = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM tracks WHERE server_source = ?", (server_source,))
track_count = cursor.fetchone()[0]
else:
# Get total counts (all servers)
cursor.execute("SELECT COUNT(*) FROM artists")
artist_count = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM albums")
album_count = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM tracks")
track_count = cursor.fetchone()[0]
return {
'artists': artist_count,
'albums': album_count,
'tracks': track_count
}
except Exception as e:
logger.error(f"Error getting database statistics for {server_source}: {e}")
return {'artists': 0, 'albums': 0, 'tracks': 0}
def clear_all_data(self):
"""Clear all data from database (for full refresh)"""
"""Clear all data from database (for full refresh) - DEPRECATED: Use clear_server_data instead"""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
@ -265,6 +463,38 @@ class MusicDatabase:
logger.error(f"Error clearing database: {e}")
raise
def clear_server_data(self, server_source: str):
"""Clear data for specific server only (server-aware full refresh)"""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
# Delete only data from the specified server
# Order matters: tracks -> albums -> artists (foreign key constraints)
cursor.execute("DELETE FROM tracks WHERE server_source = ?", (server_source,))
tracks_deleted = cursor.rowcount
cursor.execute("DELETE FROM albums WHERE server_source = ?", (server_source,))
albums_deleted = cursor.rowcount
cursor.execute("DELETE FROM artists WHERE server_source = ?", (server_source,))
artists_deleted = cursor.rowcount
conn.commit()
# Only VACUUM if we deleted a significant amount of data
if tracks_deleted > 1000 or albums_deleted > 100:
logger.info("Vacuuming database to reclaim disk space...")
cursor.execute("VACUUM")
logger.info(f"Cleared {server_source} data: {artists_deleted} artists, {albums_deleted} albums, {tracks_deleted} tracks")
# Note: Watchlist and wishlist are preserved as they are server-agnostic
except Exception as e:
logger.error(f"Error clearing {server_source} database data: {e}")
raise
def cleanup_orphaned_records(self) -> Dict[str, int]:
"""Remove artists and albums that have no associated tracks"""
try:
@ -314,26 +544,50 @@ class MusicDatabase:
# Artist operations
def insert_or_update_artist(self, plex_artist) -> bool:
"""Insert or update artist from Plex artist object"""
"""Insert or update artist from Plex artist object - DEPRECATED: Use insert_or_update_media_artist instead"""
return self.insert_or_update_media_artist(plex_artist, server_source='plex')
def insert_or_update_media_artist(self, artist_obj, server_source: str = 'plex') -> bool:
"""Insert or update artist from media server artist object (Plex or Jellyfin)"""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
artist_id = int(plex_artist.ratingKey)
name = plex_artist.title
thumb_url = getattr(plex_artist, 'thumb', None)
summary = getattr(plex_artist, 'summary', None)
# Convert artist ID to string (handles both Plex integer IDs and Jellyfin GUIDs)
artist_id = str(artist_obj.ratingKey)
name = artist_obj.title
thumb_url = getattr(artist_obj, 'thumb', None)
# Get genres
# Only preserve timestamps and flags from summary, not full biography
full_summary = getattr(artist_obj, 'summary', None) or ''
summary = None
if full_summary:
# Extract only our tracking markers (timestamps and ignore flags)
import re
markers = []
# Extract timestamp marker
timestamp_match = re.search(r'-updatedAt\d{4}-\d{2}-\d{2}', full_summary)
if timestamp_match:
markers.append(timestamp_match.group(0))
# Extract ignore flag
if '-IgnoreUpdate' in full_summary:
markers.append('-IgnoreUpdate')
# Only store markers, not full biography
summary = '\n\n'.join(markers) if markers else None
# Get genres (handle both Plex and Jellyfin formats)
genres = []
if hasattr(plex_artist, 'genres') and plex_artist.genres:
if hasattr(artist_obj, 'genres') and artist_obj.genres:
genres = [genre.tag if hasattr(genre, 'tag') else str(genre)
for genre in plex_artist.genres]
for genre in artist_obj.genres]
genres_json = json.dumps(genres) if genres else None
# Check if artist exists
cursor.execute("SELECT id FROM artists WHERE id = ?", (artist_id,))
# Check if artist exists with this ID and server source
cursor.execute("SELECT id FROM artists WHERE id = ? AND server_source = ?", (artist_id, server_source))
exists = cursor.fetchone()
if exists:
@ -341,20 +595,20 @@ class MusicDatabase:
cursor.execute("""
UPDATE artists
SET name = ?, thumb_url = ?, genres = ?, summary = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (name, thumb_url, genres_json, summary, artist_id))
WHERE id = ? AND server_source = ?
""", (name, thumb_url, genres_json, summary, artist_id, server_source))
else:
# Insert new artist
cursor.execute("""
INSERT INTO artists (id, name, thumb_url, genres, summary)
VALUES (?, ?, ?, ?, ?)
""", (artist_id, name, thumb_url, genres_json, summary))
INSERT INTO artists (id, name, thumb_url, genres, summary, server_source)
VALUES (?, ?, ?, ?, ?, ?)
""", (artist_id, name, thumb_url, genres_json, summary, server_source))
conn.commit()
return True
except Exception as e:
logger.error(f"Error inserting/updating artist {getattr(plex_artist, 'title', 'Unknown')}: {e}")
logger.error(f"Error inserting/updating {server_source} artist {getattr(artist_obj, 'title', 'Unknown')}: {e}")
return False
def get_artist(self, artist_id: int) -> Optional[DatabaseArtist]:
@ -385,30 +639,35 @@ class MusicDatabase:
# Album operations
def insert_or_update_album(self, plex_album, artist_id: int) -> bool:
"""Insert or update album from Plex album object"""
"""Insert or update album from Plex album object - DEPRECATED: Use insert_or_update_media_album instead"""
return self.insert_or_update_media_album(plex_album, artist_id, server_source='plex')
def insert_or_update_media_album(self, album_obj, artist_id: str, server_source: str = 'plex') -> bool:
"""Insert or update album from media server album object (Plex or Jellyfin)"""
try:
conn = self._get_connection()
cursor = conn.cursor()
album_id = int(plex_album.ratingKey)
title = plex_album.title
year = getattr(plex_album, 'year', None)
thumb_url = getattr(plex_album, 'thumb', None)
# Convert album ID to string (handles both Plex integer IDs and Jellyfin GUIDs)
album_id = str(album_obj.ratingKey)
title = album_obj.title
year = getattr(album_obj, 'year', None)
thumb_url = getattr(album_obj, 'thumb', None)
# Get track count and duration
track_count = getattr(plex_album, 'leafCount', None)
duration = getattr(plex_album, 'duration', None)
# Get track count and duration (handle different server attributes)
track_count = getattr(album_obj, 'leafCount', None) or getattr(album_obj, 'childCount', None)
duration = getattr(album_obj, 'duration', None)
# Get genres
# Get genres (handle both Plex and Jellyfin formats)
genres = []
if hasattr(plex_album, 'genres') and plex_album.genres:
if hasattr(album_obj, 'genres') and album_obj.genres:
genres = [genre.tag if hasattr(genre, 'tag') else str(genre)
for genre in plex_album.genres]
for genre in album_obj.genres]
genres_json = json.dumps(genres) if genres else None
# Check if album exists
cursor.execute("SELECT id FROM albums WHERE id = ?", (album_id,))
# Check if album exists with this ID and server source
cursor.execute("SELECT id FROM albums WHERE id = ? AND server_source = ?", (album_id, server_source))
exists = cursor.fetchone()
if exists:
@ -417,20 +676,20 @@ class MusicDatabase:
UPDATE albums
SET artist_id = ?, title = ?, year = ?, thumb_url = ?, genres = ?,
track_count = ?, duration = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (artist_id, title, year, thumb_url, genres_json, track_count, duration, album_id))
WHERE id = ? AND server_source = ?
""", (artist_id, title, year, thumb_url, genres_json, track_count, duration, album_id, server_source))
else:
# Insert new album
cursor.execute("""
INSERT INTO albums (id, artist_id, title, year, thumb_url, genres, track_count, duration)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (album_id, artist_id, title, year, thumb_url, genres_json, track_count, duration))
INSERT INTO albums (id, artist_id, title, year, thumb_url, genres, track_count, duration, server_source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (album_id, artist_id, title, year, thumb_url, genres_json, track_count, duration, server_source))
conn.commit()
return True
except Exception as e:
logger.error(f"Error inserting/updating album {getattr(plex_album, 'title', 'Unknown')}: {e}")
logger.error(f"Error inserting/updating {server_source} album {getattr(album_obj, 'title', 'Unknown')}: {e}")
return False
def get_albums_by_artist(self, artist_id: int) -> List[DatabaseAlbum]:
@ -466,60 +725,70 @@ class MusicDatabase:
# Track operations
def insert_or_update_track(self, plex_track, album_id: int, artist_id: int) -> bool:
"""Insert or update track from Plex track object"""
try:
conn = self._get_connection()
cursor = conn.cursor()
track_id = int(plex_track.ratingKey)
title = plex_track.title
track_number = getattr(plex_track, 'trackNumber', None)
duration = getattr(plex_track, 'duration', None)
# Get file path and media info
file_path = None
bitrate = None
if hasattr(plex_track, 'media') and plex_track.media:
media = plex_track.media[0] if plex_track.media else None
if media:
if hasattr(media, 'parts') and media.parts:
part = media.parts[0]
file_path = getattr(part, 'file', None)
bitrate = getattr(media, 'bitrate', None)
# Check if track exists
cursor.execute("SELECT id FROM tracks WHERE id = ?", (track_id,))
exists = cursor.fetchone()
if exists:
# Update existing track
cursor.execute("""
UPDATE tracks
SET album_id = ?, artist_id = ?, title = ?, track_number = ?,
duration = ?, file_path = ?, bitrate = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (album_id, artist_id, title, track_number, duration, file_path, bitrate, track_id))
else:
# Insert new track
cursor.execute("""
INSERT INTO tracks (id, album_id, artist_id, title, track_number, duration, file_path, bitrate)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (track_id, album_id, artist_id, title, track_number, duration, file_path, bitrate))
conn.commit()
return True
except Exception as e:
logger.error(f"Error inserting/updating track {getattr(plex_track, 'title', 'Unknown')}: {e}")
return False
"""Insert or update track from Plex track object - DEPRECATED: Use insert_or_update_media_track instead"""
return self.insert_or_update_media_track(plex_track, album_id, artist_id, server_source='plex')
def track_exists(self, track_id: int) -> bool:
"""Check if a track exists in the database by Plex ID"""
def insert_or_update_media_track(self, track_obj, album_id: str, artist_id: str, server_source: str = 'plex') -> bool:
"""Insert or update track from media server track object (Plex or Jellyfin) with retry logic"""
max_retries = 3
retry_count = 0
while retry_count < max_retries:
try:
conn = self._get_connection()
cursor = conn.cursor()
# Set shorter timeout to prevent long locks
cursor.execute("PRAGMA busy_timeout = 10000") # 10 second timeout
# Convert track ID to string (handles both Plex integer IDs and Jellyfin GUIDs)
track_id = str(track_obj.ratingKey)
title = track_obj.title
track_number = getattr(track_obj, 'trackNumber', None)
duration = getattr(track_obj, 'duration', None)
# Get file path and media info (Plex-specific, Jellyfin may not have these)
file_path = None
bitrate = None
if hasattr(track_obj, 'media') and track_obj.media:
media = track_obj.media[0] if track_obj.media else None
if media:
if hasattr(media, 'parts') and media.parts:
part = media.parts[0]
file_path = getattr(part, 'file', None)
bitrate = getattr(media, 'bitrate', None)
# Use INSERT OR REPLACE to handle duplicate IDs gracefully
cursor.execute("""
INSERT OR REPLACE INTO tracks
(id, album_id, artist_id, title, track_number, duration, file_path, bitrate, server_source, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
""", (track_id, album_id, artist_id, title, track_number, duration, file_path, bitrate, server_source))
conn.commit()
return True
except Exception as e:
retry_count += 1
if "database is locked" in str(e).lower() and retry_count < max_retries:
logger.warning(f"Database locked on track '{getattr(track_obj, 'title', 'Unknown')}', retrying {retry_count}/{max_retries}...")
time.sleep(0.1 * retry_count) # Exponential backoff
continue
else:
logger.error(f"Error inserting/updating {server_source} track {getattr(track_obj, 'title', 'Unknown')}: {e}")
return False
return False
def track_exists(self, track_id) -> bool:
"""Check if a track exists in the database by ID (supports both int and string IDs)"""
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("SELECT 1 FROM tracks WHERE id = ? LIMIT 1", (track_id,))
# Convert to string to handle both Plex integers and Jellyfin GUIDs
track_id_str = str(track_id)
cursor.execute("SELECT 1 FROM tracks WHERE id = ? LIMIT 1", (track_id_str,))
result = cursor.fetchone()
return result is not None
@ -528,12 +797,31 @@ class MusicDatabase:
logger.error(f"Error checking if track {track_id} exists: {e}")
return False
def get_track_by_id(self, track_id: int) -> Optional[DatabaseTrackWithMetadata]:
"""Get a track with artist and album names by Plex ID"""
def track_exists_by_server(self, track_id, server_source: str) -> bool:
"""Check if a track exists in the database by ID and server source"""
try:
conn = self._get_connection()
cursor = conn.cursor()
# Convert to string to handle both Plex integers and Jellyfin GUIDs
track_id_str = str(track_id)
cursor.execute("SELECT 1 FROM tracks WHERE id = ? AND server_source = ? LIMIT 1", (track_id_str, server_source))
result = cursor.fetchone()
return result is not None
except Exception as e:
logger.error(f"Error checking if track {track_id} exists for server {server_source}: {e}")
return False
def get_track_by_id(self, track_id) -> Optional[DatabaseTrackWithMetadata]:
"""Get a track with artist and album names by ID (supports both int and string IDs)"""
try:
conn = self._get_connection()
cursor = conn.cursor()
# Convert to string to handle both Plex integers and Jellyfin GUIDs
track_id_str = str(track_id)
cursor.execute("""
SELECT t.id, t.album_id, t.artist_id, t.title, t.track_number,
t.duration, t.created_at, t.updated_at,
@ -542,7 +830,7 @@ class MusicDatabase:
JOIN artists a ON t.artist_id = a.id
JOIN albums al ON t.album_id = al.id
WHERE t.id = ?
""", (track_id,))
""", (track_id_str,))
row = cursor.fetchone()
if row:
@ -628,7 +916,7 @@ class MusicDatabase:
logger.error(f"Error searching artists with query '{query}': {e}")
return []
def search_tracks(self, title: str = "", artist: str = "", limit: int = 50) -> List[DatabaseTrack]:
def search_tracks(self, title: str = "", artist: str = "", limit: int = 50, server_source: str = None) -> List[DatabaseTrack]:
"""Search tracks by title and/or artist name with Unicode-aware fuzzy matching"""
try:
if not title and not artist:
@ -638,7 +926,7 @@ class MusicDatabase:
cursor = conn.cursor()
# STRATEGY 1: Try basic SQL LIKE search first (fastest)
basic_results = self._search_tracks_basic(cursor, title, artist, limit)
basic_results = self._search_tracks_basic(cursor, title, artist, limit, server_source)
if basic_results:
logger.debug(f"🔍 Basic search found {len(basic_results)} results")
@ -652,7 +940,7 @@ class MusicDatabase:
unicode_support = False
if unicode_support:
normalized_results = self._search_tracks_unicode_fallback(cursor, title, artist, limit)
normalized_results = self._search_tracks_unicode_fallback(cursor, title, artist, limit, server_source)
if normalized_results:
logger.debug(f"🔍 Unicode fallback search found {len(normalized_results)} results")
return normalized_results
@ -668,7 +956,7 @@ class MusicDatabase:
logger.error(f"Error searching tracks with title='{title}', artist='{artist}': {e}")
return []
def _search_tracks_basic(self, cursor, title: str, artist: str, limit: int) -> List[DatabaseTrack]:
def _search_tracks_basic(self, cursor, title: str, artist: str, limit: int, server_source: str = None) -> List[DatabaseTrack]:
"""Basic SQL LIKE search - fastest method"""
where_conditions = []
params = []
@ -681,6 +969,11 @@ class MusicDatabase:
where_conditions.append("artists.name LIKE ?")
params.append(f"%{artist}%")
# Add server filter if specified
if server_source:
where_conditions.append("tracks.server_source = ?")
params.append(server_source)
if not where_conditions:
return []
@ -699,7 +992,7 @@ class MusicDatabase:
return self._rows_to_tracks(cursor.fetchall())
def _search_tracks_unicode_fallback(self, cursor, title: str, artist: str, limit: int) -> List[DatabaseTrack]:
def _search_tracks_unicode_fallback(self, cursor, title: str, artist: str, limit: int, server_source: str = None) -> List[DatabaseTrack]:
"""Unicode-aware fallback search - tries normalized versions"""
from unidecode import unidecode
@ -719,6 +1012,11 @@ class MusicDatabase:
where_conditions.append("LOWER(artists.name) LIKE ?")
params.append(f"%{artist_norm}%")
# Add server filter if specified
if server_source:
where_conditions.append("tracks.server_source = ?")
params.append(server_source)
if not where_conditions:
return []
@ -839,7 +1137,7 @@ class MusicDatabase:
tracks.append(track)
return tracks
def search_albums(self, title: str = "", artist: str = "", limit: int = 50) -> List[DatabaseAlbum]:
def search_albums(self, title: str = "", artist: str = "", limit: int = 50, server_source: Optional[str] = None) -> List[DatabaseAlbum]:
"""Search albums by title and/or artist name with fuzzy matching"""
try:
conn = self._get_connection()
@ -857,6 +1155,10 @@ class MusicDatabase:
where_conditions.append("artists.name LIKE ?")
params.append(f"%{artist}%")
if server_source:
where_conditions.append("albums.server_source = ?")
params.append(server_source)
if not where_conditions:
# If no search criteria, return empty list
return []
@ -918,7 +1220,7 @@ class MusicDatabase:
return list(set(variations))
def check_track_exists(self, title: str, artist: str, confidence_threshold: float = 0.8) -> Tuple[Optional[DatabaseTrack], float]:
def check_track_exists(self, title: str, artist: str, confidence_threshold: float = 0.8, server_source: str = None) -> Tuple[Optional[DatabaseTrack], float]:
"""
Check if a track exists in the database with enhanced fuzzy matching and confidence scoring.
Now uses the same sophisticated matching approach as album checking for consistency.
@ -941,7 +1243,7 @@ class MusicDatabase:
potential_matches = []
artist_variations = self._get_artist_variations(artist)
for artist_variation in artist_variations:
potential_matches.extend(self.search_tracks(title=title_variation, artist=artist_variation, limit=20))
potential_matches.extend(self.search_tracks(title=title_variation, artist=artist_variation, limit=20, server_source=server_source))
if not potential_matches:
continue
@ -1091,7 +1393,7 @@ class MusicDatabase:
logger.error(f"Error checking album completeness for album_id {album_id}: {e}")
return 0, 0, False
def check_album_exists_with_completeness(self, title: str, artist: str, expected_track_count: Optional[int] = None, confidence_threshold: float = 0.8) -> Tuple[Optional[DatabaseAlbum], float, int, int, bool]:
def check_album_exists_with_completeness(self, title: str, artist: str, expected_track_count: Optional[int] = None, confidence_threshold: float = 0.8, server_source: Optional[str] = None) -> Tuple[Optional[DatabaseAlbum], float, int, int, bool]:
"""
Check if an album exists in the database with completeness information.
Enhanced to handle edition matching (standard <-> deluxe variants).
@ -1099,7 +1401,7 @@ class MusicDatabase:
"""
try:
# Try enhanced edition-aware matching first with expected track count for Smart Edition Matching
album, confidence = self.check_album_exists_with_editions(title, artist, confidence_threshold, expected_track_count)
album, confidence = self.check_album_exists_with_editions(title, artist, confidence_threshold, expected_track_count, server_source)
if not album:
return None, 0.0, 0, 0, False
@ -1113,7 +1415,7 @@ class MusicDatabase:
logger.error(f"Error checking album existence with completeness for '{title}' by '{artist}': {e}")
return None, 0.0, 0, 0, False
def check_album_exists_with_editions(self, title: str, artist: str, confidence_threshold: float = 0.8, expected_track_count: Optional[int] = None) -> Tuple[Optional[DatabaseAlbum], float]:
def check_album_exists_with_editions(self, title: str, artist: str, confidence_threshold: float = 0.8, expected_track_count: Optional[int] = None, server_source: Optional[str] = None) -> Tuple[Optional[DatabaseAlbum], float]:
"""
Enhanced album existence check that handles edition variants.
Matches standard albums with deluxe/platinum/special editions and vice versa.
@ -1131,7 +1433,7 @@ class MusicDatabase:
for variation in title_variations:
# Search for this variation
albums = self.search_albums(title=variation, artist=artist, limit=10)
albums = self.search_albums(title=variation, artist=artist, limit=10, server_source=server_source)
if albums:
logger.debug(f"📀 Found {len(albums)} albums for variation '{variation}'")
@ -1809,7 +2111,7 @@ class MusicDatabase:
return 0
def get_database_info(self) -> Dict[str, Any]:
"""Get comprehensive database information"""
"""Get comprehensive database information for all servers (legacy method)"""
try:
stats = self.get_statistics()
@ -1857,6 +2159,65 @@ class MusicDatabase:
'last_update': None,
'last_full_refresh': None
}
def get_database_info_for_server(self, server_source: str = None) -> Dict[str, Any]:
"""Get comprehensive database information filtered by server source"""
try:
# Import here to avoid circular imports
from config.settings import config_manager
# If no server specified, use active server
if server_source is None:
server_source = config_manager.get_active_media_server()
stats = self.get_statistics_for_server(server_source)
# Get database file size (always total, not server-specific)
db_size = self.database_path.stat().st_size if self.database_path.exists() else 0
db_size_mb = db_size / (1024 * 1024)
# Get last update time for this server
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT MAX(updated_at) as last_update
FROM (
SELECT updated_at FROM artists WHERE server_source = ?
UNION ALL
SELECT updated_at FROM albums WHERE server_source = ?
UNION ALL
SELECT updated_at FROM tracks WHERE server_source = ?
)
""", (server_source, server_source, server_source))
result = cursor.fetchone()
last_update = result['last_update'] if result and result['last_update'] else None
# Get last full refresh (global setting, not server-specific)
last_full_refresh = self.get_last_full_refresh()
return {
**stats,
'database_size_mb': round(db_size_mb, 2),
'database_path': str(self.database_path),
'last_update': last_update,
'last_full_refresh': last_full_refresh,
'server_source': server_source
}
except Exception as e:
logger.error(f"Error getting database info for {server_source}: {e}")
return {
'artists': 0,
'albums': 0,
'tracks': 0,
'database_size_mb': 0.0,
'database_path': str(self.database_path),
'last_update': None,
'last_full_refresh': None,
'server_source': server_source
}
# Thread-safe singleton pattern for database access
_database_instances: Dict[int, MusicDatabase] = {} # Thread ID -> Database instance

34
main.py
View file

@ -12,6 +12,7 @@ from config.settings import config_manager
from utils.logging_config import setup_logging, get_logger
from core.spotify_client import SpotifyClient
from core.plex_client import PlexClient
from core.jellyfin_client import JellyfinClient
from core.soulseek_client import SoulseekClient
from ui.sidebar import ModernSidebar
@ -27,12 +28,17 @@ logger = get_logger("main")
class ServiceStatusThread(QThread):
status_updated = pyqtSignal(str, bool)
def __init__(self, spotify_client, plex_client, soulseek_client):
def __init__(self, spotify_client, plex_client, jellyfin_client, soulseek_client):
super().__init__()
self.spotify_client = spotify_client
self.plex_client = plex_client
self.jellyfin_client = jellyfin_client
self.soulseek_client = soulseek_client
self.running = True
# Import here to avoid circular imports
from config.settings import config_manager
self.config_manager = config_manager
def run(self):
while self.running:
@ -41,9 +47,15 @@ class ServiceStatusThread(QThread):
spotify_status = self.spotify_client.sp is not None
self.status_updated.emit("spotify", spotify_status)
# Check Plex connection
plex_status = self.plex_client.is_connected()
self.status_updated.emit("plex", plex_status)
# Check active media server connection
active_server = self.config_manager.get_active_media_server()
if active_server == "plex":
server_status = self.plex_client.is_connected()
self.status_updated.emit("plex", server_status)
elif active_server == "jellyfin":
# Use the JellyfinClient for status checking
jellyfin_status = self.jellyfin_client.is_connected()
self.status_updated.emit("jellyfin", jellyfin_status)
# Check Soulseek connection (simplified check to avoid event loop issues)
soulseek_status = self.soulseek_client.is_configured()
@ -69,6 +81,7 @@ class MainWindow(QMainWindow):
self.spotify_client = SpotifyClient()
self.plex_client = PlexClient()
self.jellyfin_client = JellyfinClient()
self.soulseek_client = SoulseekClient()
self.status_thread = None
@ -161,7 +174,13 @@ class MainWindow(QMainWindow):
# Create and add pages
self.dashboard_page = DashboardPage()
self.downloads_page = DownloadsPage(self.soulseek_client)
self.sync_page = SyncPage(self.spotify_client, self.plex_client, self.soulseek_client, self.downloads_page)
self.sync_page = SyncPage(
spotify_client=self.spotify_client,
plex_client=self.plex_client,
soulseek_client=self.soulseek_client,
downloads_page=self.downloads_page,
jellyfin_client=self.jellyfin_client
)
self.artists_page = ArtistsPage(downloads_page=self.downloads_page)
self.settings_page = SettingsPage()
@ -172,7 +191,7 @@ class MainWindow(QMainWindow):
self.settings_page.set_toast_manager(self.toast_manager)
# Configure dashboard with service clients and page references
self.dashboard_page.set_service_clients(self.spotify_client, self.plex_client, self.soulseek_client)
self.dashboard_page.set_service_clients(self.spotify_client, self.plex_client, self.jellyfin_client, self.soulseek_client)
self.dashboard_page.set_page_references(self.downloads_page, self.sync_page)
self.dashboard_page.set_app_start_time(self.app_start_time)
self.dashboard_page.set_toast_manager(self.toast_manager)
@ -219,6 +238,7 @@ class MainWindow(QMainWindow):
self.status_thread = ServiceStatusThread(
self.spotify_client,
self.plex_client,
self.jellyfin_client,
self.soulseek_client
)
self.status_thread.status_updated.connect(self.update_service_status)
@ -398,7 +418,7 @@ def main():
app = QApplication(sys.argv)
app.setApplicationName("SoulSync")
app.setApplicationVersion(".5")
app.setApplicationVersion("0.6")
main_window = MainWindow()
main_window.show()

View file

@ -0,0 +1,197 @@
# 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.

370
server-source.md Normal file
View file

@ -0,0 +1,370 @@
# 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.*

View file

@ -5,6 +5,7 @@ from datetime import datetime
from utils.logging_config import get_logger
from core.spotify_client import SpotifyClient, Playlist as SpotifyPlaylist, Track as SpotifyTrack
from core.plex_client import PlexClient, PlexTrackInfo
from core.jellyfin_client import JellyfinClient
from core.soulseek_client import SoulseekClient
from core.matching_engine import MusicMatchingEngine, MatchResult
@ -40,15 +41,33 @@ class SyncProgress:
failed_tracks: int = 0
class PlaylistSyncService:
def __init__(self, spotify_client: SpotifyClient, plex_client: PlexClient, soulseek_client: SoulseekClient):
def __init__(self, spotify_client: SpotifyClient, plex_client: PlexClient, soulseek_client: SoulseekClient, jellyfin_client: JellyfinClient = None):
self.spotify_client = spotify_client
self.plex_client = plex_client
self.jellyfin_client = jellyfin_client
self.soulseek_client = soulseek_client
self.progress_callbacks = {} # Playlist-specific progress callbacks
self.syncing_playlists = set() # Track multiple syncing playlists
self._cancelled = False
self.matching_engine = MusicMatchingEngine()
def _get_active_media_client(self):
"""Get the active media client based on config settings"""
try:
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
if active_server == "jellyfin":
if not self.jellyfin_client:
logger.error("Jellyfin client not provided to sync service")
return None, "jellyfin"
return self.jellyfin_client, "jellyfin"
else: # Default to Plex
return self.plex_client, "plex"
except Exception as e:
logger.error(f"Error determining active media server: {e}")
return self.plex_client, "plex" # Fallback to Plex
@property
def is_syncing(self):
"""Check if any playlist is currently syncing"""
@ -127,7 +146,8 @@ class PlaylistSyncService:
return self._create_error_result(playlist.name, ["Sync cancelled"])
total_tracks = len(playlist.tracks)
media_client, server_type = self._get_active_media_client()
self._update_progress(playlist.name, "Matching tracks against Plex library", "", 20, 5, 2, total_tracks=total_tracks)
# Use the same robust matching approach as "Download Missing Tracks"
@ -145,7 +165,7 @@ class PlaylistSyncService:
failed_tracks=len([r for r in match_results if not r.is_match]))
# Use the robust search approach
plex_match, confidence = await self._find_track_in_plex(track)
plex_match, confidence = await self._find_track_in_media_server(track)
match_result = MatchResult(
spotify_track=track,
@ -188,23 +208,33 @@ class PlaylistSyncService:
matched_tracks=len(matched_tracks),
failed_tracks=len(unmatched_tracks))
# Get the actual Plex track objects (not PlexTrackInfo)
plex_tracks = [r.plex_track for r in matched_tracks if r.plex_track]
logger.info(f"Creating playlist with {len(plex_tracks)} matched tracks")
# Get the actual media server track objects
media_tracks = [r.plex_track for r in matched_tracks if r.plex_track] # plex_track is a generic name here
logger.info(f"Creating playlist with {len(media_tracks)} matched tracks")
# Validate that all tracks have proper ratingKey attributes for playlist creation
valid_tracks = []
for i, track in enumerate(plex_tracks):
for i, track in enumerate(media_tracks):
if track and hasattr(track, 'ratingKey'):
valid_tracks.append(track)
logger.debug(f"✔️ Track {i+1} valid for playlist: '{track.title}' (ratingKey: {track.ratingKey})")
else:
logger.warning(f"❌ Track {i+1} invalid for playlist: {track} (type: {type(track)}, has ratingKey: {hasattr(track, 'ratingKey') if track else 'N/A'})")
logger.info(f"Playlist validation: {len(valid_tracks)}/{len(plex_tracks)} tracks are valid Plex objects with ratingKeys")
plex_tracks = valid_tracks
logger.info(f"Playlist validation: {len(valid_tracks)}/{len(media_tracks)} tracks are valid {server_type.title()} objects with ratingKeys")
sync_success = self.plex_client.update_playlist(playlist.name, plex_tracks)
# Use the validated tracks for the sync
plex_tracks = valid_tracks # Keep variable name for compatibility with the rest of the function
# Use active media server for playlist sync
media_client, server_type = self._get_active_media_client()
if not media_client:
logger.error(f"No active media client available for playlist sync")
sync_success = False
else:
logger.info(f"Syncing playlist '{playlist.name}' to {server_type.upper()} server")
# THE FIX: Ensure we are passing the correct, native track objects to the client
sync_success = media_client.update_playlist(playlist.name, valid_tracks)
synced_tracks = len(plex_tracks) if sync_success else 0
failed_tracks = len(playlist.tracks) - synced_tracks - downloaded_tracks
@ -239,11 +269,13 @@ class PlaylistSyncService:
self.clear_progress_callback(playlist.name)
self._cancelled = False
async def _find_track_in_plex(self, spotify_track: SpotifyTrack) -> Tuple[Optional[PlexTrackInfo], float]:
async def _find_track_in_media_server(self, spotify_track: SpotifyTrack) -> Tuple[Optional[PlexTrackInfo], float]:
"""Find a track using the same improved database matching as Download Missing Tracks modal"""
try:
if not self.plex_client or not self.plex_client.is_connected():
logger.warning("Plex client not connected")
# Check active media server connection
media_client, server_type = self._get_active_media_client()
if not media_client or not media_client.is_connected():
logger.warning(f"{server_type.upper()} client not connected")
return None, 0.0
# Use the SAME improved database matching as PlaylistTrackAnalysisWorker
@ -258,25 +290,46 @@ class PlaylistSyncService:
artist_name = artist if isinstance(artist, str) else artist
# Use the improved database check_track_exists method
# Use the improved database check_track_exists method with server awareness
try:
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
db = MusicDatabase()
db_track, confidence = db.check_track_exists(original_title, artist_name, confidence_threshold=0.7)
db_track, confidence = db.check_track_exists(original_title, artist_name, confidence_threshold=0.7, server_source=active_server)
if db_track and confidence >= 0.7:
logger.debug(f"✔️ Database match found for '{original_title}' by '{artist_name}': '{db_track.title}' with confidence {confidence:.2f}")
# Fetch the actual Plex track object using the database track ID
# Fetch the actual track object from active media server using the database track ID
try:
actual_plex_track = self.plex_client.server.fetchItem(db_track.id)
if actual_plex_track and hasattr(actual_plex_track, 'ratingKey'):
logger.debug(f"✔️ Successfully fetched actual Plex track for '{db_track.title}' (ratingKey: {actual_plex_track.ratingKey})")
return actual_plex_track, confidence
if server_type == "jellyfin":
# For Jellyfin, create a track object from database info (Jellyfin doesn't have fetchItem)
class JellyfinTrackFromDB:
def __init__(self, db_track):
self.ratingKey = db_track.id
self.title = db_track.title
self.id = db_track.id
actual_track = JellyfinTrackFromDB(db_track)
logger.debug(f"✔️ Created Jellyfin track object for '{db_track.title}' (ID: {actual_track.ratingKey})")
return actual_track, confidence
else:
logger.warning(f"❌ Fetched Plex track for '{db_track.title}' lacks ratingKey attribute")
# For Plex, use the original fetchItem approach
# Validate that the track ID is numeric (Plex requirement)
try:
track_id = int(db_track.id)
actual_plex_track = media_client.server.fetchItem(track_id)
if actual_plex_track and hasattr(actual_plex_track, 'ratingKey'):
logger.debug(f"✔️ Successfully fetched actual Plex track for '{db_track.title}' (ratingKey: {actual_plex_track.ratingKey})")
return actual_plex_track, confidence
else:
logger.warning(f"❌ Fetched Plex track for '{db_track.title}' lacks ratingKey attribute")
except ValueError:
logger.warning(f"❌ Invalid Plex track ID format for '{db_track.title}' (ID: {db_track.id}) - skipping this track")
continue
except Exception as fetch_error:
logger.error(f"❌ Failed to fetch actual Plex track for '{db_track.title}' (ID: {db_track.id}): {fetch_error}")
logger.error(f"❌ Failed to fetch actual {server_type} track for '{db_track.title}' (ID: {db_track.id}): {fetch_error}")
# Continue to try other artists rather than fail completely
continue

BIN
ui/assets/jellyfin_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

BIN
ui/assets/plex_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View file

@ -33,8 +33,15 @@ class DatabaseUpdaterWidget(QFrame):
header_label.setFont(QFont("Arial", 14, QFont.Weight.Bold))
header_label.setStyleSheet("color: #ffffff;")
# Info label
info_label = QLabel("Syncs your Plex music library into the local database for faster searches and analytics")
# Info label - dynamic based on active server
try:
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
server_name = "Jellyfin" if active_server == "jellyfin" else "Plex"
except:
server_name = "Plex" # Fallback
info_label = QLabel(f"Syncs your {server_name} music library into the local database for faster searches and analytics")
info_label.setFont(QFont("Arial", 9))
info_label.setStyleSheet("color: #b3b3b3; margin-bottom: 5px;")
info_label.setWordWrap(True)

View file

@ -13,7 +13,7 @@ class VersionInfoModal(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("What's New in SoulSync v0.51")
self.setWindowTitle("What's New in SoulSync v0.6")
self.setModal(True)
self.setFixedSize(600, 500)
self.setup_ui()
@ -68,7 +68,7 @@ class VersionInfoModal(QDialog):
""")
# Version subtitle
version_subtitle = QLabel("Version 0.51 - Latest Features & Improvements")
version_subtitle = QLabel("Version 0.6 - Complete Multi-Server Support")
version_subtitle.setFont(QFont("SF Pro Text", 11, QFont.Weight.Medium))
version_subtitle.setStyleSheet("""
color: rgba(255, 255, 255, 0.7);
@ -112,50 +112,52 @@ class VersionInfoModal(QDialog):
content_layout.setContentsMargins(30, 25, 30, 25)
content_layout.setSpacing(25)
# Settings Page Improvements
settings_section = self.create_feature_section(
"⚙️ Settings Page Improvements",
"Enhanced settings interface for better usability and visual consistency",
# Multi-Server Support
multiserver_section = self.create_feature_section(
"🔀 Complete Multi-Server Support",
"Full Plex and Jellyfin compatibility across all features and workflows",
[
"• Added vertical scrolling support for better usability on small screens",
"• Cleaned up text backgrounds throughout settings for better visual consistency",
"• Applied consistent green monospace styling to log file path display",
"• Added rounded corners to API configuration frames (Spotify, Plex, Soulseek)",
"• Enhanced label styling with transparent backgrounds and proper white text"
]
)
content_layout.addWidget(settings_section)
# Watchlist System Enhancements
watchlist_section = self.create_feature_section(
"👀 Watchlist System Enhancements",
"Improved reliability and user experience for artist monitoring",
[
"• Enhanced rate limiting with comprehensive multi-layer protection to prevent API bans",
"• Increased delays between artists (2.0s) and albums (0.5s) for safer scanning",
"• Enhanced Spotify API interval timing and error handling",
"• Unified scan implementation - manual and auto scans now use same worker for consistency",
"• Fixed progress bars resetting when reopening modal during active scans",
"• Professional artist cards with improved visual hierarchy and spacing",
"• Modern hover effects and enhanced delete button styling"
]
)
content_layout.addWidget(watchlist_section)
# Artist Search Enhancements
artist_section = self.create_feature_section(
"🎵 Artist Search Enhancements",
"Better visual feedback and improved navigation for artist discovery",
[
"• Added watchlist eye indicators (👁️) to artist search results",
"• Green eye icon appears in top-right corner for artists already in watchlist",
"• Real-time updates when artists are added/removed from watchlist",
"• Enhanced horizontal scrolling with mouse wheel support for artist results",
"• Smooth navigation through search results without manual scrollbar dragging"
"• Added native Jellyfin client with full API integration for artists, albums, and tracks",
"• Automatic server detection and dynamic client switching throughout the application",
"• All sync tools now work seamlessly with both Plex and Jellyfin servers",
"• Database filtering by server source ensures clean separation of library data",
"• Updated all download and analysis workers for multi-server compatibility",
"• Server-specific UI adaptations (e.g., metadata updater hidden for Jellyfin)",
"• Unified media client architecture for consistent cross-server functionality"
],
"Look for the green eye icon (👁️) in artist search results to instantly see which artists you're already tracking!"
"Switch between Plex and Jellyfin in Settings → Server Configuration - all features automatically adapt!"
)
content_layout.addWidget(artist_section)
content_layout.addWidget(multiserver_section)
# Enhanced Compatibility
compatibility_section = self.create_feature_section(
"⚙️ Enhanced Cross-Server Features",
"All major workflows updated for seamless multi-server operation",
[
"• Playlist sync (Spotify & YouTube) now supports both Plex and Jellyfin",
"• Download Missing Tracks modals work with active server automatically",
"• Artist browsing and album analysis adapt to your selected media server",
"• Database updates and library scans respect server-specific configurations",
"• Playlist track analysis workers dynamically use the correct server API",
"• All toast notifications and UI feedback are server-aware"
]
)
content_layout.addWidget(compatibility_section)
# Technical Improvements
technical_section = self.create_feature_section(
"🔧 Technical Architecture Updates",
"Robust foundation for reliable multi-server operation",
[
"• Implemented server-agnostic database schema with source tracking",
"• Added unified configuration management for multiple media servers",
"• Created wrapper classes for consistent API interfaces across servers",
"• Enhanced error handling with server-specific messaging",
"• Optimized performance with intelligent client caching and connection pooling",
"• Future-ready architecture for additional media server integrations"
]
)
content_layout.addWidget(technical_section)
scroll_area.setWidget(content_widget)
return scroll_area

View file

@ -544,8 +544,10 @@ class SinglesEPsLibraryWorker(QThread):
print(f" 🔍 Searching for single track: '{track_name}' by '{artist_name}'")
# Search for the track anywhere in the library
db_track, confidence = db.check_track_exists(track_name, artist_name, confidence_threshold=0.7)
# Search for the track anywhere in the library (active server only)
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
db_track, confidence = db.check_track_exists(track_name, artist_name, confidence_threshold=0.7, server_source=active_server)
if db_track and confidence >= 0.7:
print(f" ✅ Single found: '{track_name}' in album '{db_track.album_title}' (confidence: {confidence:.2f})")
@ -645,8 +647,10 @@ class SinglesEPsLibraryWorker(QThread):
track_name = track['name']
artist_name = track['artists'][0]['name'] if track['artists'] else (ep_release.artists[0] if ep_release.artists else "")
# Search for this track
db_track, confidence = db.check_track_exists(track_name, artist_name, confidence_threshold=0.7)
# Search for this track (active server only)
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
db_track, confidence = db.check_track_exists(track_name, artist_name, confidence_threshold=0.7, server_source=active_server)
if db_track and confidence >= 0.7:
owned_tracks += 1
@ -707,6 +711,15 @@ class DatabaseLibraryWorker(QThread):
# Get database instance
db = get_database()
# Get active server for filtering
try:
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
print(f"🔍 Checking albums against {active_server.upper()} library only")
except Exception as e:
print(f"⚠️ Could not get active server, defaulting to 'plex': {e}")
active_server = 'plex'
if self._stop_requested:
return
@ -757,7 +770,7 @@ class DatabaseLibraryWorker(QThread):
# Search database for this combination with completeness info
print(f" 🔍 Searching database: album='{album_name}', artist='{artist_clean}'")
db_album, confidence, owned_tracks, expected_tracks, is_complete = db.check_album_exists_with_completeness(
album_name, artist_clean, expected_track_count, confidence_threshold=0.7
album_name, artist_clean, expected_track_count, confidence_threshold=0.7, server_source=active_server
)
if db_album and confidence > best_confidence:
@ -776,7 +789,7 @@ class DatabaseLibraryWorker(QThread):
if not db_album and artist and artist != artist_clean:
print(f" 🔄 Backup search with original artist: album='{album_name}', artist='{artist}'")
db_album_backup, confidence_backup, owned_backup, expected_backup, complete_backup = db.check_album_exists_with_completeness(
album_name, artist, expected_track_count, confidence_threshold=0.7
album_name, artist, expected_track_count, confidence_threshold=0.7, server_source=active_server
)
if db_album_backup and confidence_backup > best_confidence:
@ -793,7 +806,7 @@ class DatabaseLibraryWorker(QThread):
artist_no_comma = ' '.join(artist_no_comma.split())
print(f" 🔄 Comma-removal fallback: album='{album_name}', artist='{artist_no_comma}'")
db_album_comma, confidence_comma, owned_comma, expected_comma, complete_comma = db.check_album_exists_with_completeness(
album_name, artist_no_comma, expected_track_count, confidence_threshold=0.7
album_name, artist_no_comma, expected_track_count, confidence_threshold=0.7, server_source=active_server
)
if db_album_comma and confidence_comma > best_confidence:
@ -1803,14 +1816,15 @@ class DownloadMissingAlbumTracksModal(QDialog):
"""Enhanced modal for downloading missing album tracks with live progress tracking"""
process_finished = pyqtSignal()
def __init__(self, album, album_card, parent_page, downloads_page, plex_client):
def __init__(self, album, album_card, parent_page, downloads_page, media_client, server_type):
super().__init__(parent_page)
self.album = album
self.album_card = album_card
self.parent_page = parent_page
self.parent_artists_page = parent_page # Reference to artists page for scan manager
self.downloads_page = downloads_page
self.plex_client = plex_client
self.media_client = media_client
self.server_type = server_type
self.matching_engine = MusicMatchingEngine()
self.wishlist_service = get_wishlist_service()
@ -2382,9 +2396,9 @@ class DownloadMissingAlbumTracksModal(QDialog):
self.start_plex_analysis()
def start_plex_analysis(self):
"""Start database analysis for album tracks (previously Plex analysis)"""
"""Start database analysis for album tracks (server-aware)"""
from ui.pages.sync import PlaylistTrackAnalysisWorker
worker = PlaylistTrackAnalysisWorker(self.album.tracks, self.plex_client)
worker = PlaylistTrackAnalysisWorker(self.album.tracks, self.media_client)
worker.signals.analysis_started.connect(self.on_analysis_started)
worker.signals.track_analyzed.connect(self.on_track_analyzed)
worker.signals.analysis_completed.connect(self.on_analysis_completed)
@ -3204,7 +3218,7 @@ class ArtistsPage(QWidget):
self.download_status_timer.start(2000) # Poll every 2 seconds (consistent with sync.py)
self.download_status_pool = QThreadPool()
# Initialize Plex scan manager (will be set when clients are connected)
# Initialize unified media scan manager (will be set when clients are connected)
self.scan_manager = None
self.download_status_pool.setMaxThreadCount(1) # One worker at a time to avoid conflicts
self._is_status_update_running = False
@ -3220,16 +3234,24 @@ class ArtistsPage(QWidget):
"""Set the toast manager for showing notifications"""
self.toast_manager = toast_manager
def _on_plex_scan_completed(self):
"""Callback triggered when Plex scan completes - start automatic incremental database update"""
def _on_media_scan_completed(self):
"""Callback triggered when media scan completes - start automatic incremental database update"""
try:
# Import here to avoid circular imports
from database import get_database
from core.database_update_worker import DatabaseUpdateWorker
from config.settings import config_manager
# Get the active media client
active_server = config_manager.get_active_media_server()
if active_server == "jellyfin":
media_client = getattr(self, 'jellyfin_client', None)
else:
media_client = getattr(self, 'plex_client', None)
# Check if we should run incremental update
if not self.plex_client or not self.plex_client.is_connected():
logger.debug("Plex not connected - skipping automatic database update")
if not media_client or not media_client.is_connected():
logger.debug(f"{active_server.upper()} not connected - skipping automatic database update")
return
# Check if database has a previous full refresh
@ -3252,11 +3274,11 @@ class ArtistsPage(QWidget):
return
# All conditions met - start incremental update
logger.info("🎵 Starting automatic incremental database update after Plex scan")
logger.info(f"🎵 Starting automatic incremental database update after {active_server.upper()} scan")
self._start_automatic_incremental_update()
except Exception as e:
logger.error(f"Error in Plex scan completion callback: {e}")
logger.error(f"Error in media scan completion callback: {e}")
def _start_automatic_incremental_update(self):
"""Start the automatic incremental database update"""
@ -3270,7 +3292,7 @@ class ArtistsPage(QWidget):
# Create worker for incremental update only
self._auto_database_worker = DatabaseUpdateWorker(
self.plex_client,
self.media_client,
"database/music_library.db",
full_refresh=False # Always incremental for automatic updates
)
@ -3317,24 +3339,42 @@ class ArtistsPage(QWidget):
def setup_clients(self):
"""Initialize client connections"""
try:
from config.settings import config_manager
self.spotify_client = SpotifyClient()
self.plex_client = PlexClient()
# Add Jellyfin client for multi-server support
from core.jellyfin_client import JellyfinClient
self.jellyfin_client = JellyfinClient()
# Set up unified media client based on active server
active_server = config_manager.get_active_media_server()
if active_server == "plex":
self.media_client = self.plex_client
self.server_type = "plex"
else: # jellyfin
self.media_client = self.jellyfin_client
self.server_type = "jellyfin"
self.soulseek_client = SoulseekClient()
# --- FIX: Ensure the soulseek_client uses the download path from config ---
from config.settings import config_manager
download_path = config_manager.get('soulseek.download_path')
if download_path and hasattr(self.soulseek_client, 'download_path'):
self.soulseek_client.download_path = download_path
print(f"✅ Set soulseek_client download path for ArtistsPage to: {download_path}")
# --- END FIX ---
# Initialize Plex scan manager now that clients are available
if self.plex_client:
self.scan_manager = PlexScanManager(self.plex_client, delay_seconds=60)
# Add automatic incremental database update after Plex scan completion
self.scan_manager.add_scan_completion_callback(self._on_plex_scan_completed)
print("✅ PlexScanManager initialized for ArtistsPage")
# Initialize unified media scan manager now that clients are available
try:
from core.media_scan_manager import MediaScanManager
self.scan_manager = MediaScanManager(delay_seconds=60)
# Add automatic incremental database update after scan completion
self.scan_manager.add_scan_completion_callback(self._on_media_scan_completed)
print("✅ MediaScanManager initialized for ArtistsPage")
except Exception as e:
print(f"Failed to initialize MediaScanManager: {e}")
except Exception as e:
print(f"Failed to initialize clients: {e}")
@ -4084,10 +4124,18 @@ class ArtistsPage(QWidget):
self.singles_eps_worker.start()
def start_plex_library_check(self, albums):
"""Start Plex library check in background"""
# Show toast for Plex check start
"""Start database library check in background"""
# Get active server for dynamic toast message
try:
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
server_name = "Jellyfin" if active_server == "jellyfin" else "Plex"
except:
server_name = "Plex" # Fallback
# Show toast for library check start
if hasattr(self, 'toast_manager') and self.toast_manager:
self.toast_manager.info("Checking your Plex library for owned albums...")
self.toast_manager.info(f"Checking your {server_name} library for owned albums...")
# Stop any existing Plex worker
if self.plex_library_worker:
@ -4188,12 +4236,20 @@ class ArtistsPage(QWidget):
"""Handle Plex library check failure"""
print(f"Plex library check failed: {error}")
# Get active server for dynamic error message
try:
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
server_name = "Jellyfin" if active_server == "jellyfin" else "Plex"
except:
server_name = "Plex" # Fallback
# Show error toast
if hasattr(self, 'toast_manager') and self.toast_manager:
self.toast_manager.error("Plex connection failed - cannot check owned albums")
self.toast_manager.error(f"{server_name} connection failed - cannot check owned albums")
if self.current_albums:
self.albums_status.setText(f"Found {len(self.current_albums)} albums • Plex check failed")
self.albums_status.setText(f"Found {len(self.current_albums)} albums • {server_name} check failed")
# Display albums without ownership info
self.display_albums(self.current_albums, set())
@ -4292,7 +4348,7 @@ class ArtistsPage(QWidget):
QMessageBox.critical(self, "Error", "Downloads page is not connected. Cannot start download.")
return
if not self.plex_client:
if not self.media_client:
QMessageBox.critical(self, "Error", "Music database is not available. Cannot verify existing tracks.")
return
@ -4374,12 +4430,28 @@ class ArtistsPage(QWidget):
album_with_tracks.tracks = tracks # Add tracks attribute dynamically
# Create and show the new sophisticated modal
# Get active server and media client
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
if active_server == "jellyfin":
media_client = getattr(self, 'jellyfin_client', None)
if not media_client:
QMessageBox.critical(self, "Error", "Jellyfin client not available")
return
else:
media_client = self.plex_client
if not media_client:
QMessageBox.critical(self, "Error", "Plex client not available")
return
modal = DownloadMissingAlbumTracksModal(
album=album_with_tracks, # Use the album with tracks
album_card=album_card,
parent_page=self,
downloads_page=self.downloads_page,
plex_client=self.plex_client
media_client=media_client,
server_type=active_server
)
# Store the session for resumption

View file

@ -119,7 +119,10 @@ class PlaylistTrackAnalysisWorker(QRunnable):
for query_title in unique_title_variations:
if self._cancelled: return None, 0.0
db_track, confidence = db.check_track_exists(query_title, artist_name, confidence_threshold=0.7)
# Use server-aware database query to check only active server
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
db_track, confidence = db.check_track_exists(query_title, artist_name, confidence_threshold=0.7, server_source=active_server)
if db_track and confidence >= 0.7:
class MockPlexTrack:
@ -1401,18 +1404,19 @@ class SimpleWishlistDownloadWorker(QRunnable):
class MetadataUpdateWorker(QThread):
"""Worker thread for updating Plex artist metadata using Spotify data"""
"""Worker thread for updating artist metadata using Spotify data (supports both Plex and Jellyfin)"""
progress_updated = pyqtSignal(str, int, int, float) # current_artist, processed, total, percentage
artist_updated = pyqtSignal(str, bool, str) # artist_name, success, details
finished = pyqtSignal(int, int, int) # total_processed, successful, failed
error = pyqtSignal(str) # error_message
artists_loaded = pyqtSignal(int, int) # total_artists, artists_to_process
def __init__(self, artists, plex_client, spotify_client, refresh_interval_days=30):
def __init__(self, artists, media_client, spotify_client, server_type, refresh_interval_days=30):
super().__init__()
self.artists = artists
self.plex_client = plex_client
self.media_client = media_client # Can be plex_client or jellyfin_client
self.spotify_client = spotify_client
self.server_type = server_type # "plex" or "jellyfin"
self.matching_engine = MusicMatchingEngine()
self.refresh_interval_days = refresh_interval_days
self.should_stop = False
@ -1425,14 +1429,23 @@ class MetadataUpdateWorker(QThread):
def stop(self):
self.should_stop = True
def get_artist_name(self, artist):
"""Get artist name consistently across Plex and Jellyfin"""
# Both Plex and Jellyfin wrapper objects have .title attribute
return getattr(artist, 'title', 'Unknown Artist')
def run(self):
"""Process all artists one by one"""
try:
# Load artists in background if not provided
if self.artists is None:
all_artists = self.plex_client.get_all_artists()
# Enable lightweight mode for Jellyfin to skip track caching
if self.server_type == "jellyfin":
self.media_client.set_metadata_only_mode(True)
all_artists = self.media_client.get_all_artists()
if not all_artists:
self.error.emit("No artists found in Plex library")
self.error.emit(f"No artists found in {self.server_type.title()} library")
return
# Filter artists that need processing
@ -1457,7 +1470,7 @@ class MetadataUpdateWorker(QThread):
artist_name = getattr(artist, 'title', 'Unknown Artist')
# Double-check ignore flag right before processing (in case it was added after loading)
if self.plex_client.is_artist_ignored(artist):
if self.media_client.is_artist_ignored(artist):
return (artist_name, True, "Skipped (ignored)")
try:
@ -1502,9 +1515,12 @@ class MetadataUpdateWorker(QThread):
def artist_needs_processing(self, artist):
"""Check if an artist needs metadata processing using age-based detection"""
try:
# Use PlexClient's age-based checking with configured interval
# This also handles the ignore flag check internally
return self.plex_client.needs_update_by_age(artist, self.refresh_interval_days)
# Check if artist is manually ignored
if self.media_client.is_artist_ignored(artist):
return False
# Use media client's age-based checking with configured interval
return self.media_client.needs_update_by_age(artist, self.refresh_interval_days)
except Exception as e:
print(f"Error checking artist {getattr(artist, 'title', 'Unknown')}: {e}")
@ -1554,14 +1570,18 @@ class MetadataUpdateWorker(QThread):
if genres_updated:
changes_made.append("genres")
# Update album artwork
albums_updated = self.update_album_artwork(artist, spotify_artist)
if albums_updated > 0:
changes_made.append(f"{albums_updated} album art")
# Update album artwork (only for Plex, skip for Jellyfin due to API issues)
if self.server_type == "plex":
albums_updated = self.update_album_artwork(artist, spotify_artist)
if albums_updated > 0:
changes_made.append(f"{albums_updated} album art")
else:
# Skip album artwork for Jellyfin until API issues are resolved
logger.debug(f"Skipping album artwork updates for Jellyfin artist: {artist.title}")
if changes_made:
# Update artist biography with timestamp to track last update
biography_updated = self.plex_client.update_artist_biography(artist)
biography_updated = self.media_client.update_artist_biography(artist)
if biography_updated:
changes_made.append("timestamp")
@ -1569,7 +1589,7 @@ class MetadataUpdateWorker(QThread):
return True, details
else:
# Even if no metadata changes, update biography to record we checked this artist
self.plex_client.update_artist_biography(artist)
self.media_client.update_artist_biography(artist)
return True, "Already up to date"
except Exception as e:
@ -1639,8 +1659,8 @@ class MetadataUpdateWorker(QThread):
print(f"[DEBUG] Updating genres for '{artist.title}' to: {genre_list}")
# Use Plex API to update genres
success = self.plex_client.update_artist_genres(artist, genre_list)
# Use media client API to update genres
success = self.media_client.update_artist_genres(artist, genre_list)
if success:
print(f"[DEBUG] Successfully updated genres for '{artist.title}'")
return True
@ -1798,8 +1818,8 @@ class MetadataUpdateWorker(QThread):
print(f"Invalid image data for album '{album_title}'")
return False
# Upload to Plex using our new method
success = self.plex_client.update_album_poster(album, image_data)
# Upload using media client
success = self.media_client.update_album_poster(album, image_data)
if success:
print(f"✅ Updated artwork for album '{album_title}'")
else:
@ -1849,26 +1869,31 @@ class MetadataUpdateWorker(QThread):
return None
def upload_artist_poster(self, artist, image_data):
"""Upload poster to Plex"""
"""Upload poster using media client"""
try:
# Use Plex client's update method if available
if hasattr(self.plex_client, 'update_artist_poster'):
return self.plex_client.update_artist_poster(artist, image_data)
# Use media client's update method if available
if hasattr(self.media_client, 'update_artist_poster'):
return self.media_client.update_artist_poster(artist, image_data)
# Fallback: direct Plex API call
server = self.plex_client.server
upload_url = f"{server._baseurl}/library/metadata/{artist.ratingKey}/posters"
headers = {
'X-Plex-Token': server._token,
'Content-Type': 'image/jpeg'
}
response = requests.post(upload_url, data=image_data, headers=headers)
response.raise_for_status()
# Refresh artist to see changes
artist.refresh()
return True
# Fallback for Plex: direct API call
if self.server_type == "plex":
import requests
server = self.media_client.server
upload_url = f"{server._baseurl}/library/metadata/{artist.ratingKey}/posters"
headers = {
'X-Plex-Token': server._token,
'Content-Type': 'image/jpeg'
}
response = requests.post(upload_url, data=image_data, headers=headers)
response.raise_for_status()
# Refresh artist to see changes
artist.refresh()
return True
else:
# For other server types, return False since we only have fallback for Plex
return False
except Exception as e:
print(f"Error uploading poster: {e}")
@ -1917,6 +1942,7 @@ class DashboardDataProvider(QObject):
self.service_status = {
'spotify': ServiceStatus('Spotify', False, datetime.now()),
'plex': ServiceStatus('Plex', False, datetime.now()),
'jellyfin': ServiceStatus('Jellyfin', False, datetime.now()),
'soulseek': ServiceStatus('Soulseek', False, datetime.now())
}
self.download_stats = DownloadStats()
@ -1934,10 +1960,11 @@ class DashboardDataProvider(QObject):
self.system_stats_timer.timeout.connect(self.update_system_stats)
self.system_stats_timer.start(10000) # Update every 10 seconds
def set_service_clients(self, spotify_client, plex_client, soulseek_client):
def set_service_clients(self, spotify_client, plex_client, jellyfin_client, soulseek_client):
self.service_clients = {
'spotify_client': spotify_client,
'plex_client': plex_client,
'plex_client': plex_client,
'jellyfin_client': jellyfin_client,
'soulseek_client': soulseek_client
}
@ -2066,22 +2093,25 @@ class DashboardDataProvider(QObject):
def test_service_connection(self, service: str):
"""Test connection to a specific service"""
print(f"DEBUG: Testing {service} connection")
print(f"DEBUG: Available service clients: {list(self.service_clients.keys())}")
# Map service names to client keys
service_key_map = {
'spotify': 'spotify_client',
'plex': 'plex_client',
'plex': 'plex_client',
'jellyfin': None, # Jellyfin doesn't need a client, tests via config
'soulseek': 'soulseek_client'
}
client_key = service_key_map.get(service, service)
if client_key not in self.service_clients:
# Handle Jellyfin special case (no client needed)
if service == 'jellyfin':
client = None # Jellyfin test uses config directly
elif client_key not in self.service_clients:
print(f"DEBUG: Service {service} (key: {client_key}) not found in service_clients")
return
print(f"DEBUG: Service client for {service}: {self.service_clients[client_key]}")
else:
client = self.service_clients[client_key]
# Clean up any existing test thread for this service
if hasattr(self, '_test_threads') and service in self._test_threads:
@ -2096,11 +2126,10 @@ class DashboardDataProvider(QObject):
self._test_threads = {}
# Run connection test in background thread
test_thread = ServiceTestThread(service, self.service_clients[client_key])
test_thread = ServiceTestThread(service, client)
test_thread.test_completed.connect(self.on_service_test_completed)
test_thread.finished.connect(lambda: self._cleanup_test_thread(service))
self._test_threads[service] = test_thread
print(f"DEBUG: Starting test thread for {service}")
test_thread.start()
def _cleanup_test_thread(self, service: str):
@ -2134,6 +2163,25 @@ class ServiceTestThread(QThread):
connected = self.client.is_authenticated()
elif self.service == 'plex':
connected = self.client.is_connected()
elif self.service == 'jellyfin':
# Test Jellyfin connection using HTTP request
try:
from config.settings import config_manager
jellyfin_config = config_manager.get_jellyfin_config()
base_url = jellyfin_config.get('base_url', '').rstrip('/')
api_key = jellyfin_config.get('api_key', '')
if base_url and api_key:
import requests
headers = {'X-Emby-Token': api_key}
response = requests.get(f"{base_url}/System/Info", headers=headers, timeout=5)
connected = response.status_code == 200
else:
connected = False
error = "Missing Jellyfin configuration (base_url or api_key)"
except Exception as e:
connected = False
error = str(e)
elif self.service == 'soulseek':
# Run async method in new event loop
loop = asyncio.new_event_loop()
@ -2322,8 +2370,11 @@ class MetadataUpdaterWidget(QFrame):
layout.setContentsMargins(20, 15, 20, 15)
layout.setSpacing(12)
# Header
header_label = QLabel("Plex Metadata Updater")
# Header - Make it dynamic based on active server
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
server_display = active_server.title()
header_label = QLabel(f"{server_display} Metadata Updater")
header_label.setFont(QFont("Arial", 14, QFont.Weight.Bold))
header_label.setStyleSheet("color: #ffffff;")
@ -2661,25 +2712,29 @@ class DashboardPage(QWidget):
self.wishlist_download_modal.process_finished.connect(self.on_wishlist_modal_finished)
return True
def set_service_clients(self, spotify_client, plex_client, soulseek_client, downloads_page=None):
def set_service_clients(self, spotify_client, plex_client, jellyfin_client, soulseek_client, downloads_page=None):
"""Called from main window to provide service client references"""
self.data_provider.set_service_clients(spotify_client, plex_client, soulseek_client)
self.data_provider.set_service_clients(spotify_client, plex_client, jellyfin_client, soulseek_client)
# Store service clients for wishlist modal
self.service_clients = {
'spotify_client': spotify_client,
'plex_client': plex_client,
'jellyfin_client': jellyfin_client,
'soulseek_client': soulseek_client,
'downloads_page': downloads_page
}
# Initialize Plex scan manager for wishlist modal integration
# Initialize unified media scan manager for wishlist modal integration
self.scan_manager = None
if plex_client:
self.scan_manager = PlexScanManager(plex_client, delay_seconds=60)
# Add automatic incremental database update after Plex scan completion
self.scan_manager.add_scan_completion_callback(self._on_plex_scan_completed)
logger.info("✅ PlexScanManager initialized for Dashboard wishlist modal")
try:
from core.media_scan_manager import MediaScanManager
self.scan_manager = MediaScanManager(delay_seconds=60)
# Add automatic incremental database update after scan completion
self.scan_manager.add_scan_completion_callback(self._on_media_scan_completed)
logger.info("✅ MediaScanManager initialized for Dashboard wishlist modal")
except Exception as e:
logger.error(f"Failed to initialize MediaScanManager: {e}")
def set_page_references(self, downloads_page, sync_page):
"""Called from main window to provide page references for live data"""
@ -2695,17 +2750,24 @@ class DashboardPage(QWidget):
"""Set the toast manager for showing notifications"""
self.toast_manager = toast_manager
def _on_plex_scan_completed(self):
"""Callback triggered when Plex scan completes - start automatic incremental database update"""
def _on_media_scan_completed(self):
"""Callback triggered when media scan completes - start automatic incremental database update"""
try:
# Import here to avoid circular imports
from database import get_database
from core.database_update_worker import DatabaseUpdateWorker
from config.settings import config_manager
# Get the active media client
active_server = config_manager.get_active_media_server()
if active_server == "jellyfin":
media_client = self.service_clients.get('jellyfin_client')
else:
media_client = self.service_clients.get('plex_client')
# Check if we should run incremental update
plex_client = self.service_clients.get('plex_client')
if not plex_client or not plex_client.is_connected():
logger.debug("Plex not connected - skipping automatic database update")
if not media_client or not media_client.is_connected():
logger.debug(f"{active_server.upper()} not connected - skipping automatic database update")
return
# Check if database has a previous full refresh
@ -2728,11 +2790,11 @@ class DashboardPage(QWidget):
return
# All conditions met - start incremental update
logger.info("🎵 Starting automatic incremental database update after Plex scan")
logger.info(f"🎵 Starting automatic incremental database update after {active_server.upper()} scan")
self._start_automatic_incremental_update()
except Exception as e:
logger.error(f"Error in Plex scan completion callback: {e}")
logger.error(f"Error in media scan completion callback: {e}")
def _start_automatic_incremental_update(self):
"""Start the automatic incremental database update"""
@ -2745,11 +2807,24 @@ class DashboardPage(QWidget):
return
# Create worker for incremental update only
plex_client = self.service_clients.get('plex_client')
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
# Get the appropriate client
if active_server == "plex":
media_client = self.service_clients.get('plex_client')
elif active_server == "jellyfin":
from core.jellyfin_client import JellyfinClient
media_client = JellyfinClient()
else:
logger.error(f"Unknown active server for auto-update: {active_server}")
return
self._auto_database_worker = DatabaseUpdateWorker(
plex_client,
media_client,
"database/music_library.db",
full_refresh=False # Always incremental for automatic updates
full_refresh=False, # Always incremental for automatic updates
server_type=active_server
)
# Connect completion signal to log result
@ -2973,8 +3048,11 @@ class DashboardPage(QWidget):
cards_layout = QHBoxLayout()
cards_layout.setSpacing(20)
# Create service status cards
services = ['Spotify', 'Plex', 'Soulseek']
# Create service status cards with dynamic media server
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
server_name = "Plex" if active_server == "plex" else "Jellyfin"
services = ['Spotify', server_name, 'Soulseek']
for service in services:
card = ServiceStatusCard(service)
card.test_button.clicked.connect(lambda checked, s=service.lower(): self.test_service_connection(s))
@ -3036,13 +3114,20 @@ class DashboardPage(QWidget):
self.database_widget = DatabaseUpdaterWidget()
self.database_widget.start_button.clicked.connect(self.toggle_database_update)
# Metadata updater widget (SECOND)
self.metadata_widget = MetadataUpdaterWidget()
self.metadata_widget.start_button.clicked.connect(self.toggle_metadata_update)
# Metadata updater widget (SECOND) - only show for Plex
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
if active_server == "plex":
self.metadata_widget = MetadataUpdaterWidget()
self.metadata_widget.start_button.clicked.connect(self.toggle_metadata_update)
else:
self.metadata_widget = None # Hide for Jellyfin
layout.addWidget(header_label)
layout.addWidget(self.database_widget)
layout.addWidget(self.metadata_widget)
if self.metadata_widget: # Only add if it exists
layout.addWidget(self.metadata_widget)
return section
@ -3122,9 +3207,24 @@ class DashboardPage(QWidget):
logger.debug(f"Service clients available: {list(self.data_provider.service_clients.keys())}")
logger.debug(f"Plex client: {self.data_provider.service_clients.get('plex')}")
if not hasattr(self, 'data_provider') or not self.data_provider.service_clients.get('plex_client'):
# Check that we have a data provider
if not hasattr(self, 'data_provider'):
self.add_activity_item("", "Database Update", "Service clients not available", "Now")
return
# Get the active media server and check if client is available
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
if active_server == "plex" and not self.data_provider.service_clients.get('plex_client'):
self.add_activity_item("", "Database Update", "Plex client not available", "Now")
return
elif active_server == "jellyfin":
# Jellyfin client will be created on-demand, just verify config exists
jellyfin_config = config_manager.get_jellyfin_config()
if not jellyfin_config.get('base_url') or not jellyfin_config.get('api_key'):
self.add_activity_item("", "Database Update", "Jellyfin not configured", "Now")
return
try:
# Get update type from dropdown
@ -3147,11 +3247,28 @@ class DashboardPage(QWidget):
logger.debug("Full refresh cancelled by user")
return # Cancel the operation
# Get the active media server
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
# Get the appropriate client
if active_server == "plex":
media_client = self.data_provider.service_clients['plex_client']
elif active_server == "jellyfin":
# Import and get Jellyfin client
from core.jellyfin_client import JellyfinClient
media_client = JellyfinClient()
else:
logger.error(f"Unknown active server: {active_server}")
self.add_activity_item("", "Database Update", f"Unknown server type: {active_server}", "Now")
return
# Start the database update worker
self.database_worker = DatabaseUpdateWorker(
self.data_provider.service_clients['plex_client'],
media_client,
"database/music_library.db",
full_refresh
full_refresh,
server_type=active_server
)
# Connect signals
@ -3164,7 +3281,8 @@ class DashboardPage(QWidget):
# Update UI and start
self.database_widget.update_progress(True, "Initializing...", 0, 0, 0.0)
update_type = "Full refresh" if full_refresh else "Incremental update"
self.add_activity_item("🗄️", "Database Update", f"Starting {update_type.lower()}...", "Now")
server_display = active_server.title() # "Plex" or "Jellyfin"
self.add_activity_item("🗄️", "Database Update", f"Starting {update_type.lower()} from {server_display}...", "Now")
self.database_worker.start()
@ -3310,6 +3428,9 @@ class DashboardPage(QWidget):
def toggle_metadata_update(self):
"""Toggle metadata update process"""
if not self.metadata_widget:
return # Metadata widget not available (Jellyfin server)
current_text = self.metadata_widget.start_button.text()
if "Begin" in current_text:
# Start metadata update
@ -3324,9 +3445,22 @@ class DashboardPage(QWidget):
if hasattr(self, 'data_provider') and hasattr(self.data_provider, 'service_clients'):
logger.debug(f"Service clients available: {list(self.data_provider.service_clients.keys())}")
if not hasattr(self, 'data_provider') or not self.data_provider.service_clients.get('plex_client'):
self.add_activity_item("", "Metadata Update", "Plex client not available", "Now")
return
# Check active server and client availability
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
# Currently metadata updater only supports Plex
# Check if we have the active media server client
if active_server == "jellyfin":
media_client = self.data_provider.service_clients.get('jellyfin_client')
if not media_client:
self.add_activity_item("", "Metadata Update", "Jellyfin client not available", "Now")
return
else:
media_client = self.data_provider.service_clients.get('plex_client')
if not media_client:
self.add_activity_item("", "Metadata Update", "Plex client not available", "Now")
return
if not self.data_provider.service_clients.get('spotify_client'):
self.add_activity_item("", "Metadata Update", "Spotify client not available", "Now")
@ -3334,13 +3468,14 @@ class DashboardPage(QWidget):
try:
# Get refresh interval from dropdown
refresh_interval_days = self.metadata_widget.get_refresh_interval_days()
refresh_interval_days = self.metadata_widget.get_refresh_interval_days() if self.metadata_widget else 30
# Start the metadata update worker (it will handle artist retrieval in background)
self.metadata_worker = MetadataUpdateWorker(
None, # Artists will be loaded in the worker thread
self.data_provider.service_clients['plex_client'],
media_client,
self.data_provider.service_clients['spotify_client'],
active_server,
refresh_interval_days
)
@ -3352,8 +3487,9 @@ class DashboardPage(QWidget):
self.metadata_worker.artists_loaded.connect(self.on_artists_loaded)
# Update UI and start
self.metadata_widget.update_progress(True, "Loading artists...", 0, 0, 0.0)
self.add_activity_item("🎵", "Metadata Update", "Loading artists from Plex library...", "Now")
if self.metadata_widget:
self.metadata_widget.update_progress(True, "Loading artists...", 0, 0, 0.0)
self.add_activity_item("🎵", "Metadata Update", "Loading artists from library...", "Now")
self.metadata_worker.start()
@ -3375,7 +3511,8 @@ class DashboardPage(QWidget):
if self.metadata_worker.isRunning():
self.metadata_worker.terminate()
self.metadata_widget.update_progress(False, "", 0, 0, 0.0)
if self.metadata_widget:
self.metadata_widget.update_progress(False, "", 0, 0, 0.0)
self.add_activity_item("⏹️", "Metadata Update", "Stopped metadata update process", "Now")
def artist_needs_processing(self, artist):
@ -3414,7 +3551,8 @@ class DashboardPage(QWidget):
def on_metadata_progress(self, current_artist, processed, total, percentage):
"""Handle metadata update progress"""
self.metadata_widget.update_progress(True, current_artist, processed, total, percentage)
if self.metadata_widget:
self.metadata_widget.update_progress(True, current_artist, processed, total, percentage)
def on_artist_updated(self, artist_name, success, details):
"""Handle individual artist update completion"""
@ -3425,13 +3563,15 @@ class DashboardPage(QWidget):
def on_metadata_finished(self, total_processed, successful, failed):
"""Handle metadata update completion"""
self.metadata_widget.update_progress(False, "", 0, 0, 0.0)
if self.metadata_widget:
self.metadata_widget.update_progress(False, "", 0, 0, 0.0)
summary = f"Processed {total_processed} artists: {successful} updated, {failed} failed"
self.add_activity_item("🎵", "Metadata Complete", summary, "Now")
def on_metadata_error(self, error_message):
"""Handle metadata update error"""
self.metadata_widget.update_progress(False, "", 0, 0, 0.0)
if self.metadata_widget:
self.metadata_widget.update_progress(False, "", 0, 0, 0.0)
self.add_activity_item("", "Metadata Error", error_message, "Now")
def on_service_status_updated(self, service: str, connected: bool, response_time: float, error: str):
@ -3471,7 +3611,8 @@ class DashboardPage(QWidget):
def on_metadata_progress_updated(self, is_running: bool, current_artist: str, processed: int, total: int, percentage: float):
"""Handle metadata update progress"""
self.metadata_widget.update_progress(is_running, current_artist, processed, total, percentage)
if self.metadata_widget:
self.metadata_widget.update_progress(is_running, current_artist, processed, total, percentage)
def on_sync_progress_updated(self, current_playlist: str, active_syncs: int):
"""Handle sync progress updates"""

View file

@ -4,6 +4,9 @@ from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
from PyQt6.QtCore import Qt, QThread, pyqtSignal
from PyQt6.QtGui import QFont
from config.settings import config_manager
from utils.logging_config import get_logger
logger = get_logger("settings")
class PlexDetectionThread(QThread):
progress_updated = pyqtSignal(int, str) # progress value, current url
@ -447,6 +450,8 @@ class ServiceTestThread(QThread):
success, message = self._test_spotify()
elif self.service_type == "plex":
success, message = self._test_plex()
elif self.service_type == "jellyfin":
success, message = self._test_jellyfin()
elif self.service_type == "soulseek":
success, message = self._test_soulseek()
else:
@ -547,6 +552,48 @@ class ServiceTestThread(QThread):
except Exception as e:
return False, f"✗ Plex test failed:\n{str(e)}"
def _test_jellyfin(self):
"""Test Jellyfin connection"""
try:
import requests
base_url = self.test_config['base_url']
api_key = self.test_config['api_key']
if not base_url:
return False, "Please enter Jellyfin server URL"
if not api_key:
return False, "Please enter Jellyfin API key"
# Clean URL - remove trailing slash
if base_url.endswith('/'):
base_url = base_url[:-1]
# Test connection with system info endpoint
headers = {'X-Emby-Token': api_key} if api_key else {}
test_url = f"{base_url}/System/Info"
response = requests.get(test_url, headers=headers, timeout=5)
if response.status_code == 200:
data = response.json()
server_name = data.get('ServerName', 'Unknown')
version = data.get('Version', 'Unknown')
message = f"✓ Jellyfin connection successful!\nServer: {server_name}\nVersion: {version}"
return True, message
elif response.status_code == 401:
return False, "✗ Jellyfin authentication failed.\nCheck your API key."
else:
return False, f"✗ Jellyfin connection failed.\nHTTP {response.status_code}: {response.text}"
except requests.exceptions.Timeout:
return False, "✗ Jellyfin connection timeout.\nCheck your server URL."
except requests.exceptions.ConnectionError:
return False, "✗ Cannot connect to Jellyfin server.\nCheck your server URL and network."
except Exception as e:
return False, f"✗ Jellyfin test failed:\n{str(e)}"
def _test_soulseek(self):
"""Test Soulseek connection"""
try:
@ -594,6 +641,151 @@ class ServiceTestThread(QThread):
except Exception as e:
return False, f"✗ Unexpected error:\n{str(e)}"
class JellyfinDetectionThread(QThread):
progress_updated = pyqtSignal(int, str) # progress value, current url
detection_completed = pyqtSignal(str) # found_url (empty if not found)
def __init__(self):
super().__init__()
self.cancelled = False
def cancel(self):
self.cancelled = True
def run(self):
import requests
import socket
import ipaddress
import subprocess
import platform
from concurrent.futures import ThreadPoolExecutor, as_completed
def get_network_info():
"""Get comprehensive network information with subnet detection"""
try:
# Get local IP using socket method
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
local_ip = s.getsockname()[0]
s.close()
# Parse network info
network = ipaddress.ip_network(f"{local_ip}/24", strict=False)
return {
'local_ip': local_ip,
'network': network,
'subnet': str(network.network_address) + "/24"
}
except Exception as e:
print(f"Error getting network info: {e}")
return {'local_ip': '127.0.0.1', 'network': None, 'subnet': '127.0.0.1/32'}
try:
# Test common Jellyfin URLs first
common_urls = [
"http://localhost:8096",
"http://127.0.0.1:8096",
"http://jellyfin:8096"
]
network_info = get_network_info()
local_ip = network_info['local_ip']
# Add local IP variations
if local_ip != '127.0.0.1':
common_urls.extend([
f"http://{local_ip}:8096",
f"https://{local_ip}:8920" # HTTPS port
])
# Test common URLs first
for i, url in enumerate(common_urls):
if self.cancelled:
break
progress = int((i / len(common_urls)) * 50) # First 50% for common URLs
self.progress_updated.emit(progress, url)
if self.test_jellyfin_url(url):
self.detection_completed.emit(url)
return
# If common URLs fail, scan network subnet
if network_info['network'] and not self.cancelled:
network = network_info['network']
hosts_to_scan = list(network.hosts())[:50] # Limit to first 50 hosts
def test_host(host_ip):
if self.cancelled:
return None
test_urls = [
f"http://{host_ip}:8096",
f"https://{host_ip}:8920"
]
for url in test_urls:
if self.cancelled:
break
if self.test_jellyfin_url(url, timeout=2): # Shorter timeout for network scan
return url
return None
# Test hosts in parallel
with ThreadPoolExecutor(max_workers=10) as executor:
future_to_host = {executor.submit(test_host, str(host)): host for host in hosts_to_scan}
for i, future in enumerate(as_completed(future_to_host)):
if self.cancelled:
break
progress = 50 + int((i / len(hosts_to_scan)) * 50) # Remaining 50%
host = future_to_host[future]
self.progress_updated.emit(progress, f"Scanning {host}...")
result = future.result()
if result:
self.detection_completed.emit(result)
return
# Nothing found
self.detection_completed.emit("")
except Exception as e:
print(f"Jellyfin detection error: {e}")
self.detection_completed.emit("") # Empty string = not found
def test_jellyfin_url(self, url, timeout=5):
"""Test if a URL hosts a Jellyfin server"""
try:
import requests
# Test the system/info endpoint which is available without auth
response = requests.get(f"{url}/System/Info", timeout=timeout, verify=False)
if response.status_code == 200:
data = response.json()
# Check if it's actually Jellyfin
if 'ServerName' in data or 'Version' in data:
return True
except Exception:
pass
# Fallback: try to get the web interface
try:
import requests
response = requests.get(url, timeout=timeout, verify=False)
if response.status_code == 200:
content = response.text.lower()
# Look for Jellyfin-specific content
if 'jellyfin' in content or 'emby' in content:
return True
except Exception:
pass
return False
class SettingsGroup(QGroupBox):
def __init__(self, title: str, parent=None):
super().__init__(title, parent)
@ -772,6 +964,24 @@ class SettingsPage(QWidget):
self.plex_url_input.setText(plex_config.get('base_url', ''))
self.plex_token_input.setText(plex_config.get('token', ''))
# Load Jellyfin config
jellyfin_config = config_manager.get_jellyfin_config()
self.jellyfin_url_input.setText(jellyfin_config.get('base_url', ''))
self.jellyfin_api_key_input.setText(jellyfin_config.get('api_key', ''))
# Initialize server selection
active_server = config_manager.get_active_media_server()
self.pending_server_change = None
self.update_server_toggle_styles(active_server)
# Show/hide appropriate containers based on active server
if active_server == 'plex':
self.plex_container.show()
self.jellyfin_container.hide()
else:
self.plex_container.hide()
self.jellyfin_container.show()
# Load Soulseek config
soulseek_config = config_manager.get_soulseek_config()
self.slskd_url_input.setText(soulseek_config.get('slskd_url', ''))
@ -838,6 +1048,15 @@ class SettingsPage(QWidget):
config_manager.set('plex.base_url', self.plex_url_input.text())
config_manager.set('plex.token', self.plex_token_input.text())
# Save Jellyfin settings
config_manager.set('jellyfin.base_url', self.jellyfin_url_input.text())
config_manager.set('jellyfin.api_key', self.jellyfin_api_key_input.text())
# Save pending server change if any
if hasattr(self, 'pending_server_change') and self.pending_server_change:
config_manager.set_active_media_server(self.pending_server_change)
logger.info(f"Server changed to {self.pending_server_change} - restart required")
# Save Soulseek settings
config_manager.set('soulseek.slskd_url', self.slskd_url_input.text())
config_manager.set('soulseek.api_key', self.api_key_input.text())
@ -924,6 +1143,26 @@ class SettingsPage(QWidget):
}
self.start_service_test('spotify', test_config)
def test_active_server_connection(self):
"""Test the currently active (or pending) media server connection"""
# Determine which server to test (pending change takes priority)
active_server = getattr(self, 'pending_server_change', None) or config_manager.get_active_media_server()
if active_server == 'plex':
test_config = {
'base_url': self.plex_url_input.text(),
'token': self.plex_token_input.text()
}
self.start_service_test('plex', test_config)
elif active_server == 'jellyfin':
test_config = {
'base_url': self.jellyfin_url_input.text(),
'api_key': self.jellyfin_api_key_input.text()
}
self.start_service_test('jellyfin', test_config)
else:
logger.warning(f"Unknown active server type: {active_server}")
def test_plex_connection(self):
"""Test Plex server connection in background thread"""
test_config = {
@ -1356,6 +1595,117 @@ class SettingsPage(QWidget):
dialog.exec()
def show_jellyfin_success_dialog(self, found_url):
"""Show custom Jellyfin success dialog with copy functionality"""
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QTextEdit
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QClipboard
dialog = QDialog(self)
dialog.setWindowTitle("Jellyfin Auto-detect Success")
dialog.setModal(True)
dialog.setFixedSize(380, 160)
dialog.setWindowFlags(dialog.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint)
# Apply dark theme styling
dialog.setStyleSheet("""
QDialog {
background-color: #282828;
color: #ffffff;
border: 1px solid #404040;
border-radius: 8px;
}
QLabel {
color: #ffffff;
font-size: 12px;
}
QTextEdit {
background-color: #404040;
border: 1px solid #606060;
border-radius: 4px;
color: #ffffff;
font-size: 11px;
font-family: 'Courier New', monospace;
padding: 8px;
}
QPushButton {
background-color: #404040;
border: 1px solid #606060;
border-radius: 4px;
color: #ffffff;
padding: 6px 12px;
font-size: 11px;
min-width: 50px;
min-height: 28px;
}
QPushButton:hover {
background-color: #505050;
}
#copyButton {
background-color: #aa5cc3;
border: 1px solid #aa5cc3;
color: #ffffff;
font-weight: bold;
min-height: 28px;
}
#copyButton:hover {
background-color: #ba6cd3;
}
""")
layout = QVBoxLayout(dialog)
layout.setSpacing(8)
layout.setContentsMargins(15, 15, 15, 15)
# Success message
location_type = "locally" if "localhost" in found_url or "127.0.0.1" in found_url else "on network"
success_label = QLabel(f"✓ Found Jellyfin server running {location_type}!")
success_label.setStyleSheet("color: #aa5cc3; font-size: 13px; font-weight: bold;")
success_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(success_label)
# URL display with copy functionality
url_label = QLabel("Detected URL:")
layout.addWidget(url_label)
url_container = QHBoxLayout()
url_container.setSpacing(5)
url_display = QTextEdit()
url_display.setPlainText(found_url)
url_display.setReadOnly(True)
url_display.setFixedHeight(30)
url_display.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
url_container.addWidget(url_display)
copy_btn = QPushButton("Copy")
copy_btn.setObjectName("copyButton")
copy_btn.setFixedSize(55, 30)
copy_btn.clicked.connect(lambda: self.copy_to_clipboard(found_url, copy_btn))
url_container.addWidget(copy_btn)
layout.addLayout(url_container)
# Info text
info_label = QLabel("URL automatically filled in settings above.")
info_label.setStyleSheet("color: #ffffff; font-size: 9px; font-style: italic; background: transparent;")
info_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(info_label)
# OK button
button_layout = QHBoxLayout()
button_layout.addStretch()
ok_btn = QPushButton("OK")
ok_btn.setFixedSize(60, 28)
ok_btn.clicked.connect(dialog.accept)
ok_btn.setDefault(True)
button_layout.addWidget(ok_btn)
layout.addLayout(button_layout)
dialog.exec()
def show_success_dialog(self, found_url):
"""Show custom slskd success dialog with copy functionality"""
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QTextEdit
@ -1620,6 +1970,59 @@ class SettingsPage(QWidget):
helper_text.setWordWrap(True)
spotify_layout.addWidget(helper_text)
# Server Selection Toggle Buttons
server_selection_container = QWidget()
server_selection_container.setStyleSheet("background: transparent;")
server_selection_layout = QVBoxLayout(server_selection_container)
server_selection_layout.setContentsMargins(0, 12, 0, 12)
server_selection_layout.setSpacing(8)
# Server selection title
server_title = QLabel("Media Server Source")
server_title.setFont(QFont("Arial", 12, QFont.Weight.Bold))
server_title.setStyleSheet("color: #ffffff; background: transparent;")
server_selection_layout.addWidget(server_title)
# Toggle buttons container
toggle_container = QHBoxLayout()
toggle_container.setSpacing(8)
# Plex toggle button
self.plex_toggle_button = QPushButton()
self.plex_toggle_button.setFixedHeight(40)
self.plex_toggle_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.plex_toggle_button.clicked.connect(lambda: self.select_media_server('plex'))
# Jellyfin toggle button
self.jellyfin_toggle_button = QPushButton()
self.jellyfin_toggle_button.setFixedHeight(40)
self.jellyfin_toggle_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.jellyfin_toggle_button.clicked.connect(lambda: self.select_media_server('jellyfin'))
toggle_container.addWidget(self.plex_toggle_button)
toggle_container.addWidget(self.jellyfin_toggle_button)
server_selection_layout.addLayout(toggle_container)
# Restart warning (initially hidden)
self.restart_warning_frame = QLabel("⚠️ Server change requires restart - Save settings then restart SoulSync")
self.restart_warning_frame.setStyleSheet("""
color: #ffc107;
font-size: 11px;
font-weight: bold;
background: transparent;
margin: 8px 0px 4px 0px;
""")
self.restart_warning_frame.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.restart_warning_frame.hide()
server_selection_layout.addWidget(self.restart_warning_frame)
# Media Server Settings Container
self.plex_container = QWidget()
self.plex_container.setStyleSheet("background: transparent;")
plex_container_layout = QVBoxLayout(self.plex_container)
plex_container_layout.setContentsMargins(0, 0, 0, 0)
plex_container_layout.setSpacing(0)
# Plex settings
plex_frame = QFrame()
plex_frame.setStyleSheet("""
@ -1670,6 +2073,69 @@ class SettingsPage(QWidget):
self.form_inputs['plex.token'] = self.plex_token_input
plex_layout.addWidget(self.plex_token_input)
# Add Plex frame to its container
plex_container_layout.addWidget(plex_frame)
# Jellyfin Settings Container
self.jellyfin_container = QWidget()
self.jellyfin_container.setStyleSheet("background: transparent;")
jellyfin_container_layout = QVBoxLayout(self.jellyfin_container)
jellyfin_container_layout.setContentsMargins(0, 0, 0, 0)
jellyfin_container_layout.setSpacing(0)
# Jellyfin settings
jellyfin_frame = QFrame()
jellyfin_frame.setStyleSheet("""
QFrame {
background: #333333;
border: 1px solid #444444;
border-radius: 8px;
padding: 8px;
}
""")
jellyfin_layout = QVBoxLayout(jellyfin_frame)
jellyfin_layout.setSpacing(8)
jellyfin_title = QLabel("Jellyfin")
jellyfin_title.setFont(QFont("Arial", 12, QFont.Weight.Bold))
jellyfin_title.setStyleSheet("color: #aa5cc3;") # Jellyfin purple color
jellyfin_layout.addWidget(jellyfin_title)
# Server URL
jellyfin_url_label = QLabel("Server URL:")
jellyfin_url_label.setStyleSheet(self.get_label_style(11))
jellyfin_layout.addWidget(jellyfin_url_label)
jellyfin_url_input_layout = QHBoxLayout()
self.jellyfin_url_input = QLineEdit()
self.jellyfin_url_input.setStyleSheet(self.get_input_style())
self.jellyfin_url_input.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.form_inputs['jellyfin.base_url'] = self.jellyfin_url_input
jellyfin_detect_btn = QPushButton("Auto-detect")
jellyfin_detect_btn.setFixedSize(80, 30)
jellyfin_detect_btn.clicked.connect(self.auto_detect_jellyfin)
jellyfin_detect_btn.setStyleSheet(self.get_test_button_style())
jellyfin_url_input_layout.addWidget(self.jellyfin_url_input)
jellyfin_url_input_layout.addWidget(jellyfin_detect_btn)
jellyfin_layout.addLayout(jellyfin_url_input_layout)
# API Key
jellyfin_api_key_label = QLabel("API Key:")
jellyfin_api_key_label.setStyleSheet(self.get_label_style(11))
jellyfin_layout.addWidget(jellyfin_api_key_label)
self.jellyfin_api_key_input = QLineEdit()
self.jellyfin_api_key_input.setEchoMode(QLineEdit.EchoMode.Password)
self.jellyfin_api_key_input.setStyleSheet(self.get_input_style())
self.jellyfin_api_key_input.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.form_inputs['jellyfin.api_key'] = self.jellyfin_api_key_input
jellyfin_layout.addWidget(self.jellyfin_api_key_input)
# Add Jellyfin frame to its container
jellyfin_container_layout.addWidget(jellyfin_frame)
# Soulseek settings
soulseek_frame = QFrame()
soulseek_frame.setStyleSheet("""
@ -1722,7 +2188,9 @@ class SettingsPage(QWidget):
soulseek_layout.addWidget(self.api_key_input)
api_layout.addWidget(spotify_frame)
api_layout.addWidget(plex_frame)
api_layout.addWidget(server_selection_container)
api_layout.addWidget(self.plex_container)
api_layout.addWidget(self.jellyfin_container)
api_layout.addWidget(soulseek_frame)
# Test connections
@ -1735,11 +2203,11 @@ class SettingsPage(QWidget):
self.test_buttons['spotify'].clicked.connect(self.test_spotify_connection)
self.test_buttons['spotify'].setStyleSheet(self.get_test_button_style())
self.test_buttons['plex'] = QPushButton("Test Plex")
self.test_buttons['plex'].setFixedHeight(30)
self.test_buttons['plex'].setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.test_buttons['plex'].clicked.connect(self.test_plex_connection)
self.test_buttons['plex'].setStyleSheet(self.get_test_button_style())
self.test_buttons['server'] = QPushButton("Test Server")
self.test_buttons['server'].setFixedHeight(30)
self.test_buttons['server'].setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.test_buttons['server'].clicked.connect(self.test_active_server_connection)
self.test_buttons['server'].setStyleSheet(self.get_test_button_style())
self.test_buttons['soulseek'] = QPushButton("Test Soulseek")
self.test_buttons['soulseek'].setFixedHeight(30)
@ -1748,58 +2216,13 @@ class SettingsPage(QWidget):
self.test_buttons['soulseek'].setStyleSheet(self.get_test_button_style())
test_layout.addWidget(self.test_buttons['spotify'])
test_layout.addWidget(self.test_buttons['plex'])
test_layout.addWidget(self.test_buttons['server'])
test_layout.addWidget(self.test_buttons['soulseek'])
api_layout.addLayout(test_layout)
# Logging Settings
logging_group = SettingsGroup("Logging Settings")
logging_layout = QVBoxLayout(logging_group)
logging_layout.setContentsMargins(16, 20, 16, 16)
logging_layout.setSpacing(12)
# Log level (read-only)
log_level_layout = QHBoxLayout()
log_level_label = QLabel("Log Level:")
log_level_label.setStyleSheet(self.get_label_style(12))
self.log_level_display = QLabel("DEBUG")
self.log_level_display.setStyleSheet("""
color: #ffffff;
font-size: 11px;
background: transparent;
border: none;
""")
self.log_level_display.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
log_level_layout.addWidget(log_level_label)
log_level_layout.addWidget(self.log_level_display)
# Log file path (read-only)
log_path_container = QVBoxLayout()
log_path_label = QLabel("Log File Path:")
log_path_label.setStyleSheet(self.get_label_style(12))
log_path_container.addWidget(log_path_label)
self.log_path_display = QLabel("logs/app.log")
self.log_path_display.setStyleSheet("""
color: #1db954;
font-size: 11px;
font-family: 'Courier New', monospace;
background-color: rgba(29, 185, 84, 0.1);
border: 1px solid rgba(29, 185, 84, 0.3);
border-radius: 4px;
padding: 6px 8px;
""")
self.log_path_display.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
log_path_container.addWidget(self.log_path_display)
logging_layout.addLayout(log_level_layout)
logging_layout.addLayout(log_path_container)
layout.addWidget(api_group)
layout.addWidget(logging_group)
layout.addStretch()
return column
@ -2013,10 +2436,56 @@ class SettingsPage(QWidget):
# Add to form inputs for saving
self.form_inputs['playlist_sync.create_backup'] = self.create_backup_checkbox
# Logging Settings
logging_group = SettingsGroup("Logging Settings")
logging_layout = QVBoxLayout(logging_group)
logging_layout.setContentsMargins(16, 20, 16, 16)
logging_layout.setSpacing(12)
# Log level (read-only)
log_level_layout = QHBoxLayout()
log_level_label = QLabel("Log Level:")
log_level_label.setStyleSheet(self.get_label_style(12))
self.log_level_display = QLabel("DEBUG")
self.log_level_display.setStyleSheet("""
color: #ffffff;
font-size: 11px;
background: transparent;
border: none;
""")
self.log_level_display.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
log_level_layout.addWidget(log_level_label)
log_level_layout.addWidget(self.log_level_display)
# Log file path (read-only)
log_path_container = QVBoxLayout()
log_path_label = QLabel("Log File Path:")
log_path_label.setStyleSheet(self.get_label_style(12))
log_path_container.addWidget(log_path_label)
self.log_path_display = QLabel("logs/app.log")
self.log_path_display.setStyleSheet("""
color: #1db954;
font-size: 11px;
font-family: 'Courier New', monospace;
background-color: rgba(29, 185, 84, 0.1);
border: 1px solid rgba(29, 185, 84, 0.3);
border-radius: 4px;
padding: 6px 8px;
""")
self.log_path_display.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
log_path_container.addWidget(self.log_path_display)
logging_layout.addLayout(log_level_layout)
logging_layout.addLayout(log_path_container)
layout.addWidget(download_group)
layout.addWidget(database_group)
layout.addWidget(metadata_group)
layout.addWidget(playlist_sync_group)
layout.addWidget(logging_group)
layout.addStretch() # Push content to top, prevent stretching
return column
@ -2036,6 +2505,298 @@ class SettingsPage(QWidget):
}
"""
def select_media_server(self, server_type: str):
"""Handle media server selection toggle"""
try:
current_server = config_manager.get_active_media_server()
if server_type != current_server:
# Show restart warning
self.restart_warning_frame.show()
# Update the pending server change (but don't make it active yet)
self.pending_server_change = server_type
else:
# Hide restart warning if selecting the current server
self.restart_warning_frame.hide()
self.pending_server_change = None
# Update toggle button styles
self.update_server_toggle_styles(server_type)
# Show/hide appropriate containers
if server_type == 'plex':
self.plex_container.show()
self.jellyfin_container.hide()
else:
self.plex_container.hide()
self.jellyfin_container.show()
except Exception as e:
logger.error(f"Error selecting media server: {e}")
def update_server_toggle_styles(self, active_server=None):
"""Update the visual styles of server toggle buttons"""
if active_server is None:
active_server = getattr(self, 'pending_server_change', None) or config_manager.get_active_media_server()
from PyQt6.QtGui import QIcon, QPixmap
from PyQt6.QtCore import QSize, Qt
import requests
import os
from pathlib import Path
def download_and_cache_logo(url, cache_filename, size=32):
"""Download logo and cache it locally, return QIcon"""
cache_dir = Path("ui/assets")
cache_dir.mkdir(exist_ok=True)
cache_path = cache_dir / cache_filename
# Download if not cached
if not cache_path.exists():
try:
logger.info(f"Downloading logo from {url}")
response = requests.get(url, timeout=10)
if response.status_code == 200:
with open(cache_path, 'wb') as f:
f.write(response.content)
logger.info(f"Logo cached at {cache_path}")
else:
logger.warning(f"Failed to download logo: HTTP {response.status_code}")
return QIcon()
except Exception as e:
logger.warning(f"Error downloading logo from {url}: {e}")
return QIcon()
# Load from cache
try:
pixmap = QPixmap(str(cache_path))
if not pixmap.isNull():
# Scale to desired size while maintaining aspect ratio
scaled_pixmap = pixmap.scaled(
size, size,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
)
return QIcon(scaled_pixmap)
else:
logger.warning(f"Could not load cached logo from {cache_path}")
return QIcon()
except Exception as e:
logger.warning(f"Error loading cached logo: {e}")
return QIcon()
# Cache and load the exact logos you provided
if not hasattr(self, '_cached_plex_icon'):
self._cached_plex_icon = download_and_cache_logo(
"https://wiki.mrmc.tv/images/c/cf/Plex_icon.png",
"plex_icon.png",
32
)
if not hasattr(self, '_cached_jellyfin_icon'):
self._cached_jellyfin_icon = download_and_cache_logo(
"https://upload.wikimedia.org/wikipedia/commons/thumb/4/41/Jellyfin_-_icon-transparent.svg/2048px-Jellyfin_-_icon-transparent.svg.png",
"jellyfin_icon.png",
32
)
plex_icon = self._cached_plex_icon
jellyfin_icon = self._cached_jellyfin_icon
# Active button styles with appropriate colors
active_plex_style = """
QPushButton {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 rgba(229, 160, 13, 0.8),
stop:1 rgba(199, 140, 11, 0.9));
border: 2px solid rgba(229, 160, 13, 1);
border-radius: 8px;
}
QPushButton:hover {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 rgba(229, 160, 13, 0.9),
stop:1 rgba(199, 140, 11, 1.0));
}
"""
active_jellyfin_style = """
QPushButton {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 rgba(170, 92, 195, 0.8),
stop:1 rgba(150, 82, 175, 0.9));
border: 2px solid rgba(170, 92, 195, 1);
border-radius: 8px;
}
QPushButton:hover {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 rgba(170, 92, 195, 0.9),
stop:1 rgba(150, 82, 175, 1.0));
}
"""
# Inactive button style
inactive_style = """
QPushButton {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 8px;
}
QPushButton:hover {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.5);
}
"""
# Set icons and styles
self.plex_toggle_button.setIcon(plex_icon)
self.plex_toggle_button.setIconSize(QSize(28, 28))
self.jellyfin_toggle_button.setIcon(jellyfin_icon)
self.jellyfin_toggle_button.setIconSize(QSize(28, 28))
if active_server == 'plex':
self.plex_toggle_button.setStyleSheet(active_plex_style)
self.jellyfin_toggle_button.setStyleSheet(inactive_style)
else:
self.plex_toggle_button.setStyleSheet(inactive_style)
self.jellyfin_toggle_button.setStyleSheet(active_jellyfin_style)
def auto_detect_jellyfin(self):
"""Auto-detect Jellyfin server URL using background thread"""
# Don't start new detection if one is already running
if self.detection_thread and self.detection_thread.isRunning():
return
# Create animated loading dialog
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton
from PyQt6.QtCore import QTimer, QPropertyAnimation, QRect
from PyQt6.QtGui import QPainter, QColor
self.detection_dialog = QDialog(self)
self.detection_dialog.setWindowTitle("Auto-detecting Jellyfin Server")
self.detection_dialog.setModal(True)
self.detection_dialog.setFixedSize(400, 180)
self.detection_dialog.setWindowFlags(self.detection_dialog.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint)
# Apply dark theme styling
self.detection_dialog.setStyleSheet("""
QDialog {
background-color: #282828;
color: #ffffff;
border: 1px solid #404040;
border-radius: 8px;
}
QLabel {
color: #ffffff;
font-size: 14px;
}
QPushButton {
background-color: #404040;
border: 1px solid #606060;
border-radius: 4px;
color: #ffffff;
padding: 8px 16px;
font-size: 11px;
}
QPushButton:hover {
background-color: #505050;
}
""")
layout = QVBoxLayout(self.detection_dialog)
layout.setSpacing(20)
layout.setContentsMargins(20, 20, 20, 20)
# Title label
title_label = QLabel("Searching for Jellyfin servers...")
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(title_label)
# Status label
self.status_label = QLabel("Checking local machine...")
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.status_label.setStyleSheet("color: #ffffff; font-size: 12px; background: transparent;")
layout.addWidget(self.status_label)
# Animated loading bar container
loading_container = QLabel()
loading_container.setFixedHeight(8)
loading_container.setStyleSheet("""
QLabel {
background-color: #404040;
border: 1px solid #606060;
border-radius: 4px;
}
""")
layout.addWidget(loading_container)
# Animated purple bar for Jellyfin
self.loading_bar = QLabel(loading_container)
self.loading_bar.setFixedHeight(6)
self.loading_bar.setStyleSheet("""
background-color: #aa5cc3;
border-radius: 3px;
border: none;
""")
# Start animation
self.loading_animation = QPropertyAnimation(self.loading_bar, b"geometry")
self.loading_animation.setDuration(1500) # 1.5 seconds
self.loading_animation.setStartValue(QRect(1, 1, 0, 6))
self.loading_animation.setEndValue(QRect(1, 1, loading_container.width() - 2, 6))
self.loading_animation.setLoopCount(-1) # Infinite loop
self.loading_animation.start()
# Cancel button
button_layout = QHBoxLayout()
button_layout.addStretch()
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(self.cancel_detection)
button_layout.addWidget(cancel_btn)
layout.addLayout(button_layout)
# Start Jellyfin detection thread
self.detection_thread = JellyfinDetectionThread()
self.detection_thread.progress_updated.connect(self.on_detection_progress, Qt.ConnectionType.QueuedConnection)
self.detection_thread.detection_completed.connect(self.on_jellyfin_detection_completed, Qt.ConnectionType.QueuedConnection)
self.detection_thread.start()
self.detection_dialog.show()
def on_jellyfin_detection_completed(self, found_url):
"""Handle Jellyfin detection completion"""
# Stop animation and close dialog
if hasattr(self, 'loading_animation'):
self.loading_animation.stop()
if hasattr(self, 'detection_dialog') and self.detection_dialog:
self.detection_dialog.close()
self.detection_dialog = None
# Properly cleanup thread
if self.detection_thread:
if self.detection_thread.isRunning():
self.detection_thread.quit()
self.detection_thread.wait(1000) # Wait up to 1 second for thread to finish
self.detection_thread.deleteLater()
self.detection_thread = None
if found_url:
self.jellyfin_url_input.setText(found_url)
self.show_jellyfin_success_dialog(found_url)
logger.info(f"Jellyfin auto-detection successful: {found_url}")
else:
from PyQt6.QtWidgets import QMessageBox
msg = QMessageBox(self)
msg.setWindowTitle("No Jellyfin Server Found")
msg.setText("Could not find a Jellyfin server on your network.\n\nPlease enter your server URL manually (e.g., http://localhost:8096)")
msg.setIcon(QMessageBox.Icon.Information)
msg.exec()
logger.info("Jellyfin auto-detection failed - no server found")
def get_combo_style(self):
return """
QComboBox {

View file

@ -521,12 +521,13 @@ class PlaylistTrackAnalysisWorkerSignals(QObject):
analysis_failed = pyqtSignal(str) # error_message
class PlaylistTrackAnalysisWorker(QRunnable):
"""Background worker to analyze playlist tracks against Plex library"""
"""Background worker to analyze playlist tracks against media library"""
def __init__(self, playlist_tracks, plex_client):
def __init__(self, playlist_tracks, media_client, server_type="plex"):
super().__init__()
self.playlist_tracks = playlist_tracks
self.plex_client = plex_client
self.media_client = media_client # Can be plex_client or jellyfin_client
self.server_type = server_type
self.signals = PlaylistTrackAnalysisWorkerSignals()
self._cancelled = False
# Instantiate the matching engine once per worker for efficiency
@ -545,14 +546,14 @@ class PlaylistTrackAnalysisWorker(QRunnable):
self.signals.analysis_started.emit(len(self.playlist_tracks))
results = []
# Check if Plex is connected
plex_connected = False
# Check if media server is connected
server_connected = False
try:
if self.plex_client:
plex_connected = self.plex_client.is_connected()
if self.media_client:
server_connected = self.media_client.is_connected()
except Exception as e:
print(f"Plex connection check failed: {e}")
plex_connected = False
print(f"{self.server_type.title()} connection check failed: {e}")
server_connected = False
for i, track in enumerate(self.playlist_tracks):
if self._cancelled:
@ -563,17 +564,17 @@ class PlaylistTrackAnalysisWorker(QRunnable):
exists_in_plex=False
)
if plex_connected:
# Check if track exists in Plex
if server_connected:
# Check if track exists in media server
try:
plex_match, confidence = self._check_track_in_plex(track)
match, confidence = self._check_track_in_library(track)
# Use the 0.8 confidence threshold
if plex_match and confidence >= 0.8:
result.exists_in_plex = True
result.plex_match = plex_match
if match and confidence >= 0.8:
result.exists_in_plex = True # Keep existing field name for compatibility
result.plex_match = match # Keep existing field name for compatibility
result.confidence = confidence
except Exception as e:
result.error_message = f"Plex check failed: {str(e)}"
result.error_message = f"{self.server_type.title()} check failed: {str(e)}"
results.append(result)
self.signals.track_analyzed.emit(i + 1, result)
@ -587,11 +588,11 @@ class PlaylistTrackAnalysisWorker(QRunnable):
traceback.print_exc()
self.signals.analysis_failed.emit(str(e))
def _check_track_in_plex(self, spotify_track):
def _check_track_in_library(self, spotify_track):
"""
Check if a Spotify track exists in the database by searching for each artist and
stopping as soon as a confident match is found.
Now uses local database instead of Plex API for much faster performance.
Now uses local database instead of media server API for much faster performance.
"""
try:
original_title = spotify_track.name
@ -624,8 +625,10 @@ class PlaylistTrackAnalysisWorker(QRunnable):
for query_title in unique_title_variations:
if self._cancelled: return None, 0.0
# Use database check_track_exists method with consistent thresholds
db_track, confidence = db.check_track_exists(query_title, artist_name, confidence_threshold=0.7)
# Use database check_track_exists method with consistent thresholds and active server filter
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
db_track, confidence = db.check_track_exists(query_title, artist_name, confidence_threshold=0.7, server_source=active_server)
if db_track and confidence >= 0.7:
print(f"✔️ Database match found for '{original_title}' by '{artist_name}': '{db_track.title}' with confidence {confidence:.2f}")
@ -1633,7 +1636,8 @@ class PlaylistDetailsModal(QDialog):
self.parent_page.sync_service = PlaylistSyncService(
self.parent_page.spotify_client,
self.parent_page.plex_client,
self.parent_page.soulseek_client
self.parent_page.soulseek_client,
getattr(self.parent_page, 'jellyfin_client', None)
)
# Start sync
@ -1712,14 +1716,24 @@ class PlaylistDetailsModal(QDialog):
self.start_track_analysis()
# Show analysis started message
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
server_name = active_server.title()
QMessageBox.information(self, "Analysis Started",
f"Starting analysis of {track_count} tracks.\nChecking Plex library for existing tracks...")
f"Starting analysis of {track_count} tracks.\nChecking {server_name} library for existing tracks...")
def start_track_analysis(self):
"""Start background track analysis against Plex library"""
"""Start background track analysis against media library"""
# Create analysis worker
plex_client = getattr(self.parent_page, 'plex_client', None)
worker = PlaylistTrackAnalysisWorker(self.playlist.tracks, plex_client)
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
if active_server == "plex":
media_client = getattr(self.parent_page, 'plex_client', None)
else: # jellyfin
media_client = getattr(self.parent_page, 'jellyfin_client', None)
worker = PlaylistTrackAnalysisWorker(self.playlist.tracks, media_client, active_server)
# Connect signals
worker.signals.analysis_started.connect(self.on_analysis_started)
@ -2673,10 +2687,11 @@ class SyncPage(QWidget):
sync_activity = pyqtSignal(str, str, str, str) # icon, title, subtitle, time
database_updated_externally = pyqtSignal()
def __init__(self, spotify_client=None, plex_client=None, soulseek_client=None, downloads_page=None, parent=None):
def __init__(self, spotify_client=None, plex_client=None, soulseek_client=None, downloads_page=None, jellyfin_client=None, parent=None):
super().__init__(parent)
self.spotify_client = spotify_client
self.plex_client = plex_client
self.jellyfin_client = jellyfin_client
self.soulseek_client = soulseek_client
self.downloads_page = downloads_page
self.sync_statuses = load_sync_status()
@ -2712,12 +2727,16 @@ class SyncPage(QWidget):
self.youtube_cards = {} # url -> YouTubePlaylistCard instance
self.youtube_cards_container = None # Container for all YouTube cards
# Initialize Plex scan manager
# Initialize unified media scan manager
self.scan_manager = None
if self.plex_client:
self.scan_manager = PlexScanManager(self.plex_client, delay_seconds=60)
# Add automatic incremental database update after Plex scan completion
self.scan_manager.add_scan_completion_callback(self._on_plex_scan_completed)
try:
from core.media_scan_manager import MediaScanManager
self.scan_manager = MediaScanManager(delay_seconds=60)
# Add automatic incremental database update after scan completion
self.scan_manager.add_scan_completion_callback(self._on_media_scan_completed)
logger.info("✅ MediaScanManager initialized for SyncPage")
except Exception as e:
logger.error(f"Failed to initialize MediaScanManager: {e}")
self.setup_ui()
@ -2729,16 +2748,24 @@ class SyncPage(QWidget):
"""Set the toast manager for showing notifications"""
self.toast_manager = toast_manager
def _on_plex_scan_completed(self):
"""Callback triggered when Plex scan completes - start automatic incremental database update"""
def _on_media_scan_completed(self):
"""Callback triggered when media scan completes - start automatic incremental database update"""
try:
# Import here to avoid circular imports
from database import get_database
from core.database_update_worker import DatabaseUpdateWorker
from config.settings import config_manager
# Get the active media client
active_server = config_manager.get_active_media_server()
if active_server == "jellyfin":
media_client = getattr(self, 'jellyfin_client', None)
else:
media_client = getattr(self, 'plex_client', None)
# Check if we should run incremental update
if not self.plex_client or not self.plex_client.is_connected():
logger.debug("Plex not connected - skipping automatic database update")
if not media_client or not media_client.is_connected():
logger.debug(f"{active_server.upper()} not connected - skipping automatic database update")
return
# Check if database has a previous full refresh
@ -2761,11 +2788,11 @@ class SyncPage(QWidget):
return
# All conditions met - start incremental update
logger.info("🎵 Starting automatic incremental database update after Plex scan")
logger.info(f"🎵 Starting automatic incremental database update after {active_server.upper()} scan")
self._start_automatic_incremental_update()
except Exception as e:
logger.error(f"Error in Plex scan completion callback: {e}")
logger.error(f"Error in media scan completion callback: {e}")
def _start_automatic_incremental_update(self):
"""Start the automatic incremental database update"""
@ -2866,7 +2893,8 @@ class SyncPage(QWidget):
self.sync_service = PlaylistSyncService(
self.spotify_client,
self.plex_client,
self.soulseek_client
self.soulseek_client,
getattr(self, 'jellyfin_client', None)
)
# Create sync worker
@ -2919,7 +2947,8 @@ class SyncPage(QWidget):
self.sync_service = PlaylistSyncService(
self.spotify_client,
self.plex_client,
self.soulseek_client
self.soulseek_client,
getattr(self, 'jellyfin_client', None)
)
# Create sync worker for sequential sync
@ -6681,9 +6710,16 @@ class DownloadMissingTracksModal(QDialog):
def start_plex_analysis(self):
"""Start Plex analysis using existing worker"""
plex_client = getattr(self.parent_page, 'plex_client', None)
worker = PlaylistTrackAnalysisWorker(self.playlist.tracks, plex_client)
"""Start media server analysis using existing worker"""
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
if active_server == "plex":
media_client = getattr(self.parent_page, 'plex_client', None)
else: # jellyfin
media_client = getattr(self.parent_page, 'jellyfin_client', None)
worker = PlaylistTrackAnalysisWorker(self.playlist.tracks, media_client, active_server)
worker.signals.analysis_started.connect(self.on_analysis_started)
worker.signals.track_analyzed.connect(self.on_track_analyzed)
worker.signals.analysis_completed.connect(self.on_analysis_completed)

View file

@ -540,6 +540,11 @@ class StatusIndicator(QWidget):
font-weight: 400;
letter-spacing: 0.1px;
""")
def update_name(self, new_name: str):
"""Update the service name displayed in the status indicator"""
self.service_name = new_name
self.service_label.setText(new_name)
class LoadingAnimation(QWidget):
"""Thin horizontal loading animation for media player with dual-mode capability"""
@ -1240,7 +1245,7 @@ class ModernSidebar(QWidget):
layout.setSpacing(0)
# Version button (clickable)
self.version_button = QPushButton("v.0.51")
self.version_button = QPushButton("v.0.6")
self.version_button.setFont(QFont("SF Pro Text", 10, QFont.Weight.Medium))
self.version_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.version_button.setStyleSheet("""
@ -1299,11 +1304,17 @@ class ModernSidebar(QWidget):
# Status indicators
self.spotify_status = StatusIndicator("Spotify")
self.plex_status = StatusIndicator("Plex")
# Dynamic media server status - determine which server is active
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
server_name = "Plex" if active_server == "plex" else "Jellyfin"
self.media_server_status = StatusIndicator(server_name)
self.soulseek_status = StatusIndicator("Soulseek")
layout.addWidget(self.spotify_status)
layout.addWidget(self.plex_status)
layout.addWidget(self.media_server_status)
layout.addWidget(self.soulseek_status)
return status_widget
@ -1320,13 +1331,20 @@ class ModernSidebar(QWidget):
def update_service_status(self, service: str, connected: bool):
status_map = {
"spotify": self.spotify_status,
"plex": self.plex_status,
"plex": self.media_server_status,
"jellyfin": self.media_server_status,
"soulseek": self.soulseek_status
}
if service in status_map:
status_map[service].update_status(connected)
def update_media_server_name(self, server_type: str):
"""Update the media server status indicator name"""
server_name = "Plex" if server_type == "plex" else "Jellyfin"
if hasattr(self, 'media_server_status'):
self.media_server_status.update_name(server_name)
def show_version_info(self):
"""Show the version information modal"""
try: