Skip to content

Instantly share code, notes, and snippets.

@jonathanfann
Created February 10, 2026 18:51
Show Gist options
  • Select an option

  • Save jonathanfann/860da77c685f4f6ac4d5cd8812417b9b to your computer and use it in GitHub Desktop.

Select an option

Save jonathanfann/860da77c685f4f6ac4d5cd8812417b9b to your computer and use it in GitHub Desktop.
Generate Chronological Discography for Plex Folders
#!/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()
@jonathanfann

Copy link
Copy Markdown
Author

Requires

  • Python installed
  • Plex Media Server running locally
  • The mutagen library installed (pip install mutagen)
  • Your own .LocalAdminToken file (which you should already have if Plex is installed)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment