Merge pull request #8 from Nezreka/multi-source-support
Multi source support
This commit is contained in:
commit
5f25bc5cd8
20 changed files with 4668 additions and 586 deletions
76
README.md
76
README.md
|
|
@ -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:
|
|||
|
||||
[](https://ko-fi.com/boulderbadgedad)
|
||||
|
||||

|
||||

|
||||
|
||||
## ⚠️ 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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
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
346
core/media_scan_manager.py
Normal 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")
|
||||
|
|
@ -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})")
|
||||
|
|
|
|||
|
|
@ -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
34
main.py
|
|
@ -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()
|
||||
|
|
|
|||
197
multi-server-database-plan.md
Normal file
197
multi-server-database-plan.md
Normal 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
370
server-source.md
Normal 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.*
|
||||
|
|
@ -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
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
BIN
ui/assets/plex_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
120
ui/pages/sync.py
120
ui/pages/sync.py
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue