Eerste versie
This commit is contained in:
+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()
|
||||
Reference in New Issue
Block a user