mirror of
https://github.com/mistergibson/radio.git
synced 2026-09-09 06:19:52 -07:00
Updated ownership under liquidsoap user
This commit is contained in:
parent
c592ea3af5
commit
49ab0b725e
4 changed files with 354 additions and 156 deletions
|
|
@ -6,6 +6,12 @@ for the liquidsoap radio automation stack.
|
||||||
All data (podcasts, state DBs, logs, playlists) lives under the storage path
|
All data (podcasts, state DBs, logs, playlists) lives under the storage path
|
||||||
defined in config.json ("storage" key), keeping the boot drive clean.
|
defined in config.json ("storage" key), keeping the boot drive clean.
|
||||||
|
|
||||||
|
Concurrency model:
|
||||||
|
- An exclusive, non-blocking lockfile (state/fetch.lock) guarantees this
|
||||||
|
process and update_playlists.py never touch the databases simultaneously.
|
||||||
|
If the updater holds the lock, this run logs a skip and exits 0.
|
||||||
|
- Both databases run in WAL mode with a busy timeout as a second safety net.
|
||||||
|
|
||||||
Filters out video podcasts: only shows whose latest enclosure has an
|
Filters out video podcasts: only shows whose latest enclosure has an
|
||||||
audio/* MIME type are registered.
|
audio/* MIME type are registered.
|
||||||
|
|
||||||
|
|
@ -14,10 +20,13 @@ shows (archived=0) store only the enclosure URL for live streaming.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import fcntl
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
import socket
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import sys
|
import sys
|
||||||
import uuid
|
import uuid
|
||||||
|
|
@ -25,10 +34,16 @@ import xml.etree.ElementTree as ET
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
# Force IPv4 resolution so we don't stall on AAAA-first lookups when the box
|
||||||
|
# has no usable IPv6 route (gpodder.net publishes both A and AAAA records).
|
||||||
|
try:
|
||||||
|
import requests.packages.urllib3.util.connection as _urllib3_conn
|
||||||
|
_urllib3_conn.HAS_IPV6 = False
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
import feedparser
|
import feedparser
|
||||||
import requests
|
import requests
|
||||||
import requests.packages.urllib3.util.connection as _urllib3_conn
|
|
||||||
_urllib3_conn.HAS_IPV6 = False
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent
|
ROOT = Path(__file__).resolve().parent
|
||||||
CONFIG_PATH = ROOT / "config.json"
|
CONFIG_PATH = ROOT / "config.json"
|
||||||
|
|
@ -42,6 +57,7 @@ PLAYED_DB = None
|
||||||
PODCASTS_DIR = None
|
PODCASTS_DIR = None
|
||||||
LOGS_DIR = None
|
LOGS_DIR = None
|
||||||
PLAYLISTS_DIR = None
|
PLAYLISTS_DIR = None
|
||||||
|
LOCK_FILE = None
|
||||||
|
|
||||||
def load_config():
|
def load_config():
|
||||||
with open(CONFIG_PATH) as f:
|
with open(CONFIG_PATH) as f:
|
||||||
|
|
@ -49,7 +65,7 @@ def load_config():
|
||||||
|
|
||||||
def init_paths():
|
def init_paths():
|
||||||
"""Resolve all data paths from config.json storage key."""
|
"""Resolve all data paths from config.json storage key."""
|
||||||
global STATE_DIR, SUBS_DB, PLAYED_DB, PODCASTS_DIR, LOGS_DIR, PLAYLISTS_DIR
|
global STATE_DIR, SUBS_DB, PLAYED_DB, PODCASTS_DIR, LOGS_DIR, PLAYLISTS_DIR, LOCK_FILE
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
storage = Path(cfg["storage"]).expanduser().resolve()
|
storage = Path(cfg["storage"]).expanduser().resolve()
|
||||||
STATE_DIR = storage / "state"
|
STATE_DIR = storage / "state"
|
||||||
|
|
@ -58,6 +74,7 @@ def init_paths():
|
||||||
PODCASTS_DIR = storage / "podcasts"
|
PODCASTS_DIR = storage / "podcasts"
|
||||||
LOGS_DIR = storage / "logs"
|
LOGS_DIR = storage / "logs"
|
||||||
PLAYLISTS_DIR = storage / "playlists"
|
PLAYLISTS_DIR = storage / "playlists"
|
||||||
|
LOCK_FILE = STATE_DIR / "radio.lock"
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
|
|
@ -77,41 +94,100 @@ def _setup_logging():
|
||||||
fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
|
fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
|
||||||
log.addHandler(fh)
|
log.addHandler(fh)
|
||||||
|
|
||||||
def open_subs_db():
|
# ---------------------------------------------------------------------------
|
||||||
conn = sqlite3.connect(SUBS_DB)
|
# Mutual exclusion: one writer across fetch + update at any moment.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_lock_fd = None
|
||||||
|
|
||||||
|
def acquire_lock():
|
||||||
|
"""Acquire an exclusive non-blocking lock. Returns True if acquired,
|
||||||
|
False if another radio process already holds it."""
|
||||||
|
global _lock_fd
|
||||||
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd = os.open(str(LOCK_FILE), os.O_CREAT | os.O_RDWR, 0o644)
|
||||||
|
try:
|
||||||
|
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except OSError:
|
||||||
|
os.close(fd)
|
||||||
|
return False
|
||||||
|
# Record our PID for observability.
|
||||||
|
os.ftruncate(fd, 0)
|
||||||
|
os.write(fd, str(os.getpid()).encode())
|
||||||
|
_lock_fd = fd
|
||||||
|
return True
|
||||||
|
|
||||||
|
def release_lock():
|
||||||
|
global _lock_fd
|
||||||
|
if _lock_fd is not None:
|
||||||
|
try:
|
||||||
|
fcntl.flock(_lock_fd, fcntl.LOCK_UN)
|
||||||
|
os.close(_lock_fd)
|
||||||
|
finally:
|
||||||
|
_lock_fd = None
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Schema: single source of truth per database, applied idempotently.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
SHOWS_COLUMNS = {
|
||||||
|
"slug": "TEXT PRIMARY KEY",
|
||||||
|
"guid": "TEXT NOT NULL UNIQUE",
|
||||||
|
"name": "TEXT NOT NULL",
|
||||||
|
"feed_url": "TEXT NOT NULL UNIQUE",
|
||||||
|
"source": "TEXT DEFAULT 'manual'",
|
||||||
|
"opml_import": "INTEGER DEFAULT 0",
|
||||||
|
"archived": "INTEGER DEFAULT 1",
|
||||||
|
"created_at": "TEXT DEFAULT (datetime('now'))",
|
||||||
|
}
|
||||||
|
|
||||||
|
EPISODES_COLUMNS = {
|
||||||
|
"id": "INTEGER PRIMARY KEY AUTOINCREMENT",
|
||||||
|
"show_slug": "TEXT NOT NULL",
|
||||||
|
"guid": "TEXT NOT NULL",
|
||||||
|
"title": "TEXT",
|
||||||
|
"file_path": "TEXT",
|
||||||
|
"enclosure_url": "TEXT",
|
||||||
|
"runlength": "INTEGER",
|
||||||
|
"played": "INTEGER DEFAULT 0",
|
||||||
|
"played_at": "TEXT",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _connect(db_path):
|
||||||
|
conn = sqlite3.connect(str(db_path), timeout=5.0)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
conn.execute("""
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
CREATE TABLE IF NOT EXISTS shows (
|
conn.execute("PRAGMA busy_timeout=5000")
|
||||||
slug TEXT PRIMARY KEY,
|
return conn
|
||||||
guid TEXT NOT NULL UNIQUE,
|
|
||||||
name TEXT NOT NULL,
|
def _ensure_columns(conn, table, columns):
|
||||||
feed_url TEXT NOT NULL UNIQUE,
|
"""Create the table if absent, else add any missing columns."""
|
||||||
source TEXT DEFAULT 'manual',
|
exists = conn.execute(
|
||||||
opml_import INTEGER DEFAULT 0,
|
"SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table,)
|
||||||
archived INTEGER DEFAULT 1,
|
).fetchone()
|
||||||
created_at TEXT DEFAULT (datetime('now'))
|
if exists is None:
|
||||||
)
|
defs = ",\n ".join(f"{name} {spec}" for name, spec in columns.items())
|
||||||
""")
|
extra = ""
|
||||||
|
if table == "episodes":
|
||||||
|
extra = "\n ,UNIQUE(show_slug, guid)"
|
||||||
|
conn.execute(f"CREATE TABLE {table} (\n {defs}{extra}\n )")
|
||||||
|
else:
|
||||||
|
existing = {row[1] for row in conn.execute(f"PRAGMA table_info({table})")}
|
||||||
|
for name, spec in columns.items():
|
||||||
|
if name not in existing:
|
||||||
|
col_default = spec.split("DEFAULT", 1)[1].strip() if "DEFAULT" in spec else "NULL"
|
||||||
|
conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {col_default}")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
def open_subs_db():
|
||||||
|
conn = _connect(SUBS_DB)
|
||||||
|
_ensure_columns(conn, "shows", SHOWS_COLUMNS)
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
def open_played_db():
|
def open_played_db():
|
||||||
conn = sqlite3.connect(PLAYED_DB)
|
conn = _connect(PLAYED_DB)
|
||||||
conn.row_factory = sqlite3.Row
|
_ensure_columns(conn, "episodes", EPISODES_COLUMNS)
|
||||||
conn.execute("""
|
conn.execute(
|
||||||
CREATE TABLE IF NOT EXISTS episodes (
|
"CREATE INDEX IF NOT EXISTS idx_episodes_show_played ON episodes (show_slug, played)"
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
)
|
||||||
show_slug TEXT NOT NULL,
|
|
||||||
guid TEXT NOT NULL,
|
|
||||||
title TEXT,
|
|
||||||
file_path TEXT,
|
|
||||||
enclosure_url TEXT,
|
|
||||||
runlength INTEGER,
|
|
||||||
played INTEGER DEFAULT 0,
|
|
||||||
played_at TEXT,
|
|
||||||
UNIQUE(show_slug, guid)
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|
@ -416,6 +492,7 @@ def set_archive(slug, value):
|
||||||
row = db.execute("SELECT name FROM shows WHERE slug = ?", (slug,)).fetchone()
|
row = db.execute("SELECT name FROM shows WHERE slug = ?", (slug,)).fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
log_error(f"No show found with slug '{slug}'.")
|
log_error(f"No show found with slug '{slug}'.")
|
||||||
|
db.close()
|
||||||
return
|
return
|
||||||
db.execute("UPDATE shows SET archived = ? WHERE slug = ?", (value, slug))
|
db.execute("UPDATE shows SET archived = ? WHERE slug = ?", (value, slug))
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
@ -436,6 +513,7 @@ def delete_show(slug):
|
||||||
row = db.execute("SELECT name FROM shows WHERE slug = ?", (slug,)).fetchone()
|
row = db.execute("SELECT name FROM shows WHERE slug = ?", (slug,)).fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
log_error(f"No show found with slug '{slug}'.")
|
log_error(f"No show found with slug '{slug}'.")
|
||||||
|
db.close()
|
||||||
return
|
return
|
||||||
remove_show_data(slug)
|
remove_show_data(slug)
|
||||||
db.execute("DELETE FROM shows WHERE slug = ?", (slug,))
|
db.execute("DELETE FROM shows WHERE slug = ?", (slug,))
|
||||||
|
|
@ -513,20 +591,32 @@ def main():
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
|
|
||||||
if args.archive:
|
# Read-only administrative commands don't need the write lock.
|
||||||
set_archive(args.archive, 1)
|
needs_lock = not args.list_shows
|
||||||
elif args.unarchive:
|
|
||||||
set_archive(args.unarchive, 0)
|
if needs_lock and not acquire_lock():
|
||||||
elif args.list_shows:
|
log.info("Another radio process holds the lock; skipping this run.")
|
||||||
list_shows(detail=args.detail)
|
return
|
||||||
elif args.add_show:
|
|
||||||
add_show(args.add_show)
|
try:
|
||||||
elif args.delete_show:
|
if args.archive:
|
||||||
delete_show(args.delete_show)
|
set_archive(args.archive, 1)
|
||||||
elif args.import_opml:
|
elif args.unarchive:
|
||||||
import_opml(args.import_opml)
|
set_archive(args.unarchive, 0)
|
||||||
else:
|
elif args.list_shows:
|
||||||
run_fetch(config)
|
list_shows(detail=args.detail)
|
||||||
|
elif args.add_show:
|
||||||
|
add_show(args.add_show)
|
||||||
|
elif args.delete_show:
|
||||||
|
delete_show(args.delete_show)
|
||||||
|
elif args.import_opml:
|
||||||
|
import_opml(args.import_opml)
|
||||||
|
else:
|
||||||
|
run_fetch(config)
|
||||||
|
finally:
|
||||||
|
if needs_lock:
|
||||||
|
release_lock()
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,9 @@ echo "==> Installing gems one at a time into ${GEMS_DIR} ..."
|
||||||
"$JRUBY_BIN" -S gem install --no-document jdbc-sqlite3
|
"$JRUBY_BIN" -S gem install --no-document jdbc-sqlite3
|
||||||
"$JRUBY_BIN" -S gem install --no-document json
|
"$JRUBY_BIN" -S gem install --no-document json
|
||||||
|
|
||||||
|
# Give the service user ownership of the gem cache so cron runs work.
|
||||||
|
chown -R "$LIQUIDSOAP_USER":"$LIQUIDSOAP_USER" "$GEMS_DIR"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 6. Interactive configuration (defaults from existing config.json)
|
# 6. Interactive configuration (defaults from existing config.json)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -279,22 +282,32 @@ exec("liquidsoap", File.expand_path("station.liq"))
|
||||||
RBRUN
|
RBRUN
|
||||||
chown "$LIQUIDSOAP_USER":"$LIQUIDSOAP_USER" "${INSTALL_DIR}/station.rb"
|
chown "$LIQUIDSOAP_USER":"$LIQUIDSOAP_USER" "${INSTALL_DIR}/station.rb"
|
||||||
|
|
||||||
|
# Allow the service user to traverse the install dir.
|
||||||
|
chmod o+x "$INSTALL_DIR"
|
||||||
|
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 11. Cron jobs (deduplicated)
|
# 11. Cron jobs — installed into the liquidsoap user's crontab so all
|
||||||
|
# database/media writes happen under the same identity as the service.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
echo "==> Setting up cron jobs ..."
|
echo "==> Setting up cron jobs under user '${LIQUIDSOAP_USER}' ..."
|
||||||
FETCH_PREFIX="cd ${INSTALL_DIR} && GEM_HOME=${GEMS_DIR} GEM_PATH=${GEMS_DIR} ${JRUBY_BIN} -S fetch_podcasts.rb"
|
FETCH_PREFIX="cd ${INSTALL_DIR} && GEM_HOME=${GEMS_DIR} GEM_PATH=${GEMS_DIR} ${JRUBY_BIN} -S fetch_podcasts.rb"
|
||||||
UPDATE_PREFIX="cd ${INSTALL_DIR} && GEM_HOME=${GEMS_DIR} GEM_PATH=${GEMS_DIR} ${JRUBY_BIN} -S update_playlists.rb"
|
UPDATE_PREFIX="cd ${INSTALL_DIR} && GEM_HOME=${GEMS_DIR} GEM_PATH=${GEMS_DIR} ${JRUBY_BIN} -S update_playlists.rb"
|
||||||
FETCH_CRON="0 * * * * ${FETCH_PREFIX} >> ${STORAGE_PATH}/logs/fetch.log 2>&1"
|
FETCH_CRON="0 * * * * ${FETCH_PREFIX} >> ${STORAGE_PATH}/logs/fetch.log 2>&1"
|
||||||
UPDATE_CRON="30 * * * * ${UPDATE_PREFIX} >> ${STORAGE_PATH}/logs/update.log 2>&1"
|
UPDATE_CRON="30 * * * * ${UPDATE_PREFIX} >> ${STORAGE_PATH}/logs/update.log 2>&1"
|
||||||
|
|
||||||
crontab -l 2>/dev/null | grep -vF "fetch_podcasts.rb" | grep -vF "update_playlists.rb" > /tmp/cron_radio_rb.$$ || true
|
# Remove any stale entries from the liquidsoap user's crontab, then add ours.
|
||||||
echo "$FETCH_CRON" >> /tmp/cron_radio_rb.$$
|
sudo -u "$LIQUIDSOAP_USER" crontab -l 2>/dev/null | grep -vF "fetch_podcasts.rb" | grep -vF "update_playlists.rb" > /tmp/cron_ls_rb.$$ || true
|
||||||
echo "$UPDATE_CRON" >> /tmp/cron_radio_rb.$$
|
echo "$FETCH_CRON" >> /tmp/cron_ls_rb.$$
|
||||||
crontab /tmp/cron_radio_rb.$$
|
echo "$UPDATE_CRON" >> /tmp/cron_ls_rb.$$
|
||||||
rm -f /tmp/cron_radio_rb.$$
|
sudo -u "$LIQUIDSOAP_USER" crontab /tmp/cron_ls_rb.$$
|
||||||
|
rm -f /tmp/cron_ls_rb.$$
|
||||||
|
|
||||||
|
# Also scrub these from root's crontab in case an earlier install put them there.
|
||||||
|
crontab -l 2>/dev/null | grep -vF "fetch_podcasts.rb" | grep -vF "update_playlists.rb" > /tmp/cron_root_rb.$$ || true
|
||||||
|
crontab /tmp/cron_root_rb.$$
|
||||||
|
rm -f /tmp/cron_root_rb.$$
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 12. Enable services
|
# 12. Enable services
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ fi
|
||||||
CONFIG_JSON="${INSTALL_DIR}/config.json"
|
CONFIG_JSON="${INSTALL_DIR}/config.json"
|
||||||
|
|
||||||
prompt() {
|
prompt() {
|
||||||
local var_name="$1" prompt_text="$2" default="${3:-}"
|
local prompt_text="$1" default="${2:-}"
|
||||||
local current=""
|
local current=""
|
||||||
if [[ -n "$default" ]]; then
|
if [[ -n "$default" ]]; then
|
||||||
read -rp "${prompt_text} [${default}]: " current || true
|
read -rp "${prompt_text} [${default}]: " current || true
|
||||||
|
|
@ -71,22 +71,22 @@ fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "--- Storage ---"
|
echo "--- Storage ---"
|
||||||
STORAGE_PATH=$(prompt storage_path "Storage path (media + state + logs)" "/mnt/storage/radio${existing_storage:+|$existing_storage}")
|
STORAGE_PATH=$(prompt "Storage path (media + state + logs)" "${existing_storage:-/mnt/storage/radio}")
|
||||||
[[ -z "$STORAGE_PATH" ]] && STORAGE_PATH="${existing_storage:-/mnt/storage/radio}"
|
[[ -z "$STORAGE_PATH" ]] && STORAGE_PATH="${existing_storage:-/mnt/storage/radio}"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "--- Icecast (source credentials) ---"
|
echo "--- Icecast (source credentials) ---"
|
||||||
IC_HOST=$(prompt ic_host "Icecast host" "$existing_ic_host"); IC_HOST=${IC_HOST:-localhost}
|
IC_HOST=$(prompt "Icecast host" "$existing_ic_host"); IC_HOST=${IC_HOST:-localhost}
|
||||||
IC_PORT=$(prompt ic_port "Icecast source port" "$existing_ic_port"); IC_PORT=${IC_PORT:-7777}
|
IC_PORT=$(prompt "Icecast source port" "$existing_ic_port"); IC_PORT=${IC_PORT:-7777}
|
||||||
IC_MOUNT=$(prompt ic_mount "Mount point" "$existing_ic_mount"); IC_MOUNT=${IC_MOUNT:-/data}
|
IC_MOUNT=$(prompt "Mount point" "$existing_ic_mount"); IC_MOUNT=${IC_MOUNT:-/data}
|
||||||
IC_USER=$(prompt ic_user "Source username" "$existing_ic_user"); IC_USER=${IC_USER:-source}
|
IC_USER=$(prompt "Source username" "$existing_ic_user"); IC_USER=${IC_USER:-source}
|
||||||
read -rsp "Source password: " IC_PASS || true; echo; IC_PASS=${IC_PASS:-$existing_ic_pass}
|
read -rsp "Source password: " IC_PASS || true; echo; IC_PASS=${IC_PASS:-$existing_ic_pass}
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "--- gPodder sync (optional) ---"
|
echo "--- gPodder sync (optional) ---"
|
||||||
GP_ENABLE=$(prompt gp_enable "Enable gPodder sync? (true/false)" "$existing_gp_enable"); GP_ENABLE=${GP_ENABLE:-false}
|
GP_ENABLE=$(prompt "Enable gPodder sync? (true/false)" "$existing_gp_enable"); GP_ENABLE=${GP_ENABLE:-false}
|
||||||
GP_HOST=$(prompt gp_host "gPodder host" "$existing_gp_host"); GP_HOST=${GP_HOST:-https://gpodder.net}
|
GP_HOST=$(prompt "gPodder host" "$existing_gp_host"); GP_HOST=${GP_HOST:-https://gpodder.net}
|
||||||
GP_USER=$(prompt gp_user "gPodder username" "$existing_gp_user")
|
GP_USER=$(prompt "gPodder username" "$existing_gp_user")
|
||||||
read -rsp "gPodder password: " GP_PASS || true; echo; GP_PASS=${GP_PASS:-$existing_gp_pass}
|
read -rsp "gPodder password: " GP_PASS || true; echo; GP_PASS=${GP_PASS:-$existing_gp_pass}
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|
@ -195,6 +195,10 @@ echo "==> Installing Python dependencies (feedparser, requests) ..."
|
||||||
"${VENV}/bin/pip" install --quiet --upgrade pip
|
"${VENV}/bin/pip" install --quiet --upgrade pip
|
||||||
"${VENV}/bin/pip" install --quiet feedparser requests
|
"${VENV}/bin/pip" install --quiet feedparser requests
|
||||||
|
|
||||||
|
# Make sure the liquidsoap user can traverse the install dir and use the venv.
|
||||||
|
chmod o+x "$INSTALL_DIR"
|
||||||
|
chown -R "$LIQUIDSOAP_USER":"$LIQUIDSOAP_USER" "$VENV"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 7. systemd unit (named after the directory)
|
# 7. systemd unit (named after the directory)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -229,17 +233,24 @@ chown "$LIQUIDSOAP_USER":"$LIQUIDSOAP_USER" "${INSTALL_DIR}/station_runner.py"
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 8. Cron jobs (deduplicated)
|
# 8. Cron jobs — installed into the liquidsoap user's crontab so all
|
||||||
|
# database/media writes happen under the same identity as the service.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
echo "==> Setting up cron jobs ..."
|
echo "==> Setting up cron jobs under user '${LIQUIDSOAP_USER}' ..."
|
||||||
FETCH_CRON="0 * * * * cd ${INSTALL_DIR} && ./.venv/bin/python fetch_podcasts.py >> ${STORAGE_PATH}/logs/fetch.log 2>&1"
|
FETCH_CRON="0 * * * * cd ${INSTALL_DIR} && ./.venv/bin/python fetch_podcasts.py >> ${STORAGE_PATH}/logs/fetch.log 2>&1"
|
||||||
UPDATE_CRON="30 * * * * cd ${INSTALL_DIR} && ./.venv/bin/python update_playlists.py >> ${STORAGE_PATH}/logs/update.log 2>&1"
|
UPDATE_CRON="30 * * * * cd ${INSTALL_DIR} && ./.venv/bin/python update_playlists.py >> ${STORAGE_PATH}/logs/update.log 2>&1"
|
||||||
|
|
||||||
crontab -l 2>/dev/null | grep -vF "fetch_podcasts.py" | grep -vF "update_playlists.py" > /tmp/cron_radio.$$ || true
|
# Remove any stale entries from the liquidsoap user's crontab, then add ours.
|
||||||
echo "$FETCH_CRON" >> /tmp/cron_radio.$$
|
sudo -u "$LIQUIDSOAP_USER" crontab -l 2>/dev/null | grep -vF "fetch_podcasts.py" | grep -vF "update_playlists.py" > /tmp/cron_ls_py.$$ || true
|
||||||
echo "$UPDATE_CRON" >> /tmp/cron_radio.$$
|
echo "$FETCH_CRON" >> /tmp/cron_ls_py.$$
|
||||||
crontab /tmp/cron_radio.$$
|
echo "$UPDATE_CRON" >> /tmp/cron_ls_py.$$
|
||||||
rm -f /tmp/cron_radio.$$
|
sudo -u "$LIQUIDSOAP_USER" crontab /tmp/cron_ls_py.$$
|
||||||
|
rm -f /tmp/cron_ls_py.$$
|
||||||
|
|
||||||
|
# Also scrub these from root's crontab in case an earlier install put them there.
|
||||||
|
crontab -l 2>/dev/null | grep -vF "fetch_podcasts.py" | grep -vF "update_playlists.py" > /tmp/cron_root_py.$$ || true
|
||||||
|
crontab /tmp/cron_root_py.$$
|
||||||
|
rm -f /tmp/cron_root_py.$$
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 9. Enable services
|
# 9. Enable services
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,28 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
update_playlists.py - Select the next unplayed episode per show and write an
|
update_playlists.py - Select the next unplayed episode per show and write an
|
||||||
annotated URI line for station.liq to consume. Runs via cron hourly at :30.
|
annotated URI queue file for station.liq to consume.
|
||||||
|
|
||||||
Selection is driven by the episodes table (keyed on guid + played flag), not
|
Concurrency model mirrors fetch_podcasts.py:
|
||||||
by scanning the filesystem, so it works identically for archived shows
|
- Shares the same exclusive lockfile (state/radio.lock). If the fetcher is
|
||||||
(local file_path) and live shows (remote enclosure_url). All data under the
|
still running, this run logs a skip and exits 0 instead of hitting a
|
||||||
storage path from config.json.
|
locked database.
|
||||||
|
- Databases run in WAL mode with a busy timeout as a second safety net.
|
||||||
|
|
||||||
|
Selection: for each show, pick the earliest episode with played=0, write
|
||||||
|
playlists/<slug>.txt as a single annotated URI line, then mark it played.
|
||||||
|
Works identically for archived (local file_path) and live (enclosure_url)
|
||||||
|
shows because both store their episodes in the same table.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import fcntl
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import sys
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent
|
ROOT = Path(__file__).resolve().parent
|
||||||
|
|
@ -23,23 +32,25 @@ STATE_DIR = None
|
||||||
SUBS_DB = None
|
SUBS_DB = None
|
||||||
PLAYED_DB = None
|
PLAYED_DB = None
|
||||||
PODCASTS_DIR = None
|
PODCASTS_DIR = None
|
||||||
PLAYLISTS_DIR = None
|
|
||||||
LOGS_DIR = None
|
LOGS_DIR = None
|
||||||
|
PLAYLISTS_DIR = None
|
||||||
|
LOCK_FILE = None
|
||||||
|
|
||||||
def load_config():
|
def load_config():
|
||||||
with open(CONFIG_PATH) as f:
|
with open(CONFIG_PATH) as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
|
|
||||||
def init_paths():
|
def init_paths():
|
||||||
global STATE_DIR, SUBS_DB, PLAYED_DB, PODCASTS_DIR, PLAYLISTS_DIR, LOGS_DIR
|
global STATE_DIR, SUBS_DB, PLAYED_DB, PODCASTS_DIR, LOGS_DIR, PLAYLISTS_DIR, LOCK_FILE
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
storage = Path(cfg["storage"]).expanduser().resolve()
|
storage = Path(cfg["storage"]).expanduser().resolve()
|
||||||
STATE_DIR = storage / "state"
|
STATE_DIR = storage / "state"
|
||||||
SUBS_DB = STATE_DIR / "subscriptions.db"
|
SUBS_DB = STATE_DIR / "subscriptions.db"
|
||||||
PLAYED_DB = STATE_DIR / "played.db"
|
PLAYED_DB = STATE_DIR / "played.db"
|
||||||
PODCASTS_DIR = storage / "podcasts"
|
PODCASTS_DIR = storage / "podcasts"
|
||||||
PLAYLISTS_DIR = storage / "playlists"
|
|
||||||
LOGS_DIR = storage / "logs"
|
LOGS_DIR = storage / "logs"
|
||||||
|
PLAYLISTS_DIR = storage / "playlists"
|
||||||
|
LOCK_FILE = STATE_DIR / "radio.lock"
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
|
|
@ -50,125 +61,187 @@ logging.basicConfig(
|
||||||
)
|
)
|
||||||
log = logging.getLogger("update_playlists")
|
log = logging.getLogger("update_playlists")
|
||||||
|
|
||||||
|
def log_error(msg):
|
||||||
|
log.error(msg)
|
||||||
|
|
||||||
def _setup_logging():
|
def _setup_logging():
|
||||||
fh = logging.FileHandler(LOGS_DIR / "update.log")
|
fh = logging.FileHandler(LOGS_DIR / "update.log")
|
||||||
fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
|
fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
|
||||||
log.addHandler(fh)
|
log.addHandler(fh)
|
||||||
|
|
||||||
def open_subs_db():
|
# ---------------------------------------------------------------------------
|
||||||
conn = sqlite3.connect(SUBS_DB)
|
# Shared schema definitions (must match fetch_podcasts.py).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
SHOWS_COLUMNS = {
|
||||||
|
"slug": "TEXT PRIMARY KEY",
|
||||||
|
"guid": "TEXT NOT NULL UNIQUE",
|
||||||
|
"name": "TEXT NOT NULL",
|
||||||
|
"feed_url": "TEXT NOT NULL UNIQUE",
|
||||||
|
"source": "TEXT DEFAULT 'manual'",
|
||||||
|
"opml_import": "INTEGER DEFAULT 0",
|
||||||
|
"archived": "INTEGER DEFAULT 1",
|
||||||
|
"created_at": "TEXT DEFAULT (datetime('now'))",
|
||||||
|
}
|
||||||
|
|
||||||
|
EPISODES_COLUMNS = {
|
||||||
|
"id": "INTEGER PRIMARY KEY AUTOINCREMENT",
|
||||||
|
"show_slug": "TEXT NOT NULL",
|
||||||
|
"guid": "TEXT NOT NULL",
|
||||||
|
"title": "TEXT",
|
||||||
|
"file_path": "TEXT",
|
||||||
|
"enclosure_url": "TEXT",
|
||||||
|
"runlength": "INTEGER",
|
||||||
|
"played": "INTEGER DEFAULT 0",
|
||||||
|
"played_at": "TEXT",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _connect(db_path):
|
||||||
|
conn = sqlite3.connect(str(db_path), timeout=5.0)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
conn.execute("""
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
CREATE TABLE IF NOT EXISTS shows (
|
conn.execute("PRAGMA busy_timeout=5000")
|
||||||
slug TEXT PRIMARY KEY,
|
return conn
|
||||||
guid TEXT NOT NULL UNIQUE,
|
|
||||||
name TEXT NOT NULL,
|
def _ensure_columns(conn, table, columns):
|
||||||
feed_url TEXT NOT NULL UNIQUE,
|
exists = conn.execute(
|
||||||
source TEXT DEFAULT 'manual',
|
"SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table,)
|
||||||
opml_import INTEGER DEFAULT 0,
|
).fetchone()
|
||||||
archived INTEGER DEFAULT 1,
|
if exists is None:
|
||||||
created_at TEXT DEFAULT (datetime('now'))
|
defs = ",\n ".join(f"{name} {spec}" for name, spec in columns.items())
|
||||||
)
|
extra = ""
|
||||||
""")
|
if table == "episodes":
|
||||||
|
extra = "\n ,UNIQUE(show_slug, guid)"
|
||||||
|
conn.execute(f"CREATE TABLE {table} (\n {defs}{extra}\n )")
|
||||||
|
else:
|
||||||
|
existing = {row[1] for row in conn.execute(f"PRAGMA table_info({table})")}
|
||||||
|
for name, spec in columns.items():
|
||||||
|
if name not in existing:
|
||||||
|
col_default = spec.split("DEFAULT", 1)[1].strip() if "DEFAULT" in spec else "NULL"
|
||||||
|
conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {col_default}")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
def open_subs_db():
|
||||||
|
conn = _connect(SUBS_DB)
|
||||||
|
_ensure_columns(conn, "shows", SHOWS_COLUMNS)
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
def open_played_db():
|
def open_played_db():
|
||||||
conn = sqlite3.connect(PLAYED_DB)
|
conn = _connect(PLAYED_DB)
|
||||||
conn.row_factory = sqlite3.Row
|
_ensure_columns(conn, "episodes", EPISODES_COLUMNS)
|
||||||
conn.execute("""
|
conn.execute(
|
||||||
CREATE TABLE IF NOT EXISTS episodes (
|
"CREATE INDEX IF NOT EXISTS idx_episodes_show_played ON episodes (show_slug, played)"
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
)
|
||||||
show_slug TEXT NOT NULL,
|
|
||||||
guid TEXT NOT NULL,
|
|
||||||
title TEXT,
|
|
||||||
file_path TEXT,
|
|
||||||
enclosure_url TEXT,
|
|
||||||
runlength INTEGER,
|
|
||||||
played INTEGER DEFAULT 0,
|
|
||||||
played_at TEXT,
|
|
||||||
UNIQUE(show_slug, guid)
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
def select_unplayed_episode(slug, played_db):
|
# ---------------------------------------------------------------------------
|
||||||
"""Pick the next unplayed episode for a show, keyed by guid.
|
# Mutual exclusion (shared with fetch_podcasts.py via the same lockfile).
|
||||||
Archived -> local file_path; Live -> remote enclosure_url."""
|
# ---------------------------------------------------------------------------
|
||||||
|
_lock_fd = None
|
||||||
|
|
||||||
|
def acquire_lock():
|
||||||
|
global _lock_fd
|
||||||
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd = os.open(str(LOCK_FILE), os.O_CREAT | os.O_RDWR, 0o644)
|
||||||
|
try:
|
||||||
|
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except OSError:
|
||||||
|
os.close(fd)
|
||||||
|
return False
|
||||||
|
os.ftruncate(fd, 0)
|
||||||
|
os.write(fd, str(os.getpid()).encode())
|
||||||
|
_lock_fd = fd
|
||||||
|
return True
|
||||||
|
|
||||||
|
def release_lock():
|
||||||
|
global _lock_fd
|
||||||
|
if _lock_fd is not None:
|
||||||
|
try:
|
||||||
|
fcntl.flock(_lock_fd, fcntl.LOCK_UN)
|
||||||
|
os.close(_lock_fd)
|
||||||
|
finally:
|
||||||
|
_lock_fd = None
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Core logic
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _annotate_uri(runlength, title, uri):
|
||||||
|
"""Build a liquidsoap annotated URI line. Values must be double-quoted
|
||||||
|
and separated by commas; the whole annotation precedes a colon before
|
||||||
|
the URI. Escapes embedded double quotes minimally."""
|
||||||
|
def q(v):
|
||||||
|
return '"' + str(v).replace('"', '\\"') + '"'
|
||||||
|
ann = f"annotate:liq_runlength={q(runlength)},liq_title={q(title)}:"
|
||||||
|
return ann + uri
|
||||||
|
|
||||||
|
def select_next_episode(slug, played_db):
|
||||||
row = played_db.execute(
|
row = played_db.execute(
|
||||||
"SELECT guid, title, file_path, enclosure_url, runlength "
|
"SELECT guid, title, file_path, enclosure_url, runlength "
|
||||||
"FROM episodes WHERE show_slug = ? AND played = 0 ORDER BY id ASC LIMIT 1",
|
"FROM episodes WHERE show_slug=? AND played=0 ORDER BY id ASC LIMIT 1",
|
||||||
(slug,),
|
(slug,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
return row
|
return row
|
||||||
|
|
||||||
def annotated_uri(ep):
|
|
||||||
"""Build the annotate: URI line station.liq consumes.
|
|
||||||
Archived -> local file path; Live -> remote enclosure URL."""
|
|
||||||
uri = ep["file_path"] or ep["enclosure_url"]
|
|
||||||
if not uri:
|
|
||||||
return None
|
|
||||||
rl = int(ep["runlength"] or 0)
|
|
||||||
title = (ep["title"] or "").replace('"', "'")
|
|
||||||
return f'annotate:liq_runlength="{rl}",liq_title="{title}":{uri}'
|
|
||||||
|
|
||||||
def write_queue_line(slug, line, out_path):
|
|
||||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
out_path.write_text(line + "\n")
|
|
||||||
|
|
||||||
def mark_as_played(slug, guid, played_db):
|
def mark_as_played(slug, guid, played_db):
|
||||||
played_db.execute(
|
played_db.execute(
|
||||||
"UPDATE episodes SET played = 1, played_at = datetime('now') WHERE show_slug = ? AND guid = ?",
|
"UPDATE episodes SET played=1, played_at=datetime('now') "
|
||||||
|
"WHERE show_slug=? AND guid=?",
|
||||||
(slug, guid),
|
(slug, guid),
|
||||||
)
|
)
|
||||||
played_db.commit()
|
played_db.commit()
|
||||||
|
|
||||||
|
def write_queue_file(slug, ep, archived):
|
||||||
|
if archived:
|
||||||
|
uri = ep["file_path"]
|
||||||
|
else:
|
||||||
|
uri = ep["enclosure_url"]
|
||||||
|
if not uri:
|
||||||
|
return None
|
||||||
|
line = _annotate_uri(ep["runlength"], ep["title"], uri)
|
||||||
|
out = PLAYLISTS_DIR / f"{slug}.txt"
|
||||||
|
out.write_text(line + "\n")
|
||||||
|
return out
|
||||||
|
|
||||||
def update_all():
|
def update_all():
|
||||||
subs_db = open_subs_db()
|
subs_db = open_subs_db()
|
||||||
played_db = open_played_db()
|
played_db = open_played_db()
|
||||||
shows = subs_db.execute("SELECT slug, name, archived FROM shows ORDER BY name").fetchall()
|
shows = subs_db.execute("SELECT slug, name, archived FROM shows ORDER BY name").fetchall()
|
||||||
queued = 0
|
updated = 0
|
||||||
skipped = 0
|
|
||||||
for show in shows:
|
for show in shows:
|
||||||
slug = show["slug"]
|
slug = show["slug"]
|
||||||
ep = select_unplayed_episode(slug, played_db)
|
archived = show["archived"] == 1
|
||||||
|
ep = select_next_episode(slug, played_db)
|
||||||
if ep is None:
|
if ep is None:
|
||||||
log.info("%s: no unplayed episodes, skipping.", slug)
|
|
||||||
skipped += 1
|
|
||||||
continue
|
continue
|
||||||
line = annotated_uri(ep)
|
out = write_queue_file(slug, ep, archived)
|
||||||
if line is None:
|
if out is None:
|
||||||
log.info("%s: episode %s has no usable URI, skipping.", slug, ep["guid"])
|
log.warning("No playable URI for %s (%s); skipping.", show["name"], slug)
|
||||||
skipped += 1
|
|
||||||
continue
|
continue
|
||||||
out_txt = PLAYLISTS_DIR / f"{slug}.txt"
|
|
||||||
write_queue_line(slug, line, out_txt)
|
|
||||||
mark_as_played(slug, ep["guid"], played_db)
|
mark_as_played(slug, ep["guid"], played_db)
|
||||||
kind = "downloaded" if ep["file_path"] else "live"
|
updated += 1
|
||||||
log.info("%s: queued %s [%s] runlength=%ss", slug, ep["title"], kind, int(ep["runlength"] or 0))
|
log.info("Queued %s: %s -> %s", slug, ep["title"], out.name)
|
||||||
queued += 1
|
|
||||||
subs_db.close()
|
subs_db.close()
|
||||||
played_db.close()
|
played_db.close()
|
||||||
log.info("=== Update complete: %d queued, %d skipped ===", queued, skipped)
|
log.info("=== Update complete: %d show(s) queued ===", updated)
|
||||||
|
|
||||||
def json_summary():
|
def json_summary():
|
||||||
subs_db = open_subs_db()
|
subs_db = open_subs_db()
|
||||||
played_db = open_played_db()
|
played_db = open_played_db()
|
||||||
shows = subs_db.execute("SELECT slug FROM shows ORDER BY name").fetchall()
|
shows = subs_db.execute("SELECT slug, name FROM shows ORDER BY name").fetchall()
|
||||||
summary = {}
|
result = {}
|
||||||
for show in shows:
|
for show in shows:
|
||||||
slug = show["slug"]
|
slug = show["slug"]
|
||||||
total = played_db.execute(
|
counts = played_db.execute(
|
||||||
"SELECT COUNT(*) as c FROM episodes WHERE show_slug = ?", (slug,)
|
"SELECT COUNT(*) AS total, SUM(CASE WHEN played=0 THEN 1 ELSE 0 END) AS unplayed "
|
||||||
).fetchone()["c"]
|
"FROM episodes WHERE show_slug=?",
|
||||||
played = played_db.execute(
|
(slug,),
|
||||||
"SELECT COUNT(*) as c FROM episodes WHERE show_slug = ? AND played = 1", (slug,)
|
).fetchone()
|
||||||
).fetchone()["c"]
|
total = counts["total"] or 0
|
||||||
summary[slug] = {"total_episodes": total, "played_count": played, "unplayed": total - played}
|
unplayed = counts["unplayed"] or 0
|
||||||
|
result[slug] = {"name": show["name"], "total": total, "unplayed": unplayed, "played": total - unplayed}
|
||||||
subs_db.close()
|
subs_db.close()
|
||||||
played_db.close()
|
played_db.close()
|
||||||
print(json.dumps(summary, indent=2))
|
print(json.dumps(result, indent=2))
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="Playlist updater for radio automation")
|
parser = argparse.ArgumentParser(description="Playlist updater for radio automation")
|
||||||
|
|
@ -181,10 +254,21 @@ def main():
|
||||||
PLAYLISTS_DIR.mkdir(parents=True, exist_ok=True)
|
PLAYLISTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
_setup_logging()
|
_setup_logging()
|
||||||
|
|
||||||
if args.json:
|
# --json is read-only and doesn't need the write lock.
|
||||||
json_summary()
|
needs_lock = not args.json
|
||||||
else:
|
|
||||||
update_all()
|
if needs_lock and not acquire_lock():
|
||||||
|
log.info("Another radio process holds the lock; skipping this run.")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
if args.json:
|
||||||
|
json_summary()
|
||||||
|
else:
|
||||||
|
update_all()
|
||||||
|
finally:
|
||||||
|
if needs_lock:
|
||||||
|
release_lock()
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue