Last active
April 19, 2026 18:23
-
-
Save adam137016/857b6e5a03caca8af83814adc70fa09d to your computer and use it in GitHub Desktop.
Sonic Adventure Generator for Plex — Genre-Aware Playlist Script
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| ================================================================================ | |
| generate_sonic_adventures.py — v2 | |
| A genre-aware Sonic Adventure playlist generator for Plex Music libraries | |
| Requires: Plex Media Server with a Music library and Plex Pass | |
| ================================================================================ | |
| WHAT IT DOES: | |
| Uses Plex's built-in Sonic Adventure engine (the same one Plexamp uses) to | |
| generate a playlist that travels musically from one seed track to another. | |
| This script lets you pick 2-6 genre waypoints and a seed-selection mode for | |
| each one, then stitches the segments together into a single playlist saved | |
| directly to your Plex server. | |
| Example arc: Punk & Hardcore -> Hip-Hop & Rap -> Classic & Hard Rock | |
| Plex picks seed tracks from each genre and builds sonic bridges between them. | |
| REQUIREMENTS: | |
| - Python 3.8+ | |
| - plexapi (pip install plexapi) | |
| - requests (pip install requests) | |
| - Plex Pass subscription (required for Sonic Adventure) | |
| - Sonic Analysis enabled and run on your Music library | |
| (In Plex: Settings > Sonic Analysis — enable it and let it fully complete | |
| before running this script) | |
| SETUP: | |
| 1. Install dependencies: | |
| pip install plexapi requests | |
| 2. Fill in the CONFIG section below: | |
| PLEX_URL -- usually http://localhost:32400 if running on the same machine, | |
| or http://<your-server-ip>:32400 if running remotely | |
| PLEX_TOKEN -- find yours at: | |
| https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/ | |
| LIBRARY_ID -- the numeric ID of your Music library section. | |
| To find it: open Plex Web, go to your Music library, | |
| look at the URL — it will contain /library/sections/XX/ | |
| where XX is your library ID. | |
| LIBRARY_NAME -- the display name of your Music library (e.g. "Music") | |
| TARGET_TRACKS -- maximum playlist length. Set high (200+) to avoid trimming. | |
| MIN_RATING -- Plex rates tracks 1-10. Tracks must meet this threshold | |
| to qualify for "Highest Rated" mode. Default is 6. | |
| SKIP_LIVE_ALBUMS -- True skips tracks whose album title contains common | |
| live/bootleg keywords. Recommended to keep True. | |
| 3. Run the script: | |
| python generate_sonic_adventures.py | |
| 4. Follow the interactive prompts: | |
| - Choose a style threshold (how strictly artists must match a genre) | |
| - Choose how many genre waypoints (2-6) | |
| - For each waypoint: pick a genre and a seed selection mode | |
| - Hit Enter to accept the auto-generated playlist name, or type your own | |
| HOW GENRES WORK: | |
| Each genre preset is a named bucket of Plex style tags. The script finds | |
| artists in your library whose tags match 2+ styles in the bucket (configurable) | |
| and picks one of their tracks as the sonic adventure seed for that waypoint. | |
| You can add, remove, or rename genre presets in the PRESETS list below to | |
| match your library. Style names must match exactly what Plex uses — you can | |
| browse your library's styles at: | |
| http://<your-plex-ip>:32400/library/sections/<LIBRARY_ID>/style?type=8&X-Plex-Token=<YOUR_TOKEN> | |
| SEED SELECTION MODES: | |
| Each waypoint can use a different mode to pick its seed track: | |
| 1. Random -- pure shuffle | |
| 2. Most Played -- top 20% by play count | |
| 3. Least Played -- bottom 20% by play count (played tracks only) | |
| 4. Unplayed Only -- tracks with zero plays | |
| 5. Highest Rated -- tracks rated >= MIN_RATING | |
| 6. Favorite Artists First -- artists ranked by total plays, top 20% | |
| 7. Top Artists, Unplayed -- your most-listened artists, but unheard tracks only | |
| 8. Least Played Artists -- seeds from artists you've heard the least (NEW in v2) | |
| 9. Unplayed Artists Only -- seeds from artists with zero total plays (NEW in v2) | |
| WHAT'S NEW IN V2: | |
| - Modes 8 and 9 added (Least Played Artists, Unplayed Artists Only) | |
| - Holiday/Christmas album filter added (excluded same as live albums) | |
| - Genre preset count expanded to 31 | |
| - Minor style tag additions across several existing presets | |
| PLAYLIST NAMING: | |
| Default name format: MM/DD HH:MM | SeedSong1 -> SeedSong2 -> SeedSong3 | |
| The date/time prefix keeps playlists sortable in Plexamp. | |
| You can type a custom name at the prompt instead. | |
| SEGMENT BEHAVIOR: | |
| - Each segment is a sonic adventure from one seed track to the next. | |
| - A segment returning 0 tracks means those two seeds are sonically identical | |
| — Plex has nothing to put between them and places them back-to-back. | |
| - Duplicate tracks across segments are automatically removed. | |
| TIPS: | |
| - Make sure Plex Sonic Analysis has fully completed before running this script | |
| — incomplete analysis means fewer sonic connections and shorter segments. | |
| - 3-4 waypoints with varied genres gives the most interesting results. | |
| - Mixing modes (e.g. "Top Artists, Unplayed" for start, "Random" for middle) | |
| adds variety while still anchoring the arc to familiar artists. | |
| - Set TARGET_TRACKS to 200 or higher so the playlist is never trimmed. | |
| ================================================================================ | |
| """ | |
| import random | |
| import requests | |
| from collections import defaultdict | |
| from datetime import datetime | |
| from plexapi.server import PlexServer | |
| # -- CONFIG ------------------------------------------------------------------- | |
| PLEX_URL = "http://localhost:32400" # change if Plex is on another machine | |
| PLEX_TOKEN = "YOUR_PLEX_TOKEN_HERE" # Settings → Troubleshooting → Get Token | |
| LIBRARY_ID = "21" # find in Plex URL when browsing music | |
| LIBRARY_NAME = "Music" # must match your library name exactly | |
| MIN_DURATION_MS = 30000 # skip tracks shorter than 30 seconds | |
| TARGET_TRACKS = 200 # target total tracks in the adventure | |
| MIN_RATING = 6 # minimum userRating for "Highest Rated" mode (Plex 1-10 scale) | |
| SKIP_LIVE_ALBUMS = True | |
| LIVE_KEYWORDS = { | |
| "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", | |
| } | |
| HOLIDAY_KEYWORDS = { | |
| "christmas", "xmas", "holiday", "holidays", "winter wonderland", | |
| "a very special", "season's", "seasons greetings", "noel", | |
| "jingle", "yuletide", | |
| } | |
| # ----------------------------------------------------------------------------- | |
| HEADERS = {"X-Plex-Token": PLEX_TOKEN, "Accept": "application/json"} | |
| MODE_NAMES = { | |
| "1": "Random", | |
| "2": "Most Played", | |
| "3": "Least Played", | |
| "4": "Unplayed Only", | |
| "5": "Highest Rated", | |
| "6": "Favorite Artists First", | |
| "7": "Top Artists, Unplayed Tracks", | |
| "8": "Least Played Artists", | |
| "9": "Unplayed Artists Only", | |
| } | |
| PRESETS = [ | |
| {"name": "Punk & Hardcore", "styles": [ | |
| "American Punk", "Anarchist Punk", "British Punk", "Cowpunk", "Emo", "Emo-Pop", | |
| "Frat Rock", "Garage Punk", "Hardcore Punk", "L.A. Punk", "New York Punk", "Oi!", | |
| "Pop Punk", "Post-Hardcore", "Proto-Punk", "Punk", "Punk Blues", | |
| "Punk Revival", "Riot Grrrl", "Screamo", "Ska-Punk", "Skatepunk", "Straight-Edge", | |
| ]}, | |
| {"name": "Classic & Hard Rock", "styles": [ | |
| "Album Rock", "American Trad Rock", "Arena Rock", "Aussie Rock", "Boogie Rock", | |
| "British Invasion", "British Trad Rock", "Detroit Rock", "Euro-Rock", | |
| "Garage Rock", "Garage Rock Revival", "Glam Rock", "Glitter", "Hard Rock", | |
| "Heartland Rock", "Instrumental Rock", "Merseybeat", "Mod", "Nouvelle Chanson", | |
| "Power Pop", "Rock & Roll", "Roots Rock", "Scandinavian Pop", | |
| "Southern Rock", "Swedish Pop/Rock", | |
| ]}, | |
| {"name": "Psychedelic & Art Rock", "styles": [ | |
| "Acid Rock", "Art Rock", "British Psychedelia", "Dream Pop", "Garage", | |
| "Neo-Psychedelia", "Post-Rock", "Psychedelic Pop", "Psychedelic/Garage", "Shoegaze", | |
| ]}, | |
| {"name": "Progressive & Neo-Prog", "styles": [ | |
| "Art Rock", "Jazz-Rock", "Math Rock", "Neo-Prog", "Post-Rock", "Prog-Rock", | |
| ]}, | |
| {"name": "Rockabilly & Retro Rock", "styles": [ | |
| "Bar Band", "Early Pop/Rock", "Rock & Roll", "Rockabilly", "Rockabilly Revival", | |
| ]}, | |
| {"name": "Surf & California Pop", "styles": [ | |
| "Surf", "Surf Revival", "Sunshine Pop", | |
| ]}, | |
| {"name": "Metal", "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", "styles": [ | |
| "Avant-Garde Metal", "Black Metal", "Death Metal", "Deathcore", "Doom Metal", | |
| "Folk-Metal", "Goth Metal", "Grindcore", "Neo-Classical Metal", "Post-Metal", | |
| "Power Metal", "Progressive Metal", "Scandinavian Metal", "Sludge Metal", | |
| "Stoner Metal", "Symphonic Black Metal", "Symphonic Metal", | |
| ]}, | |
| {"name": "Goth & Dark Rock", "styles": [ | |
| "Doom Metal", "Goth Metal", "Goth Rock", "Industrial Metal", | |
| "No Wave", "Noise-Rock", "Post-Punk", "Sludge Metal", | |
| ]}, | |
| {"name": "Alternative & Grunge", "styles": [ | |
| "Alternative Pop/Rock", "Alternative/Indie Rock", "American Underground", | |
| "Britpop", "College Rock", "Dream Pop", "Garage Rock Revival", "Grunge", | |
| "Math Rock", "Noise Pop", "Noise-Rock", "Post-Grunge", "Post-Rock", "Power Pop", | |
| "Shoegaze", | |
| ]}, | |
| {"name": "Indie", "styles": [ | |
| "Bedroom Pop", "Chamber Pop", "Dream Pop", "Indie Electronic", "Indie Folk", | |
| "Indie Pop", "Indie Rock", "Jangle Pop", "Left-Field Pop", "Lo-Fi", | |
| "Noise Pop", "Post-Rock", "Power Pop", "Shoegaze", "Twee Pop", | |
| ]}, | |
| {"name": "New Wave & Post-Punk", "styles": [ | |
| "Dream Pop", "New Wave", "New Wave/Post-Punk Revival", "No Wave", | |
| "Post-Punk", "Post-Rock", "Punk/New Wave", | |
| ]}, | |
| {"name": "Jam Bands & Experimental", "styles": [ | |
| "Ambient", "Experimental", "Experimental Ambient", "Experimental Rock", | |
| "Fusion", "Jam Bands", "Jazz-Funk", "Jazz-Rock", "Prog-Rock", | |
| ]}, | |
| {"name": "Hip-Hop & Rap", "styles": [ | |
| "Alternative Rap", "Bay Area Rap", "British Rap", "Cloud Rap", "Comedy Rap", | |
| "Contemporary Rap", "Dirty Rap", "Dirty South", "Drill", "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)", "UK Garage", "Underground Rap", | |
| "West Coast Rap", | |
| ]}, | |
| {"name": "Pop", "styles": [ | |
| "Adult Alternative Pop/Rock", "Adult Contemporary", "AM Pop", | |
| "American Popular Song", "Baroque Pop", "Brill Building Pop", "Bubblegum", | |
| "Contemporary Pop/Rock", "Country-Pop", "Dance-Pop", "Early Pop/Rock", | |
| "Euro-Pop", "Folk-Pop", "French Pop", "Girl Groups", "Jazz-Pop", | |
| "New Romantic", "Pop", "Pop Idol", "Pop/Rock", | |
| "Social Media Pop", "Soft Rock", "Sophisti-Pop", | |
| "Synth Pop", "Teen Idols", "Teen Pop", "Vocal Pop", | |
| ]}, | |
| {"name": "Electronic & Dance", "styles": [ | |
| "Acid House", "Alternative Dance", "Ambient", "Bass Music", "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", "Neo-Disco", "Post-Disco", | |
| "Synthwave", "Techno", "Trap (EDM)", "Trip-Hop", "UK Garage", | |
| ]}, | |
| {"name": "Soul, R&B & Funk", "styles": [ | |
| "Adult Contemporary R&B", "Alternative R&B", "Blue-Eyed Soul", | |
| "Chicago Soul", "Contemporary R&B", "Country Soul", "Deep Funk Revival", | |
| "Deep Soul", "Early R&B", "Funk", "Jazz-Funk", "Memphis Soul", "Motown", | |
| "Neo-Soul", "New Jack Swing", "New Orleans R&B", "Pop-Soul", | |
| "Psychedelic Soul", "Quiet Storm", "Retro-Soul", "Smooth Soul", "Soul", | |
| "Soul-Blues", "Southern Soul", "Uptown Soul", | |
| ]}, | |
| {"name": "Folk, Americana & Bluegrass", "styles": [ | |
| "Alternative Folk", "Alternative Singer/Songwriter", "Americana", | |
| "Appalachian", "Bluegrass", "Bluegrass-Gospel", "British Folk-Rock", | |
| "Contemporary Bluegrass", "Contemporary Folk", "Contemporary Singer/Songwriter", | |
| "Country-Folk", "Folk-Pop", "Folk-Rock", "Indie Folk", "Jug Band", | |
| "Neo-Traditional Folk", "New Acoustic", "North American Traditions", "Old-Timey", | |
| "Political Folk", "Progressive Bluegrass", "Protest Songs", "Scottish Folk", | |
| "Singer/Songwriter", "String Bands", "Traditional Bluegrass", "Traditional Folk", | |
| ]}, | |
| {"name": "Country & Americana", "styles": [ | |
| "Alt-Country", "Alternative Country-Rock", "Contemporary Country", | |
| "Country-Rock", "Honky Tonk", "Nashville Sound/Countrypolitan", | |
| "Neo-Traditionalist Country", "Outlaw Country", "Progressive Country", | |
| "Traditional Country", "Truck Driving Country", "Urban Cowboy", | |
| ]}, | |
| {"name": "Blues", "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", "Jump Blues", "Memphis Blues", | |
| "Modern Electric Blues", "Modern Electric Chicago Blues", | |
| "Modern Electric Texas Blues", "New Orleans R&B", "Piano Blues", | |
| "Regional Blues", "Slide Guitar Blues", "Soul-Blues", "Swamp Blues", | |
| "Swamp Pop", "Texas Blues", "Urban Blues", | |
| ]}, | |
| {"name": "Jazz", "styles": [ | |
| "Acid Jazz", "Avant-Garde Jazz", "Beat Poetry", "Bop", "Bossa Nova", "Cool", | |
| "Contemporary Jazz", "Crossover Jazz", "Early Jazz", "Electro-Jazz", | |
| "Free Jazz", "Fusion", "Hard Bop", "Jazz", "Jazz Instrument", "Jazz-Funk", | |
| "Jazz-Pop", "Jazz-Rock", "Mainstream Jazz", "Modal Music", "New Orleans Jazz", | |
| "Piano Jazz", "Post-Bop", "Progressive Jazz", "Saxophone Jazz", "Smooth Jazz", | |
| "Soul Jazz", "Trumpet Jazz", "Vocal Jazz", "West Coast Jazz", | |
| ]}, | |
| {"name": "Swing & Big Band", "styles": [ | |
| "Big Band", "Calypso", "Dance Bands", "Dixieland", "Jive", "Jump Blues", | |
| "Modern Big Band", "New Orleans Brass Bands", "Retro Swing", | |
| "Swing", "Sweet Bands", | |
| ]}, | |
| {"name": "Reggae & Ska", "styles": [ | |
| "Caribbean Traditions", "Contemporary Reggae", "Dancehall", "Dub", | |
| "Reggae-Pop", "Reggaeton", "Roots Reggae", "Ska Revival", | |
| "Third Wave Ska Revival", | |
| ]}, | |
| {"name": "Yacht Rock", "styles": [ | |
| "Adult Contemporary", "Blue-Eyed Soul", "Soft Rock", "Sophisti-Pop", | |
| ]}, | |
| {"name": "World Music", "styles": [ | |
| "African Traditions", "Afro-beat", "Brazilian Traditions", "Calypso", | |
| "Caribbean Traditions", "Celtic", "Celtic Rock", "Gypsy", "Latin Rock", | |
| "South/Eastern European Traditions", "Ukrainian", "Worldbeat", | |
| ]}, | |
| {"name": "Latin", "styles": [ | |
| "Afro-beat", "Bossa Nova", "Brazilian Traditions", "Calypso", | |
| "Latin Pop", "Latin Rap", "Latin Rock", "Reggaeton", "Urbano", | |
| ]}, | |
| {"name": "Classic Vocal & Standards", "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", | |
| ]}, | |
| {"name": "Trip-Hop & Downtempo", "styles": [ | |
| "Ambient", "Electronica", "Experimental Electronic", "Trip-Hop", | |
| ]}, | |
| {"name": "Power Pop & Twee", "styles": [ | |
| "Bubblegum", "Frat Rock", "Jangle Pop", "Noise Pop", "Power Pop", "Shoegaze", "Twee Pop", | |
| ]}, | |
| {"name": "Gospel", "styles": [ | |
| "Black Gospel", "Christian Rock", "Contemporary Gospel", | |
| "Country Gospel", "Gospel", | |
| ]}, | |
| {"name": "Comedy & Novelty", "styles": [ | |
| "Comedy", "Comedy Rap", "Comedy Rock", "Country Comedy", | |
| "Novelty", "Song Parody", | |
| ]}, | |
| {"name": "Soundtrack & Score", "styles": [ | |
| "Cast Recordings", "Film Music", "Film Score", | |
| "Musical Theater", "Original Score", "Show Tunes", | |
| "Show/Musical", "Soundtracks", | |
| ]}, | |
| ] | |
| # -- Plex REST helpers -------------------------------------------------------- | |
| def get_library_styles(): | |
| 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() | |
| return {d["title"] for d in resp.json().get("MediaContainer", {}).get("Directory", [])} | |
| def get_artists_for_style(style): | |
| artists, start = [], 0 | |
| while True: | |
| params = {"type": 8, "X-Plex-Token": PLEX_TOKEN, | |
| "X-Plex-Container-Start": start, "X-Plex-Container-Size": 500, | |
| "style": style} | |
| resp = requests.get(f"{PLEX_URL}/library/sections/{LIBRARY_ID}/all", params=params, headers=HEADERS) | |
| resp.raise_for_status() | |
| mc = resp.json().get("MediaContainer", {}) | |
| batch = mc.get("Metadata", []) | |
| if not batch: | |
| break | |
| artists.extend(batch) | |
| if len(artists) >= int(mc.get("totalSize", len(artists))): | |
| break | |
| start += 500 | |
| return artists | |
| def get_tracks_for_artist(artist_key): | |
| tracks, start = [], 0 | |
| while True: | |
| params = {"type": 10, "X-Plex-Token": PLEX_TOKEN, | |
| "X-Plex-Container-Start": start, "X-Plex-Container-Size": 500, | |
| "artist.id": artist_key} | |
| resp = requests.get(f"{PLEX_URL}/library/sections/{LIBRARY_ID}/all", params=params, headers=HEADERS) | |
| resp.raise_for_status() | |
| mc = resp.json().get("MediaContainer", {}) | |
| batch = mc.get("Metadata", []) | |
| if not batch: | |
| break | |
| tracks.extend(batch) | |
| if len(tracks) >= int(mc.get("totalSize", len(tracks))): | |
| break | |
| start += 500 | |
| return tracks | |
| def get_artists_for_preset(preset, library_styles, min_style_matches=2): | |
| valid = [s for s in preset["styles"] if s in library_styles] | |
| artist_style_counts = defaultdict(int) | |
| artist_data = {} | |
| for style in valid: | |
| for artist in get_artists_for_style(style): | |
| key = artist.get("ratingKey") | |
| if key: | |
| artist_style_counts[key] += 1 | |
| artist_data[key] = artist | |
| return [artist_data[k] for k, count in artist_style_counts.items() if count >= min_style_matches] | |
| def get_tracks_for_preset(preset, library_styles, min_style_matches=2): | |
| tracks, seen_keys = [], set() | |
| for artist in get_artists_for_preset(preset, library_styles, min_style_matches): | |
| for track in get_tracks_for_artist(artist.get("ratingKey")): | |
| key = track.get("ratingKey") | |
| if key and key not in seen_keys: | |
| seen_keys.add(key) | |
| tracks.append(track) | |
| return tracks | |
| def is_live_album(track): | |
| if not SKIP_LIVE_ALBUMS: | |
| return False | |
| return any(kw in track.get("parentTitle", "").lower() for kw in LIVE_KEYWORDS) | |
| def is_holiday_album(track): | |
| return any(kw in track.get("parentTitle", "").lower() for kw in HOLIDAY_KEYWORDS) | |
| def get_artist_play_counts(tracks): | |
| artist_plays = defaultdict(int) | |
| for t in tracks: | |
| artist_plays[t.get("grandparentTitle", "Unknown")] += t.get("viewCount", 0) | |
| return artist_plays | |
| def pick_seed_track(tracks, mode, used_titles): | |
| eligible = [t for t in tracks | |
| if t.get("duration", 0) >= MIN_DURATION_MS | |
| and not is_live_album(t) | |
| and not is_holiday_album(t) | |
| and t.get("title") not in used_titles] | |
| if not eligible: | |
| return None | |
| if mode == "1": | |
| return random.choice(eligible) | |
| elif mode == "2": | |
| eligible.sort(key=lambda t: t.get("viewCount", 0), reverse=True) | |
| return random.choice(eligible[:max(1, len(eligible) // 5)]) | |
| elif mode == "3": | |
| played = [t for t in eligible if t.get("viewCount", 0) > 0] | |
| pool = played if played else eligible | |
| pool.sort(key=lambda t: t.get("viewCount", 0)) | |
| return random.choice(pool[:max(1, len(pool) // 5)]) | |
| elif mode == "4": | |
| unplayed = [t for t in eligible if t.get("viewCount", 0) == 0] | |
| return random.choice(unplayed) if unplayed else random.choice(eligible) | |
| elif mode == "5": | |
| rated = [t for t in eligible if (t.get("userRating") or 0) >= MIN_RATING] | |
| if not rated: | |
| return random.choice(eligible) | |
| rated.sort(key=lambda t: t.get("userRating", 0), reverse=True) | |
| return random.choice(rated[:max(1, len(rated) // 5)]) | |
| elif mode == "6": | |
| artist_plays = get_artist_play_counts(eligible) | |
| eligible.sort(key=lambda t: artist_plays[t.get("grandparentTitle", "")], reverse=True) | |
| return random.choice(eligible[:max(1, len(eligible) // 5)]) | |
| elif mode == "7": | |
| artist_plays = get_artist_play_counts(eligible) | |
| sorted_artists = sorted(artist_plays.items(), key=lambda x: x[1], reverse=True) | |
| top_names = {name for name, _ in sorted_artists[:max(1, len(sorted_artists) // 5)]} | |
| unplayed_top = [t for t in eligible if t.get("grandparentTitle", "") in top_names and t.get("viewCount", 0) == 0] | |
| if unplayed_top: | |
| return random.choice(unplayed_top) | |
| any_top = [t for t in eligible if t.get("grandparentTitle", "") in top_names] | |
| if any_top: | |
| return random.choice(any_top) | |
| any_unplayed = [t for t in eligible if t.get("viewCount", 0) == 0] | |
| return random.choice(any_unplayed) if any_unplayed else random.choice(eligible) | |
| elif mode == "8": | |
| artist_plays = get_artist_play_counts(eligible) | |
| sorted_artists = sorted(artist_plays.items(), key=lambda x: x[1]) | |
| bottom_names = {name for name, _ in sorted_artists[:max(1, len(sorted_artists) // 5)]} | |
| from_bottom = [t for t in eligible if t.get("grandparentTitle", "") in bottom_names] | |
| if from_bottom: | |
| return random.choice(from_bottom) | |
| half_names = {name for name, _ in sorted_artists[:max(1, len(sorted_artists) // 2)]} | |
| from_half = [t for t in eligible if t.get("grandparentTitle", "") in half_names] | |
| return random.choice(from_half) if from_half else random.choice(eligible) | |
| elif mode == "9": | |
| artist_plays = get_artist_play_counts(eligible) | |
| unplayed_artists = {name for name, plays in artist_plays.items() if plays == 0} | |
| from_unplayed = [t for t in eligible if t.get("grandparentTitle", "") in unplayed_artists] | |
| if from_unplayed: | |
| return random.choice(from_unplayed) | |
| sorted_artists = sorted(artist_plays.items(), key=lambda x: x[1]) | |
| bottom_names = {name for name, _ in sorted_artists[:max(1, len(sorted_artists) // 5)]} | |
| from_bottom = [t for t in eligible if t.get("grandparentTitle", "") in bottom_names] | |
| return random.choice(from_bottom) if from_bottom else random.choice(eligible) | |
| return random.choice(eligible) | |
| # -- Menus -------------------------------------------------------------------- | |
| def show_preset_menu(label): | |
| print(f"\n{label}\n") | |
| for i, p in enumerate(PRESETS, 1): | |
| print(f" {i:>2}. {p['name']}") | |
| while True: | |
| try: | |
| choice = int(input("\nEnter number: ").strip()) | |
| if 1 <= choice <= len(PRESETS): | |
| return choice - 1 | |
| except ValueError: | |
| pass | |
| print("Please enter a valid number.") | |
| def show_mode_menu(label): | |
| print(f"\n Seed mode for {label}:\n") | |
| 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") | |
| print(" 7. Top artists, unplayed tracks -- your most-played artists, fresh songs only") | |
| print(" 8. Least played artists -- seeds from artists you've heard the least") | |
| print(" 9. Unplayed artists only -- seeds from artists with zero total plays") | |
| while True: | |
| mode = input("\n Enter 1-9: ").strip() | |
| if mode in MODE_NAMES: | |
| return mode | |
| print(" Please enter a number between 1 and 9.") | |
| def show_style_threshold_menu(): | |
| print("\nMinimum genre tag matches required per artist seed:") | |
| print(" 1 -- Any artist with at least 1 matching style (broader, may drift)") | |
| print(" 2 -- Artist must match 2+ styles (recommended)") | |
| print(" 3 -- Artist must match 3+ styles (strictest, most genre-pure)") | |
| while True: | |
| try: | |
| val = int(input("\nEnter 1-3 (press Enter for 2): ").strip() or "2") | |
| if 1 <= val <= 3: | |
| return val | |
| except ValueError: | |
| pass | |
| print("Please enter 1, 2, or 3.") | |
| # -- Main --------------------------------------------------------------------- | |
| def main(): | |
| print(f"\n{'='*60}") | |
| print(f" Sonic Adventure Generator") | |
| print(f" {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") | |
| print(f"{'='*60}") | |
| print("\nChecking library styles...") | |
| library_styles = get_library_styles() | |
| print(f" {len(library_styles)} styles found.") | |
| min_style_matches = show_style_threshold_menu() | |
| print("\nHow many genre waypoints? (2-6, press Enter for 3):") | |
| while True: | |
| raw = input("> ").strip() | |
| if raw == "": | |
| num_waypoints = 3 | |
| break | |
| try: | |
| num_waypoints = int(raw) | |
| if 2 <= num_waypoints <= 6: | |
| break | |
| except ValueError: | |
| pass | |
| print("Please enter a number between 2 and 6.") | |
| selected_presets = [] | |
| selected_modes = [] | |
| labels = ["START"] + [f"MIDDLE {i}" for i in range(1, num_waypoints - 1)] + ["END"] | |
| for label in labels: | |
| idx = show_preset_menu(f"Select {label} genre:") | |
| mode = show_mode_menu(f"{label}: {PRESETS[idx]['name']}") | |
| selected_presets.append(PRESETS[idx]) | |
| selected_modes.append(mode) | |
| arc_label = " -> ".join(p['name'] for p in selected_presets) | |
| print(f"\n Arc: {arc_label}") | |
| print(f" Min style matches: {min_style_matches}") | |
| for i, (p, m) in enumerate(zip(selected_presets, selected_modes)): | |
| print(f" {labels[i]}: {p['name']} [{MODE_NAMES[m]}]") | |
| plex = PlexServer(PLEX_URL, PLEX_TOKEN) | |
| music_section = plex.library.section(LIBRARY_NAME) | |
| waypoints = [] | |
| used_titles = set() | |
| for preset, mode, label in zip(selected_presets, selected_modes, labels): | |
| print(f"\n Loading tracks for: {preset['name']} (min {min_style_matches} style matches)...") | |
| tracks = get_tracks_for_preset(preset, library_styles, min_style_matches) | |
| print(f" -> {len(tracks)} tracks found across qualifying artists") | |
| seed = pick_seed_track(tracks, mode, used_titles) | |
| if not seed: | |
| print(f" X No eligible seed for {preset['name']}. Aborting.") | |
| return | |
| used_titles.add(seed.get("title")) | |
| print(f" OK Seed [{label}]: {seed.get('title')} -- {seed.get('grandparentTitle')} [{MODE_NAMES[mode]}]") | |
| waypoints.append(seed) | |
| print(f"\nBuilding adventure segments...") | |
| all_tracks = [] | |
| seen_keys = set() | |
| for i in range(len(waypoints) - 1): | |
| seg_start = waypoints[i] | |
| seg_end = waypoints[i + 1] | |
| print(f" Segment {i+1}: {seg_start.get('title')} -> {seg_end.get('title')}") | |
| try: | |
| start_obj = plex.fetchItem(int(seg_start["ratingKey"])) | |
| end_obj = plex.fetchItem(int(seg_end["ratingKey"])) | |
| segment = music_section.sonicAdventure(start=start_obj, end=end_obj) | |
| print(f" -> {len(segment)} tracks") | |
| for t in segment: | |
| if t.key not in seen_keys: | |
| all_tracks.append(t) | |
| seen_keys.add(t.key) | |
| except Exception as e: | |
| print(f" WARNING Segment {i+1} failed: {e}") | |
| if not all_tracks: | |
| print("\nX No tracks returned. Aborting.") | |
| return | |
| if len(all_tracks) > TARGET_TRACKS: | |
| step = len(all_tracks) / TARGET_TRACKS | |
| all_tracks = [all_tracks[int(i * step)] for i in range(TARGET_TRACKS)] | |
| timestamp = datetime.now().strftime("%m/%d %H:%M") | |
| seed_label = " -> ".join(w.get("title", "?") for w in waypoints) | |
| print(f"\nPlaylist name? (press Enter to use default):") | |
| print(f" Default: {timestamp} | {seed_label}") | |
| custom_name = input("> ").strip() | |
| playlist_name = custom_name if custom_name else f"{timestamp} | {seed_label}" | |
| try: | |
| plex.playlist(playlist_name).delete() | |
| print(f"\n Deleted existing: {playlist_name}") | |
| except Exception: | |
| pass | |
| plex.createPlaylist(playlist_name, section=music_section, items=all_tracks) | |
| print(f"\n{'='*60}") | |
| print(f" Created: \"{playlist_name}\"") | |
| print(f" {len(all_tracks)} tracks") | |
| print(f"{'='*60}\n") | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment