|
|
|
@@ -1,4 +1,6 @@
|
|
|
|
|
import os
|
|
|
|
|
import sys
|
|
|
|
|
import random
|
|
|
|
|
import time
|
|
|
|
|
import json
|
|
|
|
|
import sqlite3
|
|
|
|
@@ -25,11 +27,11 @@ HASH_PATH = os.path.join(DB_DIR, "library.hash")
|
|
|
|
|
AI_PLAYLIST_DEFINITIONS = [
|
|
|
|
|
{
|
|
|
|
|
"name": "AI: Christmas Bangers",
|
|
|
|
|
"prompt": "Upbeat Christmas classics and energetic winter anthems to get things moving."
|
|
|
|
|
"prompt": "Upbeat Christmas classics and energetic winter anthems to get things moving. STRICT EXCLUSIONS: No traditional classical orchestral pieces or solo choral carols (keep those for the classical playlist)."
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"name": "AI: Reflective Misery",
|
|
|
|
|
"prompt": "Melancholic, atmospheric tracks with deep emotional weight, great for late-night listening. We will skip classical tracks here"
|
|
|
|
|
"prompt": "Melancholic, atmospheric tracks with deep emotional weight, great for late-night listening. STRICT EXCLUSIONS: Strictly skip classical tracks, acoustic chamber music, and long orchestral suites—focus entirely on modern alternative, dark indie, and sad electronic/pop."
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"name": "AI: Classical Christmas",
|
|
|
|
@@ -37,14 +39,30 @@ AI_PLAYLIST_DEFINITIONS = [
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"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."
|
|
|
|
|
"prompt": "Unapologetically joyful, high-energy, uplifting tracks with infectious grooves, bright melodies, and an undeniable feel-good factor to instantly boost the mood. STRICT EXCLUSIONS: Exclude classical compositions, slow movements, and traditional orchestral pieces."
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"name": "AI: Midnight Transit",
|
|
|
|
|
"prompt": "Hypnotic, rhythm-driven electronic, dark synth, driving indie rock, and propulsive bass-heavy tracks built for moving through the dark. Features a steady forward momentum, cinematic atmospheres, and immersive nocturnal energy. STRICT EXCLUSIONS: Exclude traditional classical/orchestral pieces, slow ambient tracks with no percussion, high-tempo EDM club bangers, and generic acoustic pop."
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"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."
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"name": "AI: Lazy Sunday Morning",
|
|
|
|
|
"prompt": "Warm, acoustic, gentle indie folk and stripped-back singer-songwriter tracks with an organic, cozy, and laid-back feel. Perfect for a slow weekend morning. STRICT EXCLUSIONS: Exclude heavy percussion, electronic club beats, aggressive rock, and complex orchestral classical music."
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"name": "AI: Deep Focus Lab",
|
|
|
|
|
"prompt": "Hypnotic, steady-tempo instrumental electronic, ambient techno, IDM, and minimal synth beats designed for deep concentration, coding, and technical workflows. STRICT EXCLUSIONS: Absolutely zero vocals or spoken word, no acoustic singer-songwriter tracks, and no classical or orchestral arrangements."
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
jitter = random.randint(0, 300)
|
|
|
|
|
print(f"Jitter active: sleeping for {jitter} seconds...", flush=True)
|
|
|
|
|
time.sleep(jitter)
|
|
|
|
|
|
|
|
|
|
# Initialize Gemini Client
|
|
|
|
|
gemini_client = genai.Client(api_key=GEMINI_API_KEY)
|
|
|
|
|
|
|
|
|
@@ -82,7 +100,7 @@ def init_db():
|
|
|
|
|
conn.close()
|
|
|
|
|
|
|
|
|
|
def sync_library_to_sqlite():
|
|
|
|
|
print("Fetching full track catalog from Gonic API...")
|
|
|
|
|
print("Fetching full track catalog from Gonic API...", flush=True)
|
|
|
|
|
auth = get_auth_params()
|
|
|
|
|
|
|
|
|
|
res = requests.get(f"{GONIC_URL}/getArtists.view", params=auth).json()
|
|
|
|
@@ -116,7 +134,7 @@ def sync_library_to_sqlite():
|
|
|
|
|
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.")
|
|
|
|
|
print(f"Synced {len(all_tracks)} tracks to local SQLite index.", flush=True)
|
|
|
|
|
|
|
|
|
|
def check_library_changed():
|
|
|
|
|
auth = get_auth_params()
|
|
|
|
@@ -203,7 +221,7 @@ def ask_gemini_for_tracks(prompt, candidates):
|
|
|
|
|
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})...")
|
|
|
|
|
print(f"Gemini capacity spike (503). Retrying in {delay}s (Attempt {attempt + 1}/{max_retries})...", flush=True)
|
|
|
|
|
time.sleep(delay)
|
|
|
|
|
delay *= 2
|
|
|
|
|
continue
|
|
|
|
@@ -215,7 +233,7 @@ def ask_gemini_for_tracks(prompt, candidates):
|
|
|
|
|
try:
|
|
|
|
|
return json.loads(response.text)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
print(f"Error parsing Gemini response: {e}\nRaw output: {response.text}")
|
|
|
|
|
print(f"Error parsing Gemini response: {e}\nRaw output: {response.text}", flush=True)
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
def ensure_and_process_ai_playlists(library_has_changed):
|
|
|
|
@@ -239,7 +257,7 @@ def ensure_and_process_ai_playlists(library_has_changed):
|
|
|
|
|
|
|
|
|
|
# 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...")
|
|
|
|
|
print(f"Playlist '{name}' not found for user. Creating it...", flush=True)
|
|
|
|
|
requests.get(f"{GONIC_URL}/createPlaylist.view", params={
|
|
|
|
|
**auth,
|
|
|
|
|
"name": name,
|
|
|
|
@@ -254,7 +272,7 @@ def ensure_and_process_ai_playlists(library_has_changed):
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
if not playlist_id:
|
|
|
|
|
print(f"Failed to create or locate playlist '{name}'. Skipping.")
|
|
|
|
|
print(f"Failed to create or locate playlist '{name}'. Skipping.", flush=True)
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
# 2. Fetch current playlist contents from Gonic
|
|
|
|
@@ -275,19 +293,19 @@ def ensure_and_process_ai_playlists(library_has_changed):
|
|
|
|
|
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.")
|
|
|
|
|
print(f"Playlist '{name}' is up to date (Last curated {(now - last_curated) // 3600}h ago). Skipping Gemini.", flush=True)
|
|
|
|
|
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}'")
|
|
|
|
|
print(f"Processing AI Playlist ({reason}): '{name}' with prompt: '{prompt}'", flush=True)
|
|
|
|
|
|
|
|
|
|
candidates = query_candidate_tracks(prompt)
|
|
|
|
|
if not candidates:
|
|
|
|
|
print("No matching candidate tracks found in local index.")
|
|
|
|
|
print("No matching candidate tracks found in local index.", flush=True)
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
selected_ids = ask_gemini_for_tracks(prompt, candidates)
|
|
|
|
|
print(f"Gemini selected {len(selected_ids)} tracks.")
|
|
|
|
|
print(f"Gemini selected {len(selected_ids)} tracks.", flush=True)
|
|
|
|
|
|
|
|
|
|
if selected_ids:
|
|
|
|
|
existing_song_ids = {str(song.get("id")) for song in existing_entries}
|
|
|
|
@@ -306,11 +324,11 @@ def ensure_and_process_ai_playlists(library_has_changed):
|
|
|
|
|
|
|
|
|
|
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}'!")
|
|
|
|
|
print(f"Successfully added {len(tracks_to_add)} new tracks to '{name}'!", flush=True)
|
|
|
|
|
else:
|
|
|
|
|
print(f"Failed to update playlist: {update_res}")
|
|
|
|
|
else:
|
|
|
|
|
print(f"Playlist '{name}' has no new unique tracks to add from this selection.")
|
|
|
|
|
print(f"Playlist '{name}' has no new unique tracks to add from this selection.", flush=True)
|
|
|
|
|
|
|
|
|
|
# Update last curated timestamp in SQLite state table
|
|
|
|
|
cursor.execute("INSERT OR REPLACE INTO playlist_state (name, last_curated) VALUES (?, ?)", (name, now))
|
|
|
|
@@ -320,23 +338,23 @@ def ensure_and_process_ai_playlists(library_has_changed):
|
|
|
|
|
|
|
|
|
|
def main():
|
|
|
|
|
init_db()
|
|
|
|
|
print("AI Playlist Sync one-shot execution started.")
|
|
|
|
|
print("AI Playlist Sync one-shot execution started.", flush=True)
|
|
|
|
|
|
|
|
|
|
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...")
|
|
|
|
|
print("Library changes detected (or missing local index). Updating local SQLite index...", flush=True)
|
|
|
|
|
sync_library_to_sqlite()
|
|
|
|
|
else:
|
|
|
|
|
print("Library catalog unchanged. Using existing local SQLite index.")
|
|
|
|
|
print("Library catalog unchanged. Using existing local SQLite index.", flush=True)
|
|
|
|
|
|
|
|
|
|
# 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.")
|
|
|
|
|
print("AI Playlist Sync execution completed successfully.", flush=True)
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
print(f"Error during execution: {e}")
|
|
|
|
|
print(f"Error during execution: {e}", flush=True)
|
|
|
|
|
raise e
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|