diff --git a/.env.example b/.env.example index eb8ae04..a3d88b8 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,16 @@ PLEX_BASE_URL=http://your-plex-server:32400 PLEX_TOKEN=your-plex-token + +# LLM configuration +# PLEXMUSE_MODEL is any LiteLLM model string; it sets the default model and, +# together with the API key you provide, selects the provider. +# OpenAI: gpt-4o (needs OPENAI_API_KEY) +# Anthropic: anthropic/claude-3-5-sonnet-latest (needs ANTHROPIC_API_KEY) +# Gemini: gemini/gemini-1.5-pro (needs GEMINI_API_KEY) +PLEXMUSE_MODEL=gpt-4 +# Optional: comma-separated models to offer in the UI dropdown (default is shown first). +# PLEXMUSE_MODELS=gpt-4o,gpt-3.5-turbo,anthropic/claude-3-5-sonnet-latest + +# Provide the API key(s) for whichever provider(s) your model(s) use. OPENAI_API_KEY=your-openai-api-key -ANTHROPIC_API_KEY=your-anthropic-api-key \ No newline at end of file +ANTHROPIC_API_KEY=your-anthropic-api-key diff --git a/README.md b/README.md index 41f15cd..28e5c49 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ Welcome to **Plexmuse**! This project leverages the power of AI to generate pers ## Features ✨ - **AI-Powered Recommendations**: Generate playlists using advanced language models like GPT-4 and Claude. +- **Configurable Model & Provider**: Pick any [LiteLLM](https://docs.litellm.ai/docs/providers)-supported model (OpenAI, Anthropic, Gemini, …) via a single environment variable — no code changes. +- **Multiple Music Libraries**: Works across every music library on your server, regardless of what they're named. - **Seamless Plex Integration**: Fetch and manage your music library directly from Plex. - **Customizable Playlists**: Tailor your playlists with specific prompts and models. @@ -41,6 +43,34 @@ Welcome to **Plexmuse**! This project leverages the power of AI to generate pers For setting up OpenAI, Anthropic, or other LLM keys, follow the instructions in the LiteLLM documentation: [LiteLLM - Set Keys](https://docs.litellm.ai/docs/set_keys). +### Configuration ⚙️ + +The LLM model, provider, and API key are all configurable via environment variables, so you can switch providers without changing any code. + +| Variable | Required | Description | +| --- | --- | --- | +| `PLEX_BASE_URL` | Yes | URL of your Plex Media Server, e.g. `http://192.168.1.10:32400`. | +| `PLEX_TOKEN` | Yes | Your Plex authentication token. | +| `PLEXMUSE_MODEL` | No (default `gpt-4`) | Any [LiteLLM model string](https://docs.litellm.ai/docs/providers). Sets the default model and selects the provider. | +| `PLEXMUSE_MODELS` | No | Comma-separated list of models to offer in the UI dropdown. The default model is always included. | +| `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / … | Depends on model | The API key for whichever provider your model(s) use. | + +The API key required is determined by the chosen model. If it is missing or empty, the app fails fast with a clear error naming the environment variable to set, rather than making a doomed API call. + +Examples: + +```sh +# Use Anthropic Claude +PLEXMUSE_MODEL=anthropic/claude-3-5-sonnet-latest +ANTHROPIC_API_KEY=sk-ant-... + +# Offer a choice of models in the UI +PLEXMUSE_MODEL=gpt-4o +PLEXMUSE_MODELS=gpt-4o,gpt-3.5-turbo,anthropic/claude-3-5-sonnet-latest +OPENAI_API_KEY=sk-... +ANTHROPIC_API_KEY=sk-ant-... +``` + ### Running the Application You can run the application using the Makefile or directly with Docker. @@ -51,10 +81,6 @@ You can run the application using the Makefile or directly with Docker. ```sh make run ``` -2. **Start**: - ```sh - make start - ``` #### Using Docker @@ -63,11 +89,24 @@ You can run the application using the Makefile or directly with Docker. docker compose build ``` -2. **Start the Docker container**: +2. **Start the Docker container** (reads configuration from your `.env` file): ```sh docker compose up ``` +#### Using `docker run` with inline configuration + +You can also configure the model, provider, and API key directly at launch: + +```sh +docker run -p 8000:8000 \ + -e PLEX_BASE_URL=http://your-plex-server:32400 \ + -e PLEX_TOKEN=your-plex-token \ + -e PLEXMUSE_MODEL=anthropic/claude-3-5-sonnet-latest \ + -e ANTHROPIC_API_KEY=sk-ant-... \ + ghcr.io/lubergalexander/plexmuse:latest +``` + ## Usage 📖 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..edd783f --- /dev/null +++ b/app/config.py @@ -0,0 +1,44 @@ +""" +Application configuration sourced from environment variables. + +This makes the LLM model/provider (and therefore which API key is required) +configurable at runtime, e.g. when launching the Docker container: + + docker run -e PLEXMUSE_MODEL=anthropic/claude-3-5-sonnet-latest \ + -e ANTHROPIC_API_KEY=sk-... ... + +Any model string supported by LiteLLM works, so switching provider is just a +matter of changing ``PLEXMUSE_MODEL`` and supplying the matching API key. +""" + +import os +from functools import lru_cache +from typing import List + +# Used only when ``PLEXMUSE_MODEL`` is not set. +DEFAULT_MODEL = "gpt-4" + + +class Settings: # pylint: disable=too-few-public-methods + """Runtime settings resolved from environment variables.""" + + def __init__(self) -> None: + # The model used when a request does not specify one. + self.default_model: str = (os.getenv("PLEXMUSE_MODEL") or DEFAULT_MODEL).strip() + + # Optional comma-separated list of models offered in the UI dropdown. + raw_models = os.getenv("PLEXMUSE_MODELS", "") + models: List[str] = [m.strip() for m in raw_models.split(",") if m.strip()] + + # The default model must always be selectable, listed first. + if self.default_model in models: + models.remove(self.default_model) + models.insert(0, self.default_model) + + self.available_models: List[str] = models + + +@lru_cache(maxsize=1) +def get_settings() -> Settings: + """Return the cached settings singleton (read once after dotenv loads).""" + return Settings() diff --git a/app/main.py b/app/main.py index 56a355e..2ac376c 100644 --- a/app/main.py +++ b/app/main.py @@ -2,6 +2,7 @@ Plexmuse API with initialization """ +import json import logging import os from contextlib import asynccontextmanager @@ -13,6 +14,7 @@ from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles +from app.config import get_settings from app.models import Artist, PlaylistRequest, PlaylistResponse, Track from .services.llm_service import LLMService @@ -59,23 +61,33 @@ async def lifespan(app_context: FastAPI): # pylint: disable=unused-argument @app.get("/") async def root(): - """Serve the index.html file with Plex configuration injected""" + """Serve the index.html file with Plex and model configuration injected""" plex_base_url = os.getenv("PLEX_BASE_URL") plex_token = os.getenv("PLEX_TOKEN") + settings = get_settings() with open("static/index.html", "r", encoding="utf-8") as f: html_content = f.read() - # Inject Plex configuration before closing body tag + # Inject Plex and model configuration before closing body tag script_tag = f"""""" html_content = html_content.replace("", f"{script_tag}") return HTMLResponse(content=html_content) +@app.get("/config") +async def get_config(): + """Expose the runtime model configuration to clients""" + settings = get_settings() + return {"default_model": settings.default_model, "available_models": settings.available_models} + + @app.get("/health") async def health_check(): """Health check endpoint""" @@ -92,10 +104,13 @@ async def get_artists(): async def create_recommendations(request: PlaylistRequest): """Create playlist recommendations""" try: + # Resolve the model once so the request, prompts, and playlist metadata agree. + model = request.model or get_settings().default_model + # Step 1: Get artist recommendations artists = plex_service.get_all_artists() recommended_artists = llm_service.get_artist_recommendations( - prompt=request.prompt, artists=artists, model=request.model + prompt=request.prompt, artists=artists, model=model ) # Step 2: Get all recommended artists' albums in one call @@ -105,18 +120,20 @@ async def create_recommendations(request: PlaylistRequest): track_recommendations = llm_service.get_track_recommendations( prompt=request.prompt, artist_tracks=artist_albums, - model=request.model, + model=model, min_tracks=request.min_tracks, max_tracks=request.max_tracks, ) # Step 4: Generate playlist name - playlist_name = llm_service.generate_playlist_name(prompt=request.prompt, model=request.model) + playlist_name = llm_service.generate_playlist_name(prompt=request.prompt, model=model) # Step 5: Create the playlist playlist = plex_service.create_curated_playlist( name=playlist_name, track_recommendations=track_recommendations, + prompt=request.prompt, + model=model, ) return PlaylistResponse( name=playlist.title, diff --git a/app/models.py b/app/models.py index cfac516..c4488f4 100644 --- a/app/models.py +++ b/app/models.py @@ -19,7 +19,9 @@ class PlaylistRequest(BaseModel): """Request model for playlist generation""" prompt: str = Field(..., description="Description of the desired playlist") - model: str = Field(default="gpt-4", description="AI model to use") + model: Optional[str] = Field( + default=None, description="LiteLLM model string to use; defaults to the server-configured model" + ) min_tracks: int = Field(default=30, ge=1, le=100, description="Minimum number of tracks") max_tracks: int = Field(default=50, ge=1, le=200, description="Maximum number of tracks") diff --git a/app/services/llm_service.py b/app/services/llm_service.py index 8243a1c..e955f87 100644 --- a/app/services/llm_service.py +++ b/app/services/llm_service.py @@ -2,16 +2,22 @@ LLM Service This module provides the LLMService class for generating playlist recommendations -using language models. +using language models via LiteLLM. The model (and therefore provider) is +configurable, and the required API key is validated before every call so that a +missing or empty key produces a clear, actionable error instead of an opaque +provider failure. """ import json import logging +import os import re -from typing import List +from typing import List, Optional +import litellm from litellm import completion +from app.config import get_settings from app.models import Artist logger = logging.getLogger(__name__) @@ -31,7 +37,44 @@ class LLMService: A service class for generating playlist recommendations using language models. """ - def get_artist_recommendations(self, prompt: str, artists: List[Artist], model: str = "gpt-4"): + def _resolve_model(self, model: Optional[str]) -> str: + """Fall back to the configured default model when none is provided.""" + return model or get_settings().default_model + + @staticmethod + def _validate_model_environment(model: str) -> None: + """ + Ensure the API key required by ``model``'s provider is set and non-empty. + + LiteLLM's ``validate_environment`` only checks that the key is *present* + in the environment, so an empty string (e.g. ``OPENAI_API_KEY=``) slips + through and fails later with an opaque error. We temporarily drop + empty/whitespace ``*_API_KEY`` variables so they are correctly reported + as missing, then raise a clear error naming what to set. + """ + blanked = {k: v for k, v in os.environ.items() if k.endswith("_API_KEY") and not v.strip()} + for key in blanked: + del os.environ[key] + try: + result = litellm.validate_environment(model=model) + finally: + os.environ.update(blanked) + + if not result.get("keys_in_environment", False): + missing = ", ".join(result.get("missing_keys", [])) or "the required API key" + raise ValueError( + f"Missing or empty API key for model '{model}'. " + f"Set the following environment variable(s): {missing}." + ) + + def _complete(self, model: Optional[str], messages: List[dict], temperature: float = 0.7): + """Validate credentials for the chosen model, then run the completion.""" + resolved = self._resolve_model(model) + self._validate_model_environment(resolved) + logger.debug("Requesting completion from model: %s", resolved) + return completion(model=resolved, messages=messages, temperature=temperature) + + def get_artist_recommendations(self, prompt: str, artists: List[Artist], model: str = None): """First step: Get relevant artists based on the prompt""" try: artist_context = "Available artists and their genres:\n" + "\n".join( @@ -49,8 +92,8 @@ def get_artist_recommendations(self, prompt: str, artists: List[Artist], model: Do not add any explanations or other text - just the JSON object. Select 10-15 artists that match the mood/theme, only from the provided list.""" - response = completion( - model=model, + response = self._complete( + model, messages=[ {"role": "system", "content": system_prompt}, { @@ -83,8 +126,8 @@ def get_artist_recommendations(self, prompt: str, artists: List[Artist], model: raise def get_track_recommendations( - self, prompt: str, artist_tracks: dict, model: str = "gpt-4", min_tracks: int = 30, max_tracks: int = 50 - ): # pylint: disable=too-many-arguments,too-many-locals,too-many-positional-arguments + self, prompt: str, artist_tracks: dict, model: str = None, min_tracks: int = 30, max_tracks: int = 50 + ): # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals """Get track recommendations with simplified album context""" try: # Format just album information for context @@ -94,7 +137,7 @@ def get_track_recommendations( for album in albums: albums_context += f"- {album['name']} ({album['year']})\n" - system_prompt = """You are a multilingual music curator creating a cohesive playlist. + system_prompt = f"""You are a multilingual music curator creating a cohesive playlist. Your responses must ALWAYS be in English and contain ONLY a valid JSON object. Based on your knowledge of these artists' albums and the playlist theme, @@ -102,17 +145,19 @@ def get_track_recommendations( any tracks you know exist on these albums - you don't need to see the track list. You must respond with ONLY a JSON object in this exact format: - { + {{ "tracks": [ - {"artist": "artist name", "title": "track title"} + {{"artist": "artist name", "title": "track title"}} ] - } + }} - Select between {min_tracks} and {max_tracks} tracks total. + You MUST select between {min_tracks} and {max_tracks} tracks in total. + Ensure variety by selecting different tracks from different albums and artists. + Avoid repeating the same tracks or selecting too many tracks from the same album. Do not add any explanations or additional text.""" - response = completion( - model=model, + response = self._complete( + model, messages=[ {"role": "system", "content": system_prompt}, { @@ -122,7 +167,7 @@ def get_track_recommendations( """, }, ], - temperature=0.7, + temperature=0.8, ) content = clean_llm_response(response.choices[0].message.content) @@ -139,7 +184,7 @@ def get_track_recommendations( logger.error("Track recommendation failed: %s", str(e)) raise - def generate_playlist_name(self, prompt: str, model: str = "gpt-4") -> str: + def generate_playlist_name(self, prompt: str, model: str = None) -> str: """Generate a playlist name based on the prompt""" try: system_prompt = """ @@ -147,8 +192,8 @@ def generate_playlist_name(self, prompt: str, model: str = "gpt-4") -> str: Generate a SINGLE catchy and relevant playlist name based on the following prompt. Do not wrap in quotes. """ - response = completion( - model=model, + response = self._complete( + model, messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": prompt}, diff --git a/app/services/plex_service.py b/app/services/plex_service.py index d813c37..192eefd 100644 --- a/app/services/plex_service.py +++ b/app/services/plex_service.py @@ -70,7 +70,7 @@ def __init__(self, base_url: str, token: str): self.token = token self._server: Optional[PlexServer] = None self.machine_identifier: Optional[str] = None - self._music_library = None + self._music_libraries: List = [] # Only cache artists self._artists_cache: Dict[str, Artist] = {} # key: artist_id -> Artist @@ -86,17 +86,29 @@ def initialize(self): self._server = PlexServer(self.base_url, self.token) self.machine_identifier = self._server.machineIdentifier - self._music_library = self._server.library.section("Music") - - # Load all artists - artists = self._music_library.search(libtype="artist") - for artist in artists: - artist_id = str(artist.ratingKey) - self._artists_cache[artist_id] = Artist( - id=artist_id, name=artist.title, genres=[genre.tag for genre in getattr(artist, "genres", [])] - ) - - logger.info("Cached %d artists", len(self._artists_cache)) + # Find all music libraries instead of assuming a single one named "Music". + self._music_libraries = [section for section in self._server.library.sections() if section.type == "artist"] + for library in self._music_libraries: + logger.info("Found music library: %s", library.title) + + if not self._music_libraries: + logger.warning("No music libraries found on the Plex server") + return + + # Load all artists from every music library, avoiding cross-library duplicates. + for library in self._music_libraries: + for artist in library.search(libtype="artist"): + artist_id = str(artist.ratingKey) + if artist_id not in self._artists_cache: + self._artists_cache[artist_id] = Artist( + id=artist_id, + name=artist.title, + genres=[genre.tag for genre in getattr(artist, "genres", [])], + ) + + logger.info( + "Cached %d artists from %d music libraries", len(self._artists_cache), len(self._music_libraries) + ) except Exception as e: logger.error("Failed to initialize Plex cache: %s", str(e)) @@ -119,10 +131,13 @@ def get_artists_albums_bulk(self, artist_names: List[str]) -> dict: # First try cache lookup by name for artist in self._artists_cache.values(): if artist.name.lower() == artist_name.lower(): - # Found in cache, now get the Plex object - matches = self._music_library.search(artist.name, libtype="artist") - if matches: - artist_found = matches[0] + # Found in cache, now get the Plex object from whichever library holds it. + for library in self._music_libraries: + matches = library.search(artist.name, libtype="artist") + if matches: + artist_found = matches[0] + break + if artist_found: break if artist_found: @@ -140,7 +155,7 @@ def get_artists_albums_bulk(self, artist_names: List[str]) -> dict: return result def create_curated_playlist( - self, name: str, track_recommendations: List[dict] + self, name: str, track_recommendations: List[dict], prompt: str = None, model: str = None ): # pylint: disable=too-many-locals,too-many-branches """Create a playlist with fuzzy track matching""" if not self._server: @@ -154,12 +169,18 @@ def create_curated_playlist( # Process each artist's tracks in bulk for artist_name, track_titles in artist_tracks.items(): - artists = self._music_library.search(artist_name, libtype="artist") - if not artists: + # Search for the artist across all music libraries. + artist = None + for library in self._music_libraries: + found_artists = library.search(artist_name, libtype="artist") + if found_artists: + artist = found_artists[0] + break + + if not artist: logger.warning("Artist not found: %s", artist_name) continue - artist = artists[0] # Get all tracks for this artist at once all_tracks = [] for album in artist.albums(): @@ -172,8 +193,10 @@ def create_curated_playlist( logger.debug("Matched '%s' to '%s' (score: %.2f)", title, track.title, score) matched_tracks.append(track) else: - # If no match found for artist, try global search - global_tracks = self._music_library.search(title, libtype="track") + # If no match found for artist, try a global search across all libraries. + global_tracks = [] + for library in self._music_libraries: + global_tracks.extend(library.search(title, libtype="track")) if global_tracks: track, score = find_best_track_match(global_tracks, title, threshold=0.75) if track and track.artist().title.lower() == artist_name.lower(): @@ -188,4 +211,13 @@ def create_curated_playlist( raise ValueError("No tracks could be matched from recommendations") playlist = self._server.createPlaylist(name, items=matched_tracks) + + # Embed generation metadata in the playlist summary so it isn't lost over time. + summary = "Generated by Plexmuse" + if prompt: + summary += f"\nPrompt: {prompt}" + if model: + summary += f"\nModel: {model}" + playlist.edit(summary=summary) + return playlist diff --git a/static/app.js b/static/app.js index b28d819..709c6f1 100644 --- a/static/app.js +++ b/static/app.js @@ -11,6 +11,28 @@ document.addEventListener('DOMContentLoaded', () => { const errorText = document.getElementById('errorText'); const dismissError = document.getElementById('dismissError'); + // Model selection handling (options provided by server config) + const modelSelect = document.getElementById('modelSelect'); + const modelSelectorWrapper = document.getElementById('modelSelectorWrapper'); + const availableModels = Array.isArray(window.availableModels) ? window.availableModels : []; + const defaultModel = window.defaultModel || availableModels[0] || 'gpt-4'; + + if (modelSelect) { + availableModels.forEach(model => { + const option = document.createElement('option'); + option.value = model; + option.textContent = model; + if (model === defaultModel) { + option.selected = true; + } + modelSelect.appendChild(option); + }); + // Only surface the selector when there's an actual choice to make. + if (availableModels.length > 1 && modelSelectorWrapper) { + modelSelectorWrapper.classList.remove('hidden'); + } + } + // Playlist length handling const lengthButtons = document.querySelectorAll('.playlist-length-btn'); let selectedLength = 'medium'; // Default length @@ -93,7 +115,7 @@ document.addEventListener('DOMContentLoaded', () => { }, body: JSON.stringify({ prompt, - model: 'gpt-4', + model: modelSelect ? modelSelect.value : defaultModel, min_tracks: min, max_tracks: max }), diff --git a/static/index.html b/static/index.html index 8b26aa2..a70c53d 100644 --- a/static/index.html +++ b/static/index.html @@ -73,6 +73,17 @@

Plex + + +