Created
February 10, 2026 18:51
-
-
Save jonathanfann/860da77c685f4f6ac4d5cd8812417b9b to your computer and use it in GitHub Desktop.
Generate Chronological Discography for Plex Folders
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Complete playlist regeneration and upload to Plex. | |
| Creates chronological playlists from music directories and uploads them to Plex, | |
| cleaning up old uploaded playlists first. | |
| Usage: | |
| python regenerate_all_playlists.py | |
| Edit the MUSIC_FOLDERS list below to specify which directories to process. | |
| """ | |
| import sys | |
| import os | |
| import argparse | |
| import requests | |
| import xml.etree.ElementTree as ET | |
| from pathlib import Path | |
| from urllib.parse import quote | |
| from mutagen.easyid3 import EasyID3 | |
| from mutagen.flac import FLAC | |
| from mutagen.mp4 import MP4 | |
| from mutagen.oggvorbis import OggVorbis | |
| from datetime import datetime | |
| from typing import List, Tuple, Optional | |
| # Configuration - EDIT THIS | |
| PLEX_URL = "http://127.0.0.1:32400" | |
| MUSIC_FOLDERS = [ | |
| # Add your music folders here, one per line | |
| # Example: | |
| # "F:\Music\Biffy Clyro", | |
| # "F:\Music\Another Artist", | |
| ] | |
| # These will be auto-detected if not set | |
| PLEX_TOKEN = None | |
| MUSIC_SECTION_ID = None | |
| def get_audio_extensions() -> set: | |
| """Return set of supported audio file extensions.""" | |
| return {'.mp3', '.flac', '.m4a', '.aac', '.ogg', '.opus', '.wma'} | |
| def extract_date(date_str: Optional[str]) -> Optional[datetime]: | |
| """Extract and parse date from various formats.""" | |
| if not date_str: | |
| return None | |
| date_str = str(date_str).strip() | |
| formats = [ | |
| '%Y-%m-%d', | |
| '%Y-%m', | |
| '%Y', | |
| ] | |
| for fmt in formats: | |
| try: | |
| return datetime.strptime(date_str, fmt) | |
| except ValueError: | |
| continue | |
| return None | |
| def get_metadata(file_path: Path) -> Tuple[Optional[datetime], str, str]: | |
| """Extract release date, artist, and title from audio file metadata.""" | |
| artist = "Unknown Artist" | |
| title = file_path.stem | |
| release_date = None | |
| try: | |
| if file_path.suffix.lower() == '.mp3': | |
| audio = EasyID3(str(file_path)) | |
| artist = audio.get('artist', ['Unknown Artist'])[0] | |
| title = audio.get('title', [file_path.stem])[0] | |
| date_str = audio.get('date', [None])[0] | |
| release_date = extract_date(date_str) | |
| elif file_path.suffix.lower() == '.flac': | |
| audio = FLAC(str(file_path)) | |
| artist = audio.get('artist', ['Unknown Artist'])[0] | |
| title = audio.get('title', [file_path.stem])[0] | |
| date_str = audio.get('date', [None])[0] | |
| release_date = extract_date(date_str) | |
| elif file_path.suffix.lower() in ['.m4a', '.aac']: | |
| audio = MP4(str(file_path)) | |
| artist = audio.get('\xa9ART', ['Unknown Artist'])[0] | |
| title = audio.get('\xa9nam', [file_path.stem])[0] | |
| date_str = audio.get('\xa9day', [None])[0] | |
| release_date = extract_date(date_str) | |
| elif file_path.suffix.lower() == '.ogg': | |
| audio = OggVorbis(str(file_path)) | |
| artist = audio.get('artist', ['Unknown Artist'])[0] | |
| title = audio.get('title', [file_path.stem])[0] | |
| date_str = audio.get('date', [None])[0] | |
| release_date = extract_date(date_str) | |
| except Exception as e: | |
| print(f" Warning: Could not read metadata from {file_path.name}") | |
| return release_date, artist, title | |
| def find_audio_files(directory: Path) -> List[Path]: | |
| """Recursively find all audio files in directory.""" | |
| audio_extensions = get_audio_extensions() | |
| audio_files = [] | |
| for file_path in directory.rglob('*'): | |
| if file_path.is_file() and file_path.suffix.lower() in audio_extensions: | |
| audio_files.append(file_path) | |
| return audio_files | |
| def create_playlist(directory: Path, output_file: Optional[Path] = None) -> bool: | |
| """ | |
| Create a chronologically sorted m3u playlist from audio files. | |
| Returns True if successful, False otherwise. | |
| """ | |
| # Find all audio files | |
| audio_files = find_audio_files(directory) | |
| if not audio_files: | |
| print(f" No audio files found in {directory}") | |
| return False | |
| print(f" Found {len(audio_files)} audio file(s)") | |
| # Extract metadata for all files | |
| files_with_metadata = [] | |
| for file_path in audio_files: | |
| release_date, artist, title = get_metadata(file_path) | |
| files_with_metadata.append({ | |
| 'path': file_path, | |
| 'release_date': release_date, | |
| 'artist': artist, | |
| 'title': title | |
| }) | |
| # Sort by release date (None values go last), then by file path | |
| files_with_metadata.sort(key=lambda x: ( | |
| x['release_date'] if x['release_date'] else datetime.max, | |
| str(x['path']) | |
| )) | |
| # Determine output file | |
| if output_file is None: | |
| output_file = directory / f"{directory.name}_chronological.m3u" | |
| # Write m3u file | |
| with open(output_file, 'w', encoding='utf-8') as f: | |
| f.write("#EXTM3U\n") | |
| f.write("#PLAYLIST: Chronological by Release Date\n\n") | |
| for item in files_with_metadata: | |
| # Use relative path from output file location | |
| try: | |
| rel_path = item['path'].relative_to(output_file.parent) | |
| except ValueError: | |
| # If relative path fails, use absolute path | |
| rel_path = item['path'] | |
| # Convert backslashes to forward slashes for m3u compatibility | |
| rel_path_str = str(rel_path).replace('\\', '/') | |
| date_str = item['release_date'].strftime('%Y-%m-%d') if item['release_date'] else 'Unknown' | |
| artist = item['artist'] | |
| title = item['title'] | |
| # Write extended info line | |
| f.write(f"#EXTINF:-1,{artist} - {title} ({date_str})\n") | |
| f.write(f"{rel_path_str}\n") | |
| print(f" Playlist created: {output_file.name}") | |
| return True | |
| def get_token() -> Optional[str]: | |
| """Get Plex token from .LocalAdminToken file.""" | |
| token_path = Path(os.getenv('LOCALAPPDATA')) / 'Plex Media Server' / '.LocalAdminToken' | |
| if token_path.exists(): | |
| with open(token_path, 'r') as f: | |
| return f.read().strip() | |
| return None | |
| def get_music_section_id(token: str) -> Optional[int]: | |
| """Query Plex to find Music library section ID.""" | |
| try: | |
| url = f"{PLEX_URL}/library/sections?X-Plex-Token={token}" | |
| response = requests.get(url, timeout=10) | |
| response.raise_for_status() | |
| root = ET.fromstring(response.content) | |
| for directory in root.findall('Directory'): | |
| if directory.get('type') == 'artist': | |
| return int(directory.get('key')) | |
| return None | |
| except Exception as e: | |
| print(f"Error querying Plex: {e}") | |
| return None | |
| def delete_old_playlists(token: str) -> None: | |
| """Delete old uploaded playlists (ratingKey > 200000).""" | |
| try: | |
| url = f"{PLEX_URL}/playlists?X-Plex-Token={token}" | |
| response = requests.get(url, timeout=10) | |
| response.raise_for_status() | |
| root = ET.fromstring(response.content) | |
| deleted_count = 0 | |
| for playlist in root.findall('Playlist'): | |
| rating_key = int(playlist.get('ratingKey')) | |
| title = playlist.get('title') | |
| # Only delete uploaded playlists (ratingKey > 200000) | |
| if rating_key > 200000: | |
| delete_url = f"{PLEX_URL}/playlists/{rating_key}?X-Plex-Token={token}" | |
| requests.delete(delete_url, timeout=10) | |
| print(f" Deleted old playlist: {title}") | |
| deleted_count += 1 | |
| if deleted_count > 0: | |
| print(f"Removed {deleted_count} old uploaded playlist(s)") | |
| except Exception as e: | |
| print(f"Error deleting old playlists: {e}") | |
| def upload_playlist(playlist_path: Path, token: str, section_id: int) -> bool: | |
| """Upload playlist to Plex via the API.""" | |
| try: | |
| encoded_path = quote(str(playlist_path.absolute()), safe='') | |
| url = f"{PLEX_URL}/playlists/upload?sectionID={section_id}&path={encoded_path}&X-Plex-Token={token}" | |
| response = requests.post(url, timeout=30) | |
| response.raise_for_status() | |
| if response.status_code == 200: | |
| print(f" Uploaded: {playlist_path.name}") | |
| return True | |
| else: | |
| print(f" Error uploading {playlist_path.name}: status {response.status_code}") | |
| return False | |
| except Exception as e: | |
| print(f" Error uploading {playlist_path.name}: {e}") | |
| return False | |
| def main(): | |
| global PLEX_TOKEN, MUSIC_SECTION_ID | |
| print("=" * 60) | |
| print("Plex Playlist Regeneration") | |
| print("=" * 60) | |
| # Check if folders are configured | |
| if not MUSIC_FOLDERS: | |
| print("Error: No music folders configured!") | |
| print("Edit the MUSIC_FOLDERS list in this script.") | |
| sys.exit(1) | |
| # Get token | |
| print("\n--- Authentication ---") | |
| PLEX_TOKEN = get_token() | |
| if not PLEX_TOKEN: | |
| print("Error: Could not find Plex token") | |
| sys.exit(1) | |
| print("✓ Found Plex token") | |
| # Get section ID | |
| print("\n--- Finding Music Library ---") | |
| MUSIC_SECTION_ID = get_music_section_id(PLEX_TOKEN) | |
| if not MUSIC_SECTION_ID: | |
| print("Error: Could not find Music library") | |
| sys.exit(1) | |
| print(f"✓ Found Music library (Section ID: {MUSIC_SECTION_ID})") | |
| # Create playlists | |
| print("\n--- Creating Playlists ---") | |
| created_playlists = [] | |
| for folder_str in MUSIC_FOLDERS: | |
| folder = Path(folder_str) | |
| if not folder.is_dir(): | |
| print(f"Error: {folder} is not a valid directory") | |
| continue | |
| print(f"Processing: {folder.name}") | |
| if create_playlist(folder): | |
| created_playlists.append(folder / f"{folder.name}_chronological.m3u") | |
| if not created_playlists: | |
| print("No playlists were created!") | |
| sys.exit(1) | |
| # Delete old playlists | |
| print("\n--- Cleaning Up Old Playlists ---") | |
| delete_old_playlists(PLEX_TOKEN) | |
| # Upload new playlists | |
| print("\n--- Uploading Playlists ---") | |
| for playlist_path in created_playlists: | |
| upload_playlist(playlist_path, PLEX_TOKEN, MUSIC_SECTION_ID) | |
| print("\n" + "=" * 60) | |
| print("Done! Refresh your Plex library to see the playlists.") | |
| print("=" * 60) | |
| if __name__ == '__main__': | |
| main() |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Requires