├── .gitattributes ├── .gitignore ├── LICENSE ├── README.md ├── config.json.example ├── database └── artists.db ├── logs └── app.log ├── requirements.txt └── src ├── config_handler.py ├── database_handler.py ├── logger.py ├── main.py ├── plex_handler.py └── spotify_handler.py /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python virtual environment 2 | venv/ 3 | env/ 4 | ENV/ 5 | 6 | # Cache files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | .cache 11 | .cache-* 12 | 13 | # JSON files (configuration, etc) 14 | *.json 15 | 16 | # IDE specific files 17 | .idea/ 18 | .vscode/ 19 | *.swp 20 | *.swo 21 | 22 | # Logs 23 | 24 | 25 | # Database files 26 | #*.db 27 | #*.sqlite 28 | #*.sqlite3 29 | 30 | # Environment variables 31 | .env -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Broque Thomas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Music Meta for PLEX 2 | 3 | A Python command-line application that automatically enriches your Plex music library metadata by fetching artist information from Spotify. 4 | 5 | ## Features 6 | 7 | - Automatically matches Plex artists with Spotify artists 8 | - Updates missing or invalid artist images using: 9 | - Spotify artist profile pictures 10 | - Album art as a fallback if no Spotify image is available 11 | - Enhances artist genres by combining: 12 | - Existing Plex genres 13 | - Spotify artist genres 14 | - Album genres 15 | - Validates image quality and format before updating 16 | - Maintains a database of processed artists 17 | - Progress tracking for large libraries 18 | - Detailed logging of all operations 19 | - Graceful error handling 20 | 21 | ## Prerequisites 22 | 23 | - Python 3.8 or higher 24 | - Plex server with a music library 25 | - Spotify Developer API credentials 26 | - Access to your Plex server's API 27 | 28 | ## Installation 29 | 30 | 1. Clone the repository: 31 | ```bash 32 | git clone https://github.com/Nezreka/Plex-Music-Meta.git 33 | cd plex-music-meta 34 | ``` 35 | 2. Create and activate a virtual environment: 36 | ```bash 37 | python -m venv venv 38 | source venv/bin/activate 39 | ``` 40 | 3. Create and activate a virtual environment on Windows: 41 | ```bash 42 | python -m venv venv 43 | venv\Scripts\activate 44 | ``` 45 | 4. Install required packages: 46 | ```bash 47 | pip install -r requirements.txt 48 | ``` 49 | 5. Rename 'config.json.example' to 'config.json' 50 | 51 | ## Configuration 52 | 53 | Create a `config.json` file in the project root with the following structure: 54 | 55 | ```json 56 | { 57 | "plex": { 58 | "base_url": "http://your-plex-server:32400", 59 | "token": "your-plex-token" 60 | }, 61 | "spotify": { 62 | "client_id": "your-spotify-client-id", 63 | "client_secret": "your-spotify-client-secret" 64 | }, 65 | "database": { 66 | "path": "artists.db" 67 | }, 68 | "logging": { 69 | "path": "logs/app.log", 70 | "level": "DEBUG" 71 | } 72 | } 73 | ``` 74 | 75 | ### Getting Required Credentials 76 | 77 | 1. **Plex Token:** 78 | - Sign in to Plex 79 | - Visit account settings 80 | - Find your token under the authorized devices section 81 | 2. **Spotify API Credentials:** 82 | - Visit [Spotify Developer Dashboard](https://developer.spotify.com/dashboard) 83 | - Create a new application 84 | - Get your Client ID and Client Secret 85 | 86 | ## Usage 87 | 88 | Run the application: 89 | 90 | ```bash 91 | python src/main.py 92 | ``` 93 | 94 | The application will: 95 | 96 | 1. Connect to your Plex server 97 | 2. Retrieve all music artists 98 | 3. Check each artist against the processed database 99 | 4. For unprocessed artists: 100 | - Search for matching artist on Spotify 101 | - Download and validate artist images if not existing 102 | - Update artist genres 103 | - Store processing results in the database 104 | 105 | ## Project Structure 106 | 107 | ``` 108 | plex-music-meta/ 109 | ├── database/ 110 | │ ├── artists.db 111 | ├── src/ 112 | │ ├── __init__.py 113 | │ ├── main.py 114 | │ ├── config_handler.py 115 | │ ├── database_handler.py 116 | │ ├── plex_handler.py 117 | │ └── spotify_handler.py 118 | │ └── logger.py 119 | ├── config.json 120 | ├── requirements.txt 121 | └── README.md 122 | ``` 123 | 124 | ## File Descriptions 125 | 126 | - `main.py`: Application entry point and main logic 127 | - `config_handler.py`: Handles configuration file loading and validation 128 | - `database_handler.py`: Manages SQLite database for tracking processed artists 129 | - `plex_handler.py`: Handles all Plex server interactions and metadata updates 130 | - `spotify_handler.py`: Manages Spotify API interactions and artist searches 131 | 132 | ## Error Handling 133 | 134 | The application includes comprehensive error handling: 135 | 136 | - Invalid configurations 137 | - Network connectivity issues 138 | - API rate limiting 139 | - Invalid image formats 140 | - Database errors 141 | 142 | All errors are logged with detailed information for troubleshooting. 143 | 144 | ## Contributing 145 | 146 | 1. Fork the repository 147 | 2. Create a feature branch 148 | 3. Commit your changes 149 | 4. Push to the branch 150 | 5. Create a Pull Request 151 | 152 | ## License 153 | 154 | This project is licensed under the MIT License - see the LICENSE file for details. 155 | 156 | ## Acknowledgments 157 | 158 | - PlexAPI for Python 159 | - Spotipy (Spotify API wrapper) 160 | - TQDM for progress bars 161 | - Pillow for image processing 162 | 163 | ## Support 164 | 165 | For issues, questions, or contributions, please open an issue on the GitHub repository. 166 | 167 | -------------------------------------------------------------------------------- /config.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "plex": { 3 | "base_url": "http://192.168.86.21:32400", 4 | "token": "YOUR PLEX TOKEN" 5 | }, 6 | "spotify": { 7 | "client_id": "YOUR SPOTIFY ID", 8 | "client_secret": "YOUR SPOTIFY SECRET" 9 | }, 10 | "database": { 11 | "path": "database/artists.db" 12 | }, 13 | "logging": { 14 | "path": "logs/app.log", 15 | "level": "DEBUG" 16 | } 17 | } -------------------------------------------------------------------------------- /database/artists.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nezreka/Plex-Music-Meta/e79106b8f09c23c21d6a66bbc7b8b878f565e6d8/database/artists.db -------------------------------------------------------------------------------- /logs/app.log: -------------------------------------------------------------------------------- 1 | 2025-01-21 23:05:16,349 - INFO - Database initialized successfully 2 | 2025-01-21 23:05:16,354 - INFO - Successfully connected to Plex server 3 | 2025-01-21 23:05:16,360 - INFO - Initializing Spotify client... 4 | 2025-01-21 23:05:16,360 - INFO - Spotify client initialized successfully 5 | 2025-01-21 23:06:51,193 - INFO - Database initialized successfully 6 | 2025-01-21 23:06:51,197 - INFO - Successfully connected to Plex server 7 | 2025-01-21 23:06:51,201 - INFO - Initializing Spotify client... 8 | 2025-01-21 23:06:51,201 - INFO - Spotify client initialized successfully 9 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | plexapi>=4.15.4 2 | spotipy>=2.23.0 3 | tqdm>=4.66.1 4 | python-dotenv>=1.0.0 5 | requests>=2.31.0 6 | Pillow>=10.1.0 -------------------------------------------------------------------------------- /src/config_handler.py: -------------------------------------------------------------------------------- 1 | # src/config_handler.py 2 | import json 3 | import os 4 | 5 | class ConfigHandler: 6 | def __init__(self, config_path='config.json'): 7 | self.config_path = config_path 8 | self.config = self._load_config() 9 | 10 | def _load_config(self): 11 | """Load configuration from JSON file.""" 12 | if not os.path.exists(self.config_path): 13 | raise FileNotFoundError(f"Configuration file not found: {self.config_path}") 14 | 15 | with open(self.config_path, 'r') as f: 16 | config = json.load(f) 17 | 18 | self._validate_config(config) 19 | return config 20 | 21 | def _validate_config(self, config): 22 | """Validate that all required configuration items are present.""" 23 | required_keys = { 24 | 'plex': ['base_url', 'token'], # Changed from 'baseurl' to 'base_url' 25 | 'spotify': ['client_id', 'client_secret'], 26 | 'database': ['path'], 27 | 'logging': ['path', 'level'] 28 | } 29 | 30 | for section, keys in required_keys.items(): 31 | if section not in config: 32 | raise KeyError(f"Missing configuration section: {section}") 33 | 34 | for key in keys: 35 | if key not in config[section]: 36 | raise KeyError(f"Missing configuration key: {section}.{key}") 37 | 38 | def get_plex_config(self): 39 | """Return Plex configuration.""" 40 | return self.config['plex'] 41 | 42 | def get_spotify_config(self): 43 | """Return Spotify configuration.""" 44 | return self.config['spotify'] 45 | 46 | def get_database_config(self): 47 | """Return database configuration.""" 48 | return self.config['database'] 49 | 50 | def get_logging_config(self): 51 | """Return logging configuration.""" 52 | return self.config['logging'] -------------------------------------------------------------------------------- /src/database_handler.py: -------------------------------------------------------------------------------- 1 | # src/database_handler.py 2 | import sqlite3 3 | import logging 4 | from datetime import datetime 5 | 6 | class DatabaseHandler: 7 | def __init__(self, db_path): 8 | self.db_path = db_path 9 | self.logger = logging.getLogger('PlexMusicEnricher') 10 | self._init_db() 11 | 12 | def _init_db(self): 13 | """Initialize the database and create tables if they don't exist.""" 14 | try: 15 | with sqlite3.connect(self.db_path) as conn: 16 | cursor = conn.cursor() 17 | cursor.execute(''' 18 | CREATE TABLE IF NOT EXISTS processed_artists ( 19 | artist_id TEXT PRIMARY KEY, 20 | artist_name TEXT NOT NULL, 21 | spotify_id TEXT, 22 | processed_date TIMESTAMP, 23 | success BOOLEAN, 24 | error_message TEXT 25 | ) 26 | ''') 27 | conn.commit() 28 | self.logger.info("Database initialized successfully") 29 | except sqlite3.Error as e: 30 | self.logger.error(f"Database initialization error: {str(e)}") 31 | raise 32 | 33 | def is_artist_processed(self, artist_id): 34 | """Check if an artist has already been processed.""" 35 | try: 36 | with sqlite3.connect(self.db_path) as conn: 37 | cursor = conn.cursor() 38 | cursor.execute( 39 | "SELECT success FROM processed_artists WHERE artist_id = ?", 40 | (artist_id,) 41 | ) 42 | result = cursor.fetchone() 43 | return bool(result) 44 | except sqlite3.Error as e: 45 | self.logger.error(f"Database query error: {str(e)}") 46 | return False 47 | 48 | def mark_artist_processed(self, artist_id, artist_name, spotify_id=None, success=True, error_message=None): 49 | """Mark an artist as processed in the database.""" 50 | try: 51 | with sqlite3.connect(self.db_path) as conn: 52 | cursor = conn.cursor() 53 | cursor.execute(''' 54 | INSERT OR REPLACE INTO processed_artists 55 | (artist_id, artist_name, spotify_id, processed_date, success, error_message) 56 | VALUES (?, ?, ?, ?, ?, ?) 57 | ''', ( 58 | artist_id, 59 | artist_name, 60 | spotify_id, 61 | datetime.now().isoformat(), 62 | success, 63 | error_message 64 | )) 65 | conn.commit() 66 | self.logger.debug(f"Artist {artist_name} marked as processed") 67 | except sqlite3.Error as e: 68 | self.logger.error(f"Database update error: {str(e)}") 69 | raise 70 | 71 | def get_processing_stats(self): 72 | """Get statistics about processed artists.""" 73 | try: 74 | with sqlite3.connect(self.db_path) as conn: 75 | cursor = conn.cursor() 76 | cursor.execute(''' 77 | SELECT 78 | COUNT(*) as total, 79 | SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as successful, 80 | SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) as failed 81 | FROM processed_artists 82 | ''') 83 | return cursor.fetchone() 84 | except sqlite3.Error as e: 85 | self.logger.error(f"Database stats query error: {str(e)}") 86 | return (0, 0, 0) -------------------------------------------------------------------------------- /src/logger.py: -------------------------------------------------------------------------------- 1 | # src/logger.py 2 | import logging 3 | import os 4 | from datetime import datetime 5 | 6 | def setup_logger(log_path, log_level): 7 | """Configure and return a logger instance.""" 8 | # Ensure log directory exists 9 | os.makedirs(os.path.dirname(log_path), exist_ok=True) 10 | 11 | # Create logger 12 | logger = logging.getLogger('PlexMusicEnricher') 13 | logger.setLevel(log_level) 14 | 15 | # Create file handler 16 | file_handler = logging.FileHandler(log_path) 17 | file_handler.setLevel(log_level) 18 | 19 | # Create console handler 20 | console_handler = logging.StreamHandler() 21 | console_handler.setLevel(log_level) 22 | 23 | # Create formatter 24 | formatter = logging.Formatter( 25 | '%(asctime)s - %(name)s - %(levelname)s - %(message)s' 26 | ) 27 | 28 | # Add formatter to handlers 29 | file_handler.setFormatter(formatter) 30 | console_handler.setFormatter(formatter) 31 | 32 | # Add handlers to logger 33 | logger.addHandler(file_handler) 34 | logger.addHandler(console_handler) 35 | 36 | return logger -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from tqdm import tqdm 3 | import time 4 | from config_handler import ConfigHandler 5 | from database_handler import DatabaseHandler 6 | from plex_handler import PlexHandler 7 | from spotify_handler import SpotifyHandler 8 | 9 | def sanitize_text(text): 10 | """Sanitize text for console output""" 11 | try: 12 | return text.encode('ascii', 'replace').decode('ascii') 13 | except: 14 | return '[Complex Name]' 15 | 16 | def setup_logging(config): 17 | logger = logging.getLogger('PlexMusicEnricher') 18 | logger.setLevel(logging.INFO) 19 | 20 | # Create custom formatter that sanitizes messages 21 | class SanitizedFormatter(logging.Formatter): 22 | def format(self, record): 23 | record.msg = sanitize_text(str(record.msg)) 24 | return super().format(record) 25 | 26 | # File handler with sanitized formatter 27 | file_handler = logging.FileHandler(config.get_logging_config()['path'], encoding='utf-8') 28 | file_handler.setLevel(logging.INFO) 29 | file_formatter = SanitizedFormatter('%(asctime)s - %(levelname)s - %(message)s') 30 | file_handler.setFormatter(file_formatter) 31 | logger.addHandler(file_handler) 32 | 33 | return logger 34 | 35 | def main(): 36 | # Load configuration 37 | config = ConfigHandler() 38 | 39 | # Setup logger 40 | logger = setup_logging(config) 41 | 42 | try: 43 | # Initialize handlers quietly 44 | db_handler = DatabaseHandler(config.get_database_config()['path']) 45 | plex_handler = PlexHandler(**config.get_plex_config()) 46 | spotify_handler = SpotifyHandler(**config.get_spotify_config()) 47 | 48 | # Get all artists 49 | artists = plex_handler.get_all_artists() 50 | total_artists = len(artists) 51 | 52 | # Setup progress bar with custom format 53 | pbar = tqdm( 54 | total=total_artists, 55 | bar_format='{percentage:3.0f}% |{bar:20}| {n_fmt}/{total_fmt} ' 56 | '[{elapsed}<{remaining}, {rate_fmt}] {desc}', 57 | ncols=100 58 | ) 59 | 60 | # Process artists in batches 61 | batch_size = 3 62 | for i in range(0, len(artists), batch_size): 63 | batch = artists[i:i + batch_size] 64 | 65 | # Process batch 66 | processed = plex_handler.process_artist_batch(batch, db_handler, spotify_handler) 67 | 68 | # Update progress bar with current artist (sanitized) 69 | if batch: 70 | current_artist = sanitize_text(batch[0].title)[:30] 71 | pbar.set_description(f"Current: {current_artist:<30}") 72 | 73 | # Update progress 74 | pbar.update(len(batch)) 75 | 76 | # Rate limiting 77 | time.sleep(0.5) 78 | 79 | pbar.close() 80 | 81 | # Show final statistics 82 | total, successful, failed = db_handler.get_processing_stats() 83 | print(f"\nCompleted: {successful} successful, {failed} failed") 84 | 85 | except Exception as e: 86 | logger.error(f"Application error: {sanitize_text(str(e))}") 87 | raise 88 | 89 | if __name__ == "__main__": 90 | main() -------------------------------------------------------------------------------- /src/plex_handler.py: -------------------------------------------------------------------------------- 1 | # src/plex_handler.py 2 | from plexapi.server import PlexServer 3 | import logging 4 | import requests 5 | from urllib.parse import urlparse 6 | import os 7 | import time 8 | from concurrent.futures import ThreadPoolExecutor 9 | import threading 10 | 11 | class PlexHandler: 12 | def __init__(self, base_url, token): 13 | self.logger = logging.getLogger('PlexMusicEnricher') 14 | self.server = self._connect_to_plex(base_url, token) 15 | self.music_library = self._get_music_library() 16 | self.thread_lock = threading.Lock() 17 | self.max_workers = 4 # Adjust based on your system 18 | 19 | def process_artist_batch(self, artists, db_handler, spotify_handler): 20 | """Process a batch of artists in parallel.""" 21 | def process_single_artist(artist): 22 | try: 23 | with self.thread_lock: 24 | if db_handler.is_artist_processed(artist.ratingKey): 25 | return None 26 | 27 | spotify_data = spotify_handler.search_artist(artist.title) 28 | if spotify_data: 29 | details = spotify_handler.get_artist_details(spotify_data['spotify_id']) 30 | if details: 31 | spotify_data.update(details) 32 | 33 | success = self.update_artist_metadata(artist, spotify_data) 34 | 35 | with self.thread_lock: 36 | db_handler.mark_artist_processed( 37 | artist.ratingKey, 38 | artist.title, 39 | spotify_id=spotify_data['spotify_id'], 40 | success=success 41 | ) 42 | return artist.title 43 | else: 44 | with self.thread_lock: 45 | db_handler.mark_artist_processed( 46 | artist.ratingKey, 47 | artist.title, 48 | success=False, 49 | error_message="Not found on Spotify" 50 | ) 51 | return None 52 | 53 | except Exception as e: 54 | self.logger.error(f"Error processing artist {artist.title}: {str(e)}") 55 | with self.thread_lock: 56 | db_handler.mark_artist_processed( 57 | artist.ratingKey, 58 | artist.title, 59 | success=False, 60 | error_message=str(e) 61 | ) 62 | return None 63 | 64 | with ThreadPoolExecutor(max_workers=self.max_workers) as executor: 65 | futures = [executor.submit(process_single_artist, artist) for artist in artists] 66 | return [f.result() for f in futures if f.result() is not None] 67 | 68 | def _connect_to_plex(self, base_url, token): 69 | """Establish connection to Plex server.""" 70 | try: 71 | server = PlexServer(base_url, token) 72 | self.logger.info("Successfully connected to Plex server") 73 | return server 74 | except Exception as e: 75 | self.logger.error(f"Failed to connect to Plex server: {str(e)}") 76 | raise 77 | 78 | def _get_music_library(self): 79 | """Get the music library section.""" 80 | try: 81 | music_sections = [section for section in self.server.library.sections() if section.type == 'artist'] 82 | if not music_sections: 83 | raise Exception("No music library found") 84 | return music_sections[0] 85 | except Exception as e: 86 | self.logger.error(f"Failed to get music library: {str(e)}") 87 | raise 88 | 89 | 90 | 91 | def get_all_artists(self): 92 | """Retrieve all artists from the music library.""" 93 | try: 94 | return self.music_library.all() 95 | except Exception as e: 96 | self.logger.error(f"Failed to retrieve artists: {str(e)}") 97 | return [] 98 | 99 | def _download_image(self, url): 100 | """Download image from URL.""" 101 | try: 102 | response = requests.get(url) 103 | response.raise_for_status() 104 | return response.content 105 | except Exception as e: 106 | self.logger.error(f"Failed to download image from {url}: {str(e)}") 107 | return None 108 | 109 | def _validate_image(self, image_data): 110 | """Validate image data before upload.""" 111 | try: 112 | from PIL import Image 113 | import io 114 | 115 | # Try to open the image data with PIL to verify it's valid 116 | image = Image.open(io.BytesIO(image_data)) 117 | 118 | # Get image details for logging 119 | format = image.format 120 | width, height = image.size 121 | self.logger.info(f"Validating image: Format={format}, Size={width}x{height}") 122 | 123 | # Check minimum dimensions 124 | if width < 200 or height < 200: 125 | self.logger.error(f"Image too small: {width}x{height}") 126 | return None 127 | 128 | # Convert to JPEG if it's not JPEG or PNG 129 | if format not in ['JPEG', 'PNG']: 130 | self.logger.info("Converting image to JPEG") 131 | buffer = io.BytesIO() 132 | image.convert('RGB').save(buffer, format='JPEG', quality=95) 133 | return buffer.getvalue() 134 | 135 | # If it's PNG, convert to JPEG for consistency 136 | if format == 'PNG': 137 | self.logger.info("Converting PNG to JPEG") 138 | buffer = io.BytesIO() 139 | image.convert('RGB').save(buffer, format='JPEG', quality=95) 140 | return buffer.getvalue() 141 | 142 | return image_data 143 | 144 | except Exception as e: 145 | self.logger.error(f"Image validation failed: {str(e)}") 146 | return None 147 | 148 | def _upload_poster(self, artist, image_url, source="unknown"): 149 | """Upload poster using direct upload method.""" 150 | try: 151 | self.logger.info(f"Starting poster upload for {artist.title} from {source}") 152 | self.logger.debug(f"Image URL: {image_url}") 153 | 154 | # Download image 155 | response = requests.get(image_url, timeout=10) 156 | response.raise_for_status() 157 | 158 | # Validate and potentially convert image 159 | image_data = self._validate_image(response.content) 160 | if not image_data: 161 | self.logger.error("Failed to validate/convert image") 162 | return False 163 | 164 | # Get the upload URL from Plex 165 | upload_url = f"{self.server._baseurl}/library/metadata/{artist.ratingKey}/posters" 166 | headers = { 167 | 'X-Plex-Token': self.server._token, 168 | 'Accept': 'application/json', 169 | 'Content-Type': 'image/jpeg' 170 | } 171 | 172 | self.logger.debug(f"Uploading to Plex URL: {upload_url}") 173 | 174 | # Try direct upload first 175 | try: 176 | upload_response = requests.post( 177 | upload_url, 178 | data=image_data, # Send raw image data 179 | headers=headers 180 | ) 181 | upload_response.raise_for_status() 182 | except Exception as e: 183 | self.logger.warning(f"Direct upload failed, trying multipart: {str(e)}") 184 | # Try multipart upload as fallback 185 | files = {'file': ('poster.jpg', image_data, 'image/jpeg')} 186 | upload_response = requests.post(upload_url, headers=headers, files=files) 187 | upload_response.raise_for_status() 188 | 189 | self.logger.debug(f"Upload response status: {upload_response.status_code}") 190 | self.logger.debug(f"Upload response content: {upload_response.text[:200]}") 191 | 192 | # Force refresh and verify 193 | artist.refresh() 194 | time.sleep(2) # Give Plex time to process the image 195 | artist.reload() 196 | 197 | # Verify the image is actually accessible 198 | if artist.thumb: 199 | try: 200 | # Try to download the new thumb to verify it's valid 201 | verify_response = requests.get(artist.thumbUrl) 202 | verify_response.raise_for_status() 203 | if len(verify_response.content) > 0: 204 | self.logger.info(f"Successfully verified thumb update for {artist.title}") 205 | return True 206 | else: 207 | self.logger.warning(f"Thumb URL exists but returns no content for {artist.title}") 208 | return False 209 | except Exception as e: 210 | self.logger.error(f"Failed to verify new thumb: {str(e)}") 211 | return False 212 | else: 213 | self.logger.warning(f"Upload appeared successful but thumb not updated for {artist.title}") 214 | return False 215 | 216 | except Exception as e: 217 | self.logger.error(f"Direct upload failed for {artist.title} from {source}: {str(e)}") 218 | return False 219 | 220 | def _verify_thumb(self, artist): 221 | """Verify that the artist's thumb is actually valid and usable.""" 222 | if not artist.thumb: 223 | self.logger.debug(f"No thumb exists for {artist.title}") 224 | return False 225 | 226 | try: 227 | from PIL import Image 228 | import io 229 | 230 | # Try to download the current thumb 231 | response = requests.get(artist.thumbUrl, timeout=5) 232 | response.raise_for_status() 233 | 234 | # Log the content type and size 235 | content_type = response.headers.get('content-type', 'unknown') 236 | content_size = len(response.content) 237 | self.logger.debug(f"Thumb check for {artist.title}: Type={content_type}, Size={content_size} bytes") 238 | 239 | # If image is too small, it's probably invalid 240 | if content_size < 1000: # Less than 1KB is suspicious 241 | self.logger.warning(f"Thumb exists but is suspiciously small for {artist.title} ({content_size} bytes)") 242 | return False 243 | 244 | # Try to open and validate the image 245 | try: 246 | image = Image.open(io.BytesIO(response.content)) 247 | 248 | # Check image format 249 | format = image.format 250 | if format not in ['JPEG', 'PNG']: 251 | self.logger.warning(f"Invalid image format for {artist.title}: {format}") 252 | return False 253 | 254 | # Check image dimensions 255 | width, height = image.size 256 | if width < 100 or height < 100: # Arbitrary minimum size 257 | self.logger.warning(f"Image too small for {artist.title}: {width}x{height}") 258 | return False 259 | 260 | # Try to verify image data 261 | image.verify() 262 | self.logger.debug(f"Valid image found for {artist.title}: {format} {width}x{height}") 263 | return True 264 | 265 | except Exception as img_e: 266 | self.logger.warning(f"Invalid image data for {artist.title}: {str(img_e)}") 267 | return False 268 | 269 | except Exception as e: 270 | self.logger.warning(f"Failed to verify existing thumb for {artist.title}: {str(e)}") 271 | return False 272 | 273 | def needs_processing(self, artist): 274 | """Check if an artist needs processing.""" 275 | has_valid_thumb = self._verify_thumb(artist) 276 | existing_genres = set(artist.genres if artist.genres else []) 277 | 278 | self.logger.info(f"Checking processing needs for {artist.title}") 279 | self.logger.info(f"Current thumb status: {'Valid' if has_valid_thumb else 'Invalid/Missing'}") 280 | self.logger.info(f"Current genres: {existing_genres}") 281 | self.logger.info(f"Needs processing: No genres") 282 | 283 | return True 284 | 285 | 286 | def update_artist_metadata(self, artist, spotify_data): 287 | """Update artist metadata with Spotify information.""" 288 | try: 289 | if not spotify_data: 290 | self.logger.error("No Spotify data provided") 291 | return False 292 | 293 | changes_needed = False 294 | changes_made = False 295 | self.logger.info(f"Starting metadata update for {artist.title}") 296 | 297 | # Check genres 298 | try: 299 | existing_genres = set(artist.genres if artist.genres else []) 300 | spotify_genres = set(spotify_data.get('genres', [])) 301 | album_genres = set() 302 | 303 | self.logger.info(f"Existing genres: {existing_genres}") 304 | self.logger.info(f"Spotify genres: {spotify_genres}") 305 | 306 | for album in artist.albums(): 307 | if album.genres: 308 | album_genres.update(album.genres) 309 | 310 | self.logger.info(f"Album genres: {album_genres}") 311 | 312 | # Combine all genres 313 | all_genres = existing_genres.union(spotify_genres, album_genres) 314 | 315 | # Check if we actually need to update genres 316 | if not existing_genres and all_genres: 317 | self.logger.info(f"Artist has no genres, update needed") 318 | changes_needed = True 319 | elif all_genres != existing_genres: 320 | self.logger.info(f"New genres available, update needed") 321 | changes_needed = True 322 | else: 323 | self.logger.info(f"No genre updates needed - already has correct genres") 324 | 325 | if changes_needed: 326 | genres_to_set = list(all_genres) if all_genres else ['Unknown'] 327 | self.logger.info(f"Attempting to set genres to: {genres_to_set}") 328 | artist.addGenre(genres_to_set) 329 | artist.reload() 330 | changes_made = True 331 | self.logger.info(f"Successfully updated genres") 332 | 333 | except Exception as e: 334 | self.logger.error(f"Error updating genres for {artist.title}: {str(e)}") 335 | 336 | # Check if poster update is needed 337 | if not self._verify_thumb(artist): 338 | self.logger.info(f"Artist needs poster update (invalid or missing)") 339 | changes_needed = True 340 | 341 | poster_updated = False 342 | # Try Spotify image first 343 | if spotify_data.get('images'): 344 | try: 345 | largest_image = max(spotify_data['images'], key=lambda x: x['width'] * x['height']) 346 | self.logger.info(f"Found Spotify image: {largest_image['url']}") 347 | self.logger.debug(f"Image dimensions: {largest_image['width']}x{largest_image['height']}") 348 | 349 | if self._upload_poster(artist, largest_image['url'], source="Spotify"): 350 | poster_updated = True 351 | changes_made = True 352 | self.logger.info(f"Successfully updated poster from Spotify") 353 | except Exception as e: 354 | self.logger.error(f"Error uploading Spotify image: {str(e)}") 355 | 356 | # If Spotify image failed, try album art 357 | if not poster_updated: 358 | try: 359 | albums = artist.albums() 360 | if albums: 361 | # Sort albums by newest first (might have better quality art) 362 | sorted_albums = sorted(albums, key=lambda x: x.year if x.year else 0, reverse=True) 363 | for album in sorted_albums: 364 | if album.thumb: 365 | self.logger.info(f"Attempting to use album art from: {album.title} ({album.year if album.year else 'Unknown year'})") 366 | if self._upload_poster(artist, album.thumbUrl, source=f"Album: {album.title}"): 367 | changes_made = True 368 | self.logger.info(f"Successfully set album art as artist poster") 369 | break 370 | except Exception as e: 371 | self.logger.error(f"Error setting album art as poster: {str(e)}") 372 | else: 373 | self.logger.info(f"No poster update needed - has valid poster") 374 | 375 | # Final status 376 | if not changes_needed: 377 | self.logger.info(f"No updates needed for {artist.title} - already up to date") 378 | return True 379 | elif changes_made: 380 | self.logger.info(f"Successfully made needed updates for {artist.title}") 381 | return True 382 | else: 383 | self.logger.warning(f"Updates were needed but could not be made for {artist.title}") 384 | return False 385 | 386 | except Exception as e: 387 | self.logger.error(f"Failed to update metadata for {artist.title}: {str(e)}") 388 | return False -------------------------------------------------------------------------------- /src/spotify_handler.py: -------------------------------------------------------------------------------- 1 | import spotipy 2 | from spotipy.oauth2 import SpotifyClientCredentials 3 | import logging 4 | import time 5 | from tqdm import tqdm 6 | 7 | def sanitize_text(text): 8 | try: 9 | return text.encode('ascii', 'replace').decode('ascii') 10 | except: 11 | return '[Complex Name]' 12 | 13 | class SpotifyHandler: 14 | def __init__(self, client_id, client_secret): 15 | self.logger = logging.getLogger('PlexMusicEnricher') 16 | self.last_request_time = None 17 | self.rate_limit_delay = 1 # Minimum seconds between requests 18 | self.spotify = self._init_spotify(client_id, client_secret) 19 | 20 | def _init_spotify(self, client_id, client_secret): 21 | """Initialize Spotify client.""" 22 | try: 23 | self.logger.info("Initializing Spotify client...") 24 | client_credentials_manager = SpotifyClientCredentials( 25 | client_id=client_id, 26 | client_secret=client_secret 27 | ) 28 | spotify = spotipy.Spotify(client_credentials_manager=client_credentials_manager) 29 | self.logger.info("Spotify client initialized successfully") 30 | return spotify 31 | except Exception as e: 32 | self.logger.error(f"Failed to initialize Spotify client: {str(e)}") 33 | raise 34 | 35 | def handle_rate_limit(self, retry_after): 36 | """Handle rate limit by waiting the required time""" 37 | self.logger.warning(f"Spotify rate limit reached. Waiting {retry_after} seconds...") 38 | 39 | # Create a countdown progress bar 40 | for remaining in tqdm( 41 | range(retry_after, 0, -1), 42 | desc="Rate limit cooldown", 43 | bar_format='{desc}: {n:>2d}s remaining |{bar:20}|', 44 | ncols=60 45 | ): 46 | time.sleep(1) 47 | 48 | self.logger.info("Rate limit cooldown complete, resuming operations...") 49 | 50 | def _rate_limit(self): 51 | """Implement basic rate limiting.""" 52 | if self.last_request_time: 53 | elapsed = time.time() - self.last_request_time 54 | if elapsed < self.rate_limit_delay: 55 | time.sleep(self.rate_limit_delay - elapsed) 56 | self.last_request_time = time.time() 57 | 58 | def search_artist(self, artist_name): 59 | """Search for an artist on Spotify and return their details.""" 60 | self._rate_limit() 61 | self.logger.info(f"Searching Spotify for artist: {sanitize_text(artist_name)}") 62 | try: 63 | results = self.spotify.search(q=artist_name, type='artist', limit=1) 64 | if results['artists']['items']: 65 | artist = results['artists']['items'][0] 66 | self.logger.info(f"Found artist on Spotify: {sanitize_text(artist['name'])}") 67 | self.logger.debug(f"Spotify artist details: {artist}") 68 | return { 69 | 'spotify_id': artist['id'], 70 | 'name': artist['name'], 71 | 'genres': artist['genres'], 72 | 'images': artist['images'], 73 | 'popularity': artist['popularity'] 74 | } 75 | self.logger.info(f"No results found on Spotify for: {sanitize_text(artist_name)}") 76 | return None 77 | except Exception as e: 78 | if hasattr(e, 'headers') and 'Retry-After' in e.headers: 79 | retry_after = int(e.headers['Retry-After']) 80 | self.handle_rate_limit(retry_after) 81 | # Retry the search after waiting 82 | return self.search_artist(artist_name) 83 | self.logger.error(f"Error searching for artist {sanitize_text(artist_name)}: {str(e)}") 84 | return None 85 | 86 | def get_artist_details(self, spotify_id): 87 | """Get detailed information about an artist.""" 88 | self._rate_limit() 89 | self.logger.info(f"Getting additional details for Spotify ID: {spotify_id}") 90 | try: 91 | artist = self.spotify.artist(spotify_id) 92 | self.logger.debug(f"Retrieved additional details: {artist}") 93 | return { 94 | 'biography': None, 95 | 'genres': artist['genres'], 96 | 'images': artist['images'], 97 | 'popularity': artist['popularity'] 98 | } 99 | except Exception as e: 100 | if hasattr(e, 'headers') and 'Retry-After' in e.headers: 101 | retry_after = int(e.headers['Retry-After']) 102 | self.handle_rate_limit(retry_after) 103 | # Retry after waiting 104 | return self.get_artist_details(spotify_id) 105 | self.logger.error(f"Error getting artist details for ID {spotify_id}: {str(e)}") 106 | return None --------------------------------------------------------------------------------