import requests import hashlib import secrets import time import sys import re import os import json import argparse import logging from datetime import datetime # ========================================== # CONFIGURATION # ========================================== # Logging Configuration LOG_FILE = "octotrigger.log" # Setup Logger logger = logging.getLogger("OctoTrigger") logger.setLevel(logging.INFO) # Formatter formatter = logging.Formatter('%(asctime)s [%(levelname)s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S') # File Handler file_handler = logging.FileHandler(LOG_FILE) file_handler.setFormatter(formatter) logger.addHandler(file_handler) # Console Handler stream_handler = logging.StreamHandler(sys.stdout) stream_handler.setFormatter(formatter) logger.addHandler(stream_handler) # URLs ND_PROXY_URL = os.getenv("ND_PROXY_URL", "https://ndproxy.wouterverduin.nl") ND_DIRECT_URL = os.getenv("ND_DIRECT_URL", "https://navidrome.wouterverduin.nl") # Credentials (Environment variables preferred, fallback to hardcoded) ND_USERNAME = os.getenv("ND_USERNAME", "wouser") ND_PASSWORD = os.getenv("ND_PASSWORD", "G53^cZI3QVOMge*1x3$b%D2bJ$v*D&!") # Client identification CLIENT_NAME = "OctoTriggerScript" CLIENT_VERSION = "2.1.0" # Delays SCAN_DELAY_SECONDS = 60 POST_SCAN_WAIT_SECONDS = 30 # ========================================== # AUTH & HELPERS # ========================================== def get_auth_params(): salt = secrets.token_hex(6) token = hashlib.md5((ND_PASSWORD + salt).encode('utf-8')).hexdigest() return { 'u': ND_USERNAME, 't': token, 's': salt, 'v': '1.16.1', 'c': CLIENT_NAME, 'f': 'json' } def validate_input(json_data): """Validate format of incoming JSON""" if 'playlist_name' not in json_data: raise ValueError("Missing 'playlist_name' in JSON") if 'tracks' not in json_data or not isinstance(json_data['tracks'], list): raise ValueError("Missing 'tracks' list in JSON") return True def is_local_file(song): """ Determines if a song object from the API is a local file. """ song_id = song.get('id', '') path = song.get('path', '').lower() suffix = song.get('suffix', '').lower() local_exts = ('.mp3', '.flac', '.m4a', '.ogg', '.opus', '.wav', '.alac', '.ape') # CHECK 1: Valid File Extension in Path or Suffix if path.endswith(local_exts) or suffix in [e.replace('.', '') for e in local_exts]: return True # CHECK 2: Standard Navidrome MD5 ID (32 chars, hex only) if len(song_id) == 32 and re.match(r'^[0-9a-fA-F]+$', song_id): return True return False def find_best_match(songs, artist, title): """ Prioritizes LOCAL matches over remote ones. """ artist_clean = artist.lower().strip() title_clean = title.lower().strip() local_matches = [] remote_matches = [] for song in songs: res_artist = song.get('artist', '').lower() res_title = song.get('title', '').lower() if artist_clean in res_artist and title_clean in res_title: if is_local_file(song): local_matches.append(song) else: remote_matches.append(song) if local_matches: logger.info(f" [+] Found {len(local_matches)} local candidate(s). Selecting local.") return local_matches[0] if remote_matches: logger.info(f" [-] No local copy found. Selected remote candidate.") return remote_matches[0] return None def search_song(session, artist, title, base_url=ND_PROXY_URL): query = f"{artist} {title}" params = get_auth_params() params['query'] = query params['songCount'] = 20 target_name = "Proxy" if base_url == ND_PROXY_URL else "Direct" logger.info(f"[*] Searching ({target_name}): {artist} - {title}...") try: url = f"{base_url}/rest/search3" r = session.get(url, params=params, timeout=15) r.raise_for_status() data = r.json() subsonic_resp = data.get('subsonic-response', {}) songs = subsonic_resp.get('searchResult3', {}).get('song', []) if not songs: logger.warning(f" ! No results found for: {artist} - {title}") return None selected_song = find_best_match(songs, artist, title) if selected_song: origin = "LOCAL" if is_local_file(selected_song) else "REMOTE" logger.info(f" -> Match ({origin}): {selected_song['artist']} - {selected_song['title']} (ID: {selected_song['id']})") return selected_song except Exception as e: logger.error(f" ! Error searching: {e}") return None def trigger_stream_download(session, song_id): params = get_auth_params() params['id'] = song_id params['maxBitRate'] = 320 max_retries = 2 # The first number is connection timeout, the second is the read timeout (3 mins) current_timeout = (10, 180) for attempt in range(1, max_retries + 1): logger.info(f" -> Triggering proxy download (Streaming) - Attempt {attempt}/{max_retries}...") try: # We keep stream=True to avoid loading the whole file into RAM with session.get(ND_PROXY_URL + "/rest/stream", params=params, stream=True, timeout=current_timeout) as r: r.raise_for_status() total_bytes = 0 # We still want to read the 2MB threshold you originally had to ensure # the proxy has actually started piping data back to us. for chunk in r.iter_content(chunk_size=16384): if chunk: total_bytes += len(chunk) if total_bytes > 2 * 1024 * 1024: break logger.info(f" [OK] Trigger successful. (Read {total_bytes/1024:.2f} KB)") return True except (requests.exceptions.ReadTimeout, requests.exceptions.ConnectionError) as e: logger.warning(f" [!] Attempt {attempt} timed out after {current_timeout[1]} seconds.") if attempt < max_retries: wait_time = 15 logger.info(f" [i] Waiting {wait_time} seconds before retrying...") time.sleep(wait_time) # Optionally increase timeout for the second attempt current_timeout = (10, 300) else: logger.error(f" [X] Stream failed after {max_retries} attempts: {e}") return False except Exception as e: logger.error(f" [X] Unexpected error during stream trigger: {e}") return False def trigger_navidrome_scan(session): """ Triggers a library scan in Navidrome after a delay. """ logger.info("=" * 40) logger.info(f"Waiting {SCAN_DELAY_SECONDS} seconds for downloads to settle before scanning...") time.sleep(SCAN_DELAY_SECONDS) logger.info(f"[*] Triggering Navidrome Library Scan...") params = get_auth_params() try: url = f"{ND_PROXY_URL}/rest/startScan" r = session.get(url, params=params, timeout=15) r.raise_for_status() data = r.json() resp = data.get('subsonic-response', {}) if resp.get('status') == 'ok': scan_status = resp.get('scanStatus', {}) count = scan_status.get('count', 'Unknown') logger.info(f" [OK] Scan started successfully. (Current Count: {count})") # Post-Scan Wait logger.info(f"[*] Waiting {POST_SCAN_WAIT_SECONDS} seconds for scan initialization...") time.sleep(POST_SCAN_WAIT_SECONDS) logger.info(" [OK] Wait complete.") else: logger.warning(f" [!] Scan trigger request completed but status was not 'ok'.") except Exception as e: logger.error(f" [X] Failed to trigger scan: {e}") def update_navidrome_playlist(session, playlist_name, song_ids): """ Creates or updates a playlist on the direct Navidrome instance. """ logger.info("=" * 40) logger.info(f"[*] Configuring Playlist: '{playlist_name}'") logger.info(f" Tracks to add: {len(song_ids)}") if not song_ids: logger.warning(" [!] No tracks to add. Aborting playlist update.") return params = get_auth_params() try: # 1. Check for existing playlist to avoid duplicates url_get = f"{ND_DIRECT_URL}/rest/getPlaylists" r = session.get(url_get, params=params, timeout=15) r.raise_for_status() data = r.json() playlists = data.get('subsonic-response', {}).get('playlists', {}).get('playlist', []) target_id = None for pl in playlists: if pl.get('name') == playlist_name: target_id = pl.get('id') logger.info(f" [i] Found existing playlist ID: {target_id} (Updating)") break if not target_id: logger.info(f" [i] Playlist does not exist. Creating new.") # 2. Create or Update Playlist params = get_auth_params() params['name'] = playlist_name params['songId'] = song_ids # requests handles list -> songId=1&songId=2 if target_id: params['playlistId'] = target_id url_create = f"{ND_DIRECT_URL}/rest/createPlaylist" r = session.get(url_create, params=params, timeout=15) r.raise_for_status() logger.info(f" [OK] Playlist '{playlist_name}' successfully saved.") except Exception as e: logger.error(f" [X] Failed to update playlist: {e}") # ========================================== # MAIN PROCESS EXECUTION # ========================================== def run_process(playlist_data): """ Main Logic Function. Can be called by CLI main() or external Import. """ # 1. Prepare Data base_playlist_name = playlist_data.get("playlist_name", "Unknown Playlist") track_list = playlist_data.get("tracks", []) # 2. Append Date (DD-MM-YY) current_date = datetime.now().strftime("%d-%m-%y") #final_playlist_name = f"{base_playlist_name} ({current_date})" final_playlist_name = f"{current_date} {base_playlist_name}" #deze zelf gemaakt ipv bovenste logger.info(f"--- {CLIENT_NAME} v{CLIENT_VERSION} ---") logger.info(f"Playlist Target: {final_playlist_name}") logger.info(f"Tracks Loaded: {len(track_list)}") session = requests.Session() stats = {'cached': 0, 'downloaded': 0, 'failed': 0} # --------------------------------------------------------- # PHASE 1: Download Missing Tracks (Using Proxy) # --------------------------------------------------------- logger.info(">>> PHASE 1: Checking and Downloading Tracks") for artist, title in track_list: song = search_song(session, artist, title, base_url=ND_PROXY_URL) if song: if is_local_file(song): logger.info(f" [i] File exists locally.") stats['cached'] += 1 else: logger.info(f" [!] NEW SONG DETECTED") if trigger_stream_download(session, song['id']): stats['downloaded'] += 1 else: stats['failed'] += 1 else: logger.warning(" [X] Not found in Search") stats['failed'] += 1 logger.info("-" * 40) time.sleep(1) logger.info(f"Phase 1 Done: {stats['cached']} Existing, {stats['downloaded']} New, {stats['failed']} Failed.") # --------------------------------------------------------- # PHASE 2: Trigger Scan (If needed) # --------------------------------------------------------- if stats['downloaded'] > 0: trigger_navidrome_scan(session) else: logger.info("No new downloads. Skipping library scan.") # --------------------------------------------------------- # PHASE 3: Build Playlist (Using Direct URL) # --------------------------------------------------------- logger.info(">>> PHASE 3: Resolving Local IDs for Playlist") final_song_ids = [] for artist, title in track_list: song = search_song(session, artist, title, base_url=ND_DIRECT_URL) if song: final_song_ids.append(song['id']) else: logger.warning(f" [X] Could not resolve ID for: {artist} - {title}") time.sleep(0.5) update_navidrome_playlist(session, final_playlist_name, final_song_ids) logger.info("--- End of Script ---") def main(): # Argument Parsing parser = argparse.ArgumentParser(description="Octo-Fiesta Smart Trigger") parser.add_argument('json_input', help='JSON string containing playlist name and tracks') args = parser.parse_args() # Load JSON try: playlist_data = json.loads(args.json_input) validate_input(playlist_data) # Call the logic run_process(playlist_data) except json.JSONDecodeError as e: logger.critical(f"Invalid JSON provided: {e}") sys.exit(1) except ValueError as e: logger.critical(f"Invalid Data Structure: {e}") sys.exit(1) except Exception as e: logger.critical(f"Unexpected Error: {e}") sys.exit(1) if __name__ == "__main__": main()