import urllib.parse from plexapi.myplex import MyPlexAccount from plexapi.server import PlexServer class PlexConnectionError(RuntimeError): pass def normalize_server_target(server_ip): target = (server_ip or "").strip() if not target: return "" if "://" not in target: target = f"http://{target}" parsed = urllib.parse.urlparse(target) return (parsed.hostname or "").strip().lower() def resolve_plex_server_url(token, server_ip): target_host = normalize_server_target(server_ip) if not token: raise PlexConnectionError("Missing Plex token") if not target_host: raise PlexConnectionError("Missing Plex server address") try: account = MyPlexAccount(token=token) except Exception as exc: raise PlexConnectionError(f"Failed to authenticate with Plex: {exc}") from exc for resource in account.resources(): for connection in resource.connections: connection_host = normalize_server_target(connection.uri) address = (connection.address or "").strip().lower() if connection_host == target_host or target_host == address or target_host in address: return connection.uri raise PlexConnectionError( f"Server not found at {server_ip}. Verify the configured address and that Plex is reachable." ) def connect_to_plex_server(token, server_ip): server_url = resolve_plex_server_url(token, server_ip) try: return PlexServer(server_url, token) except Exception as exc: raise PlexConnectionError(f"Failed to connect to Plex server at {server_url}: {exc}") from exc def playlist_item_count(playlist): leaf_count = getattr(playlist, "leafCount", None) if isinstance(leaf_count, int): return leaf_count try: return len(playlist.items()) except Exception: return 0 def fetch_playlists(token, server_ip, music_only=False): plex = connect_to_plex_server(token, server_ip) return fetch_playlists_for_plex(plex, music_only) def fetch_playlists_for_plex(plex, music_only=False): playlists = [] for playlist in plex.playlists(): playlist_type = getattr(playlist, "playlistType", "unknown") if music_only and playlist_type != "audio": continue playlists.append( { "title": playlist.title, "playlist_type": playlist_type, "item_count": playlist_item_count(playlist), } ) playlists.sort(key=lambda item: (item["title"].lower(), item["playlist_type"])) return playlists def validate_playlist_target(token, server_ip, playlist_name, music_only=False): plex = connect_to_plex_server(token, server_ip) playlist_name = (playlist_name or "").strip() if not playlist_name: raise PlexConnectionError("Playlist name is required") playlists = fetch_playlists_for_plex(plex, music_only=False) for playlist in playlists: if playlist["title"].strip().lower() == playlist_name.lower(): if music_only and playlist["playlist_type"] != "audio": raise PlexConnectionError("Selected playlist is not a music playlist") return { "exists": True, "playlist": playlist, "message": f"Ready to write to existing playlist '{playlist_name}'", } try: plex.library.section("Music") except Exception as exc: raise PlexConnectionError(f"Music library is not accessible on this server: {exc}") from exc return { "exists": False, "playlist": { "title": playlist_name, "playlist_type": "audio", "item_count": 0, }, "message": f"Playlist '{playlist_name}' will be created on first sync", } def _normalize_artist(name): """Return a simplified artist token for fuzzy comparison.""" import re name = name.lower() # Drop featured artists — everything after feat/ft/with/w/ name = re.sub(r"\s+(feat\.?|ft\.?|featuring|w/|with)\s+.*", "", name) # Drop parenthetical / bracketed suffixes name = re.sub(r"[\(\[].*?[\)\]]", "", name) # Expand & / + to "and" name = re.sub(r"\s*[&+]\s*", " and ", name) # Collapse articles at start: "the foo" → "foo" name = re.sub(r"^(the|a|an)\s+", "", name) # Remove common punctuation that Plex sometimes strips name = re.sub(r"['\.\-,]", "", name) # Collapse whitespace name = re.sub(r"\s+", " ", name).strip() return name def _artists_match(search_artist, track_artist_raw): """ Return True if the station artist string plausibly refers to the same artist stored in Plex, using several normalisation / containment passes. """ sa = _normalize_artist(search_artist) ta = _normalize_artist(track_artist_raw) if not sa or not ta: return False # Exact normalised match if sa == ta: return True # One fully contains the other (e.g. "Brandon Lake" in "Brandon Lake & Maverick") if sa in ta or ta in sa: return True # Token-set overlap: every token in the shorter name appears in the longer sa_tokens = set(sa.split()) ta_tokens = set(ta.split()) if sa_tokens and ta_tokens: shorter = sa_tokens if len(sa_tokens) <= len(ta_tokens) else ta_tokens longer = ta_tokens if len(sa_tokens) <= len(ta_tokens) else sa_tokens # All tokens of the shorter name found in the longer (handles "for king country" ↔ "for king and country") if shorter <= longer: return True # At least >60 % of tokens match (handles minor word-count diffs) overlap = len(shorter & longer) if overlap / len(shorter) >= 0.6: return True return False def create_or_update_playlist(plex, songs, playlist_name, dry_run=False): import re music_library = plex.library.section("Music") tracks = [] seen_keys = set() missing = [] skipped = [] added_songs = [] duplicate_songs = [] for song in songs: clean_title = re.sub(r"\([^)]*\)", "", song["title"]).strip() clean_title = re.sub(r"\s+", " ", clean_title) clean_artist = re.sub(r"\([^)]*\)", "", song["artist"]).strip() clean_artist = re.sub(r"\s+", " ", clean_artist) if not clean_title or len(clean_title) < 3 or clean_title.lower() in {"by", "recently played", "now playing:", "search"}: skipped.append({"title": song["title"], "artist": song["artist"], "reason": "invalid-title"}) continue if re.match(r"^[!?\s]+$", clean_title): skipped.append({"title": song["title"], "artist": song["artist"], "reason": "punctuation-only-title"}) continue search_title = re.sub(r"[!?]", "", clean_title).strip() search_artist = re.sub(r"[!?]", "", clean_artist).strip() # Expand & / + in title for Plex search (artist expansion handled by _artists_match) search_title = re.sub(r"\s*[&+]\s*", " and ", search_title, flags=re.IGNORECASE) results = music_library.searchTracks(title=search_title) if not results: # Secondary search: try the raw title in case Plex stores it differently results = music_library.searchTracks(title=clean_title) if not results: missing.append({"title": song["title"], "artist": song["artist"], "reason": "not-found"}) continue matched = False for track in results: try: track_artist_raw = track.artist().title except Exception: continue if _artists_match(search_artist, track_artist_raw): if track.ratingKey not in seen_keys: tracks.append((track, song)) seen_keys.add(track.ratingKey) matched = True break if not matched: missing.append({"title": song["title"], "artist": song["artist"], "reason": "artist-mismatch"}) matched_count = len(tracks) added_count = 0 if tracks: try: try: existing = plex.playlist(playlist_name) existing_keys = {item.ratingKey for item in existing.items()} new_tracks = [] for track, song in tracks: if track.ratingKey in existing_keys: duplicate_songs.append({"title": track.title, "artist": track.artist().title, "reason": "already-in-playlist"}) continue new_tracks.append((track, song)) if new_tracks: if not dry_run: existing.addItems([item[0] for item in new_tracks]) for track, song in new_tracks: track.addLabel("Journey FM") track.rate(5) added_songs.append(f"{track.title} by {track.artist().title} ({song['source']})") else: for track, song in new_tracks: added_songs.append(f"{track.title} by {track.artist().title} ({song['source']})") added_count = len(new_tracks) except Exception: if not dry_run: plex.createPlaylist(playlist_name, [item[0] for item in tracks]) for track, song in tracks: track.addLabel("Journey FM") track.rate(5) added_songs.append(f"{track.title} by {track.artist().title} ({song['source']})") else: for track, song in tracks: added_songs.append(f"{track.title} by {track.artist().title} ({song['source']})") added_count = len(tracks) except Exception as exc: raise PlexConnectionError(f"Failed updating playlist '{playlist_name}': {exc}") from exc return { "matched_count": matched_count, "added_count": added_count, "added_songs": added_songs, "missing_songs": missing, "duplicate_count": len(duplicate_songs), "duplicate_songs": duplicate_songs, "skipped_songs": skipped, }