From 863504c789a7c7b3f4619b17128e977baa284081 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 2 Jul 2026 05:52:57 +0200 Subject: [PATCH] Eerste versie --- __pycache__/octotrigger.cpython-311.pyc | Bin 0 -> 20319 bytes cron.log | 70 ++++ instructie.txt | 55 ++++ last.fm | 4 + ocotrigger.log | 0 octo_backup.py | 380 ++++++++++++++++++++++ octo_backup2.py | 397 +++++++++++++++++++++++ octotrigger.py | 411 ++++++++++++++++++++++++ readme.md | 83 +++++ send.py | 116 +++++++ server.py | 47 +++ 11 files changed, 1563 insertions(+) create mode 100644 __pycache__/octotrigger.cpython-311.pyc create mode 100644 cron.log create mode 100644 instructie.txt create mode 100644 last.fm create mode 100644 ocotrigger.log create mode 100644 octo_backup.py create mode 100644 octo_backup2.py create mode 100644 octotrigger.py create mode 100644 readme.md create mode 100755 send.py create mode 100644 server.py diff --git a/__pycache__/octotrigger.cpython-311.pyc b/__pycache__/octotrigger.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a8883ba1aafb36250eb1e5f7e84a1d9a82bbd8a6 GIT binary patch literal 20319 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: Failed to establish a new connection: [Errno -3] Temporary failure in name resolution')) +Sending data to webhook... +✅ Successfully sent to webhook (Status: 200) +❌ Login Failed: HTTPSConnectionPool(host='navidrome.wouterverduin.nl', port=443): Max retries exceeded with url: /auth/login (Caused by NewConnectionError(': Failed to establish a new connection: [Errno -3] Temporary failure in name resolution')) +Sending data to webhook... +✅ Successfully sent to webhook (Status: 200) +Sending data to webhook... +✅ Successfully sent to webhook (Status: 200) +Sending data to webhook... +✅ Successfully sent to webhook (Status: 200) diff --git a/instructie.txt b/instructie.txt new file mode 100644 index 0000000..c6dab9c --- /dev/null +++ b/instructie.txt @@ -0,0 +1,55 @@ +sudo systemctl daemon-reload +sudo systemctl restart octotrigger + +sudo systemctl restart octotrigger +/etc/systemd/system/octotrigger.service + + +Command: + +curl -X POST http://192.168.1.72:5000/trigger \ + -H "Content-Type: application/json" \ + -d '{ + "playlist_name": "Cyberpunk Industrial Ritual", + "tracks": [ + ["In This Moment", "The In-Between"], + ["In This Moment", "Roots"], + ["Poppy", "Bloodmoney"], + ["Poppy", "Her"], + ["Void of Vision", "ALTAR"] + ] + }' + + +curl -X POST http://192.168.1.72:5000/trigger \ + -H "Content-Type: application/json" \ + -d '{ + "playlist_name": "Melodic Fury & Siren Screams", + "tracks": [ + ["Jinjer", "Pisces"], + ["Amaranthe", "Archangel"], + ["Nightwish", "Noise"], + ["Halestorm", "I Miss The Misery"], + ["In This Moment", "Whore"], + ["Beyond The Black", "Reincarnation"], + ["Ad Infinitum", "Seth"], + ["The Agonist", "Remnants in Time"], + ["Epica", "Abyss of Time - Countdown to Singularity"], + ["New Years Day", "Angel Eyes"], + ["Seven Spires", "Gods of Debauchery"], + ["Delain", "Burning Bridges"], + ["Cellar Darling", "Black Moon"], + ["Battle Beast", "Eye of the Storm"], + ["Eluveitie", "Rebirth"], + ["Enemy Inside", "Falling Away"], + ["Once Human", "Deadlock"], + ["Visions of Atlantis", "Legion of the Seas"], + ["Dream State", "Primordial"], + ["Flyleaf", "I'm So Sick"], + ["Sirenia", "Into the Night"], + ["Eleine", "We Shall Remain"], + ["Xandria", "You Will Never Be Our God"], + ["Leaves' Eyes", "Dark Love Empress"], + ["Ignea", "Nomad's Luck"] + ] + }' diff --git a/last.fm b/last.fm new file mode 100644 index 0000000..ea678b3 --- /dev/null +++ b/last.fm @@ -0,0 +1,4 @@ +api +c436f6b00e30221fe9b791bb1b3204ad +shared secret +5e157ac8fba6c1480caa6bbf03fed58d diff --git a/ocotrigger.log b/ocotrigger.log new file mode 100644 index 0000000..e69de29 diff --git a/octo_backup.py b/octo_backup.py new file mode 100644 index 0000000..9467d5f --- /dev/null +++ b/octo_backup.py @@ -0,0 +1,380 @@ +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 + + logger.info(f" -> Triggering proxy download (Streaming)...") + + try: + url = f"{ND_PROXY_URL}/rest/stream" + + with session.get(url, params=params, stream=True, timeout=20) as r: + r.raise_for_status() + + total_bytes = 0 + for chunk in r.iter_content(chunk_size=8192): + if chunk: + total_bytes += len(chunk) + if total_bytes > 2 * 1024 * 1024: + break + + logger.info(f" [OK] Trigger sent. (Read {total_bytes/1024:.2f} KB)") + return True + + except Exception as e: + logger.error(f" [X] Stream failed: {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() diff --git a/octo_backup2.py b/octo_backup2.py new file mode 100644 index 0000000..e84bcfa --- /dev/null +++ b/octo_backup2.py @@ -0,0 +1,397 @@ +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() diff --git a/octotrigger.py b/octotrigger.py new file mode 100644 index 0000000..9fc2ffb --- /dev/null +++ b/octotrigger.py @@ -0,0 +1,411 @@ +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 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() diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..0bf9654 --- /dev/null +++ b/readme.md @@ -0,0 +1,83 @@ +Van dit project is een service gemaakt: + +cat /etc/systemd/system/octotrigger.service +[Unit] +Description=OctoTrigger Web Listener +After=network.target + +[Service] +# Replace with your actual username (e.g., pi, root, wouter) +User=root +Group=root + +# Replace with the output of 'pwd' +WorkingDirectory=/root/octotrigger + +# Replace with output of 'which python3' and your script path +ExecStart=/usr/bin/python3 -u server.py + +# Restart automatically if it crashes +Restart=always +RestartSec=5 + +# Output logs to system journal +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target + +Relevante bijbehorende commandos bij de service: +sudo systemctl daemon-reload +sudo systemctl restart octotrigger +/etc/systemd/system/octotrigger.service + + +Twee voorbeeld commandos om de service aan te roepen: + +curl -X POST http://192.168.1.72:5000/trigger \ + -H "Content-Type: application/json" \ + -d '{ + "playlist_name": "Cyberpunk Industrial Ritual", + "tracks": [ + ["In This Moment", "The In-Between"], + ["In This Moment", "Roots"], + ["Poppy", "Bloodmoney"], + ["Poppy", "Her"], + ["Void of Vision", "ALTAR"] + ] + }' + + +curl -X POST http://192.168.1.72:5000/trigger \ + -H "Content-Type: application/json" \ + -d '{ + "playlist_name": "Melodic Fury & Siren Screams", + "tracks": [ + ["Jinjer", "Pisces"], + ["Amaranthe", "Archangel"], + ["Nightwish", "Noise"], + ["Halestorm", "I Miss The Misery"], + ["In This Moment", "Whore"], + ["Beyond The Black", "Reincarnation"], + ["Ad Infinitum", "Seth"], + ["The Agonist", "Remnants in Time"], + ["Epica", "Abyss of Time - Countdown to Singularity"], + ["New Years Day", "Angel Eyes"], + ["Seven Spires", "Gods of Debauchery"], + ["Delain", "Burning Bridges"], + ["Cellar Darling", "Black Moon"], + ["Battle Beast", "Eye of the Storm"], + ["Eluveitie", "Rebirth"], + ["Enemy Inside", "Falling Away"], + ["Once Human", "Deadlock"], + ["Visions of Atlantis", "Legion of the Seas"], + ["Dream State", "Primordial"], + ["Flyleaf", "I'm So Sick"], + ["Sirenia", "Into the Night"], + ["Eleine", "We Shall Remain"], + ["Xandria", "You Will Never Be Our God"], + ["Leaves' Eyes", "Dark Love Empress"], + ["Ignea", "Nomad's Luck"] + ] + }' diff --git a/send.py b/send.py new file mode 100755 index 0000000..7b4c079 --- /dev/null +++ b/send.py @@ -0,0 +1,116 @@ +import requests +import json + +# ========================================== +# CONFIGURATION +# ========================================== +ND_PASSWORD = "G53^cZI3QVOMge*1x3$b%D2bJ$v*D&!" +ND_DIRECT_URL = "https://navidrome.wouterverduin.nl" +ND_USERNAME = "wouser" + +# The webhook where data will be sent +#WEBHOOK_URL = "https://n8n.wouterverduin.nl/webhook-test/c3129d3a-dca8-43ae-8003-8d94b7f9097d" +WEBHOOK_URL = "https://n8n.wouterverduin.nl/webhook/c3129d3a-dca8-43ae-8003-8d94b7f9097d" + +# ========================================== +# INTERNAL API AUTHENTICATION +# ========================================== +def get_auth_token(): + auth_url = f"{ND_DIRECT_URL}/auth/login" + credentials = { + "username": ND_USERNAME, + "password": ND_PASSWORD + } + + try: + response = requests.post(auth_url, json=credentials) + response.raise_for_status() + return response.json().get('token') + except requests.exceptions.RequestException as e: + print(f"❌ Login Failed: {e}") + return None + +# ========================================== +# DATA RETRIEVAL +# ========================================== +def get_song_stats(token): + headers = { + 'x-nd-authorization': f'Bearer {token}', + 'Content-Type': 'application/json' + } + api_url = f"{ND_DIRECT_URL}/api/song" + + payload = { + "recently_played": [], + "most_played": [] + } + + # 1. Get Recently Played Songs + recent_params = { + '_end': 10, + '_order': 'DESC', + '_sort': 'play_date', + '_start': 0 + } + + try: + res = requests.get(api_url, headers=headers, params=recent_params) + res.raise_for_status() + songs = res.json() + for song in songs: + payload["recently_played"].append({ + "artist": song.get('artist', 'Unknown'), + "title": song.get('title', 'Unknown') + }) + except Exception as e: + print(f"Error fetching recent songs: {e}") + + # 2. Get Most Played Songs (All Time) + top_params = { + '_end': 10, + '_order': 'DESC', + '_sort': 'play_count', + '_start': 0 + } + + try: + res = requests.get(api_url, headers=headers, params=top_params) + res.raise_for_status() + songs = res.json() + for song in songs: + payload["most_played"].append({ + "artist": song.get('artist', 'Unknown'), + "title": song.get('title', 'Unknown') + }) + except Exception as e: + print(f"Error fetching top songs: {e}") + + return payload + +# ========================================== +# WEBHOOK DELIVERY +# ========================================== +def send_to_webhook(data): + try: + print(f"Sending data to webhook...") + response = requests.post(WEBHOOK_URL, json=data) + response.raise_for_status() + print(f"✅ Successfully sent to webhook (Status: {response.status_code})") + except Exception as e: + print(f"❌ Failed to send to webhook: {e}") + +# ========================================== +# MAIN +# ========================================== +if __name__ == "__main__": + if ND_PASSWORD == "YOUR_NEW_PASSWORD_HERE": + print("⚠️ Please update the ND_PASSWORD variable in the script first.") + else: + token = get_auth_token() + + if token: + # Gather the data + stats_data = get_song_stats(token) + + # Send the data to n8n + send_to_webhook(stats_data) diff --git a/server.py b/server.py new file mode 100644 index 0000000..82b112e --- /dev/null +++ b/server.py @@ -0,0 +1,47 @@ +from flask import Flask, request, jsonify +import logging +import threading + +# Import your existing script +# NOTE: Ensure octotrigger.py is in the same folder +import octotrigger + +# Add this to server.py to see if the logger is alive +logger = logging.getLogger("OctoTrigger") + +app = Flask(__name__) + +@app.route('/trigger', methods=['POST']) +def trigger_download(): + # 1. Get JSON from the request + try: + data = request.get_json(force=True) + logger.info(f"Received request for playlist: {data.get('playlist_name')}") + # Validate input using the function we wrote in octotrigger.py + octotrigger.validate_input(data) + except Exception as e: + return jsonify({"status": "error", "message": str(e)}), 400 + + # 2. Run the script logic in a background thread + # (so the web request doesn't time out while downloading) + def run_job(json_data): + # We need to hack sys.argv or modify main() to accept args directly. + # The cleanest way without rewriting octotrigger.py is to modify + # octotrigger.py slightly to accept a dict in main(), + # but here is a wrapper approach: + + # We will create a temporary version of the main logic here + # Or ideally, modify octotrigger.py's main to look like this: + # def run(playlist_data): ... + + # For now, let's assume we update octotrigger.py slightly (see below) + octotrigger.run_process(json_data) + + thread = threading.Thread(target=run_job, args=(data,)) + thread.start() + + return jsonify({"status": "accepted", "message": "Job started in background"}), 202 + +if __name__ == '__main__': + # Listen on all network interfaces (0.0.0.0) on port 5000 + app.run(host='0.0.0.0', port=5000)