mirror of https://github.com/Nezreka/SoulSync.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
379 lines
15 KiB
379 lines
15 KiB
"""Debug info endpoint — lifted from web_server.py.
|
|
|
|
The function bodies are byte-identical to the originals. Module-level
|
|
shims for ``spotify_client`` and ``tidal_client`` (proxies that resolve
|
|
through the metadata registry / runtime client registry) plus injected
|
|
state dicts and helpers let the bodies resolve their original names
|
|
without modification.
|
|
"""
|
|
import logging
|
|
import os
|
|
import platform
|
|
from pathlib import Path
|
|
|
|
from flask import jsonify, request
|
|
|
|
from config.settings import config_manager
|
|
from core.metadata.registry import get_spotify_client
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class _SpotifyClientProxy:
|
|
"""Resolves the global Spotify client lazily through core.metadata.registry."""
|
|
|
|
def __getattr__(self, name):
|
|
client = get_spotify_client()
|
|
if client is None:
|
|
raise AttributeError(name)
|
|
return getattr(client, name)
|
|
|
|
def __bool__(self):
|
|
return get_spotify_client() is not None
|
|
|
|
|
|
class _TidalClientProxy:
|
|
"""Resolves the global Tidal client lazily via an injected getter so a
|
|
Tidal re-auth that rebinds web_server.tidal_client is visible here."""
|
|
|
|
def __getattr__(self, name):
|
|
if _get_tidal_client is None:
|
|
raise AttributeError(name)
|
|
client = _get_tidal_client()
|
|
if client is None:
|
|
raise AttributeError(name)
|
|
return getattr(client, name)
|
|
|
|
def __bool__(self):
|
|
if _get_tidal_client is None:
|
|
return False
|
|
return _get_tidal_client() is not None
|
|
|
|
|
|
spotify_client = _SpotifyClientProxy()
|
|
tidal_client = _TidalClientProxy()
|
|
_get_tidal_client = None # injected via init()
|
|
|
|
|
|
# Injected at runtime via init().
|
|
SOULSYNC_VERSION = None
|
|
_DIRECT_RUN = None
|
|
_status_cache = None
|
|
qobuz_enrichment_worker = None
|
|
download_batches = None
|
|
sync_states = None
|
|
youtube_playlist_states = None
|
|
tidal_discovery_states = None
|
|
download_orchestrator = None
|
|
_log_path = None
|
|
_log_dir = None
|
|
app = None
|
|
get_database = None
|
|
|
|
|
|
def init(
|
|
soulsync_version,
|
|
direct_run,
|
|
status_cache,
|
|
qobuz_worker,
|
|
download_batches_dict,
|
|
sync_states_dict,
|
|
youtube_playlist_states_dict,
|
|
tidal_discovery_states_dict,
|
|
download_orchestrator_obj,
|
|
log_path,
|
|
log_dir,
|
|
flask_app,
|
|
get_database_fn,
|
|
tidal_client_getter,
|
|
):
|
|
"""Bind shared state/helpers from web_server."""
|
|
global SOULSYNC_VERSION, _DIRECT_RUN, _status_cache, qobuz_enrichment_worker
|
|
global download_batches, sync_states, youtube_playlist_states
|
|
global tidal_discovery_states, download_orchestrator, _log_path, _log_dir
|
|
global app, get_database, _get_tidal_client
|
|
SOULSYNC_VERSION = soulsync_version
|
|
_DIRECT_RUN = direct_run
|
|
_status_cache = status_cache
|
|
qobuz_enrichment_worker = qobuz_worker
|
|
download_batches = download_batches_dict
|
|
sync_states = sync_states_dict
|
|
youtube_playlist_states = youtube_playlist_states_dict
|
|
tidal_discovery_states = tidal_discovery_states_dict
|
|
download_orchestrator = download_orchestrator_obj
|
|
_log_path = log_path
|
|
_log_dir = log_dir
|
|
app = flask_app
|
|
get_database = get_database_fn
|
|
_get_tidal_client = tidal_client_getter
|
|
|
|
|
|
def _safe_check(fn, default=False):
|
|
"""Safely evaluate a check function, returning default on any error."""
|
|
try:
|
|
return fn()
|
|
except Exception:
|
|
return default
|
|
|
|
|
|
def get_debug_info():
|
|
"""Collect system diagnostics for troubleshooting support requests."""
|
|
import sys
|
|
import psutil
|
|
import time
|
|
from datetime import timedelta
|
|
|
|
log_lines = request.args.get('lines', 20, type=int)
|
|
log_lines = max(10, min(log_lines, 500))
|
|
log_source = request.args.get('log', 'app')
|
|
|
|
info = {}
|
|
|
|
# App info
|
|
info['version'] = SOULSYNC_VERSION
|
|
info['os'] = f"{platform.system()} {platform.release()}"
|
|
info['python'] = sys.version.split()[0]
|
|
info['docker'] = os.path.exists('/.dockerenv')
|
|
info['runner'] = 'gunicorn' if not _DIRECT_RUN else 'direct (python web_server.py)'
|
|
|
|
# ffmpeg version
|
|
try:
|
|
import subprocess
|
|
result = subprocess.run(['ffmpeg', '-version'], capture_output=True, text=True, timeout=5)
|
|
first_line = result.stdout.split('\n')[0] if result.stdout else ''
|
|
# e.g. "ffmpeg version 6.1.1 Copyright ..."
|
|
info['ffmpeg'] = first_line.split('Copyright')[0].replace('ffmpeg version', '').strip() if first_line else 'installed (version unknown)'
|
|
except FileNotFoundError:
|
|
info['ffmpeg'] = 'NOT INSTALLED'
|
|
except Exception:
|
|
info['ffmpeg'] = 'unknown'
|
|
|
|
# Uptime
|
|
start_time = getattr(app, 'start_time', time.time())
|
|
uptime_seconds = time.time() - start_time
|
|
info['uptime'] = str(timedelta(seconds=int(uptime_seconds)))
|
|
|
|
# Paths
|
|
download_path = config_manager.get('soulseek.download_path', './downloads')
|
|
transfer_folder = config_manager.get('soulseek.transfer_path', './Transfer')
|
|
staging_folder = config_manager.get('import.staging_path', '')
|
|
info['paths'] = {
|
|
'download_path': download_path,
|
|
'download_path_exists': os.path.isdir(download_path) if download_path else False,
|
|
'download_path_writable': os.access(download_path, os.W_OK) if download_path and os.path.isdir(download_path) else False,
|
|
'transfer_folder': transfer_folder,
|
|
'transfer_folder_exists': os.path.isdir(transfer_folder) if transfer_folder else False,
|
|
'transfer_folder_writable': os.access(transfer_folder, os.W_OK) if transfer_folder and os.path.isdir(transfer_folder) else False,
|
|
'staging_folder': staging_folder,
|
|
'staging_folder_exists': os.path.isdir(staging_folder) if staging_folder else False,
|
|
}
|
|
# Music library paths (Settings > Library)
|
|
music_paths = config_manager.get('library.music_paths', [])
|
|
if isinstance(music_paths, list) and music_paths:
|
|
info['paths']['music_library_paths'] = []
|
|
for p in music_paths:
|
|
if p and isinstance(p, str):
|
|
info['paths']['music_library_paths'].append({
|
|
'path': p,
|
|
'exists': os.path.isdir(p),
|
|
})
|
|
# Music videos directory
|
|
music_videos_path = config_manager.get('library.music_videos_path', '')
|
|
if music_videos_path:
|
|
info['paths']['music_videos_path'] = music_videos_path
|
|
info['paths']['music_videos_path_exists'] = os.path.isdir(music_videos_path)
|
|
|
|
# Services from status cache
|
|
spotify_cache = _status_cache.get('spotify', {})
|
|
media_server_cache = _status_cache.get('media_server', {})
|
|
soulseek_cache = _status_cache.get('soulseek', {})
|
|
info['services'] = {
|
|
'music_source': spotify_cache.get('source', 'unknown'),
|
|
'spotify_connected': spotify_cache.get('connected', False),
|
|
'spotify_rate_limited': spotify_cache.get('rate_limited', False),
|
|
'media_server_type': media_server_cache.get('type', 'none'),
|
|
'media_server_connected': media_server_cache.get('connected', False),
|
|
'soulseek_connected': soulseek_cache.get('connected', False),
|
|
'download_source': config_manager.get('download_source.mode', 'hybrid'),
|
|
'tidal_connected': _safe_check(lambda: bool(tidal_client and tidal_client.is_authenticated())),
|
|
'qobuz_connected': _safe_check(lambda: bool(qobuz_enrichment_worker and qobuz_enrichment_worker.client and qobuz_enrichment_worker.client.is_authenticated())),
|
|
}
|
|
|
|
# Enrichment workers
|
|
workers = {}
|
|
worker_names = ['musicbrainz', 'audiodb', 'deezer', 'spotify', 'itunes', 'lastfm', 'genius', 'discogs', 'tidal', 'qobuz']
|
|
for name in worker_names:
|
|
paused_key = f'{name}_enrichment_paused'
|
|
workers[name] = 'paused' if config_manager.get(paused_key, False) else 'active'
|
|
info['enrichment_workers'] = workers
|
|
|
|
# Library stats — use same method as dashboard (filters by active server)
|
|
try:
|
|
db = get_database()
|
|
lib_stats = db.get_database_info_for_server()
|
|
info['library'] = {
|
|
'artists': lib_stats.get('artists', 0),
|
|
'albums': lib_stats.get('albums', 0),
|
|
'tracks': lib_stats.get('tracks', 0),
|
|
}
|
|
except Exception:
|
|
info['library'] = {'artists': 0, 'albums': 0, 'tracks': 0}
|
|
|
|
# Watchlist count
|
|
try:
|
|
db = get_database()
|
|
info['watchlist_count'] = db.get_watchlist_count()
|
|
except Exception:
|
|
info['watchlist_count'] = 0
|
|
|
|
# Wishlist pending count
|
|
try:
|
|
db = get_database()
|
|
info['wishlist_count'] = db.get_wishlist_count()
|
|
except Exception:
|
|
info['wishlist_count'] = 0
|
|
|
|
# Automation count
|
|
try:
|
|
db = get_database()
|
|
automations = db.get_automations()
|
|
info['automations'] = {
|
|
'total': len(automations),
|
|
'enabled': len([a for a in automations if a.get('enabled', False)]),
|
|
}
|
|
except Exception:
|
|
info['automations'] = {'total': 0, 'enabled': 0}
|
|
|
|
# Active downloads & syncs (use list() snapshots to avoid RuntimeError from concurrent mutation)
|
|
try:
|
|
active_downloads = len([bid for bid, bd in list(download_batches.items()) if bd.get('phase') == 'downloading'])
|
|
except Exception:
|
|
active_downloads = 0
|
|
active_syncs = 0
|
|
try:
|
|
for _pid, ss in list(sync_states.items()):
|
|
if ss.get('status') == 'syncing':
|
|
active_syncs += 1
|
|
for _uh, st in list(youtube_playlist_states.items()):
|
|
if st.get('phase') == 'syncing':
|
|
active_syncs += 1
|
|
for _pid, st in list(tidal_discovery_states.items()):
|
|
if st.get('phase') == 'syncing':
|
|
active_syncs += 1
|
|
except Exception as e:
|
|
logger.debug("count active syncs failed: %s", e)
|
|
info['active_downloads'] = active_downloads
|
|
info['active_syncs'] = active_syncs
|
|
|
|
# Config settings relevant to troubleshooting
|
|
source_mode = config_manager.get('download_source.mode', 'hybrid')
|
|
info['config'] = {
|
|
'source_mode': source_mode,
|
|
'quality_profile': config_manager.get('download_source.quality_profile', 'default'),
|
|
'organization_template': config_manager.get('organization.folder_template', ''),
|
|
'post_processing_enabled': config_manager.get('post_processing.enabled', True),
|
|
'acoustid_enabled': bool(config_manager.get('acoustid.api_key', '')),
|
|
'auto_scan_enabled': config_manager.get('watchlist.auto_scan', False),
|
|
'm3u_export_enabled': config_manager.get('m3u.enabled', False),
|
|
'log_level': config_manager.get('logging.level', 'INFO'),
|
|
'primary_metadata_source': config_manager.get('metadata.fallback_source', 'deezer'),
|
|
'lossy_copy_enabled': config_manager.get('post_processing.lossy_copy.enabled', False),
|
|
'lossy_copy_format': config_manager.get('post_processing.lossy_copy.format', 'mp3'),
|
|
'lossy_copy_bitrate': config_manager.get('post_processing.lossy_copy.bitrate', 320),
|
|
'allow_duplicate_tracks': config_manager.get('library.allow_duplicate_tracks', False),
|
|
'replace_lower_quality': config_manager.get('import.replace_lower_quality', False),
|
|
'auto_import_enabled': config_manager.get('import.auto_import_enabled', False),
|
|
}
|
|
# Hybrid source priority order
|
|
if source_mode == 'hybrid':
|
|
info['config']['hybrid_sources'] = config_manager.get('download_source.hybrid_order', [])
|
|
# Discogs connection status
|
|
info['services']['discogs_connected'] = bool(config_manager.get('discogs.token', ''))
|
|
|
|
# Download client init failures
|
|
info['download_client_failures'] = []
|
|
if download_orchestrator and hasattr(download_orchestrator, '_init_failures'):
|
|
info['download_client_failures'] = download_orchestrator._init_failures
|
|
elif not download_orchestrator:
|
|
info['download_client_failures'] = ['ALL (orchestrator failed to initialize)']
|
|
|
|
# API rate monitor — current calls/min, 24h totals, peaks, rate limit events
|
|
try:
|
|
from core.api_call_tracker import api_call_tracker
|
|
from core.metadata.status import get_spotify_status
|
|
rates = api_call_tracker.get_all_rates()
|
|
info['api_rates'] = rates
|
|
# Rich 24h debug summary with peaks, totals, per-endpoint breakdown, events
|
|
info['api_debug_summary'] = api_call_tracker.get_debug_summary()
|
|
# Spotify rate limit details
|
|
spotify_status = get_spotify_status(spotify_client=spotify_client)
|
|
rl_info = spotify_status.get('rate_limit')
|
|
if spotify_status.get('rate_limited') and rl_info:
|
|
info['spotify_rate_limit'] = {
|
|
'active': True,
|
|
'remaining_seconds': rl_info.get('remaining_seconds', 0),
|
|
'retry_after': rl_info.get('retry_after', 0),
|
|
'endpoint': rl_info.get('endpoint', ''),
|
|
'expires_at': rl_info.get('expires_at', ''),
|
|
}
|
|
else:
|
|
info['spotify_rate_limit'] = {'active': False}
|
|
except Exception:
|
|
info['api_rates'] = {}
|
|
info['api_debug_summary'] = {}
|
|
info['spotify_rate_limit'] = {'active': False}
|
|
|
|
# Database size
|
|
db_path = os.path.join('database', 'music_library.db')
|
|
if os.path.exists(db_path):
|
|
db_size_mb = os.path.getsize(db_path) / (1024 * 1024)
|
|
info['database_size'] = f"{db_size_mb:.1f} MB"
|
|
else:
|
|
info['database_size'] = 'not found'
|
|
|
|
# Memory & CPU
|
|
process = psutil.Process(os.getpid())
|
|
mem = process.memory_info()
|
|
info['memory_usage'] = f"{mem.rss / (1024 * 1024):.0f} MB"
|
|
info['system_memory'] = f"{psutil.virtual_memory().percent}%"
|
|
try:
|
|
info['cpu_percent'] = f"{process.cpu_percent(interval=0.1):.1f}%"
|
|
except Exception:
|
|
info['cpu_percent'] = 'unknown'
|
|
info['thread_count'] = process.num_threads()
|
|
|
|
# Log lines
|
|
log_map = {
|
|
'app': Path(_log_path),
|
|
'acoustid': _log_dir / 'acoustid.log',
|
|
'post_processing': _log_dir / 'post_processing.log',
|
|
'source_reuse': _log_dir / 'source_reuse.log',
|
|
}
|
|
log_path = log_map.get(log_source, log_map['app'])
|
|
info['log_source'] = log_source
|
|
info['log_lines_requested'] = log_lines
|
|
info['recent_logs'] = []
|
|
if os.path.exists(log_path):
|
|
try:
|
|
with open(log_path, 'r', encoding='utf-8', errors='replace') as f:
|
|
lines = f.readlines()
|
|
info['recent_logs'] = [line.rstrip() for line in lines[-log_lines:]]
|
|
except Exception:
|
|
info['recent_logs'] = ['(could not read log file)']
|
|
|
|
# Available log files
|
|
info['available_logs'] = []
|
|
logs_dir = 'logs'
|
|
if os.path.isdir(logs_dir):
|
|
for fname in sorted(os.listdir(logs_dir)):
|
|
if fname.endswith('.log'):
|
|
fpath = os.path.join(logs_dir, fname)
|
|
size_kb = os.path.getsize(fpath) / 1024
|
|
info['available_logs'].append({
|
|
'name': fname.replace('.log', ''),
|
|
'file': fname,
|
|
'size': f"{size_kb:.0f} KB" if size_kb < 1024 else f"{size_kb/1024:.1f} MB",
|
|
})
|
|
|
|
return jsonify(info)
|