Merge branch 'dev' into performance-improvements
This commit is contained in:
@@ -40,6 +40,8 @@ DEFAULT_MAIN_CONFIG = {
|
||||
"tracknumPadding": True,
|
||||
"saveCover": True,
|
||||
"maxConcurrentDownloads": 3,
|
||||
"utilityConcurrency": 1,
|
||||
"librespotConcurrency": 2,
|
||||
"maxRetries": 3,
|
||||
"retryDelaySeconds": 5,
|
||||
"retryDelayIncrease": 5,
|
||||
|
||||
@@ -6,10 +6,11 @@ import os
|
||||
import sys
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Import Celery task utilities
|
||||
from .celery_config import get_config_params, MAX_CONCURRENT_DL
|
||||
from .celery_config import get_config_params, MAX_CONCURRENT_DL # noqa: E402
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -40,15 +41,22 @@ class CeleryManager:
|
||||
self.concurrency = get_config_params().get(
|
||||
"maxConcurrentDownloads", MAX_CONCURRENT_DL
|
||||
)
|
||||
self.utility_concurrency = max(
|
||||
1, int(get_config_params().get("utilityConcurrency", 1))
|
||||
)
|
||||
logger.info(
|
||||
f"CeleryManager initialized. Download concurrency set to: {self.concurrency}"
|
||||
f"CeleryManager initialized. Download concurrency set to: {self.concurrency} | Utility concurrency: {self.utility_concurrency}"
|
||||
)
|
||||
|
||||
def _get_worker_command(
|
||||
self, queues, concurrency, worker_name_suffix, log_level_env=None
|
||||
):
|
||||
# Use LOG_LEVEL from environment if provided, otherwise default to INFO
|
||||
log_level = log_level_env if log_level_env else os.getenv("LOG_LEVEL", "WARNING").upper()
|
||||
log_level = (
|
||||
log_level_env
|
||||
if log_level_env
|
||||
else os.getenv("LOG_LEVEL", "WARNING").upper()
|
||||
)
|
||||
# Use a unique worker name to avoid conflicts.
|
||||
# %h is replaced by celery with the actual hostname.
|
||||
hostname = f"worker_{worker_name_suffix}@%h"
|
||||
@@ -167,12 +175,19 @@ class CeleryManager:
|
||||
if self.utility_worker_process and self.utility_worker_process.poll() is None:
|
||||
logger.info("Celery Utility Worker is already running.")
|
||||
else:
|
||||
self.utility_concurrency = max(
|
||||
1,
|
||||
int(
|
||||
get_config_params().get(
|
||||
"utilityConcurrency", self.utility_concurrency
|
||||
)
|
||||
),
|
||||
)
|
||||
utility_cmd = self._get_worker_command(
|
||||
queues="utility_tasks,default", # Listen to utility and default
|
||||
concurrency=5, # Increased concurrency for SSE updates and utility tasks
|
||||
concurrency=self.utility_concurrency,
|
||||
worker_name_suffix="utw", # Utility Worker
|
||||
log_level_env=os.getenv("LOG_LEVEL", "WARNING").upper(),
|
||||
|
||||
)
|
||||
logger.info(
|
||||
f"Starting Celery Utility Worker with command: {' '.join(utility_cmd)}"
|
||||
@@ -197,7 +212,7 @@ class CeleryManager:
|
||||
self.utility_log_thread_stdout.start()
|
||||
self.utility_log_thread_stderr.start()
|
||||
logger.info(
|
||||
f"Celery Utility Worker (PID: {self.utility_worker_process.pid}) started with concurrency 5."
|
||||
f"Celery Utility Worker (PID: {self.utility_worker_process.pid}) started with concurrency {self.utility_concurrency}."
|
||||
)
|
||||
|
||||
if (
|
||||
@@ -221,7 +236,9 @@ class CeleryManager:
|
||||
)
|
||||
while not self.stop_event.is_set():
|
||||
try:
|
||||
time.sleep(10) # Check every 10 seconds
|
||||
# Wait using stop_event to be responsive to shutdown and respect interval
|
||||
if self.stop_event.wait(CONFIG_CHECK_INTERVAL):
|
||||
break
|
||||
if self.stop_event.is_set():
|
||||
break
|
||||
|
||||
@@ -229,6 +246,14 @@ class CeleryManager:
|
||||
new_max_concurrent_downloads = current_config.get(
|
||||
"maxConcurrentDownloads", self.concurrency
|
||||
)
|
||||
new_utility_concurrency = max(
|
||||
1,
|
||||
int(
|
||||
current_config.get(
|
||||
"utilityConcurrency", self.utility_concurrency
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
if new_max_concurrent_downloads != self.concurrency:
|
||||
logger.info(
|
||||
@@ -272,7 +297,10 @@ class CeleryManager:
|
||||
|
||||
# Restart only the download worker
|
||||
download_cmd = self._get_worker_command(
|
||||
"downloads", self.concurrency, "dlw", log_level_env=os.getenv("LOG_LEVEL", "WARNING").upper()
|
||||
"downloads",
|
||||
self.concurrency,
|
||||
"dlw",
|
||||
log_level_env=os.getenv("LOG_LEVEL", "WARNING").upper(),
|
||||
)
|
||||
logger.info(
|
||||
f"Restarting Celery Download Worker with command: {' '.join(download_cmd)}"
|
||||
@@ -303,6 +331,82 @@ class CeleryManager:
|
||||
f"Celery Download Worker (PID: {self.download_worker_process.pid}) restarted with new concurrency {self.concurrency}."
|
||||
)
|
||||
|
||||
# Handle utility worker concurrency changes
|
||||
if new_utility_concurrency != self.utility_concurrency:
|
||||
logger.info(
|
||||
f"CeleryManager: Detected change in utilityConcurrency from {self.utility_concurrency} to {new_utility_concurrency}. Restarting utility worker only."
|
||||
)
|
||||
|
||||
if (
|
||||
self.utility_worker_process
|
||||
and self.utility_worker_process.poll() is None
|
||||
):
|
||||
logger.info(
|
||||
f"Stopping Celery Utility Worker (PID: {self.utility_worker_process.pid}) for config update..."
|
||||
)
|
||||
self.utility_worker_process.terminate()
|
||||
try:
|
||||
self.utility_worker_process.wait(timeout=10)
|
||||
logger.info(
|
||||
f"Celery Utility Worker (PID: {self.utility_worker_process.pid}) terminated."
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning(
|
||||
f"Celery Utility Worker (PID: {self.utility_worker_process.pid}) did not terminate gracefully, killing."
|
||||
)
|
||||
self.utility_worker_process.kill()
|
||||
self.utility_worker_process = None
|
||||
|
||||
# Wait for log threads of utility worker to finish
|
||||
if (
|
||||
self.utility_log_thread_stdout
|
||||
and self.utility_log_thread_stdout.is_alive()
|
||||
):
|
||||
self.utility_log_thread_stdout.join(timeout=5)
|
||||
if (
|
||||
self.utility_log_thread_stderr
|
||||
and self.utility_log_thread_stderr.is_alive()
|
||||
):
|
||||
self.utility_log_thread_stderr.join(timeout=5)
|
||||
|
||||
self.utility_concurrency = new_utility_concurrency
|
||||
|
||||
# Restart only the utility worker
|
||||
utility_cmd = self._get_worker_command(
|
||||
"utility_tasks,default",
|
||||
self.utility_concurrency,
|
||||
"utw",
|
||||
log_level_env=os.getenv("LOG_LEVEL", "WARNING").upper(),
|
||||
)
|
||||
logger.info(
|
||||
f"Restarting Celery Utility Worker with command: {' '.join(utility_cmd)}"
|
||||
)
|
||||
self.utility_worker_process = subprocess.Popen(
|
||||
utility_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
universal_newlines=True,
|
||||
)
|
||||
self.utility_log_thread_stdout = threading.Thread(
|
||||
target=self._process_output_reader,
|
||||
args=(self.utility_worker_process.stdout, "Celery[UW-STDOUT]"),
|
||||
)
|
||||
self.utility_log_thread_stderr = threading.Thread(
|
||||
target=self._process_output_reader,
|
||||
args=(
|
||||
self.utility_worker_process.stderr,
|
||||
"Celery[UW-STDERR]",
|
||||
True,
|
||||
),
|
||||
)
|
||||
self.utility_log_thread_stdout.start()
|
||||
self.utility_log_thread_stderr.start()
|
||||
logger.info(
|
||||
f"Celery Utility Worker (PID: {self.utility_worker_process.pid}) restarted with new concurrency {self.utility_concurrency}."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"CeleryManager: Error in config monitor thread: {e}", exc_info=True
|
||||
|
||||
@@ -44,7 +44,11 @@ def get_client() -> LibrespotClient:
|
||||
_shared_client.close()
|
||||
except Exception:
|
||||
pass
|
||||
_shared_client = LibrespotClient(stored_credentials_path=desired_blob)
|
||||
cfg = get_config_params() or {}
|
||||
max_workers = int(cfg.get("librespotConcurrency", 2) or 2)
|
||||
_shared_client = LibrespotClient(
|
||||
stored_credentials_path=desired_blob, max_workers=max_workers
|
||||
)
|
||||
_shared_blob_path = desired_blob
|
||||
return _shared_client
|
||||
|
||||
@@ -59,7 +63,9 @@ def create_client(credentials_path: str) -> LibrespotClient:
|
||||
abs_path = os.path.abspath(credentials_path)
|
||||
if not os.path.isfile(abs_path):
|
||||
raise FileNotFoundError(f"Credentials file not found: {abs_path}")
|
||||
return LibrespotClient(stored_credentials_path=abs_path)
|
||||
cfg = get_config_params() or {}
|
||||
max_workers = int(cfg.get("librespotConcurrency", 2) or 2)
|
||||
return LibrespotClient(stored_credentials_path=abs_path, max_workers=max_workers)
|
||||
|
||||
|
||||
def close_client(client: LibrespotClient) -> None:
|
||||
|
||||
@@ -171,7 +171,6 @@ def download_track(
|
||||
convert_to=convert_to,
|
||||
bitrate=bitrate,
|
||||
artist_separator=artist_separator,
|
||||
spotify_metadata=spotify_metadata,
|
||||
pad_number_width=pad_number_width,
|
||||
)
|
||||
print(
|
||||
|
||||
@@ -167,6 +167,46 @@ def get_watch_config():
|
||||
watch_cfg["maxItemsPerRun"] = clamped_value
|
||||
migrated = True
|
||||
|
||||
# Enforce sane ranges and types for poll/delay intervals to prevent tight loops
|
||||
def _safe_int(value, default):
|
||||
try:
|
||||
return int(value)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
# Clamp poll interval to at least 1 second
|
||||
poll_val = _safe_int(
|
||||
watch_cfg.get(
|
||||
"watchPollIntervalSeconds",
|
||||
DEFAULT_WATCH_CONFIG["watchPollIntervalSeconds"],
|
||||
),
|
||||
DEFAULT_WATCH_CONFIG["watchPollIntervalSeconds"],
|
||||
)
|
||||
if poll_val < 1:
|
||||
watch_cfg["watchPollIntervalSeconds"] = 1
|
||||
migrated = True
|
||||
# Clamp per-item delays to at least 1 second
|
||||
delay_pl = _safe_int(
|
||||
watch_cfg.get(
|
||||
"delayBetweenPlaylistsSeconds",
|
||||
DEFAULT_WATCH_CONFIG["delayBetweenPlaylistsSeconds"],
|
||||
),
|
||||
DEFAULT_WATCH_CONFIG["delayBetweenPlaylistsSeconds"],
|
||||
)
|
||||
if delay_pl < 1:
|
||||
watch_cfg["delayBetweenPlaylistsSeconds"] = 1
|
||||
migrated = True
|
||||
delay_ar = _safe_int(
|
||||
watch_cfg.get(
|
||||
"delayBetweenArtistsSeconds",
|
||||
DEFAULT_WATCH_CONFIG["delayBetweenArtistsSeconds"],
|
||||
),
|
||||
DEFAULT_WATCH_CONFIG["delayBetweenArtistsSeconds"],
|
||||
)
|
||||
if delay_ar < 1:
|
||||
watch_cfg["delayBetweenArtistsSeconds"] = 1
|
||||
migrated = True
|
||||
|
||||
if migrated or legacy_file_found:
|
||||
# Persist migration back to main.json
|
||||
main_cfg["watch"] = watch_cfg
|
||||
@@ -670,7 +710,9 @@ def check_watched_playlists(specific_playlist_id: str = None):
|
||||
|
||||
# Only sleep between items when running a batch (no specific ID)
|
||||
if not specific_playlist_id:
|
||||
time.sleep(max(1, config.get("delayBetweenPlaylistsSeconds", 2)))
|
||||
time.sleep(
|
||||
max(1, _safe_to_int(config.get("delayBetweenPlaylistsSeconds"), 2))
|
||||
)
|
||||
|
||||
logger.info("Playlist Watch Manager: Finished checking all watched playlists.")
|
||||
|
||||
@@ -817,7 +859,9 @@ def check_watched_artists(specific_artist_id: str = None):
|
||||
|
||||
# Only sleep between items when running a batch (no specific ID)
|
||||
if not specific_artist_id:
|
||||
time.sleep(max(1, config.get("delayBetweenArtistsSeconds", 5)))
|
||||
time.sleep(
|
||||
max(1, _safe_to_int(config.get("delayBetweenArtistsSeconds"), 5))
|
||||
)
|
||||
|
||||
logger.info("Artist Watch Manager: Finished checking all watched artists.")
|
||||
|
||||
@@ -832,6 +876,14 @@ def playlist_watch_scheduler():
|
||||
interval = current_config.get("watchPollIntervalSeconds", 3600)
|
||||
watch_enabled = current_config.get("enabled", False) # Get enabled status
|
||||
|
||||
# Ensure interval is a positive integer to avoid tight loops
|
||||
try:
|
||||
interval = int(interval)
|
||||
except Exception:
|
||||
interval = 3600
|
||||
if interval < 1:
|
||||
interval = 1
|
||||
|
||||
if not watch_enabled:
|
||||
logger.info(
|
||||
"Watch Scheduler: Watch feature is disabled in config. Skipping checks."
|
||||
@@ -907,6 +959,13 @@ def run_playlist_check_over_intervals(playlist_spotify_id: str) -> None:
|
||||
# Determine if we are done: no active processing snapshot and no pending sync
|
||||
cfg = get_watch_config()
|
||||
interval = cfg.get("watchPollIntervalSeconds", 3600)
|
||||
# Ensure interval is a positive integer
|
||||
try:
|
||||
interval = int(interval)
|
||||
except Exception:
|
||||
interval = 3600
|
||||
if interval < 1:
|
||||
interval = 1
|
||||
# Use local helper that leverages Librespot client
|
||||
metadata = _fetch_playlist_metadata(playlist_spotify_id)
|
||||
if not metadata:
|
||||
@@ -1169,6 +1228,17 @@ def update_playlist_m3u_file(playlist_spotify_id: str):
|
||||
# Helper to build a Librespot client from active account
|
||||
|
||||
|
||||
# Add a small internal helper for safe int conversion
|
||||
_def_safe_int_added = True
|
||||
|
||||
|
||||
def _safe_to_int(value, default):
|
||||
try:
|
||||
return int(value)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _build_librespot_client():
|
||||
try:
|
||||
# Reuse shared client managed in routes.utils.get_info
|
||||
|
||||
Reference in New Issue
Block a user