diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 87d6545..8938843 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -2,9 +2,9 @@ name: CI/CD on: push: - branches: [master] + branches: ['*'] pull_request: - branches: [master] + branches: ['*'] env: REGISTRY: ghcr.io @@ -19,7 +19,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: "3.13" + python-version: '3.13' - name: Install dependencies run: make dev-requirements @@ -30,8 +30,6 @@ jobs: build-and-push: needs: test runs-on: ubuntu-latest - # Only run this job on master branch pushes, not on PRs - if: github.event_name == 'push' && github.ref == 'refs/heads/master' permissions: contents: read packages: write @@ -46,7 +44,7 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract metadata for Docker + - name: Set up Docker tags id: meta uses: docker/metadata-action@v5 with: @@ -57,7 +55,8 @@ jobs: type=ref,event=pr type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} - type=raw,value=latest + # Only add latest tag for master branch + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }} - name: Build and push Docker image uses: docker/build-push-action@v5 diff --git a/README.md b/README.md index 41f15cd..aec0d12 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ 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. +- **Model Selection**: Choose between GPT-4 (higher quality) or GPT-3.5 Turbo (more affordable) for cost optimization. - **Seamless Plex Integration**: Fetch and manage your music library directly from Plex. - **Customizable Playlists**: Tailor your playlists with specific prompts and models. @@ -20,12 +21,14 @@ Welcome to **Plexmuse**! This project leverages the power of AI to generate pers ### Installation 1. **Clone the repository**: + ```sh git clone git@github.com:LubergAlexander/plexmuse.git cd plexmuse ``` 2. **Setup**: + ```sh make all ``` @@ -48,26 +51,74 @@ You can run the application using the Makefile or directly with Docker. #### Using the Makefile 1. **Set up and run the application**: + ```sh make run ``` -2. **Start**: - ```sh - make start - ``` #### Using Docker 1. **Build the Docker image**: + ```sh docker compose build ``` 2. **Start the Docker container**: + ```sh docker compose up ``` +#### Using the Published Docker Image + +You can also use the pre-built Docker image from GitHub Container Registry, which is automatically built and published on each push to the master branch: + +1. **Pull the Docker image**: + + ```sh + docker pull ghcr.io/lubergalexander/plexmuse:latest + ``` + +2. **Run the Docker container**: + + ```sh + docker run -p 8000:8000 \ + -e PLEX_BASE_URL=your_plex_url \ + -e PLEX_TOKEN=your_plex_token \ + -e OPENAI_API_KEY=your_openai_key \ + ghcr.io/lubergalexander/plexmuse:latest + ``` + +3. **Using Docker Compose**: + + Create a `docker-compose.yml` file: + + ```yaml + version: '3' + services: + plexmuse: + image: ghcr.io/lubergalexander/plexmuse:latest + ports: + - "8000:8000" + environment: + - PLEX_BASE_URL=your_plex_url + - PLEX_TOKEN=your_plex_token + - OPENAI_API_KEY=your_openai_key + # Add other environment variables as needed + restart: unless-stopped + ``` + + Then run: + + ```sh + docker compose up -d + ``` + +4. **Available Tags**: + + - `latest`: The most recent build from the master branch + - SHA tags: Each image is also tagged with the Git commit SHA ## Usage 📖 @@ -76,7 +127,6 @@ You can run the application using the Makefile or directly with Docker. Access the user interface at the root route `/`. This UI allows you to interact with the API, select playlist length, and is mobile-friendly.  - ### API Send a POST request to `/recommendations` with the following JSON body: diff --git a/app/main.py b/app/main.py index 56a355e..e273278 100644 --- a/app/main.py +++ b/app/main.py @@ -117,6 +117,8 @@ async def create_recommendations(request: PlaylistRequest): playlist = plex_service.create_curated_playlist( name=playlist_name, track_recommendations=track_recommendations, + prompt=request.prompt, + model=request.model, ) return PlaylistResponse( name=playlist.title, diff --git a/app/models.py b/app/models.py index cfac516..04a68cc 100644 --- a/app/models.py +++ b/app/models.py @@ -19,7 +19,7 @@ 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: str = Field(default="gpt-4", description="AI model to use (gpt-4 or gpt-3.5-turbo)") 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..bf4c068 100644 --- a/app/services/llm_service.py +++ b/app/services/llm_service.py @@ -94,7 +94,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,13 +102,15 @@ 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( @@ -122,7 +124,7 @@ def get_track_recommendations( """, }, ], - temperature=0.7, + temperature=0.8, ) content = clean_llm_response(response.choices[0].message.content) diff --git a/app/services/plex_service.py b/app/services/plex_service.py index d813c37..dd250b9 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 = [] # Only cache artists self._artists_cache: Dict[str, Artist] = {} # key: artist_id -> Artist @@ -86,17 +86,33 @@ 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 one called "Music" + self._music_libraries = [] + for section in self._server.library.sections(): + if section.type == "artist": + self._music_libraries.append(section) + logger.info(f"Found music library: {section.title}") + + if not self._music_libraries: + logger.warning("No music libraries found on the Plex server") + return + + # Load all artists from all music libraries + for library in self._music_libraries: + artists = library.search(libtype="artist") + for artist in artists: + artist_id = str(artist.ratingKey) + # Only add if not already in cache (avoid duplicates across libraries) + 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)) @@ -120,9 +136,13 @@ def get_artists_albums_bulk(self, artist_names: List[str]) -> dict: 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] + # Search across all music libraries + 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 +160,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,7 +174,14 @@ 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") + # Search for artist across all music libraries + artists = [] + for library in self._music_libraries: + found_artists = library.search(artist_name, libtype="artist") + if found_artists: + artists.append(found_artists[0]) + break # Found in one library, no need to check others + if not artists: logger.warning("Artist not found: %s", artist_name) continue @@ -172,8 +199,12 @@ 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 global search across all music libraries + global_tracks = [] + for library in self._music_libraries: + found_tracks = library.search(title, libtype="track") + global_tracks.extend(found_tracks) + 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 +219,14 @@ def create_curated_playlist( raise ValueError("No tracks could be matched from recommendations") playlist = self._server.createPlaylist(name, items=matched_tracks) + + # Always add a summary with at least "Generated by Plexmuse" + 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..57bcec5 100644 --- a/static/app.js +++ b/static/app.js @@ -1,130 +1,170 @@ document.addEventListener('DOMContentLoaded', () => { - // DOM elements - const form = document.getElementById('playlistForm'); - const loadingState = document.getElementById('loadingState'); - const results = document.getElementById('results'); - const playlistName = document.getElementById('playlistName'); - const tracksList = document.getElementById('tracksList'); - const trackCount = document.getElementById('trackCount'); - const plexLink = document.getElementById('plexLink'); - const errorMessage = document.getElementById('errorMessage'); - const errorText = document.getElementById('errorText'); - const dismissError = document.getElementById('dismissError'); - - // Playlist length handling - const lengthButtons = document.querySelectorAll('.playlist-length-btn'); - let selectedLength = 'medium'; // Default length - - const lengthConfigs = { - short: { min: 20, max: 40 }, - medium: { min: 50, max: 70 }, - long: { min: 100, max: 140 } - }; - - function setActiveLength(length) { - selectedLength = length; - lengthButtons.forEach(btn => { - const isActive = btn.dataset.length === length; - // Reset all buttons first - btn.classList.remove('border-plexorange', 'border-gray-300', 'bg-orange-50', 'dark:bg-orange-900/20', 'dark:border-gray-600'); - // Add appropriate styles - if (isActive) { - btn.classList.add('border-plexorange', 'bg-orange-50', 'dark:bg-orange-900/20'); - } else { - btn.classList.add('border-gray-300', 'dark:border-gray-600'); - } - }); + // DOM elements + const form = document.getElementById('playlistForm') + const loadingState = document.getElementById('loadingState') + const results = document.getElementById('results') + const playlistName = document.getElementById('playlistName') + const tracksList = document.getElementById('tracksList') + const trackCount = document.getElementById('trackCount') + const plexLink = document.getElementById('plexLink') + const errorMessage = document.getElementById('errorMessage') + const errorText = document.getElementById('errorText') + const dismissError = document.getElementById('dismissError') + + // Playlist length handling + const lengthButtons = document.querySelectorAll('.playlist-length-btn') + let selectedLength = 'medium' // Default length + + const lengthConfigs = { + short: {min: 20, max: 40}, + medium: {min: 50, max: 70}, + long: {min: 100, max: 140}, + } + + // Model selection handling + const modelButtons = document.querySelectorAll('.model-btn') + let selectedModel = 'gpt-4' // Default model + + function setActiveLength(length) { + selectedLength = length + lengthButtons.forEach((btn) => { + const isActive = btn.dataset.length === length + // Reset all buttons first + btn.classList.remove( + 'border-plexorange', + 'border-gray-300', + 'bg-orange-50', + 'dark:bg-orange-900/20', + 'dark:border-gray-600' + ) + // Add appropriate styles + if (isActive) { + btn.classList.add('border-plexorange', 'bg-orange-50', 'dark:bg-orange-900/20') + } else { + btn.classList.add('border-gray-300', 'dark:border-gray-600') + } + }) + } + + function setActiveModel(model) { + selectedModel = model + modelButtons.forEach((btn) => { + const isActive = btn.dataset.model === model + // Reset all buttons first + btn.classList.remove( + 'border-plexorange', + 'border-gray-300', + 'bg-orange-50', + 'dark:bg-orange-900/20', + 'dark:border-gray-600' + ) + // Add appropriate styles + if (isActive) { + btn.classList.add('border-plexorange', 'bg-orange-50', 'dark:bg-orange-900/20') + } else { + btn.classList.add('border-gray-300', 'dark:border-gray-600') + } + }) + } + + // Set initial selections + setActiveLength(selectedLength) + setActiveModel(selectedModel) + + // Handle length button clicks + lengthButtons.forEach((button) => { + button.addEventListener('click', () => { + setActiveLength(button.dataset.length) + }) + }) + + // Handle model button clicks + modelButtons.forEach((button) => { + button.addEventListener('click', () => { + setActiveModel(button.dataset.model) + }) + }) + + // Error handling + function showError(message) { + // Make sure error elements exist + if (!errorMessage || !errorText) { + console.error('Error elements not found in DOM') + alert(message) // Fallback to alert if error elements don't exist + return } - // Set initial selection - setActiveLength(selectedLength); - - // Handle button clicks - lengthButtons.forEach(button => { - button.addEventListener('click', () => { - setActiveLength(button.dataset.length); - }); - }); - - // Error handling - function showError(message) { - // Make sure error elements exist - if (!errorMessage || !errorText) { - console.error('Error elements not found in DOM'); - alert(message); // Fallback to alert if error elements don't exist - return; - } + errorText.textContent = message + errorMessage.classList.remove('hidden') + // Scroll error into view smoothly + errorMessage.scrollIntoView({behavior: 'smooth', block: 'nearest'}) + } - errorText.textContent = message; - errorMessage.classList.remove('hidden'); - // Scroll error into view smoothly - errorMessage.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + function hideError() { + if (errorMessage) { + errorMessage.classList.add('hidden') } - - function hideError() { - if (errorMessage) { - errorMessage.classList.add('hidden'); + } + + // Add error dismiss handler if element exists + if (dismissError) { + dismissError.addEventListener('click', hideError) + } + + // Handle form submission + form.addEventListener('submit', async (e) => { + e.preventDefault() + + // Show loading state + loadingState.classList.remove('hidden') + results.classList.add('hidden') + hideError() // Hide any previous errors + + // Get form data + const prompt = document.getElementById('prompt').value + const {min, max} = lengthConfigs[selectedLength] + + try { + const response = await fetch('/recommendations', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + prompt, + model: selectedModel, + min_tracks: min, + max_tracks: max, + }), + }) + + let errorMessage + if (!response.ok) { + const contentType = response.headers.get('content-type') + if (contentType && contentType.includes('application/json')) { + const errorData = await response.json() + errorMessage = errorData.detail || `HTTP error! status: ${response.status}` + } else { + const textError = await response.text() + errorMessage = textError || `HTTP error! status: ${response.status}` } - } + throw new Error(errorMessage) + } - // Add error dismiss handler if element exists - if (dismissError) { - dismissError.addEventListener('click', hideError); - } + const data = await response.json() - // Handle form submission - form.addEventListener('submit', async (e) => { - e.preventDefault(); - - // Show loading state - loadingState.classList.remove('hidden'); - results.classList.add('hidden'); - hideError(); // Hide any previous errors - - // Get form data - const prompt = document.getElementById('prompt').value; - const { min, max } = lengthConfigs[selectedLength]; - - try { - const response = await fetch('/recommendations', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - prompt, - model: 'gpt-4', - min_tracks: min, - max_tracks: max - }), - }); - - let errorMessage; - if (!response.ok) { - const contentType = response.headers.get("content-type"); - if (contentType && contentType.includes("application/json")) { - const errorData = await response.json(); - errorMessage = errorData.detail || `HTTP error! status: ${response.status}`; - } else { - const textError = await response.text(); - errorMessage = textError || `HTTP error! status: ${response.status}`; - } - throw new Error(errorMessage); - } - - const data = await response.json(); - - if (!data.tracks || !Array.isArray(data.tracks)) { - throw new Error('Invalid response format: missing tracks data'); - } - - // Update UI with results - playlistName.textContent = data.name; - trackCount.textContent = `${data.track_count} tracks`; - - // Render tracks list - tracksList.innerHTML = data.tracks - .map((track, index) => ` + if (!data.tracks || !Array.isArray(data.tracks)) { + throw new Error('Invalid response format: missing tracks data') + } + + // Update UI with results + playlistName.textContent = data.name + trackCount.textContent = `${data.track_count} tracks` + + // Render tracks list + tracksList.innerHTML = data.tracks + .map( + (track, index) => `
${track.artist}