From 3bbd47652115e47a549d50a7e72c7573e9cacd3e Mon Sep 17 00:00:00 2001 From: Fred Boniface Date: Sun, 1 Nov 2026 14:05:08 +0000 Subject: [PATCH] Initial --- .dockerignore | 1 + .gitea/workflows/buildpush.yaml | 39 ++++ Dockerfile | 6 + main.py | 343 ++++++++++++++++++++++++++++++++ requirements.txt | 2 + 5 files changed, 391 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitea/workflows/buildpush.yaml create mode 100644 Dockerfile create mode 100644 main.py create mode 100644 requirements.txt diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2eea525 --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/.gitea/workflows/buildpush.yaml b/.gitea/workflows/buildpush.yaml new file mode 100644 index 0000000..21ad438 --- /dev/null +++ b/.gitea/workflows/buildpush.yaml @@ -0,0 +1,39 @@ +name: Build and Push container image +run-name: ${{ gitea.actor }} is building and pushing + +on: + create: + tags: "*" + +env: + GITEA_DOMAIN: git.fjla.uk + GITEA_REGISTRY_USER: fred.boniface + RESULT_IMAGE_NAME: fred.boniface/ai-playlists + +jobs: + build-and-push-image: + runs-on: ubuntu-latest + container: + image: catthehacker/ubuntu:act-latest + options: --privileged + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Login to Gitea Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.GITEA_DOMAIN }} + username: ${{ env.GITEA_REGISTRY_USER }} + password: ${{ secrets.REGISTRY_TOKEN }} + - name: Build and Push image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: true + tags: | + ${{ env.GITEA_DOMAIN }}/${{ env.RESULT_IMAGE_NAME }}:${{ gitea.ref_name }} + ${{ env.GITEA_DOMAIN }}/${{ env.RESULT_IMAGE_NAME }}:latest \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..28c6a4f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.11-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY main.py . +CMD ["python", "main.py"] \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..fae31c5 --- /dev/null +++ b/main.py @@ -0,0 +1,343 @@ +import os +import time +import json +import sqlite3 +import hashlib +import requests +from google import genai +from dotenv import load_dotenv + +load_dotenv() + +# --- CONFIGURATION --- +GONIC_URL = os.getenv("GONIC_URL", "http://gonic:8080/rest") +GONIC_USER = os.getenv("GONIC_USER") +GONIC_PASS = os.getenv("GONIC_PASS") +GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") +CURATION_COOLDOWN = 7 * 86400 # Re-curate existing playlists every 7 days for fresh rotation + +DB_DIR = os.getenv("DB_DIR", ".") +DB_PATH = os.path.join(DB_DIR, "library.db") +HASH_PATH = os.path.join(DB_DIR, "library.hash") + +# Declare your AI playlists and prompts here. +# The script will automatically create and own them under the script's user credentials. +AI_PLAYLIST_DEFINITIONS = [ + { + "name": "AI: Christmas Bangers", + "prompt": "Upbeat Christmas classics and energetic winter anthems to get things moving." + }, + { + "name": "AI: Reflective Misery", + "prompt": "Melancholic, atmospheric tracks with deep emotional weight, great for late-night listening. We will skip classical tracks here" + }, + { + "name": "AI: Classical Christmas", + "prompt": "Classical Christmas orchestral arrangements, traditional choral carols, solemn holiday brass, and festive symphonic masterpieces." + }, + { + "name": "AI: Pure Dopamine", + "prompt": "Unapologetically joyful, high-energy, uplifting tracks with infectious grooves, bright melodies, and an undeniable feel-good factor to instantly boost the mood." + }, + { + "name": "AI: Halloween & Horror", + "prompt": "Spooky, eerie, and Halloween-party-ready tracks with high nocturnal energy. Includes classic Halloween bops (e.g. Thriller, Time Warp), dark pop (e.g. Disturbia), creepy alt-rock with supernatural themes (e.g. Zombie), and punchy, dramatic classical pieces or dark film scores (standalone tone poems up to 8 minutes like Danse Macabre or Fossils). STRICT EXCLUSIONS: Exclude multi-movement symphonies, extended concertos, or suites over 8 minutes (no 15+ minute works like Totentanz), standard emo/pop-punk songs (no blink-182), and generic alt-rock songs that merely have dark titles without an authentic Halloween or supernatural vibe." + } +] + +# Initialize Gemini Client +gemini_client = genai.Client(api_key=GEMINI_API_KEY) + +def get_auth_params(): + return { + "u": GONIC_USER, + "p": GONIC_PASS, + "v": "1.16.1", + "c": "AI-Playlist-Agent", + "f": "json" + } + +def init_db(): + if DB_DIR and DB_DIR != ".": + os.makedirs(DB_DIR, exist_ok=True) + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS tracks ( + id TEXT PRIMARY KEY, + title TEXT, + artist TEXT, + album TEXT, + genre TEXT, + year INTEGER + ) + """) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS playlist_state ( + name TEXT PRIMARY KEY, + last_curated INTEGER + ) + """) + conn.commit() + conn.close() + +def sync_library_to_sqlite(): + print("Fetching full track catalog from Gonic API...") + auth = get_auth_params() + + res = requests.get(f"{GONIC_URL}/getArtists.view", params=auth).json() + indexes = res.get("subsonic-response", {}).get("artists", {}).get("index", []) + + all_tracks = [] + for index_group in indexes: + for artist in index_group.get("artist", []): + artist_id = artist["id"] + artist_res = requests.get(f"{GONIC_URL}/getArtist.view", params={**auth, "id": artist_id}).json() + albums = artist_res.get("subsonic-response", {}).get("artist", {}).get("album", []) + + for album in albums: + album_id = album["id"] + album_res = requests.get(f"{GONIC_URL}/getAlbum.view", params={**auth, "id": album_id}).json() + songs = album_res.get("subsonic-response", {}).get("album", {}).get("song", []) + + for song in songs: + all_tracks.append(( + str(song.get("id")), + song.get("title", "Unknown"), + song.get("artist", "Unknown"), + song.get("album", "Unknown"), + song.get("genre", "Unknown"), + song.get("year", 0) + )) + + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + cursor.execute("DELETE FROM tracks") # Full refresh + cursor.executemany("INSERT OR REPLACE INTO tracks VALUES (?, ?, ?, ?, ?, ?)", all_tracks) + conn.commit() + conn.close() + print(f"Synced {len(all_tracks)} tracks to local SQLite index.") + +def check_library_changed(): + auth = get_auth_params() + res = requests.get(f"{GONIC_URL}/getIndexes.view", params=auth).json() + indexes_data = str(res.get("subsonic-response", {}).get("indexes", {})) + current_hash = hashlib.md5(indexes_data.encode()).hexdigest() + + if os.path.exists(HASH_PATH): + with open(HASH_PATH, "r") as f: + stored_hash = f.read() + if stored_hash == current_hash: + return False + + with open(HASH_PATH, "w") as f: + f.write(current_hash) + return True + +def query_candidate_tracks(prompt): + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + + words = [w for w in prompt.lower().split() if len(w) > 3] + rows = [] + + # 1. Grab a focused subset of keyword matches (capped at 200 so it doesn't drown out the random pool) + if words: + query_clauses = " OR ".join([f"genre LIKE ? OR title LIKE ? OR artist LIKE ? OR album LIKE ?" for _ in words]) + params = [] + for w in words: + params.extend([f"%{w}%", f"%{w}%", f"%{w}%", f"%{w}%"]) + + cursor.execute(f"SELECT id, title, artist, album, genre, year FROM tracks WHERE {query_clauses} LIMIT 200", params) + rows = cursor.fetchall() + + # 2. Pad aggressively with random tracks to reach a target pool of 350 + # This ensures high serendipity and unearths unexpected gems from across the library. + target_pool_size = 350 + existing_ids = {r[0] for r in rows} + + if len(rows) < target_pool_size: + needed = target_pool_size - len(rows) + cursor.execute("SELECT id, title, artist, album, genre, year FROM tracks ORDER BY RANDOM() LIMIT ?", (needed,)) + extra_rows = cursor.fetchall() + + for r in extra_rows: + if r[0] not in existing_ids: + rows.append(r) + existing_ids.add(r[0]) + + conn.close() + + return [{"id": r[0], "title": r[1], "artist": r[2], "album": r[3], "genre": r[4], "year": r[5]} for r in rows] + +def ask_gemini_for_tracks(prompt, candidates): + system_instruction = ( + "You are an expert music archivist, tastemaker, and DJ. " + "Your task is to curate tracks from a candidate pool that match a given thematic prompt or vibe. " + "CRITICAL INSTRUCTIONS:\n" + "1. Leverage your deep internal knowledge of music, artist styles, moods, and instrumentation. Do not rely solely on database tags or genres, as metadata can be messy or absent; use your understanding of the artist and song.\n" + "2. Prioritize absolute stylistic and emotional fit. Quality and vibe alignment matter far more than quantity: do not pad the selection with weak or unrelated matches.\n" + "3. You have complete freedom over the count: select anywhere from zero up to every single track that genuinely fits the criteria. There are no quotas, minimums, or maximum limits.\n" + "4. Return ONLY a raw JSON array containing the string IDs of the chosen tracks.\n" + "5. Output NO markdown, NO code blocks (do not use ```json ... ```), and NO conversational text whatsoever." + ) + + user_content = f"Prompt/Theme: {prompt}\n\nCandidate Tracks:\n{json.dumps(candidates, indent=2)}" + + max_retries = 10 + delay = 5 + response = None + + for attempt in range(max_retries): + try: + response = gemini_client.models.generate_content( + model="gemini-3.8-flash", + contents=user_content, + config=dict( + system_instruction=system_instruction, + temperature=0.3, + response_mime_type="application/json" + ) + ) + break + except Exception as e: + if "503" in str(e) or "UNAVAILABLE" in str(e): + if attempt < max_retries - 1: + print(f"Gemini capacity spike (503). Retrying in {delay}s (Attempt {attempt + 1}/{max_retries})...") + time.sleep(delay) + delay *= 2 + continue + raise e + + if not response: + return [] + + try: + return json.loads(response.text) + except Exception as e: + print(f"Error parsing Gemini response: {e}\nRaw output: {response.text}") + return [] + +def ensure_and_process_ai_playlists(library_has_changed): + auth = get_auth_params() + now = int(time.time()) + + # Fetch existing playlists owned by our script user + res = requests.get(f"{GONIC_URL}/getPlaylists.view", params=auth).json() + existing_playlists = { + pl.get("name"): pl.get("id") + for pl in res.get("subsonic-response", {}).get("playlists", {}).get("playlist", []) + } + + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + + for defn in AI_PLAYLIST_DEFINITIONS: + name = defn["name"] + prompt = defn["prompt"] + playlist_id = existing_playlists.get(name) + + # 1. Create playlist automatically if it doesn't exist for this user account + if not playlist_id: + print(f"Playlist '{name}' not found for user. Creating it...") + requests.get(f"{GONIC_URL}/createPlaylist.view", params={ + **auth, + "name": name, + "public": "true" + }) + + # Re-fetch to grab the newly generated ID + res = requests.get(f"{GONIC_URL}/getPlaylists.view", params=auth).json() + for pl in res.get("subsonic-response", {}).get("playlists", {}).get("playlist", []): + if pl.get("name") == name: + playlist_id = pl.get("id") + break + + if not playlist_id: + print(f"Failed to create or locate playlist '{name}'. Skipping.") + continue + + # 2. Fetch current playlist contents from Gonic + pl_detail_res = requests.get(f"{GONIC_URL}/getPlaylist.view", params={**auth, "id": playlist_id}).json() + existing_entries = pl_detail_res.get("subsonic-response", {}).get("playlist", {}).get("entry", []) + + # 3. Check last curation timestamp from SQLite state table + cursor.execute("SELECT last_curated FROM playlist_state WHERE name = ?", (name,)) + row = cursor.fetchone() + last_curated = row[0] if row else 0 + + # Decision Matrix for running Gemini: + # - Run if empty/new + # - Run if weekly cooldown (7 days) has expired (periodic refresh for variety) + # - Run if library changed AND it's been at least 24 hours since last curation + is_empty = not existing_entries + cooldown_expired = (now - last_curated) > CURATION_COOLDOWN + library_refresh_due = library_has_changed and (now - last_curated) > 86400 + + if not (is_empty or cooldown_expired or library_refresh_due): + print(f"Playlist '{name}' is up to date (Last curated {(now - last_curated) // 3600}h ago). Skipping Gemini.") + continue + + reason = "Empty/New" if is_empty else ("Periodic refresh" if cooldown_expired else "Library update") + print(f"Processing AI Playlist ({reason}): '{name}' with prompt: '{prompt}'") + + candidates = query_candidate_tracks(prompt) + if not candidates: + print("No matching candidate tracks found in local index.") + continue + + selected_ids = ask_gemini_for_tracks(prompt, candidates) + print(f"Gemini selected {len(selected_ids)} tracks.") + + if selected_ids: + existing_song_ids = {str(song.get("id")) for song in existing_entries} + tracks_to_add = [sid for sid in selected_ids if sid not in existing_song_ids] + + if tracks_to_add: + query_list = [ + ("u", auth["u"]), ("p", auth["p"]), ("v", auth["v"]), + ("c", auth["c"]), ("f", auth["f"]), + ("playlistId", playlist_id), ("name", name), + ("comment", prompt), + ("public", "true") + ] + for sid in tracks_to_add: + query_list.append(("songIdToAdd", sid)) + + update_res = requests.get(f"{GONIC_URL}/updatePlaylist.view", params=query_list).json() + if update_res.get("subsonic-response", {}).get("status") == "ok": + print(f"Successfully added {len(tracks_to_add)} new tracks to '{name}'!") + else: + print(f"Failed to update playlist: {update_res}") + else: + print(f"Playlist '{name}' has no new unique tracks to add from this selection.") + + # Update last curated timestamp in SQLite state table + cursor.execute("INSERT OR REPLACE INTO playlist_state (name, last_curated) VALUES (?, ?)", (name, now)) + conn.commit() + + conn.close() + +def main(): + init_db() + print("AI Playlist Sync one-shot execution started.") + + try: + # 1. Sync library catalog only if changes are detected or local DB is missing + lib_changed = check_library_changed() + if lib_changed or not os.path.exists(DB_PATH): + print("Library changes detected (or missing local index). Updating local SQLite index...") + sync_library_to_sqlite() + else: + print("Library catalog unchanged. Using existing local SQLite index.") + + # 2. Check and process playlists using the time-based curation cooldown matrix + ensure_and_process_ai_playlists(lib_changed) + print("AI Playlist Sync execution completed successfully.") + + except Exception as e: + print(f"Error during execution: {e}") + raise e + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1be705f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +requests +google-genai \ No newline at end of file