Skip to content

Instantly share code, notes, and snippets.

@adam137016
Last active April 6, 2026 11:26
Show Gist options
  • Select an option

  • Save adam137016/fa9cd110ef24bca1079c4e5419ff2417 to your computer and use it in GitHub Desktop.

Select an option

Save adam137016/fa9cd110ef24bca1079c4e5419ff2417 to your computer and use it in GitHub Desktop.
Plex music playlist generator
import requests
import random
from collections import defaultdict
from datetime import datetime
import time
# ── Configuration ────────────────────────────────────────────────────────────
PLEX_URL = "http://YOUR_PLEX_SERVER_IP:32400"
PLEX_TOKEN = "YOUR_PLEX_TOKEN"
LIBRARY_ID = "YOUR_MUSIC_LIBRARY_ID"
# ─────────────────────────────────────────────────────────────────────────────
HEADERS = {"X-Plex-Token": PLEX_TOKEN, "Accept": "application/json"}
# ── Defaults — change these to adjust all playlists at once ──────────────────
DEFAULT_SONGS_PER_ARTIST = 2
DEFAULT_TARGET_SONGS = 50
DEFAULT_MODE = "1" # 1=Random, 2=Most Played, 3=Least Played,
# 4=Unplayed Only, 5=Highest Rated,
# 6=Favorite Artists First
MIN_DURATION_MS = 30000 # Skip tracks shorter than 30 seconds
SKIP_LIVE_ALBUMS = True # Skip tracks from live/concert albums
LIVE_KEYWORDS = { # Album title phrases that indicate a live recording
"live at", "live in", "live from", "live on", "live &",
"concert", "in concert", "unplugged",
"bbc session", "bbc sessions", "bootleg",
"at budokan", "at fillmore", "at montreux", "at madison",
}
# ─────────────────────────────────────────────────────────────────────────────
MODE_NAMES = {
"1": "Random", "2": "Most Played", "3": "Least Played",
"4": "Unplayed Only", "5": "Highest Rated", "6": "Favorite Artists First",
}
# ── Presets ───────────────────────────────────────────────────────────────────
PRESETS = [
{
"name": "Punk & Hardcore",
"playlist_name": "Auto: Punk Mix",
"styles": [
"American Punk", "Anarchist Punk", "British Punk", "Cowpunk", "Emo", "Emo-Pop",
"Garage Punk", "Hardcore Punk", "L.A. Punk", "Metalcore", "New York Punk", "Oi!",
"Pop Punk", "Post-Hardcore", "Proto-Punk", "Punk", "Punk Blues", "Punk Metal",
"Punk Revival", "Punk/New Wave", "Riot Grrrl", "Screamo", "Ska-Punk", "Skatepunk",
"Straight-Edge",
],
},
{
"name": "Classic & Hard Rock",
"playlist_name": "Auto: Classic Rock Mix",
"styles": [
"Album Rock", "American Trad Rock", "Arena Rock", "Aussie Rock",
"Boogie Rock", "British Invasion", "British Trad Rock", "Garage Rock Revival",
"Glam Rock", "Hard Rock", "Heartland Rock", "Instrumental Rock",
"Merseybeat", "Mod", "Rock & Roll", "Roots Rock", "Southern Rock",
"Surf", "Surf Revival",
],
},
{
"name": "Metal",
"playlist_name": "Auto: Metal Mix",
"styles": [
"Alternative Metal", "British Metal", "Funk Metal", "Hair Metal",
"Heavy Metal", "Industrial Metal", "Metalcore", "New Wave of British Heavy Metal",
"Nü Metal", "Pop-Metal", "Punk Metal", "Rap-Metal", "Speed/Thrash Metal",
],
},
{
"name": "Extreme & Underground Metal",
"playlist_name": "Auto: Extreme Metal Mix",
"styles": [
"Black Metal", "Death Metal", "Doom Metal", "Folk-Metal", "Goth Metal",
"Neo-Classical Metal", "Post-Metal", "Power Metal", "Progressive Metal",
"Scandinavian Metal", "Sludge Metal", "Stoner Metal", "Symphonic Black Metal",
"Symphonic Metal",
],
},
{
"name": "Alternative & Indie",
"playlist_name": "Auto: Alt & Indie Mix",
"styles": [
"Alternative Pop/Rock", "Alternative/Indie Rock", "American Underground",
"Britpop", "Chamber Pop", "College Rock", "Electronic", "Experimental",
"Experimental Rock", "Garage Rock Revival", "Grunge", "Indie Electronic",
"Indie Pop", "Indie Rock", "Lo-Fi", "New Wave", "New Wave/Post-Punk Revival",
"No Wave", "Noise-Rock", "Post-Grunge", "Post-Punk",
],
},
{
"name": "Hip-Hop & Rap",
"playlist_name": "Auto: Hip-Hop Mix",
"styles": [
"Alternative Rap", "Bay Area Rap", "British Rap", "Cloud Rap",
"Contemporary Rap", "Dirty South", "East Coast Rap", "G-Funk", "Gangsta Rap",
"Golden Age", "Hardcore Rap", "Horror Rap", "Hyperpop", "Instrumental Hip-Hop",
"Jazz-Rap", "Latin Rap", "Left-Field Rap", "Midwest Rap", "Old-School Rap",
"Party Rap", "Political Rap", "Pop-Rap", "Rap-Rock", "Southern Rap",
"Trap (Rap)", "Underground Rap", "West Coast Rap",
],
},
{
"name": "Pop",
"playlist_name": "Auto: Pop Mix",
"styles": [
"Adult Alternative Pop/Rock", "Adult Contemporary", "AM Pop",
"American Popular Song", "Baroque Pop", "Brill Building Pop",
"Bubblegum", "Chamber Pop", "Contemporary Pop/Rock", "Country-Pop",
"Dance-Pop", "Early Pop/Rock", "Euro-Pop", "Folk-Pop", "French Pop",
"Girl Groups", "Indie Pop", "Jazz-Pop", "Left-Field Pop",
"New Romantic", "Pop", "Pop Idol", "Pop/Rock", "Scandinavian Pop",
"Social Media Pop", "Soft Rock", "Sophisti-Pop", "Sunshine Pop",
"Swedish Pop/Rock", "Synth Pop", "Teen Idols", "Teen Pop", "Vocal Pop",
],
},
{
"name": "Electronic & Dance",
"playlist_name": "Auto: Electronic Mix",
"styles": [
"Alternative Dance", "Ambient", "Club/Dance", "Dance-Pop", "Dance-Rock",
"Detroit Techno", "Disco", "EDM", "Electronic", "Euro-Dance", "Euro-Disco",
"Experimental Ambient", "Hi-NRG", "House", "Indie Electronic",
"Left-Field House", "Lo-Fi", "Neo-Disco", "Post-Disco", "Synthwave", "Techno",
],
},
{
"name": "Soul, R&B & Funk",
"playlist_name": "Auto: Soul & R&B Mix",
"styles": [
"Adult Contemporary R&B", "Alternative R&B", "Blue-Eyed Soul",
"Contemporary R&B", "Country Soul", "Deep Funk Revival", "Early R&B",
"Funk", "Jazz-Funk", "Memphis Soul", "Motown", "Neo-Soul",
"New Jack Swing", "Pop-Soul", "Psychedelic Soul", "Quiet Storm",
"Retro-Soul", "Smooth Soul", "Soul", "Soul-Blues",
],
},
{
"name": "Folk, Americana & Country",
"playlist_name": "Auto: Folk & Americana Mix",
"styles": [
"Alt-Country", "Alternative Country-Rock", "Alternative Folk",
"Alternative Singer/Songwriter", "Americana", "Appalachian", "Blues-Rock",
"British Folk-Rock", "Contemporary Country", "Contemporary Folk",
"Contemporary Singer/Songwriter", "Country-Folk", "Country-Rock", "Folk-Pop",
"Folk-Rock", "Indie Folk", "Neo-Traditional Folk", "Neo-Traditionalist Country",
"New Acoustic", "Political Folk", "Progressive Country", "Singer/Songwriter",
"Traditional Country", "Traditional Folk", "Truck Driving Country",
],
},
{
"name": "Bluegrass",
"playlist_name": "Auto: Bluegrass Mix",
"styles": [
"Appalachian", "Bluegrass", "Bluegrass-Gospel", "Contemporary Bluegrass",
"Jug Band", "New Acoustic", "North American Traditions", "Old-Timey",
"Progressive Bluegrass", "Scottish Folk", "String Bands", "Traditional Bluegrass",
"Traditional Folk",
],
},
{
"name": "Psychedelic & Art Rock",
"playlist_name": "Auto: Psychedelic Mix",
"styles": [
"Acid Rock", "Art Rock", "British Psychedelia", "Garage",
"Goth Rock", "Neo-Psychedelia", "Psychedelic Pop",
"Psychedelic Soul", "Psychedelic/Garage",
],
},
{
"name": "Progressive & Neo-Prog",
"playlist_name": "Auto: Prog Mix",
"styles": [
"Art Rock", "Experimental Rock", "Jazz-Rock", "Neo-Prog",
"Prog-Rock", "Progressive Metal",
],
},
{
"name": "Blues",
"playlist_name": "Auto: Blues Mix",
"styles": [
"Acoustic Blues", "Acoustic Chicago Blues", "Blues Revival", "Blues-Rock",
"British Blues", "Chicago Blues", "Contemporary Blues", "Country Blues",
"Delta Blues", "Detroit Blues", "East Coast Blues", "Electric Blues",
"Electric Chicago Blues", "Electric Delta Blues", "Electric Memphis Blues",
"Electric Texas Blues", "Jazz Blues", "Jug Band", "Jump Blues", "Memphis Blues",
"Modern Electric Blues", "Modern Electric Texas Blues", "Piano Blues",
"Regional Blues", "Slide Guitar Blues", "Soul-Blues", "Swamp Blues",
"Texas Blues", "Urban Blues",
],
},
{
"name": "Jazz",
"playlist_name": "Auto: Jazz Mix",
"styles": [
"Avant-Garde Jazz", "Big Band", "Bop", "Bossa Nova", "Cool",
"Contemporary Jazz", "Crossover Jazz", "Dance Bands", "Dixieland",
"Early Jazz", "Electro-Jazz", "Free Jazz", "Fusion", "Hard Bop",
"Jazz", "Jazz Instrument", "Jazz-Funk", "Jazz-Pop", "Jazz-Rock",
"Jive", "Mainstream Jazz", "Modal Music", "Modern Big Band",
"New Orleans Brass Bands", "New Orleans Jazz", "Piano Jazz", "Post-Bop",
"Progressive Jazz", "Saxophone Jazz", "Smooth Jazz", "Soul Jazz",
"Standards", "Swing", "Tin Pan Alley Pop", "Traditional Pop",
"Trumpet Jazz", "Vocal Jazz", "West Coast Jazz",
],
},
{
"name": "Reggae & Ska",
"playlist_name": "Auto: Reggae & Ska Mix",
"styles": [
"Contemporary Reggae", "Dancehall", "Dub", "Reggaeton", "Roots Reggae",
"Ska Revival", "Ska-Punk", "Third Wave Ska Revival",
],
},
{
"name": "Yacht Rock",
"playlist_name": "Auto: Yacht Rock Mix",
"styles": [
"Adult Contemporary", "Blue-Eyed Soul", "Soft Rock", "Sophisti-Pop",
],
},
{
"name": "Swing & Big Band",
"playlist_name": "Auto: Swing Mix",
"styles": [
"Big Band", "Dance Bands", "Dixieland", "Jive", "Jump Blues",
"Modern Big Band", "New Orleans Brass Bands", "Retro Swing",
"Swing", "Sweet Bands",
],
},
{
"name": "Rockabilly & Retro Rock",
"playlist_name": "Auto: Rockabilly Mix",
"styles": [
"Bar Band", "Early Pop/Rock", "Rock & Roll", "Rockabilly",
"Rockabilly Revival", "Surf", "Surf Revival",
],
},
{
"name": "Jam Bands & Experimental",
"playlist_name": "Auto: Jam & Experimental Mix",
"styles": [
"Ambient", "Experimental", "Experimental Ambient", "Experimental Rock",
"Fusion", "Jam Bands", "Jazz-Funk", "Jazz-Rock", "Lo-Fi", "Prog-Rock",
],
},
{
"name": "Goth & Dark Rock",
"playlist_name": "Auto: Goth Mix",
"styles": [
"Doom Metal", "Goth Metal", "Goth Rock", "Industrial Metal",
"No Wave", "Noise-Rock", "Post-Punk", "Sludge Metal",
],
},
{
"name": "Euro & World Rock",
"playlist_name": "Auto: Euro Rock Mix",
"styles": [
"African Traditions", "Afro-beat", "Appalachian", "Brazilian Traditions",
"Celtic", "Celtic Rock", "Euro-Rock", "Gypsy", "Latin Rock",
"North American Traditions", "Nouvelle Chanson", "Scandinavian Metal",
"Scandinavian Pop", "Scottish Folk", "South/Eastern European Traditions",
"Swedish Pop/Rock", "Ukrainian",
],
},
{
"name": "Latin",
"playlist_name": "Auto: Latin Mix",
"styles": [
"Afro-beat", "Bossa Nova", "Brazilian Traditions", "Latin Pop",
"Latin Rap", "Latin Rock", "Reggaeton",
],
},
{
"name": "Classic Vocal & Standards",
"playlist_name": "Auto: Vocal & Standards Mix",
"styles": [
"American Popular Song", "Cabaret", "Close Harmony", "Doo Wop",
"Girl Groups", "Harmony Vocal Group", "Standards", "Tin Pan Alley Pop",
"Torch Songs", "Traditional Pop", "Vocal Jazz", "Vocal Music", "Vocal Pop",
],
},
]
def get_machine_id():
url = f"{PLEX_URL}/"
resp = requests.get(url, params={"X-Plex-Token": PLEX_TOKEN}, headers={"Accept": "application/json"})
resp.raise_for_status()
return resp.json().get("MediaContainer", {}).get("machineIdentifier", "")
def get_library_styles():
"""Fetch available styles from the Plex style browser endpoint."""
url = f"{PLEX_URL}/library/sections/{LIBRARY_ID}/style"
params = {"type": 8, "X-Plex-Token": PLEX_TOKEN}
resp = requests.get(url, params=params, headers=HEADERS)
resp.raise_for_status()
directories = resp.json().get("MediaContainer", {}).get("Directory", [])
return {d["title"] for d in directories}
def get_artists_for_style(style):
artists = []
start = 0
page_size = 500
while True:
url = f"{PLEX_URL}/library/sections/{LIBRARY_ID}/all"
params = {
"type": 8,
"X-Plex-Token": PLEX_TOKEN,
"X-Plex-Container-Start": start,
"X-Plex-Container-Size": page_size,
"style": style,
}
resp = requests.get(url, params=params, headers=HEADERS)
resp.raise_for_status()
data = resp.json()
mc = data.get("MediaContainer", {})
batch = mc.get("Metadata", [])
if not batch:
break
artists.extend(batch)
total = int(mc.get("totalSize", len(artists)))
if len(artists) >= total:
break
start += page_size
return artists
def get_tracks_for_artist(artist_key):
tracks = []
start = 0
page_size = 500
while True:
url = f"{PLEX_URL}/library/sections/{LIBRARY_ID}/all"
params = {
"type": 10,
"X-Plex-Token": PLEX_TOKEN,
"X-Plex-Container-Start": start,
"X-Plex-Container-Size": page_size,
"artist.id": artist_key,
}
resp = requests.get(url, params=params, headers=HEADERS)
resp.raise_for_status()
data = resp.json()
mc = data.get("MediaContainer", {})
batch = mc.get("Metadata", [])
if not batch:
break
tracks.extend(batch)
total = int(mc.get("totalSize", len(tracks)))
if len(tracks) >= total:
break
start += page_size
return tracks
def get_tracks_for_style(style):
artists = get_artists_for_style(style)
tracks = []
for artist in artists:
artist_key = artist.get("ratingKey")
if artist_key:
tracks.extend(get_tracks_for_artist(artist_key))
return tracks
def sort_tracks(tracks, mode):
if mode == "1":
result = list(tracks)
random.shuffle(result)
return result
elif mode == "2":
return sorted(tracks, key=lambda t: t.get("viewCount", 0), reverse=True)
elif mode == "3":
return sorted(tracks, key=lambda t: t.get("viewCount", 0))
elif mode == "4":
result = [t for t in tracks if t.get("viewCount", 0) == 0]
random.shuffle(result)
return result
elif mode == "5":
return sorted(tracks, key=lambda t: t.get("userRating", 0), reverse=True)
elif mode == "6":
artist_plays = defaultdict(int)
for t in tracks:
artist_plays[t.get("grandparentTitle", "")] += t.get("viewCount", 0)
return sorted(tracks, key=lambda t: artist_plays[t.get("grandparentTitle", "")], reverse=True)
return tracks
def interleave_by_artist(tracks):
"""
Round-robin interleave so no artist appears back-to-back.
Groups tracks by artist, shuffles each group, then pulls
one track per artist per round until all slots are filled.
"""
by_artist = defaultdict(list)
for t in tracks:
by_artist[t.get("grandparentTitle", "Unknown")].append(t)
artists = list(by_artist.keys())
random.shuffle(artists)
for a in artists:
random.shuffle(by_artist[a])
result = []
while any(by_artist[a] for a in artists):
random.shuffle(artists)
for artist in artists:
if by_artist[artist]:
result.append(by_artist[artist].pop(0))
return result
def is_live_album(track):
"""Return True if the track appears to be from a live album."""
if not SKIP_LIVE_ALBUMS:
return False
album = track.get("parentTitle", "").lower()
return any(kw in album for kw in LIVE_KEYWORDS)
def pick_tracks(style_track_map, selected_styles, mode, songs_per_artist, target_songs):
per_style = max(1, target_songs // len(selected_styles))
remainder = target_songs - per_style * len(selected_styles)
chosen = []
seen_keys = set()
for i, style in enumerate(selected_styles):
quota = per_style + (1 if i < remainder else 0)
tracks = style_track_map.get(style, [])
sorted_tracks = sort_tracks(tracks, mode)
artist_count = defaultdict(int)
added = 0
for t in sorted_tracks:
if added >= quota:
break
key = t["ratingKey"]
if key in seen_keys:
continue
if t.get("duration", 0) < MIN_DURATION_MS:
continue
if is_live_album(t):
continue
artist = t.get("grandparentTitle", "Unknown")
if artist_count[artist] >= songs_per_artist:
continue
chosen.append(t)
seen_keys.add(key)
artist_count[artist] += 1
added += 1
if len(chosen) < target_songs:
all_tracks = []
for style in selected_styles:
all_tracks.extend(style_track_map.get(style, []))
random.shuffle(all_tracks)
for t in all_tracks:
if len(chosen) >= target_songs:
break
if t["ratingKey"] in seen_keys:
continue
if t.get("duration", 0) < MIN_DURATION_MS:
continue
if is_live_album(t):
continue
chosen.append(t)
seen_keys.add(t["ratingKey"])
return interleave_by_artist(chosen[:target_songs])
def delete_existing_playlist(playlist_name):
url = f"{PLEX_URL}/playlists"
params = {"X-Plex-Token": PLEX_TOKEN}
resp = requests.get(url, params=params, headers={"Accept": "application/json"})
resp.raise_for_status()
playlists = resp.json().get("MediaContainer", {}).get("Metadata", [])
for pl in playlists:
if pl.get("title") == playlist_name:
pl_id = pl["ratingKey"]
requests.delete(f"{PLEX_URL}/playlists/{pl_id}", params=params)
return
def create_playlist(tracks, playlist_name, machine_id):
first_key = tracks[0]["ratingKey"]
uri = f"server://{machine_id}/com.plexapp.plugins.library/library/metadata/{first_key}"
params = {
"type": "audio", "title": playlist_name, "smart": 0,
"uri": uri, "X-Plex-Token": PLEX_TOKEN,
}
resp = requests.post(f"{PLEX_URL}/playlists", params=params, headers={"Accept": "application/json"})
resp.raise_for_status()
pl_id = resp.json().get("MediaContainer", {}).get("Metadata", [{}])[0].get("ratingKey")
remaining = tracks[1:]
batch_size = 50
for i in range(0, len(remaining), batch_size):
batch = remaining[i:i + batch_size]
keys = ",".join(t["ratingKey"] for t in batch)
uri = f"server://{machine_id}/com.plexapp.plugins.library/library/metadata/{keys}"
requests.put(
f"{PLEX_URL}/playlists/{pl_id}/items",
params={"uri": uri, "X-Plex-Token": PLEX_TOKEN},
headers={"Accept": "application/json"},
)
def process_preset(preset, library_styles, machine_id, mode, songs_per_artist, target_songs):
selected_styles = [s for s in preset["styles"] if s in library_styles]
playlist_name = preset["playlist_name"]
if not selected_styles:
print(f" ⚠️ Skipping '{preset['name']}' — no matching styles in library")
return False
print(f"\n[{preset['name']}]")
print(f" Styles: {', '.join(selected_styles)}")
style_track_map = {}
for style in selected_styles:
style_track_map[style] = get_tracks_for_style(style)
print(f" {style}: {len(style_track_map[style])} tracks")
picked = pick_tracks(style_track_map, selected_styles, mode, songs_per_artist, target_songs)
if not picked:
print(f" ⚠️ No tracks found, skipping.")
return False
delete_existing_playlist(playlist_name)
create_playlist(picked, playlist_name, machine_id)
print(f" ✅ '{playlist_name}' created with {len(picked)} tracks")
return True
def main():
print(f"\n{'='*55}")
print(f" Plex Auto Playlist Generator")
print(f" {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"{'='*55}")
print(f"\n Mode : {MODE_NAMES[DEFAULT_MODE]}")
print(f" Songs/artist : {DEFAULT_SONGS_PER_ARTIST}")
print(f" Target songs : {DEFAULT_TARGET_SONGS}")
print(f" Min duration : {MIN_DURATION_MS // 1000} seconds")
print(f" Presets : {len(PRESETS)}")
print(f"\n Starting in 3 seconds... (Ctrl+C to cancel)")
time.sleep(3)
print(f"\nFetching library styles...")
library_styles = get_library_styles()
print(f" Found {len(library_styles)} styles in library.")
machine_id = get_machine_id()
success = 0
skipped = 0
for preset in PRESETS:
result = process_preset(
preset, library_styles, machine_id,
DEFAULT_MODE, DEFAULT_SONGS_PER_ARTIST, DEFAULT_TARGET_SONGS
)
if result:
success += 1
else:
skipped += 1
print(f"\n{'='*55}")
print(f" Done! {success} playlists created, {skipped} skipped.")
print(f" Refresh Plexamp to see your new playlists.")
print(f"{'='*55}\n")
if __name__ == "__main__":
main()
import requests
import random
from collections import defaultdict
from datetime import datetime
# ── Configuration ────────────────────────────────────────────────────────────
PLEX_URL = "http://YOUR_PLEX_SERVER_IP:32400"
PLEX_TOKEN = "YOUR_PLEX_TOKEN"
LIBRARY_ID = "YOUR_MUSIC_LIBRARY_ID"
# ─────────────────────────────────────────────────────────────────────────────
HEADERS = {"X-Plex-Token": PLEX_TOKEN, "Accept": "application/json"}
MIN_DURATION_MS = 30000 # Skip tracks shorter than 30 seconds
SKIP_LIVE_ALBUMS = True # Skip tracks from live/concert albums
LIVE_KEYWORDS = { # Album title phrases that indicate a live recording
"live at", "live in", "live from", "live on", "live &",
"concert", "in concert", "unplugged",
"bbc session", "bbc sessions", "bootleg",
"at budokan", "at fillmore", "at montreux", "at madison",
}
# ── Presets ───────────────────────────────────────────────────────────────────
PRESETS = [
{
"name": "Punk & Hardcore",
"default_playlist_name": "Auto: Punk Mix",
"styles": [
"American Punk", "Anarchist Punk", "British Punk", "Cowpunk", "Emo", "Emo-Pop",
"Garage Punk", "Hardcore Punk", "L.A. Punk", "Metalcore", "New York Punk", "Oi!",
"Pop Punk", "Post-Hardcore", "Proto-Punk", "Punk", "Punk Blues", "Punk Metal",
"Punk Revival", "Punk/New Wave", "Riot Grrrl", "Screamo", "Ska-Punk", "Skatepunk",
"Straight-Edge",
],
},
{
"name": "Classic & Hard Rock",
"default_playlist_name": "Auto: Classic Rock Mix",
"styles": [
"Album Rock", "American Trad Rock", "Arena Rock", "Aussie Rock",
"Boogie Rock", "British Invasion", "British Trad Rock", "Garage Rock Revival",
"Glam Rock", "Hard Rock", "Heartland Rock", "Instrumental Rock",
"Merseybeat", "Mod", "Rock & Roll", "Roots Rock", "Southern Rock",
"Surf", "Surf Revival",
],
},
{
"name": "Metal",
"default_playlist_name": "Auto: Metal Mix",
"styles": [
"Alternative Metal", "British Metal", "Funk Metal", "Hair Metal",
"Heavy Metal", "Industrial Metal", "Metalcore", "New Wave of British Heavy Metal",
"Nü Metal", "Pop-Metal", "Punk Metal", "Rap-Metal", "Speed/Thrash Metal",
],
},
{
"name": "Extreme & Underground Metal",
"default_playlist_name": "Auto: Extreme Metal Mix",
"styles": [
"Black Metal", "Death Metal", "Doom Metal", "Folk-Metal", "Goth Metal",
"Neo-Classical Metal", "Post-Metal", "Power Metal", "Progressive Metal",
"Scandinavian Metal", "Sludge Metal", "Stoner Metal", "Symphonic Black Metal",
"Symphonic Metal",
],
},
{
"name": "Alternative & Indie",
"default_playlist_name": "Auto: Alt & Indie Mix",
"styles": [
"Alternative Pop/Rock", "Alternative/Indie Rock", "American Underground",
"Britpop", "Chamber Pop", "College Rock", "Electronic", "Experimental",
"Experimental Rock", "Garage Rock Revival", "Grunge", "Indie Electronic",
"Indie Pop", "Indie Rock", "Lo-Fi", "New Wave", "New Wave/Post-Punk Revival",
"No Wave", "Noise-Rock", "Post-Grunge", "Post-Punk",
],
},
{
"name": "Hip-Hop & Rap",
"default_playlist_name": "Auto: Hip-Hop Mix",
"styles": [
"Alternative Rap", "Bay Area Rap", "British Rap", "Cloud Rap",
"Contemporary Rap", "Dirty South", "East Coast Rap", "G-Funk", "Gangsta Rap",
"Golden Age", "Hardcore Rap", "Horror Rap", "Hyperpop", "Instrumental Hip-Hop",
"Jazz-Rap", "Latin Rap", "Left-Field Rap", "Midwest Rap", "Old-School Rap",
"Party Rap", "Political Rap", "Pop-Rap", "Rap-Rock", "Southern Rap",
"Trap (Rap)", "Underground Rap", "West Coast Rap",
],
},
{
"name": "Pop",
"default_playlist_name": "Auto: Pop Mix",
"styles": [
"Adult Alternative Pop/Rock", "Adult Contemporary", "AM Pop",
"American Popular Song", "Baroque Pop", "Brill Building Pop",
"Bubblegum", "Chamber Pop", "Contemporary Pop/Rock", "Country-Pop",
"Dance-Pop", "Early Pop/Rock", "Euro-Pop", "Folk-Pop", "French Pop",
"Girl Groups", "Indie Pop", "Jazz-Pop", "Left-Field Pop",
"New Romantic", "Pop", "Pop Idol", "Pop/Rock", "Scandinavian Pop",
"Social Media Pop", "Soft Rock", "Sophisti-Pop", "Sunshine Pop",
"Swedish Pop/Rock", "Synth Pop", "Teen Idols", "Teen Pop", "Vocal Pop",
],
},
{
"name": "Electronic & Dance",
"default_playlist_name": "Auto: Electronic Mix",
"styles": [
"Alternative Dance", "Ambient", "Club/Dance", "Dance-Pop", "Dance-Rock",
"Detroit Techno", "Disco", "EDM", "Electronic", "Euro-Dance", "Euro-Disco",
"Experimental Ambient", "Hi-NRG", "House", "Indie Electronic",
"Left-Field House", "Lo-Fi", "Neo-Disco", "Post-Disco", "Synthwave", "Techno",
],
},
{
"name": "Soul, R&B & Funk",
"default_playlist_name": "Auto: Soul & R&B Mix",
"styles": [
"Adult Contemporary R&B", "Alternative R&B", "Blue-Eyed Soul",
"Contemporary R&B", "Country Soul", "Deep Funk Revival", "Early R&B",
"Funk", "Jazz-Funk", "Memphis Soul", "Motown", "Neo-Soul",
"New Jack Swing", "Pop-Soul", "Psychedelic Soul", "Quiet Storm",
"Retro-Soul", "Smooth Soul", "Soul", "Soul-Blues",
],
},
{
"name": "Folk, Americana & Country",
"default_playlist_name": "Auto: Folk & Americana Mix",
"styles": [
"Alt-Country", "Alternative Country-Rock", "Alternative Folk",
"Alternative Singer/Songwriter", "Americana", "Appalachian", "Blues-Rock",
"British Folk-Rock", "Contemporary Country", "Contemporary Folk",
"Contemporary Singer/Songwriter", "Country-Folk", "Country-Rock", "Folk-Pop",
"Folk-Rock", "Indie Folk", "Neo-Traditional Folk", "Neo-Traditionalist Country",
"New Acoustic", "Political Folk", "Progressive Country", "Singer/Songwriter",
"Traditional Country", "Traditional Folk", "Truck Driving Country",
],
},
{
"name": "Bluegrass",
"default_playlist_name": "Auto: Bluegrass Mix",
"styles": [
"Appalachian", "Bluegrass", "Bluegrass-Gospel", "Contemporary Bluegrass",
"Jug Band", "New Acoustic", "North American Traditions", "Old-Timey",
"Progressive Bluegrass", "Scottish Folk", "String Bands", "Traditional Bluegrass",
"Traditional Folk",
],
},
{
"name": "Psychedelic & Art Rock",
"default_playlist_name": "Auto: Psychedelic Mix",
"styles": [
"Acid Rock", "Art Rock", "British Psychedelia", "Garage",
"Goth Rock", "Neo-Psychedelia", "Psychedelic Pop",
"Psychedelic Soul", "Psychedelic/Garage",
],
},
{
"name": "Progressive & Neo-Prog",
"default_playlist_name": "Auto: Prog Mix",
"styles": [
"Art Rock", "Experimental Rock", "Jazz-Rock", "Neo-Prog",
"Prog-Rock", "Progressive Metal",
],
},
{
"name": "Blues",
"default_playlist_name": "Auto: Blues Mix",
"styles": [
"Acoustic Blues", "Acoustic Chicago Blues", "Blues Revival", "Blues-Rock",
"British Blues", "Chicago Blues", "Contemporary Blues", "Country Blues",
"Delta Blues", "Detroit Blues", "East Coast Blues", "Electric Blues",
"Electric Chicago Blues", "Electric Delta Blues", "Electric Memphis Blues",
"Electric Texas Blues", "Jazz Blues", "Jug Band", "Jump Blues", "Memphis Blues",
"Modern Electric Blues", "Modern Electric Texas Blues", "Piano Blues",
"Regional Blues", "Slide Guitar Blues", "Soul-Blues", "Swamp Blues",
"Texas Blues", "Urban Blues",
],
},
{
"name": "Jazz",
"default_playlist_name": "Auto: Jazz Mix",
"styles": [
"Avant-Garde Jazz", "Big Band", "Bop", "Bossa Nova", "Cool",
"Contemporary Jazz", "Crossover Jazz", "Dance Bands", "Dixieland",
"Early Jazz", "Electro-Jazz", "Free Jazz", "Fusion", "Hard Bop",
"Jazz", "Jazz Instrument", "Jazz-Funk", "Jazz-Pop", "Jazz-Rock",
"Jive", "Mainstream Jazz", "Modal Music", "Modern Big Band",
"New Orleans Brass Bands", "New Orleans Jazz", "Piano Jazz", "Post-Bop",
"Progressive Jazz", "Saxophone Jazz", "Smooth Jazz", "Soul Jazz",
"Standards", "Swing", "Tin Pan Alley Pop", "Traditional Pop",
"Trumpet Jazz", "Vocal Jazz", "West Coast Jazz",
],
},
{
"name": "Reggae & Ska",
"default_playlist_name": "Auto: Reggae & Ska Mix",
"styles": [
"Contemporary Reggae", "Dancehall", "Dub", "Reggaeton", "Roots Reggae",
"Ska Revival", "Ska-Punk", "Third Wave Ska Revival",
],
},
{
"name": "Yacht Rock",
"default_playlist_name": "Auto: Yacht Rock Mix",
"styles": [
"Adult Contemporary", "Blue-Eyed Soul", "Soft Rock", "Sophisti-Pop",
],
},
{
"name": "Swing & Big Band",
"default_playlist_name": "Auto: Swing Mix",
"styles": [
"Big Band", "Dance Bands", "Dixieland", "Jive", "Jump Blues",
"Modern Big Band", "New Orleans Brass Bands", "Retro Swing",
"Swing", "Sweet Bands",
],
},
{
"name": "Rockabilly & Retro Rock",
"default_playlist_name": "Auto: Rockabilly Mix",
"styles": [
"Bar Band", "Early Pop/Rock", "Rock & Roll", "Rockabilly",
"Rockabilly Revival", "Surf", "Surf Revival",
],
},
{
"name": "Jam Bands & Experimental",
"default_playlist_name": "Auto: Jam & Experimental Mix",
"styles": [
"Ambient", "Experimental", "Experimental Ambient", "Experimental Rock",
"Fusion", "Jam Bands", "Jazz-Funk", "Jazz-Rock", "Lo-Fi", "Prog-Rock",
],
},
{
"name": "Goth & Dark Rock",
"default_playlist_name": "Auto: Goth Mix",
"styles": [
"Doom Metal", "Goth Metal", "Goth Rock", "Industrial Metal",
"No Wave", "Noise-Rock", "Post-Punk", "Sludge Metal",
],
},
{
"name": "Euro & World Rock",
"default_playlist_name": "Auto: Euro Rock Mix",
"styles": [
"African Traditions", "Afro-beat", "Appalachian", "Brazilian Traditions",
"Celtic", "Celtic Rock", "Euro-Rock", "Gypsy", "Latin Rock",
"North American Traditions", "Nouvelle Chanson", "Scandinavian Metal",
"Scandinavian Pop", "Scottish Folk", "South/Eastern European Traditions",
"Swedish Pop/Rock", "Ukrainian",
],
},
{
"name": "Latin",
"default_playlist_name": "Auto: Latin Mix",
"styles": [
"Afro-beat", "Bossa Nova", "Brazilian Traditions", "Latin Pop",
"Latin Rap", "Latin Rock", "Reggaeton",
],
},
{
"name": "Classic Vocal & Standards",
"default_playlist_name": "Auto: Vocal & Standards Mix",
"styles": [
"American Popular Song", "Cabaret", "Close Harmony", "Doo Wop",
"Girl Groups", "Harmony Vocal Group", "Standards", "Tin Pan Alley Pop",
"Torch Songs", "Traditional Pop", "Vocal Jazz", "Vocal Music", "Vocal Pop",
],
},
]
def get_machine_id():
url = f"{PLEX_URL}/"
resp = requests.get(url, params={"X-Plex-Token": PLEX_TOKEN}, headers={"Accept": "application/json"})
resp.raise_for_status()
return resp.json().get("MediaContainer", {}).get("machineIdentifier", "")
def get_library_styles():
"""Fetch available styles from the Plex style browser endpoint."""
url = f"{PLEX_URL}/library/sections/{LIBRARY_ID}/style"
params = {"type": 8, "X-Plex-Token": PLEX_TOKEN}
resp = requests.get(url, params=params, headers=HEADERS)
resp.raise_for_status()
directories = resp.json().get("MediaContainer", {}).get("Directory", [])
return {d["title"] for d in directories}
def get_artists_for_style(style):
artists = []
start = 0
page_size = 500
while True:
url = f"{PLEX_URL}/library/sections/{LIBRARY_ID}/all"
params = {
"type": 8,
"X-Plex-Token": PLEX_TOKEN,
"X-Plex-Container-Start": start,
"X-Plex-Container-Size": page_size,
"style": style,
}
resp = requests.get(url, params=params, headers=HEADERS)
resp.raise_for_status()
data = resp.json()
mc = data.get("MediaContainer", {})
batch = mc.get("Metadata", [])
if not batch:
break
artists.extend(batch)
total = int(mc.get("totalSize", len(artists)))
if len(artists) >= total:
break
start += page_size
return artists
def get_tracks_for_artist(artist_key):
tracks = []
start = 0
page_size = 500
while True:
url = f"{PLEX_URL}/library/sections/{LIBRARY_ID}/all"
params = {
"type": 10,
"X-Plex-Token": PLEX_TOKEN,
"X-Plex-Container-Start": start,
"X-Plex-Container-Size": page_size,
"artist.id": artist_key,
}
resp = requests.get(url, params=params, headers=HEADERS)
resp.raise_for_status()
data = resp.json()
mc = data.get("MediaContainer", {})
batch = mc.get("Metadata", [])
if not batch:
break
tracks.extend(batch)
total = int(mc.get("totalSize", len(tracks)))
if len(tracks) >= total:
break
start += page_size
return tracks
def get_tracks_for_style(style):
artists = get_artists_for_style(style)
tracks = []
for artist in artists:
artist_key = artist.get("ratingKey")
if artist_key:
tracks.extend(get_tracks_for_artist(artist_key))
return tracks
def sort_tracks(tracks, mode):
if mode == "1":
result = list(tracks)
random.shuffle(result)
return result
elif mode == "2":
return sorted(tracks, key=lambda t: t.get("viewCount", 0), reverse=True)
elif mode == "3":
return sorted(tracks, key=lambda t: t.get("viewCount", 0))
elif mode == "4":
result = [t for t in tracks if t.get("viewCount", 0) == 0]
random.shuffle(result)
return result
elif mode == "5":
return sorted(tracks, key=lambda t: t.get("userRating", 0), reverse=True)
elif mode == "6":
artist_plays = defaultdict(int)
for t in tracks:
artist_plays[t.get("grandparentTitle", "")] += t.get("viewCount", 0)
return sorted(tracks, key=lambda t: artist_plays[t.get("grandparentTitle", "")], reverse=True)
return tracks
def interleave_by_artist(tracks):
"""
Round-robin interleave so no artist appears back-to-back.
Groups tracks by artist, shuffles each group, then pulls
one track per artist per round until all slots are filled.
"""
by_artist = defaultdict(list)
for t in tracks:
by_artist[t.get("grandparentTitle", "Unknown")].append(t)
artists = list(by_artist.keys())
random.shuffle(artists)
for a in artists:
random.shuffle(by_artist[a])
result = []
while any(by_artist[a] for a in artists):
random.shuffle(artists)
for artist in artists:
if by_artist[artist]:
result.append(by_artist[artist].pop(0))
return result
def is_live_album(track):
"""Return True if the track appears to be from a live album."""
if not SKIP_LIVE_ALBUMS:
return False
album = track.get("parentTitle", "").lower()
return any(kw in album for kw in LIVE_KEYWORDS)
def pick_tracks(style_track_map, selected_styles, mode, songs_per_artist, target_songs):
per_style = max(1, target_songs // len(selected_styles))
remainder = target_songs - per_style * len(selected_styles)
chosen = []
seen_keys = set()
for i, style in enumerate(selected_styles):
quota = per_style + (1 if i < remainder else 0)
tracks = style_track_map.get(style, [])
sorted_tracks = sort_tracks(tracks, mode)
artist_count = defaultdict(int)
added = 0
for t in sorted_tracks:
if added >= quota:
break
key = t["ratingKey"]
if key in seen_keys:
continue
if t.get("duration", 0) < MIN_DURATION_MS:
continue
if is_live_album(t):
continue
artist = t.get("grandparentTitle", "Unknown")
if artist_count[artist] >= songs_per_artist:
continue
chosen.append(t)
seen_keys.add(key)
artist_count[artist] += 1
added += 1
# Pad if short (e.g. unplayed mode)
if len(chosen) < target_songs:
all_tracks = []
for style in selected_styles:
all_tracks.extend(style_track_map.get(style, []))
random.shuffle(all_tracks)
for t in all_tracks:
if len(chosen) >= target_songs:
break
if t["ratingKey"] in seen_keys:
continue
if t.get("duration", 0) < MIN_DURATION_MS:
continue
if is_live_album(t):
continue
chosen.append(t)
seen_keys.add(t["ratingKey"])
return interleave_by_artist(chosen[:target_songs])
def prompt_preset():
print("\nStep 1: Pick a Category\n")
for i, p in enumerate(PRESETS, 1):
print(f" {i:>2}. {p['name']}")
print(f" {len(PRESETS)+1:>2}. Custom (pick styles manually from full list)")
while True:
try:
choice = int(input("\nEnter number: ").strip())
if 1 <= choice <= len(PRESETS) + 1:
return choice
except ValueError:
pass
print(f"Please enter a number between 1 and {len(PRESETS)+1}.")
def prompt_custom_styles(library_styles):
sorted_styles = sorted(library_styles, key=lambda x: x.lower())
print(f"\nFound {len(sorted_styles)} artist styles in your library:\n")
col_width = 36
cols = 3
for i, style in enumerate(sorted_styles, 1):
entry = f"{i:>3}. {style}"
print(f"{entry:<{col_width}}", end="" if i % cols != 0 else "\n")
if len(sorted_styles) % cols != 0:
print()
print("\nEnter style numbers to include (e.g. 1,4,7 or 1-5,9 or mix):")
raw = input("> ").strip()
selected_indices = set()
for part in raw.split(","):
part = part.strip()
if "-" in part:
try:
a, b = part.split("-", 1)
selected_indices.update(range(int(a), int(b) + 1))
except ValueError:
print(f" Skipping invalid range: {part}")
else:
try:
selected_indices.add(int(part))
except ValueError:
print(f" Skipping invalid entry: {part}")
selected = []
for idx in sorted(selected_indices):
if 1 <= idx <= len(sorted_styles):
selected.append(sorted_styles[idx - 1])
if not selected:
print("No valid styles selected. Exiting.")
raise SystemExit(0)
return selected, "Custom Mix"
def prompt_user():
choice = prompt_preset()
print("\nChecking which styles exist in your library...")
library_styles = get_library_styles()
print(f" Found {len(library_styles)} styles in library.")
if choice == len(PRESETS) + 1:
selected_styles, default_playlist_name = prompt_custom_styles(library_styles)
else:
preset = PRESETS[choice - 1]
selected_styles = [s for s in preset["styles"] if s in library_styles]
default_playlist_name = preset["default_playlist_name"]
missing = [s for s in preset["styles"] if s not in library_styles]
if missing:
print(f" (Skipping {len(missing)} styles not in your library: {', '.join(missing)})")
print(f" Using {len(selected_styles)} styles: {', '.join(selected_styles)}")
if not selected_styles:
print("❌ None of the preset styles exist in your library.")
raise SystemExit(0)
print("\nStep 2: Song Selection Mode")
print(" 1. Random — pure shuffle from selected styles")
print(" 2. Most played — tracks with highest play counts")
print(" 3. Least played — tracks you've barely listened to")
print(" 4. Unplayed only — tracks with zero plays")
print(" 5. Highest rated — tracks you've rated highest")
print(" 6. Favorite artists first — artists with most total plays get more slots")
while True:
mode = input("\nEnter 1–6: ").strip()
if mode in ("1", "2", "3", "4", "5", "6"):
break
print("Please enter a number between 1 and 6.")
mode_names = {
"1": "Random", "2": "Most Played", "3": "Least Played",
"4": "Unplayed Only", "5": "Highest Rated", "6": "Favorite Artists First",
}
default_name = f"{default_playlist_name} ({mode_names[mode]})"
print(f"\nStep 3: Playlist Name")
print(f" Press Enter to use: '{default_name}'")
name_input = input("> ").strip()
playlist_name = name_input if name_input else default_name
print("\nStep 4: Max songs per artist per style? (press Enter for 3)")
spa_input = input("> ").strip()
songs_per_artist = int(spa_input) if spa_input.isdigit() else 3
print("\nStep 5: Target number of songs? (press Enter for 100)")
target_input = input("> ").strip()
target_songs = int(target_input) if target_input.isdigit() else 100
return selected_styles, mode, playlist_name, songs_per_artist, target_songs, mode_names[mode]
def delete_existing_playlist(playlist_name):
url = f"{PLEX_URL}/playlists"
params = {"X-Plex-Token": PLEX_TOKEN}
resp = requests.get(url, params=params, headers={"Accept": "application/json"})
resp.raise_for_status()
playlists = resp.json().get("MediaContainer", {}).get("Metadata", [])
for pl in playlists:
if pl.get("title") == playlist_name:
pl_id = pl["ratingKey"]
requests.delete(f"{PLEX_URL}/playlists/{pl_id}", params=params)
print(f"Deleted existing playlist '{playlist_name}'.")
return
print(f"No existing playlist named '{playlist_name}' found — will create fresh.")
def create_playlist(tracks, playlist_name, machine_id):
first_key = tracks[0]["ratingKey"]
uri = f"server://{machine_id}/com.plexapp.plugins.library/library/metadata/{first_key}"
params = {
"type": "audio", "title": playlist_name, "smart": 0,
"uri": uri, "X-Plex-Token": PLEX_TOKEN,
}
resp = requests.post(f"{PLEX_URL}/playlists", params=params, headers={"Accept": "application/json"})
resp.raise_for_status()
pl_id = resp.json().get("MediaContainer", {}).get("Metadata", [{}])[0].get("ratingKey")
print(f"Created playlist (ID: {pl_id}), now adding remaining tracks...")
remaining = tracks[1:]
batch_size = 50
for i in range(0, len(remaining), batch_size):
batch = remaining[i:i + batch_size]
keys = ",".join(t["ratingKey"] for t in batch)
uri = f"server://{machine_id}/com.plexapp.plugins.library/library/metadata/{keys}"
requests.put(
f"{PLEX_URL}/playlists/{pl_id}/items",
params={"uri": uri, "X-Plex-Token": PLEX_TOKEN},
headers={"Accept": "application/json"},
)
print(f" Added tracks {i + 2} to {min(i + 1 + batch_size, len(tracks))}...")
print(f"\n✅ Playlist '{playlist_name}' created successfully with {len(tracks)} tracks!")
print(f" Open Plexamp and look under Playlists to find it.")
def main():
print(f"\n{'='*55}")
print(f" Plex Preset Playlist Generator")
print(f" {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"{'='*55}")
selected_styles, mode, playlist_name, songs_per_artist, target_songs, mode_name = prompt_user()
print(f"\n── Settings ───────────────────────────────────────────")
print(f" Playlist name : {playlist_name}")
print(f" Styles : {', '.join(selected_styles)}")
print(f" Mode : {mode_name}")
print(f" Songs/artist : {songs_per_artist}")
print(f" Target songs : {target_songs}")
print(f" Min duration : {MIN_DURATION_MS // 1000} seconds")
print(f"───────────────────────────────────────────────────────\n")
machine_id = get_machine_id()
style_track_map = {}
for style in selected_styles:
print(f" Fetching tracks for style: {style}...")
style_track_map[style] = get_tracks_for_style(style)
print(f" → {len(style_track_map[style])} tracks found")
picked = pick_tracks(style_track_map, selected_styles, mode, songs_per_artist, target_songs)
if not picked:
print("❌ No tracks found. Try a different mode or category.")
return
print(f"\nFinal playlist size: {len(picked)} tracks.")
delete_existing_playlist(playlist_name)
create_playlist(picked, playlist_name, machine_id)
print("\nDone! Refresh Plexamp to see your new playlist.")
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment