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 # Get the absolute path of the directory where this script lives BASE_DIR = os.path.dirname(os.path.abspath(__file__)) LOG_FILE = os.path.join(BASE_DIR, "octotrigger.log") #LOG_FILE = "octotrigger.log" Vorige regel # 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 current_timeout = (10, 480) # Increased to 8 mins based on your album download info for attempt in range(1, max_retries + 1): logger.info(f" -> Triggering proxy download (Streaming) - Attempt {attempt}/{max_retries}...") try: # Note: We use stream=True. # If the server returns a 500, raise_for_status() will trigger the except block. with session.get(ND_PROXY_URL + "/rest/stream", params=params, stream=True, timeout=current_timeout) as r: # If the proxy returns 500, this will raise a requests.exceptions.HTTPError r.raise_for_status() total_bytes = 0 for chunk in r.iter_content(chunk_size=32768): 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.HTTPError as e: # Handle the 500 error specifically status_code = e.response.status_code logger.warning(f" [!] Proxy returned {status_code} Server Error for this track.") if attempt < max_retries: # If it's a 500, the proxy might be overwhelmed. Wait longer before retry. wait_time = 30 logger.info(f" [i] Waiting {wait_time}s to let proxy recover...") time.sleep(wait_time) else: logger.error(f" [X] Skipping song: Proxy failed after {max_retries} attempts.") return False except (requests.exceptions.ReadTimeout, requests.exceptions.ConnectionError) as e: logger.warning(f" [!] Attempt {attempt} timed out.") if attempt < max_retries: time.sleep(15) current_timeout = (10, 600) # Give it 10 mins on second attempt else: return False except Exception as e: logger.error(f" [X] Unexpected error: {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 next_playlist_number(playlists): """Return the next number after the highest numbered playlist.""" highest_number = 0 for playlist in playlists: match = re.match(r'^\s*(\d+)\.\s+', playlist.get('name', '')) if match: highest_number = max(highest_number, int(match.group(1))) return highest_number + 1 def update_navidrome_playlist(session, base_playlist_name, song_ids): """Create a newly numbered, date-prefixed playlist on Navidrome.""" logger.info("=" * 40) 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: # Read existing playlists so each newly created playlist gets the next number. 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', []) playlist_number = next_playlist_number(playlists) current_date = datetime.now().strftime("%m-%d-%y") playlist_name = f"{playlist_number}. {current_date} - {base_playlist_name}" logger.info(f"[*] Creating Playlist: '{playlist_name}'") # Always create a fresh playlist: its sequential number is unique. params = get_auth_params() params['name'] = playlist_name params['songId'] = song_ids # requests handles list -> songId=1&songId=2 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 created.") return playlist_name except Exception as e: logger.error(f" [X] Failed to create playlist: {e}") return None # ========================================== # 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", []) # The sequential number and MM-DD-YY prefix are assigned when creating the playlist. logger.info(f"--- {CLIENT_NAME} v{CLIENT_VERSION} ---") logger.info(f"Playlist Target: {base_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, base_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()