fix: wire settings.json to actually drive runtime behavior
Settings page saved to /config/settings.json but nothing downstream read that file. Schedule changes were silently ignored; max_quality and sleep_interval changes were silently ignored. The "Settings saved successfully" flash was a lie. Fix: - sync.sh reads max_quality + sleep_interval from settings.json on each run (jq -er ... // empty, falling back to env vars on missing/malformed file) - entrypoint.sh reads sync_schedule from settings.json before setting up cron, and writes the crond PID to /var/run/crond.pid so Flask can SIGHUP it - app.py adds apply_schedule(): rewrites /etc/crontabs/root, signals crond via the recorded PID, restarts crond if the PID is stale, drops the crontab when schedule is set to "manual". save_settings_route invokes it only when the schedule actually changed; any failure flashes a warning so the save still succeeds with the user informed - bare `except: pass` in get_settings replaced with explicit exception types + stderr warning so debugging malformed settings is possible - sync.sh: one bad channel no longer aborts the whole loop under set -e - Dockerfile adds jq for the JSON reads in sync.sh / entrypoint.sh - README: two stale github.com URLs fixed to Gitea; new Running Tests section under Building From Source - tests/test_settings.py: 3 pytest cases covering get_settings()'s three branches (missing file, valid file, malformed JSON) Settings hierarchy unchanged: env-var defaults seed the UI; settings.json wins when present and parseable. Timezone (TZ) is not applied live - tzdata is locked in at process start. Same behavior as before; not in scope for this commit.
This commit is contained in:
105
app.py
105
app.py
@@ -5,7 +5,9 @@ Provides a web UI for managing YouTube channel downloads
|
||||
"""
|
||||
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
@@ -23,6 +25,8 @@ CHANNELS_FILE = os.path.join(CONFIG_DIR, 'channels.txt')
|
||||
COOKIES_FILE = os.path.join(CONFIG_DIR, 'cookies.txt')
|
||||
SETTINGS_FILE = os.path.join(CONFIG_DIR, 'settings.json')
|
||||
LOG_FILE = '/var/log/youtube-sync.log'
|
||||
CRONTAB_FILE = '/etc/crontabs/root'
|
||||
CROND_PID_FILE = '/var/run/crond.pid'
|
||||
|
||||
def get_settings():
|
||||
"""Load settings from file or return defaults"""
|
||||
@@ -37,8 +41,14 @@ def get_settings():
|
||||
try:
|
||||
with open(SETTINGS_FILE, 'r') as f:
|
||||
return json.load(f)
|
||||
except:
|
||||
pass
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
# Malformed or unreadable settings.json: log and fall back to defaults
|
||||
# rather than crashing the UI. The user can re-save from the Settings page
|
||||
# to rewrite a clean file.
|
||||
print(
|
||||
f"[WARNING] Could not read {SETTINGS_FILE}: {e}; using env-var defaults",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
return defaults
|
||||
|
||||
@@ -47,6 +57,75 @@ def save_settings(settings):
|
||||
with open(SETTINGS_FILE, 'w') as f:
|
||||
json.dump(settings, f, indent=2)
|
||||
|
||||
def apply_schedule(new_schedule):
|
||||
"""
|
||||
Rewrite /etc/crontabs/root with the new schedule and reload crond so the
|
||||
change takes effect without a container restart.
|
||||
|
||||
- new_schedule == "manual": remove the crontab entirely.
|
||||
- otherwise: write a single line invoking /app/sync.sh on the given schedule.
|
||||
|
||||
crond is reloaded by SIGHUP'ing the PID recorded by entrypoint.sh. If crond
|
||||
isn't running (e.g. container started in manual mode) and we now have a real
|
||||
schedule, start it.
|
||||
|
||||
Raises whatever the underlying OS calls raise; callers are expected to
|
||||
catch and surface a warning to the user.
|
||||
"""
|
||||
if new_schedule == 'manual':
|
||||
# Drop the crontab so crond stops firing the job. Leave crond running —
|
||||
# cheaper than killing/restarting it and it'll just idle.
|
||||
if os.path.exists(CRONTAB_FILE):
|
||||
os.remove(CRONTAB_FILE)
|
||||
else:
|
||||
os.makedirs(os.path.dirname(CRONTAB_FILE), exist_ok=True)
|
||||
line = f"{new_schedule} /app/sync.sh >> {LOG_FILE} 2>&1\n"
|
||||
with open(CRONTAB_FILE, 'w') as f:
|
||||
f.write(line)
|
||||
|
||||
# Reload crond. dcron (Alpine) re-reads crontabs on SIGHUP.
|
||||
pid = None
|
||||
if os.path.exists(CROND_PID_FILE):
|
||||
try:
|
||||
with open(CROND_PID_FILE, 'r') as f:
|
||||
pid = int(f.read().strip())
|
||||
except (OSError, ValueError) as e:
|
||||
print(
|
||||
f"[WARNING] Could not read crond pid file {CROND_PID_FILE}: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
pid = None
|
||||
|
||||
if pid is not None:
|
||||
try:
|
||||
os.kill(pid, signal.SIGHUP)
|
||||
return
|
||||
except ProcessLookupError:
|
||||
# PID file is stale — crond exited. Fall through to start it fresh
|
||||
# below if we have a non-manual schedule.
|
||||
print(
|
||||
f"[WARNING] crond pid {pid} no longer running; restarting",
|
||||
file=sys.stderr,
|
||||
)
|
||||
pid = None
|
||||
|
||||
# No live crond. Start one if we have an active schedule to run.
|
||||
if new_schedule != 'manual':
|
||||
proc = subprocess.Popen(
|
||||
['crond', '-f', '-l', '2'],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
try:
|
||||
with open(CROND_PID_FILE, 'w') as f:
|
||||
f.write(str(proc.pid))
|
||||
except OSError as e:
|
||||
# Non-fatal: cron is running, we just can't reload it on the next change.
|
||||
print(
|
||||
f"[WARNING] crond started (pid {proc.pid}) but pid file write failed: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
def extract_channel_id(url_or_id):
|
||||
"""
|
||||
Extract channel ID from various YouTube URL formats or validate direct ID
|
||||
@@ -280,6 +359,7 @@ def settings():
|
||||
@app.route('/settings/save', methods=['POST'])
|
||||
def save_settings_route():
|
||||
"""Save settings"""
|
||||
previous = get_settings()
|
||||
settings = {
|
||||
'sync_schedule': request.form.get('sync_schedule', '0 2 * * *'),
|
||||
'max_quality': request.form.get('max_quality', '1080'),
|
||||
@@ -289,6 +369,27 @@ def save_settings_route():
|
||||
|
||||
save_settings(settings)
|
||||
flash('Settings saved successfully', 'success')
|
||||
|
||||
# Apply the new cron schedule immediately if it changed. max_quality /
|
||||
# sleep_interval are read fresh by sync.sh on each run, so no action needed
|
||||
# for them. Timezone changes still require a container restart (system tzdata
|
||||
# is locked in at container start); we don't claim otherwise.
|
||||
if settings['sync_schedule'] != previous.get('sync_schedule'):
|
||||
try:
|
||||
apply_schedule(settings['sync_schedule'])
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
# Save succeeded; the schedule reload didn't. Let the user know
|
||||
# it'll take effect on next restart rather than silently failing.
|
||||
print(
|
||||
f"[WARNING] Could not reload cron schedule: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
flash(
|
||||
'Schedule saved, but live reload failed. '
|
||||
'New schedule will take effect after container restart.',
|
||||
'warning',
|
||||
)
|
||||
|
||||
return redirect(url_for('settings'))
|
||||
|
||||
@app.route('/cookies', methods=['GET', 'POST'])
|
||||
|
||||
Reference in New Issue
Block a user