Files
conjurer/conjurer_betoniarka/betoniarka.py
T
gitea defc482a22
CI / compile (pull_request) Successful in 10s
CI / unit (pull_request) Successful in 19s
CI / integration (pull_request) Successful in 10s
build / build (push) Failing after 46m40s
CI / compile (push) Successful in 20s
CI / unit (push) Successful in 19s
CI / integration (push) Successful in 16s
fix: batch A - crash bugs, a leak, and startup/edge fragility
Seven confirmed defects from the code audit, each small and low-risk.

* ai_functions.get_random_cyclic_message: random.randint(0, len(CYCLIC_WORDS))
  is inclusive -> could return len -> IndexError. Now randrange(len) + guard on
  an empty CYCLIC_WORDS.
* librarian_commands.get_image_sadox: random.randrange(0, len(res)-1) never
  picked the last comic and raised ValueError('empty range') on a single file.
  Now randrange(len) + an empty-dir guard.
* ai_commands image generation: every DALL-E error branch replied but did not
  return, so control fell through to `if response:` with response unbound ->
  UnboundLocalError right after the friendly message. Each branch now returns;
  response is pre-initialised; and PermissionDeniedError no longer passes a
  (message, text) tuple as a single arg.
* search_bot DOI match: `item["DOI"] in data` was a substring test, so a DOI
  that is a prefix of a longer one (10.1/1 vs 10.1/12) produced a false 'exists'
  hit. Now matches the line's first whitespace token exactly, via an O(1) dict
  index built once per consumer (also removes the O(queried-DOIs) per-line scan
  - a real win for large databases).
* communication_subroutine.scan_incoming: matched records were never removed
  from awaiting_q, so it grew unbounded over uptime and a reused UUID could
  re-match a stale record. Matched records are now dropped after dispatch.
* communication_subroutine.id3: (resp.headers.get("icy-name") or "").title()
  guards against a stream that omits headers (was AttributeError on None,
  500-ing the /prepped_tracks "next" handler).
* betoniarka.scan_tracks: waits for the radio logs to exist instead of dying
  with FileNotFoundError on a fresh deploy (which silently killed the
  now-playing forwarder until a restart).

Verified: tests/unit/test_search_bot.py gains exact-match and trailing-metadata
cases; full unit job 43 passed. Remaining observations (image-gen stale
/home/pi fallback paths + dead FileNotFoundError-after-OSError branch; tailer
still vulnerable to mid-run log rotation; DOI-first-token assumption) noted for
follow-up - none are crashes on the normal path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 23:01:27 +02:00

360 lines
12 KiB
Python

"""Betoniarka - the radio operator service.
Lives in the SAME container as Liquidsoap and runs as the SAME unprivileged
user ('radio'), which is the whole point: the process that writes the radio
playlists is colocated with the process that watches them, so there is no
cross-host permission juggling (root-owned network shares, failing chowns)
anymore.
Responsibilities (extracted from conjurer_musician, which is now a pure
Discord music player):
- scan the local music library into all_playlist.playlist / hit.playlist
- serve the radio-management HTTP API the bot calls
(/add_to_priority, /create_priority_playlist, /request_radio_file,
/clear_pr_pls) plus /ping for health checks and /stream for the web page
- tail radio_log.log / persistence.log and forward "now playing" events to
the bot's /prepped_tracks
"""
import json
import logging
import os
import random
import re
import threading
import time
from datetime import datetime
from pathlib import Path
from typing import Dict, List
import requests
from flask import Flask, abort, jsonify, request, send_file
from waitress import serve
def _env(name: str, default: str) -> str:
return os.getenv(name, default)
API_KEY = os.getenv("CONJURER_API_KEY")
MAIN_BOT_ADDRESS = _env("CONJURER_MAIN_BOT", "http://127.0.0.1:5000")
MUSIC_TRACKER = _env("CONJURER_MUSIC_TRACKER_ENDPOINT", "/prepped_tracks")
HOST_ADDRESS = _env("BETONIARKA_HOST", "0.0.0.0")
PORT_ADDRESS = int(_env("BETONIARKA_PORT", "5005"))
DATA_DIR = Path(_env("BETONIARKA_DATA", "/srv/betoniarka/data"))
MUSIC_FOLDER = Path(_env("BETONIARKA_MUSIC", "/srv/betoniarka/music"))
PRIORITY_FOLDER = Path(_env("BETONIARKA_PRIORITY_FOLDER", str(MUSIC_FOLDER / "priority")))
STREAM_TEMPLATE = _env("BETONIARKA_STREAM_TEMPLATE", "/app/stream.html")
RESCAN_SECONDS = int(_env("BETONIARKA_RESCAN_SECONDS", str(24 * 60 * 60)))
# How many leading path tokens to ignore when keyword-matching
# (/srv/betoniarka/music/... -> '', 'srv', 'betoniarka', 'music').
PATH_SKIP = int(_env("BETONIARKA_PATH_SKIP", "4"))
ALL_PLAYLIST_PATH = DATA_DIR / "all_playlist.playlist"
HIT_PLAYLIST_PATH = DATA_DIR / "hit.playlist"
REQUEST_PLAYLIST_PATH = DATA_DIR / "request.playlist"
PRIORITY_PLAYLIST_PATH = DATA_DIR / "priority_queue.playlist"
RADIOLOG_PATH = DATA_DIR / "radio_log.log"
PERSISTENCE_PATH = DATA_DIR / "persistence.log"
ENCODING = _env("CONJURER_ENCODING", "utf-8")
logger = logging.getLogger("betoniarka")
music_file_list: List[str] = []
priority_list: List[str] = []
app = Flask(__name__)
def _build_headers() -> Dict[str, str]:
if API_KEY:
return {"X-Conjurer-Api-Key": API_KEY}
return {}
def _authorize_request() -> None:
if API_KEY and request.headers.get("X-Conjurer-Api-Key") != API_KEY:
abort(401)
def _post_to_bot(payload: List[str]) -> None:
try:
response = requests.post(
f"{MAIN_BOT_ADDRESS}{MUSIC_TRACKER}",
json=payload,
headers=_build_headers(),
timeout=60,
)
logger.info("Forwarded to bot (%s): %s", response.status_code, payload[0])
except requests.exceptions.RequestException as exc:
logger.warning("Bot unreachable, dropping %s: %s", payload[0], exc)
# ---------------------------------------------------------------- library
def rescan():
"""Scan the local library into the playlists Liquidsoap watches.
Paths written here are LOCAL container paths, the same ones Liquidsoap
resolves - no shared network filesystem involved.
"""
logger.info("Rescan triggered")
music_file_list.clear()
priority_list.clear()
for mp3_item in MUSIC_FOLDER.glob("**/*.mp3"):
music_file_list.append(mp3_item.as_posix())
for mp3_item in PRIORITY_FOLDER.glob("**/*.mp3"):
priority_list.append(mp3_item.as_posix())
with ALL_PLAYLIST_PATH.open("w", encoding=ENCODING) as w_file:
for item in music_file_list:
w_file.write(item + "\n")
with HIT_PLAYLIST_PATH.open("w", encoding=ENCODING) as w_file:
for item in priority_list:
w_file.write(item + "\n")
logger.info("Rescan done: %d tracks, %d hits", len(music_file_list), len(priority_list))
def thread_rescan():
while True:
time.sleep(RESCAN_SECONDS)
rescan()
# ---------------------------------------------------------------- search
def remove_characters(string, character):
return string.replace(character, "")
def max_weight(lista):
maximum_weight = 0
for iterator in lista:
if iterator[0] > maximum_weight:
maximum_weight = iterator[0]
return maximum_weight
_CHAR_REMOVE = [
".", "^", "$", "*", "+", "?", "{", "}", "[", "]",
"\\", "/", "|", "(", ")", "!", ",", "-", ":", "mp3",
]
def wyszukaj(word_list, how_many, _logger=None, write_to=None):
"""Keyword-score the library; optionally append hits to a playlist file.
Ported unchanged from the musician (same scoring), minus the win32
branches. ``write_to`` replaces the old ``return_to_bot`` flag: pass a
playlist Path to append the result, or None to just return it.
"""
fun_logger = _logger or logger
search_weight = [(0, "") for _ in range(len(music_file_list))]
time_start = datetime.now()
skip_start = 2 if int(how_many) > 0 else 1
for word in word_list[skip_start:]:
token_weight = len(word)
fun_logger.info("Słowo kluczowe: %s", word)
for itr, file in enumerate(music_file_list):
parts = file.split("/")
all_words = []
for f_iter in parts:
for char in _CHAR_REMOVE:
f_iter = remove_characters(f_iter, char)
all_words.extend(f_iter.split())
pingu = 1
pattern_len = len(all_words)
matched_times = 1
for itm in all_words[PATH_SKIP:]:
pingu += 1
if re.match(".*" + word + ".*", itm, re.IGNORECASE):
temp_weight = (
search_weight[itr][0]
+ (token_weight + (pingu**1.5) / pattern_len) / matched_times
)
search_weight[itr] = (temp_weight, music_file_list[itr])
matched_times += 1
fun_logger.info("Stworzylem tablice wag zajęło mi to %s", datetime.now() - time_start)
best = max_weight(search_weight)
if best == 0:
return []
return_list = []
if int(how_many) <= 0:
for weight, path in search_weight:
if weight == best:
return_list.append((weight, path))
break
else:
search_weight.sort(key=lambda x: x[0], reverse=True)
return_list.extend(search_weight[: int(how_many)])
if write_to is not None:
with write_to.open("a", encoding=ENCODING) as s_file:
for item in return_list:
s_file.write(item[1] + "\n")
fun_logger.info("Done: %s", return_list)
return return_list
# ---------------------------------------------------------------- tailer
def scan_tracks():
"""Tail the radio logs and forward play events to the bot."""
# On a fresh deploy Liquidsoap may not have written its logs yet; wait for
# them instead of dying with FileNotFoundError, which used to silently kill
# the now-playing forwarder until the container was restarted.
while not (RADIOLOG_PATH.exists() and PERSISTENCE_PATH.exists()):
logger.info("Waiting for radio logs (%s, %s)...", RADIOLOG_PATH, PERSISTENCE_PATH)
time.sleep(5)
with open(RADIOLOG_PATH, "r", encoding=ENCODING) as log_file:
log_file.seek(os.stat(RADIOLOG_PATH).st_size)
prev_size = os.stat(PERSISTENCE_PATH).st_size
while True:
current_size = os.stat(PERSISTENCE_PATH).st_size
if prev_size != current_size:
while prev_size != current_size:
prev_size = current_size
time.sleep(0.1)
current_size = os.stat(PERSISTENCE_PATH).st_size
with open(PERSISTENCE_PATH, "r", encoding=ENCODING) as persistence:
lines = persistence.readlines()
if len(lines) >= 3:
_post_to_bot(["next", lines[2]])
position = log_file.tell()
line = log_file.readline()
if not line:
time.sleep(1)
log_file.seek(position)
continue
if not re.match(r".*Prepared.*", line):
time.sleep(0.1)
continue
result = None
if re.match(r".*jingles.*", line):
result = ["jingles", line]
elif re.match(r".*priority.*", line):
result = ["priority", line]
elif re.match(r".*hit.*", line):
result = ["hit", line]
elif re.match(r".*all_playlist.*", line):
result = ["all", line]
elif re.match(r".*request.*", line):
result = ["requests", line]
if result:
logger.info("Forwarding radio log entry: %s", result[0])
_post_to_bot(result)
time.sleep(0.1)
# ---------------------------------------------------------------- routes
@app.route("/ping", methods=["GET"])
def ping():
"""Health check - the bot gates radio_commands on this answering."""
return jsonify("ALIVE")
@app.route("/stream", methods=["GET"])
def stream_page():
return send_file(STREAM_TEMPLATE)
@app.route("/clear_pr_pls", methods=["GET"])
def clear_pr_pls():
_authorize_request()
app.logger.info("CLEARING PLAYLIST")
with PRIORITY_PLAYLIST_PATH.open("w", encoding=ENCODING) as cleared_pl:
cleared_pl.write("")
return jsonify(isError=False, message="Success", statusCode=200, data=[]), 200
@app.route("/rescan", methods=["GET"])
def manual_rescan():
_authorize_request()
rescan()
return jsonify(isError=False, message="Success", statusCode=200,
data={"tracks": len(music_file_list)}), 200
@app.route("/request_radio_file", methods=["POST"])
def add_request():
_authorize_request()
record = json.loads(request.data)
app.logger.info(record)
wyszukaj(record["lista_slow"], 0, app.logger, write_to=REQUEST_PLAYLIST_PATH)
return jsonify(isError=False, message="Success", statusCode=200,
data={"status": "OK"}), 200
@app.route("/create_priority_playlist", methods=["POST"])
def create_priority_playlist():
_authorize_request()
record = json.loads(request.data)
app.logger.info(record)
return_data = wyszukaj(
record["lista_slow"], record["dlugosc_plejlisty"], app.logger, write_to=None
)
random.shuffle(return_data)
# NOTE: appends to the REQUEST playlist - behaviour inherited verbatim
# from the musician implementation (the request queue picks it up).
with REQUEST_PLAYLIST_PATH.open("a", encoding=ENCODING) as s_file:
for item in return_data:
s_file.write(item[1] + "\n")
return jsonify(isError=False, message="Success", statusCode=200,
data={"status": "OK"}), 200
@app.route("/add_to_priority", methods=["POST"])
def add_to_priority():
_authorize_request()
record = json.loads(request.data)
app.logger.info(record)
wyszukaj(
record["lista_slow"], record["dlugosc_plejlisty"], app.logger,
write_to=PRIORITY_PLAYLIST_PATH,
)
return jsonify(isError=False, message="Success", statusCode=200,
data={"status": "OK"}), 200
def waitress_run():
serve(app, host=HOST_ADDRESS, port=PORT_ADDRESS)
if __name__ == "__main__":
logger.setLevel(logging.DEBUG)
console = logging.StreamHandler()
console.setFormatter(
logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
)
logger.addHandler(console)
rescan()
logger.info("Betoniarka started on %s:%s", HOST_ADDRESS, PORT_ADDRESS)
threads = [
threading.Thread(target=waitress_run, daemon=True),
threading.Thread(target=thread_rescan, daemon=True),
]
for worker in threads:
worker.start()
time.sleep(5)
track_thread = threading.Thread(target=scan_tracks, daemon=True)
track_thread.start()
try:
for worker in threads:
worker.join()
track_thread.join()
except KeyboardInterrupt:
logger.info("Shutdown requested - exiting betoniarka")