mirror of
https://github.com/migatu/conjurer.git
synced 2026-07-14 21:38:38 +00:00
split: betoniarka (radio operator) colocated with liquidsoap; musician goes Discord-only
Permissions post-mortem that motivated this: the musician wrote radio playlists AS ROOT onto a ROOT-OWNED network share which liquidsoap then read AS USER 'radio' - chown fails on such shares by design (root squash / uid mapping), so the radio came up and died on the playlists. The fix is structural: the playlist WRITER now lives in the same container as the READER, as the same user, on a local volume. No shared partition, no chown, no uid mapping. New: conjurer_betoniarka/betoniarka.py - runs inside the radio container (started by the entrypoint as user 'radio', port 5005): - library scan -> all_playlist/hit playlists with LOCAL container paths (start + every 24h + authenticated GET /rescan) - bot-facing radio API moved from the musician: /add_to_priority, /create_priority_playlist, /request_radio_file, /clear_pr_pls, plus GET /ping (health) and /stream (web page) - radio_log/persistence tailer forwarding play events to the bot's /prepped_tracks with the shared API key (bot-unreachable = logged, not fatal) Musician: pure Discord music player now - keeps /mp3, /update_mp3, /get_music and the file-share endpoints; all radio playlist writing, radio paths/env and the tailer removed. Bot: new CONJURER_RADIO_SERVICE (defaults to CONJURER_FILE_SERVICE so un-split deployments keep working); radio_commands targets it; separate 'radio' health-gate group on betoniarka /ping (musician group now covers music_commands + file_search_commands only). Docker: betoniarka baked into the radio image (python3 + flask/waitress/ requests from Debian debs), port 5005 exposed, entrypoint starts it via setpriv as 'radio'; data-volume chown is now best-effort with a loud warning (keep the volume local); docs get the post-mortem + wiring. Verified: py_compile everything; functional stub tests - rescan writes local-path playlists, wyszukaj scores and appends to priority, auth 401/ok, tailer forward carries the API key. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,7 @@ from constants import (
|
|||||||
GET_MP3,
|
GET_MP3,
|
||||||
LIBRARIAN_SERVICE_ADDRESS,
|
LIBRARIAN_SERVICE_ADDRESS,
|
||||||
LOGFILE,
|
LOGFILE,
|
||||||
|
RADIO_SERVICE_ADDRESS,
|
||||||
TOKEN,
|
TOKEN,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -96,10 +97,15 @@ CORE_EXTENSIONS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
SERVICE_EXTENSION_GROUPS = {
|
SERVICE_EXTENSION_GROUPS = {
|
||||||
# musician (file service): music download/search, radio control, file shares
|
# musician (file service): Discord music download/search, file shares
|
||||||
"musician": {
|
"musician": {
|
||||||
"health_url": f"{FILE_SERVICE_ADDRESS}{GET_MP3}",
|
"health_url": f"{FILE_SERVICE_ADDRESS}{GET_MP3}",
|
||||||
"extensions": ["music_commands", "radio_commands", "file_search_commands"],
|
"extensions": ["music_commands", "file_search_commands"],
|
||||||
|
},
|
||||||
|
# betoniarka (radio operator colocated with Liquidsoap): radio playlists
|
||||||
|
"radio": {
|
||||||
|
"health_url": f"{RADIO_SERVICE_ADDRESS}/ping",
|
||||||
|
"extensions": ["radio_commands"],
|
||||||
},
|
},
|
||||||
# librarian: DOI / Crossref search
|
# librarian: DOI / Crossref search
|
||||||
"librarian": {
|
"librarian": {
|
||||||
|
|||||||
@@ -0,0 +1,353 @@
|
|||||||
|
"""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."""
|
||||||
|
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")
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
"""
|
"""Musician - the Discord music player service.
|
||||||
The provided Python script sets up a Flask web server to manage a list of music files, with
|
|
||||||
functions for rescanning the music folder, updating the music list, and serving the music list via
|
Serves the music library index and keyword search the bot uses for Discord
|
||||||
API endpoints.
|
playback (/mp3, /update_mp3, /get_music) plus the file-share endpoints.
|
||||||
|
|
||||||
|
Radio playlist management and the radio-log tailer moved to
|
||||||
|
conjurer_betoniarka/betoniarka.py, which runs INSIDE the radio container as
|
||||||
|
the same user as Liquidsoap - the musician no longer writes any radio files,
|
||||||
|
so the old root-owned-network-share permission mess is gone.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -14,18 +19,9 @@ import time
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from logging import handlers
|
from logging import handlers
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List
|
from typing import List
|
||||||
|
|
||||||
import requests
|
from flask import Flask, abort, jsonify, request
|
||||||
from flask import (
|
|
||||||
Flask,
|
|
||||||
abort,
|
|
||||||
jsonify,
|
|
||||||
redirect,
|
|
||||||
render_template,
|
|
||||||
request,
|
|
||||||
send_from_directory,
|
|
||||||
)
|
|
||||||
from waitress import serve
|
from waitress import serve
|
||||||
|
|
||||||
import media_search_functions
|
import media_search_functions
|
||||||
@@ -41,8 +37,6 @@ def _env_path(name: str, default: str) -> Path:
|
|||||||
|
|
||||||
|
|
||||||
API_KEY = os.getenv("CONJURER_API_KEY")
|
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("CONJURER_MUSICIAN_HOST", "0.0.0.0")
|
HOST_ADDRESS = _env("CONJURER_MUSICIAN_HOST", "0.0.0.0")
|
||||||
PORT_ADDRESS = int(_env("CONJURER_MUSICIAN_PORT", "5000"))
|
PORT_ADDRESS = int(_env("CONJURER_MUSICIAN_PORT", "5000"))
|
||||||
|
|
||||||
@@ -56,52 +50,12 @@ LOGSTORE = _env_path("CONJURER_LOGSTORE", str(BASE_DIR / "logs"))
|
|||||||
MUSIC_FOLDER = _env_path(
|
MUSIC_FOLDER = _env_path(
|
||||||
"CONJURER_MUSIC_FOLDER", str(BASE_DIR / "music")
|
"CONJURER_MUSIC_FOLDER", str(BASE_DIR / "music")
|
||||||
)
|
)
|
||||||
PRIORITY_FOLDER = _env_path(
|
|
||||||
"CONJURER_PRIORITY_FOLDER", str(MUSIC_FOLDER / "priority")
|
|
||||||
)
|
|
||||||
RADIOLOG_PATH = _env_path(
|
|
||||||
"CONJURER_RADIO_LOG", str(BASE_DIR / "radio_log.log")
|
|
||||||
)
|
|
||||||
PERSISTENCE_PATH = _env_path(
|
|
||||||
"CONJURER_PERSISTENCE_LOG", str(BASE_DIR / "persistence.log")
|
|
||||||
)
|
|
||||||
ALL_PLAYLIST_PATH = _env_path(
|
|
||||||
"CONJURER_ALL_PLAYLIST", str(BASE_DIR / "all_playlist.playlist")
|
|
||||||
)
|
|
||||||
HIT_PLAYLIST_PATH = _env_path(
|
|
||||||
"CONJURER_HIT_PLAYLIST", str(BASE_DIR / "hit.playlist")
|
|
||||||
)
|
|
||||||
REQUEST_PLAYLIST_PATH = _env_path(
|
|
||||||
"CONJURER_REQUEST_PLAYLIST", str(BASE_DIR / "request.playlist")
|
|
||||||
)
|
|
||||||
PRIORITY_PLAYLIST_PATH = _env_path(
|
|
||||||
"CONJURER_PRIORITY_PLAYLIST", str(BASE_DIR / "priority_queue.playlist")
|
|
||||||
)
|
|
||||||
STREAM_TEMPLATE = _env_path(
|
|
||||||
"CONJURER_STREAM_TEMPLATE", str(BASE_DIR / "stream.html")
|
|
||||||
)
|
|
||||||
|
|
||||||
ENCODING = _env("CONJURER_ENCODING", "utf-8")
|
ENCODING = _env("CONJURER_ENCODING", "utf-8")
|
||||||
SEPARATOR_FILE_PATH = os.sep
|
SEPARATOR_FILE_PATH = os.sep
|
||||||
|
|
||||||
for playlist_path in (
|
|
||||||
ALL_PLAYLIST_PATH,
|
|
||||||
HIT_PLAYLIST_PATH,
|
|
||||||
REQUEST_PLAYLIST_PATH,
|
|
||||||
PRIORITY_PLAYLIST_PATH,
|
|
||||||
):
|
|
||||||
playlist_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
random.seed()
|
random.seed()
|
||||||
music_file_list: List[str] = []
|
music_file_list: List[str] = []
|
||||||
priority_list: List[str] = []
|
|
||||||
|
|
||||||
|
|
||||||
def _build_headers() -> Dict[str, str]:
|
|
||||||
headers: Dict[str, str] = {}
|
|
||||||
if API_KEY:
|
|
||||||
headers["X-Conjurer-Api-Key"] = API_KEY
|
|
||||||
return headers
|
|
||||||
|
|
||||||
|
|
||||||
def _authorize_request() -> None:
|
def _authorize_request() -> None:
|
||||||
@@ -109,29 +63,16 @@ def _authorize_request() -> None:
|
|||||||
abort(401)
|
abort(401)
|
||||||
|
|
||||||
|
|
||||||
def _post_to_bot(payload: List[str]) -> None:
|
|
||||||
response = requests.post(
|
|
||||||
f"{MAIN_BOT_ADDRESS}{MUSIC_TRACKER}",
|
|
||||||
json=payload,
|
|
||||||
headers=_build_headers(),
|
|
||||||
timeout=60,
|
|
||||||
)
|
|
||||||
logger = logging.getLogger("conjurer_musician")
|
|
||||||
logger.info("SENT")
|
|
||||||
logger.info(response.status_code)
|
|
||||||
logger.info("SEND CONFIRMED")
|
|
||||||
|
|
||||||
|
|
||||||
def rescan():
|
def rescan():
|
||||||
"""
|
"""Refresh the in-memory library index used by /mp3 and /get_music.
|
||||||
The `rescan` function logs a message, scans for mp3 files in a specified folder,
|
|
||||||
and adds them to a list of music files.
|
Radio playlist files are NOT written here anymore - that is the
|
||||||
|
betoniarka's job, colocated with Liquidsoap.
|
||||||
"""
|
"""
|
||||||
logger = logging.getLogger("conjurer_musician")
|
logger = logging.getLogger("conjurer_musician")
|
||||||
logger.info("Rescan triggered")
|
logger.info("Rescan triggered")
|
||||||
|
|
||||||
music_file_list.clear()
|
music_file_list.clear()
|
||||||
priority_list.clear()
|
|
||||||
|
|
||||||
for mp3_item in MUSIC_FOLDER.glob("**/*.mp3"):
|
for mp3_item in MUSIC_FOLDER.glob("**/*.mp3"):
|
||||||
temp_music_file = mp3_item.as_posix()
|
temp_music_file = mp3_item.as_posix()
|
||||||
@@ -139,27 +80,6 @@ def rescan():
|
|||||||
temp_music_file = temp_music_file.replace("/", "\\")
|
temp_music_file = temp_music_file.replace("/", "\\")
|
||||||
music_file_list.append(temp_music_file)
|
music_file_list.append(temp_music_file)
|
||||||
|
|
||||||
for mp3_item in PRIORITY_FOLDER.glob("**/*.mp3"):
|
|
||||||
temp_music_file = mp3_item.as_posix()
|
|
||||||
if os.name == "nt":
|
|
||||||
temp_music_file = temp_music_file.replace("/", "\\")
|
|
||||||
priority_list.append(temp_music_file)
|
|
||||||
|
|
||||||
with ALL_PLAYLIST_PATH.open("w", encoding=ENCODING) as w_file:
|
|
||||||
try:
|
|
||||||
for item in music_file_list:
|
|
||||||
w_file.write(item)
|
|
||||||
w_file.write("\n")
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
pass
|
|
||||||
with HIT_PLAYLIST_PATH.open("w", encoding=ENCODING) as w_file:
|
|
||||||
try:
|
|
||||||
for item in priority_list:
|
|
||||||
w_file.write(item)
|
|
||||||
w_file.write("\n")
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def thread_rescan():
|
def thread_rescan():
|
||||||
"""
|
"""
|
||||||
@@ -174,55 +94,6 @@ def thread_rescan():
|
|||||||
rescan()
|
rescan()
|
||||||
|
|
||||||
|
|
||||||
def scan_tracks():
|
|
||||||
# Set the filename and open the file
|
|
||||||
logger = logging.getLogger("conjurer_musician")
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
# AutoIndex(app, browse_root="/")
|
# AutoIndex(app, browse_root="/")
|
||||||
|
|
||||||
@@ -328,9 +199,6 @@ def wyszukaj(word_list, how_many, _logger=None, return_to_bot=True):
|
|||||||
while not_found:
|
while not_found:
|
||||||
if search_weight[itr][0] == item_to_search:
|
if search_weight[itr][0] == item_to_search:
|
||||||
return_list.append(search_weight[itr])
|
return_list.append(search_weight[itr])
|
||||||
if not return_to_bot:
|
|
||||||
with PRIORITY_PLAYLIST_PATH.open("a", encoding=ENCODING) as s_file:
|
|
||||||
s_file.write(search_weight[itr][1] + "\n")
|
|
||||||
break
|
break
|
||||||
itr += 1
|
itr += 1
|
||||||
else:
|
else:
|
||||||
@@ -401,62 +269,6 @@ def get_share_links():
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/stream", methods=["GET"])
|
|
||||||
def stream_music():
|
|
||||||
"""
|
|
||||||
The function `stream_music` is a route handler for the "/stream" endpoint.
|
|
||||||
It returns a JSON response containing a key "music_file_list" with the value of the variable `music_file_list`.
|
|
||||||
|
|
||||||
:return: A JSON response containing a key "music_file_list" with the value of the variable `music_file_list`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# return send_from_directory("/tmp/hls", "stream.m3u8")
|
|
||||||
return render_template(str(STREAM_TEMPLATE))
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/<string:file_name>")
|
|
||||||
def stream(file_name):
|
|
||||||
"""
|
|
||||||
Stream the specified file from the video directory.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
file_name (str): The name of the file to be streamed.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Response: The response containing the streamed file.
|
|
||||||
"""
|
|
||||||
# trunk-ignore(bandit/B108)
|
|
||||||
video_dir = "/tmp/hls"
|
|
||||||
return send_from_directory(video_dir, file_name)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/stream_mp3", methods=["GET"])
|
|
||||||
def stream_music_mp3():
|
|
||||||
"""
|
|
||||||
The function `stream_music_mp3` is a route handler for the "/stream_mp3" endpoint.
|
|
||||||
It redirects the user to the URL "http://www.example.com" with a status code of 302.
|
|
||||||
|
|
||||||
:return: A redirect response to "http://www.example.com" with a status code of 302.
|
|
||||||
"""
|
|
||||||
return redirect("http://www.example.com", code=302)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/clear_pr_pls", methods=["GET"])
|
|
||||||
def clear_pr_pls():
|
|
||||||
_authorize_request()
|
|
||||||
"""
|
|
||||||
The function `clear_pr_pls` clears the contents of the priority queue playlist file.
|
|
||||||
|
|
||||||
:return: A JSON response indicating the success of the operation.
|
|
||||||
"""
|
|
||||||
app.logger.info("CLEARING PLAYLIST")
|
|
||||||
with PRIORITY_PLAYLIST_PATH.open("w", encoding=ENCODING) as cleared_pl:
|
|
||||||
cleared_pl.write("")
|
|
||||||
|
|
||||||
return_data = jsonify(isError=False, message="Success", statusCode=200, data=[])
|
|
||||||
return return_data, 200
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/mp3", methods=["GET"])
|
@app.route("/mp3", methods=["GET"])
|
||||||
def get_music_list():
|
def get_music_list():
|
||||||
"""
|
"""
|
||||||
@@ -517,96 +329,6 @@ def look_for_playlist():
|
|||||||
return return_data
|
return return_data
|
||||||
|
|
||||||
|
|
||||||
@app.route("/request_radio_file", methods=["POST"])
|
|
||||||
def add_request():
|
|
||||||
_authorize_request()
|
|
||||||
record = json.loads(request.data)
|
|
||||||
app.logger.info(record)
|
|
||||||
app.logger.info(record["lista_slow"])
|
|
||||||
app.logger.info(record["UUID"])
|
|
||||||
return_data = wyszukaj(record["lista_slow"], 0, app.logger, False)
|
|
||||||
|
|
||||||
with REQUEST_PLAYLIST_PATH.open("a", encoding=ENCODING) as s_file:
|
|
||||||
for item in return_data:
|
|
||||||
s_file.write(item[1] + "\n")
|
|
||||||
return_data = (
|
|
||||||
jsonify(
|
|
||||||
isError=False, message="Success", statusCode=200, data={"status": "OK"}
|
|
||||||
),
|
|
||||||
200,
|
|
||||||
)
|
|
||||||
return return_data
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/create_priority_playlist", methods=["POST"])
|
|
||||||
def create_priority_playlist():
|
|
||||||
"""
|
|
||||||
The function `create_priority_playlist` receives a POST request with a JSON payload, logs the received
|
|
||||||
item, adds it to a music file list, and returns a success message along with the updated record.
|
|
||||||
|
|
||||||
:return: A tuple containing a JSON response and a status code.
|
|
||||||
The JSON response includes keys `isError`,
|
|
||||||
`message`, `statusCode`, and `data`, with values
|
|
||||||
indicating the success of the operation and the
|
|
||||||
data that was received and added to the `music_file_list`.
|
|
||||||
The status code returned is 200,indicating a successful response.
|
|
||||||
"""
|
|
||||||
_authorize_request()
|
|
||||||
record = json.loads(request.data)
|
|
||||||
app.logger.info(record)
|
|
||||||
app.logger.info(record["lista_slow"])
|
|
||||||
app.logger.info(record["UUID"])
|
|
||||||
app.logger.info(record["dlugosc_plejlisty"])
|
|
||||||
return_data = wyszukaj(
|
|
||||||
record["lista_slow"], record["dlugosc_plejlisty"], app.logger, False
|
|
||||||
)
|
|
||||||
random.shuffle(return_data)
|
|
||||||
with REQUEST_PLAYLIST_PATH.open("a", encoding=ENCODING) as s_file:
|
|
||||||
for item in return_data:
|
|
||||||
s_file.write(item[1] + "\n")
|
|
||||||
return_data = (
|
|
||||||
jsonify(
|
|
||||||
isError=False, message="Success", statusCode=200, data={"status": "OK"}
|
|
||||||
),
|
|
||||||
200,
|
|
||||||
)
|
|
||||||
return return_data
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/add_to_priority", methods=["POST"])
|
|
||||||
def add_to_priority():
|
|
||||||
"""
|
|
||||||
The function `add_to_priority` receives a POST request with a JSON payload, logs the received
|
|
||||||
item, adds it to a music file list, and returns a success message along with the updated record.
|
|
||||||
|
|
||||||
:return: A tuple containing a JSON response and a status code.
|
|
||||||
The JSON response includes keys `isError`,
|
|
||||||
`message`, `statusCode`, and `data`, with values
|
|
||||||
indicating the success of the operation and the
|
|
||||||
data that was received and added to the `music_file_list`.
|
|
||||||
The status code returned is 200,indicating a successful response.
|
|
||||||
"""
|
|
||||||
_authorize_request()
|
|
||||||
record = json.loads(request.data)
|
|
||||||
app.logger.info(record)
|
|
||||||
app.logger.info(record["lista_slow"])
|
|
||||||
app.logger.info(record["UUID"])
|
|
||||||
app.logger.info(record["dlugosc_plejlisty"])
|
|
||||||
return_data = wyszukaj(
|
|
||||||
record["lista_slow"], record["dlugosc_plejlisty"], app.logger, False
|
|
||||||
)
|
|
||||||
with PRIORITY_PLAYLIST_PATH.open("a", encoding=ENCODING) as s_file:
|
|
||||||
for item in return_data:
|
|
||||||
s_file.write(item[1] + "\n")
|
|
||||||
return_data = (
|
|
||||||
jsonify(
|
|
||||||
isError=False, message="Success", statusCode=200, data={"status": "OK"}
|
|
||||||
),
|
|
||||||
200,
|
|
||||||
)
|
|
||||||
return return_data
|
|
||||||
|
|
||||||
|
|
||||||
def flask_debug():
|
def flask_debug():
|
||||||
"""
|
"""
|
||||||
The `flask_debug` function starts a Flask application in debug mode without using the reloader.
|
The `flask_debug` function starts a Flask application in debug mode without using the reloader.
|
||||||
@@ -660,13 +382,8 @@ if __name__ == "__main__":
|
|||||||
for worker in threads:
|
for worker in threads:
|
||||||
worker.start()
|
worker.start()
|
||||||
|
|
||||||
time.sleep(60)
|
|
||||||
track_thread = threading.Thread(target=scan_tracks, daemon=True)
|
|
||||||
track_thread.start()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for worker in threads:
|
for worker in threads:
|
||||||
worker.join()
|
worker.join()
|
||||||
track_thread.join()
|
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
logger.info("Shutdown requested - exiting musician service")
|
logger.info("Shutdown requested - exiting musician service")
|
||||||
|
|||||||
@@ -198,6 +198,9 @@ TRANSCRIPTS_PATH = os.getenv(
|
|||||||
|
|
||||||
FILE_SERVICE_ADDRESS = os.getenv("CONJURER_FILE_SERVICE", "http://192.168.1.15:5000")
|
FILE_SERVICE_ADDRESS = os.getenv("CONJURER_FILE_SERVICE", "http://192.168.1.15:5000")
|
||||||
RADIO_HARBOR_ADDRESS = os.getenv("CONJURER_RADIO_HARBOR", "http://192.168.1.15:54321")
|
RADIO_HARBOR_ADDRESS = os.getenv("CONJURER_RADIO_HARBOR", "http://192.168.1.15:54321")
|
||||||
|
# Betoniarka (radio-operator service colocated with Liquidsoap). Falls back to
|
||||||
|
# the musician address so deployments that have not split yet keep working.
|
||||||
|
RADIO_SERVICE_ADDRESS = os.getenv("CONJURER_RADIO_SERVICE", FILE_SERVICE_ADDRESS)
|
||||||
SKIP_TRACK = os.getenv("CONJURER_SKIP_ENDPOINT", "/skip")
|
SKIP_TRACK = os.getenv("CONJURER_SKIP_ENDPOINT", "/skip")
|
||||||
LIBRARIAN_SERVICE_ADDRESS = os.getenv(
|
LIBRARIAN_SERVICE_ADDRESS = os.getenv(
|
||||||
"CONJURER_LIBRARIAN_SERVICE", "http://192.168.1.192:5001"
|
"CONJURER_LIBRARIAN_SERVICE", "http://192.168.1.192:5001"
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
ffmpeg \
|
ffmpeg \
|
||||||
pulseaudio pulseaudio-utils \
|
pulseaudio pulseaudio-utils \
|
||||||
icecast2 jq \
|
icecast2 jq \
|
||||||
|
python3 python3-flask python3-waitress python3-requests \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Liquidsoap refuses to run as root (security exit), so it gets a dedicated
|
# Liquidsoap refuses to run as root (security exit), so it gets a dedicated
|
||||||
@@ -88,6 +89,8 @@ COPY docker/pulse-system.pa /etc/pulse/system.pa
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY conjurer_musician/radio_conjurer.liq ./radio_conjurer.liq
|
COPY conjurer_musician/radio_conjurer.liq ./radio_conjurer.liq
|
||||||
COPY conjurer_musician/script.params ./script.params
|
COPY conjurer_musician/script.params ./script.params
|
||||||
|
COPY conjurer_musician/stream.html ./stream.html
|
||||||
|
COPY conjurer_betoniarka/betoniarka.py ./betoniarka.py
|
||||||
COPY docker/icecast.xml.tpl ./icecast.xml.tpl
|
COPY docker/icecast.xml.tpl ./icecast.xml.tpl
|
||||||
COPY docker/entrypoint.radio.sh /usr/local/bin/entrypoint.sh
|
COPY docker/entrypoint.radio.sh /usr/local/bin/entrypoint.sh
|
||||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||||
@@ -98,9 +101,10 @@ RUN chmod +x /usr/local/bin/entrypoint.sh
|
|||||||
# /srv/betoniarka/secrets - icecast_credentials.json (provisioned at install)
|
# /srv/betoniarka/secrets - icecast_credentials.json (provisioned at install)
|
||||||
VOLUME ["/srv/betoniarka/data", "/srv/betoniarka/music", "/srv/betoniarka/secrets"]
|
VOLUME ["/srv/betoniarka/data", "/srv/betoniarka/music", "/srv/betoniarka/secrets"]
|
||||||
|
|
||||||
# 8000 = icecast (listeners), 54321 = harbor /skip (the bot's RADIO_HARBOR),
|
# 8000 = icecast (listeners), 5005 = betoniarka HTTP API (the bot's
|
||||||
|
# CONJURER_RADIO_SERVICE), 54321 = harbor /skip (the bot's RADIO_HARBOR),
|
||||||
# 9999 = interactive harbor.
|
# 9999 = interactive harbor.
|
||||||
EXPOSE 8000 54321 9999
|
EXPOSE 8000 5005 54321 9999
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||||
CMD ["liquidsoap", "/srv/betoniarka/data/radio_conjurer.liq"]
|
CMD ["liquidsoap", "/srv/betoniarka/data/radio_conjurer.liq"]
|
||||||
|
|||||||
@@ -23,8 +23,12 @@ services:
|
|||||||
# PULSE_SERVER: unix:/tmp/pulseaudio.socket
|
# PULSE_SERVER: unix:/tmp/pulseaudio.socket
|
||||||
# Hostname icecast reports in its status/YP pages:
|
# Hostname icecast reports in its status/YP pages:
|
||||||
ICECAST_HOSTNAME: ${ICECAST_HOSTNAME:-localhost}
|
ICECAST_HOSTNAME: ${ICECAST_HOSTNAME:-localhost}
|
||||||
|
# Betoniarka (radio-operator API + log forwarder):
|
||||||
|
CONJURER_MAIN_BOT: ${CONJURER_MAIN_BOT:-http://127.0.0.1:5000}
|
||||||
|
CONJURER_API_KEY: ${CONJURER_API_KEY:-}
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000" # icecast - listeners tune in here
|
- "8000:8000" # icecast - listeners tune in here
|
||||||
|
- "5005:5005" # betoniarka API - the bot's CONJURER_RADIO_SERVICE
|
||||||
- "54321:54321" # harbor /skip - the bot's CONJURER_RADIO_HARBOR
|
- "54321:54321" # harbor /skip - the bot's CONJURER_RADIO_HARBOR
|
||||||
- "9999:9999" # interactive harbor (keep LAN-only!)
|
- "9999:9999" # interactive harbor (keep LAN-only!)
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -91,13 +91,23 @@ esac
|
|||||||
|
|
||||||
# Liquidsoap refuses to run as root (init: security exit), so hand the data
|
# Liquidsoap refuses to run as root (init: security exit), so hand the data
|
||||||
# volume to the dedicated 'radio' user and drop privileges for the main
|
# volume to the dedicated 'radio' user and drop privileges for the main
|
||||||
# process. The script parses the icecast secret directly, so make that one
|
# process. chown is best-effort: on local volumes it always works (the
|
||||||
# file group-readable for 'radio' (kept 640, root-owned).
|
# supported layout); network filesystems with root-squash reject it, hence
|
||||||
chown -R radio:radio "$DATA"
|
# the warning instead of a fatal abort.
|
||||||
|
chown -R radio:radio "$DATA" 2>/dev/null \
|
||||||
|
|| echo "WARNING: chown of $DATA failed (network FS?) - keep this volume LOCAL to the radio VM" >&2
|
||||||
chgrp radio "$CREDS" 2>/dev/null && chmod 640 "$CREDS" || true
|
chgrp radio "$CREDS" 2>/dev/null && chmod 640 "$CREDS" || true
|
||||||
if ! setpriv --reuid radio --regid radio --init-groups -- test -r "$MUSIC"; then
|
if ! setpriv --reuid radio --regid radio --init-groups -- test -r "$MUSIC"; then
|
||||||
echo "WARNING: music dir $MUSIC is not readable by the 'radio' user" >&2
|
echo "WARNING: music dir $MUSIC is not readable by the 'radio' user" >&2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Betoniarka: the radio-operator API + radio-log forwarder, running as the
|
||||||
|
# SAME user as liquidsoap on the SAME volume - this is what makes the old
|
||||||
|
# musician-writes-as-root-over-network-share permission mess go away.
|
||||||
|
BETONIARKA_DATA="$DATA" BETONIARKA_MUSIC="$MUSIC" \
|
||||||
|
setpriv --reuid radio --regid radio --init-groups -- \
|
||||||
|
python3 /app/betoniarka.py &
|
||||||
|
echo "betoniarka started (pid $!)"
|
||||||
|
|
||||||
cd "$DATA"
|
cd "$DATA"
|
||||||
exec setpriv --reuid radio --regid radio --init-groups -- "$@"
|
exec setpriv --reuid radio --regid radio --init-groups -- "$@"
|
||||||
|
|||||||
Vendored
+4
-1
@@ -24,7 +24,10 @@ CONJURER_API_KEY=
|
|||||||
|
|
||||||
# --- Where the bot reaches the other services (other Proxmox VMs) --------
|
# --- Where the bot reaches the other services (other Proxmox VMs) --------
|
||||||
CONJURER_FILE_SERVICE=http://MUSICIAN_VM_IP:5000
|
CONJURER_FILE_SERVICE=http://MUSICIAN_VM_IP:5000
|
||||||
CONJURER_RADIO_HARBOR=http://MUSICIAN_VM_IP:54321
|
# Betoniarka (radio-operator API, runs in the radio container):
|
||||||
|
CONJURER_RADIO_SERVICE=http://RADIO_VM_IP:5005
|
||||||
|
# Liquidsoap harbor /skip (same radio container):
|
||||||
|
CONJURER_RADIO_HARBOR=http://RADIO_VM_IP:54321
|
||||||
CONJURER_LIBRARIAN_SERVICE=http://LIBRARIAN_VM_IP:5001
|
CONJURER_LIBRARIAN_SERVICE=http://LIBRARIAN_VM_IP:5001
|
||||||
|
|
||||||
# --- Conan Exiles bridge (optional; empty/0 = disabled) -----------------
|
# --- Conan Exiles bridge (optional; empty/0 = disabled) -----------------
|
||||||
|
|||||||
@@ -270,7 +270,48 @@ created empty and a silent emergency-fallback mp3 is generated if the
|
|||||||
|
|
||||||
Wire-up: listeners tune to `http://RADIO_VM_IP:8000/mp3-stream`; the bot
|
Wire-up: listeners tune to `http://RADIO_VM_IP:8000/mp3-stream`; the bot
|
||||||
points at `CONJURER_RADIO_HARBOR=http://RADIO_VM_IP:54321` (harbor `/skip`
|
points at `CONJURER_RADIO_HARBOR=http://RADIO_VM_IP:54321` (harbor `/skip`
|
||||||
lives in the script itself).
|
lives in the script itself) and `CONJURER_RADIO_SERVICE=http://RADIO_VM_IP:5005`
|
||||||
|
(the betoniarka API below).
|
||||||
|
|
||||||
|
### 3f. Betoniarka - the radio operator (and why the split)
|
||||||
|
|
||||||
|
**Permissions post-mortem.** The pre-split layout had three actors fighting
|
||||||
|
over the same files: the musician wrote radio playlists **as root**, the
|
||||||
|
radio expected them **as user `radio`**, and both met on a **root-owned
|
||||||
|
network share** where `chown` fails by design (root squash / uid mapping).
|
||||||
|
Every component worked; the combination could not.
|
||||||
|
|
||||||
|
**The fix is structural**: the process that *writes* the radio playlists now
|
||||||
|
lives in the same container as the process that *watches* them, running as
|
||||||
|
the same `radio` user on a **local** volume. No network share, no chown, no
|
||||||
|
uid mapping - the class of problem is gone, not patched.
|
||||||
|
|
||||||
|
`conjurer_betoniarka/betoniarka.py` runs inside the radio container
|
||||||
|
(started by the entrypoint as user `radio`, port **5005**) and owns:
|
||||||
|
- the library scan → `all_playlist.playlist` / `hit.playlist` (local paths,
|
||||||
|
the same ones Liquidsoap resolves), on start + every 24h + `GET /rescan`
|
||||||
|
- the bot-facing radio API: `/add_to_priority`, `/create_priority_playlist`,
|
||||||
|
`/request_radio_file`, `/clear_pr_pls` (+ `GET /ping` for health gating,
|
||||||
|
`/stream` for the web page)
|
||||||
|
- tailing `radio_log.log`/`persistence.log` and forwarding play events to
|
||||||
|
the bot's `/prepped_tracks` (with the shared API key)
|
||||||
|
|
||||||
|
The **musician** is now a pure Discord music player: `/mp3`, `/update_mp3`,
|
||||||
|
`/get_music` and the file-share endpoints. It no longer writes any radio
|
||||||
|
files and needs no shared partition with the radio VM. The music library
|
||||||
|
can still be replicated/mounted on both VMs (read-only on the radio side is
|
||||||
|
fine) - playlists reference the *radio VM's local* paths, generated locally.
|
||||||
|
|
||||||
|
Bot wiring after the split (env on the bot):
|
||||||
|
```
|
||||||
|
CONJURER_FILE_SERVICE=http://MUSICIAN_VM_IP:5000 # Discord music
|
||||||
|
CONJURER_RADIO_SERVICE=http://RADIO_VM_IP:5005 # betoniarka (radio cmds)
|
||||||
|
CONJURER_RADIO_HARBOR=http://RADIO_VM_IP:54321 # liquidsoap /skip
|
||||||
|
```
|
||||||
|
`CONJURER_RADIO_SERVICE` defaults to `CONJURER_FILE_SERVICE`, so an
|
||||||
|
un-split deployment keeps working unchanged. The bot health-gates
|
||||||
|
`radio_commands` on betoniarka's `/ping` (separate from the musician group,
|
||||||
|
which now covers only `music_commands` + `file_search_commands`).
|
||||||
|
|
||||||
**Privileges:** Liquidsoap refuses to run as root (`init: security exit`),
|
**Privileges:** Liquidsoap refuses to run as root (`init: security exit`),
|
||||||
so the main process runs as the dedicated **`radio`** user (member of
|
so the main process runs as the dedicated **`radio`** user (member of
|
||||||
|
|||||||
+5
-5
@@ -8,7 +8,7 @@ import uuid
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from constants import RADIO_HARBOR_ADDRESS, SKIP_TRACK, FILE_SERVICE_ADDRESS, ADD_TO_PRIO_PLAYLIST, REQUEST_MUSIC, CREATE_PRIO_PLAYLIST, CLEAR_PRIO, service_headers
|
from constants import RADIO_HARBOR_ADDRESS, SKIP_TRACK, RADIO_SERVICE_ADDRESS, ADD_TO_PRIO_PLAYLIST, REQUEST_MUSIC, CREATE_PRIO_PLAYLIST, CLEAR_PRIO, service_headers
|
||||||
|
|
||||||
SERVICE_HEADERS = service_headers()
|
SERVICE_HEADERS = service_headers()
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ class RadioModule(commands.Cog):
|
|||||||
}
|
}
|
||||||
coroutine = asyncio.to_thread(
|
coroutine = asyncio.to_thread(
|
||||||
requests.post,
|
requests.post,
|
||||||
f"{FILE_SERVICE_ADDRESS}{ADD_TO_PRIO_PLAYLIST}",
|
f"{RADIO_SERVICE_ADDRESS}{ADD_TO_PRIO_PLAYLIST}",
|
||||||
json=jrequest,
|
json=jrequest,
|
||||||
headers=SERVICE_HEADERS,
|
headers=SERVICE_HEADERS,
|
||||||
timeout=360,
|
timeout=360,
|
||||||
@@ -132,7 +132,7 @@ class RadioModule(commands.Cog):
|
|||||||
}
|
}
|
||||||
coroutine = asyncio.to_thread(
|
coroutine = asyncio.to_thread(
|
||||||
requests.post,
|
requests.post,
|
||||||
f"{FILE_SERVICE_ADDRESS}{REQUEST_MUSIC}",
|
f"{RADIO_SERVICE_ADDRESS}{REQUEST_MUSIC}",
|
||||||
json=jrequest,
|
json=jrequest,
|
||||||
headers=SERVICE_HEADERS,
|
headers=SERVICE_HEADERS,
|
||||||
timeout=360,
|
timeout=360,
|
||||||
@@ -170,7 +170,7 @@ class RadioModule(commands.Cog):
|
|||||||
}
|
}
|
||||||
coroutine = asyncio.to_thread(
|
coroutine = asyncio.to_thread(
|
||||||
requests.post,
|
requests.post,
|
||||||
f"{FILE_SERVICE_ADDRESS}{CREATE_PRIO_PLAYLIST}",
|
f"{RADIO_SERVICE_ADDRESS}{CREATE_PRIO_PLAYLIST}",
|
||||||
json=jrequest,
|
json=jrequest,
|
||||||
headers=SERVICE_HEADERS,
|
headers=SERVICE_HEADERS,
|
||||||
timeout=360,
|
timeout=360,
|
||||||
@@ -199,7 +199,7 @@ class RadioModule(commands.Cog):
|
|||||||
async with ctx.typing():
|
async with ctx.typing():
|
||||||
coroutine = asyncio.to_thread(
|
coroutine = asyncio.to_thread(
|
||||||
requests.get,
|
requests.get,
|
||||||
f"{FILE_SERVICE_ADDRESS}{CLEAR_PRIO}",
|
f"{RADIO_SERVICE_ADDRESS}{CLEAR_PRIO}",
|
||||||
headers=SERVICE_HEADERS,
|
headers=SERVICE_HEADERS,
|
||||||
timeout=360,
|
timeout=360,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user