Eerste versie
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,70 @@
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
❌ Login Failed: HTTPSConnectionPool(host='navidrome.wouterverduin.nl', port=443): Max retries exceeded with url: /auth/login (Caused by NewConnectionError('<urllib3.connection.HTTPSConnection object at 0x70d9dd7a3b50>: 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('<urllib3.connection.HTTPSConnection object at 0x76b6b79f7950>: 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)
|
||||
@@ -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"]
|
||||
]
|
||||
}'
|
||||
@@ -0,0 +1,4 @@
|
||||
api
|
||||
c436f6b00e30221fe9b791bb1b3204ad
|
||||
shared secret
|
||||
5e157ac8fba6c1480caa6bbf03fed58d
|
||||
+380
@@ -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()
|
||||
+397
@@ -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()
|
||||
+411
@@ -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()
|
||||
@@ -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"]
|
||||
]
|
||||
}'
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user