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