Files
octotrigger/octo_backup.py
T
2026-07-02 05:52:57 +02:00

381 lines
12 KiB
Python

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()