Last active
April 6, 2026 11:26
-
-
Save adam137016/fa9cd110ef24bca1079c4e5419ff2417 to your computer and use it in GitHub Desktop.
Plex music playlist generator
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
| 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() |
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
| 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