feat(playout): build liquidsoap entrypoint with stream config

This commit is contained in:
jo 2022-08-10 17:33:22 +02:00 committed by Kyle Robbertze
parent d9920a1196
commit b9368d1b7b
15 changed files with 486 additions and 120 deletions

View File

@ -1,6 +1,11 @@
from pathlib import Path
from libretime_shared.config import BaseConfig, GeneralConfig, RabbitMQConfig
from libretime_shared.config import (
BaseConfig,
GeneralConfig,
RabbitMQConfig,
StreamConfig,
)
from pydantic import BaseModel
from typing_extensions import Literal
@ -26,3 +31,4 @@ class Config(BaseConfig):
general: GeneralConfig
rabbitmq: RabbitMQConfig = RabbitMQConfig()
playout: PlayoutConfig = PlayoutConfig()
stream: StreamConfig = StreamConfig()

View File

@ -1,28 +1,11 @@
%include "/etc/libretime/liquidsoap.cfg"
if (log_file != "") then
set("log.file.path", log_file)
else
set("log.file", false)
end
set("server.telnet", true)
set("server.telnet.port", 1234)
time = ref string_of(gettimeofday())
#Dynamic source list
#dyn_sources = ref []
webstream_enabled = ref false
time = ref string_of(gettimeofday())
#live stream setup
set("harbor.bind_addr", "0.0.0.0")
current_dyn_id = ref '-1'
stream_metadata_type = ref 0
default_dj_fade = ref 0.
station_name = ref ''
show_name = ref ''
dynamic_metadata_callback = ref fun (s) -> begin () end

View File

@ -1,28 +1,11 @@
%include "/etc/libretime/liquidsoap.cfg"
if (log_file != "") then
set("log.file.path", log_file)
else
set("log.file", false)
end
set("server.telnet", true)
set("server.telnet.port", 1234)
time = ref string_of(gettimeofday())
#Dynamic source list
#dyn_sources = ref []
webstream_enabled = ref false
time = ref string_of(gettimeofday())
#live stream setup
set("harbor.bind_addrs", ["0.0.0.0"])
current_dyn_id = ref '-1'
stream_metadata_type = ref 0
default_dj_fade = ref 0.
station_name = ref ''
show_name = ref ''
dynamic_metadata_callback = ref fun (s) -> begin () end

View File

@ -1,28 +1,11 @@
%include "/etc/libretime/liquidsoap.cfg"
if (log_file != "") then
set("log.file.path", log_file)
else
set("log.file", false)
end
set("server.telnet", true)
set("server.telnet.port", 1234)
time = ref string_of(gettimeofday())
#Dynamic source list
#dyn_sources = ref []
webstream_enabled = ref false
time = ref string_of(gettimeofday())
#live stream setup
set("harbor.bind_addrs", ["0.0.0.0"])
current_dyn_id = ref '-1'
stream_metadata_type = ref 0
default_dj_fade = ref 0.
station_name = ref ''
show_name = ref ''
dynamic_metadata_callback = ref fun (~new_track=false, s) -> begin () end

View File

@ -0,0 +1,61 @@
# THIS FILE IS AUTO GENERATED. PLEASE DO NOT EDIT!
###########################################################
# The ignore() lines are to squash unused variable warnings
# Inputs
master_live_stream_mp = "{{ config.stream.inputs.main.mount }}"
master_live_stream_port = {{ config.stream.inputs.main.port }}
dj_live_stream_mp = "{{ config.stream.inputs.show.mount }}"
dj_live_stream_port = {{ config.stream.inputs.show.port }}
{% for output in config.stream.outputs.merged -%}
# Output s{{ loop.index }}
s{{ loop.index }}_enable = {{ output.enabled | lower }}
s{{ loop.index }}_output = "{{ output.kind }}"
s{{ loop.index }}_host = "{{ output.host }}"
s{{ loop.index }}_port = {{ output.port }}
s{{ loop.index }}_mount = "{{ output.mount }}"
s{{ loop.index }}_user = "{{ output.source_user }}"
s{{ loop.index }}_pass = "{{ output.source_password }}"
s{{ loop.index }}_channels = "{{ output.audio.channels.value }}"
s{{ loop.index }}_type = "{{ output.audio.format.value }}"
s{{ loop.index }}_bitrate = {{ output.audio.bitrate }}
s{{ loop.index }}_name = "{{ output.name or '' }}"
s{{ loop.index }}_description = "{{ output.description or '' }}"
s{{ loop.index }}_genre = "{{ output.genre or '' }}"
s{{ loop.index }}_url = "{{ output.website or '' }}"
{% endfor -%}
icecast_vorbis_metadata = {{ icecast_vorbis_metadata | lower }}
# System output
output_sound_device = {{ config.stream.outputs.system[0].enabled | lower }}
output_sound_device_type = "{{ config.stream.outputs.system[0].kind.value }}"
# Settings
auth_path = "{{ paths.auth_filepath }}"
{% if paths.log_filepath is defined -%}
set("log.file.path", "{{ paths.log_filepath }}")
{%- else -%}
set("log.file", false)
{%- endif %}
set("server.telnet", true)
set("server.telnet.port", 1234)
{% if version >= (1, 3, 3) -%}
set("harbor.bind_addrs", ["0.0.0.0"])
{%- else -%}
set("harbor.bind_addr", "0.0.0.0")
{%- endif %}
station_name = ref "{{ info.station_name }}"
off_air_meta = "{{ preferences.message_offline }}"
stream_metadata_type = ref {{ preferences.message_format.value }}
default_dj_fade = ref {{ preferences.input_fade_transition }}
%include "{{ paths.lib_filepath }}"

View File

@ -1,64 +1,70 @@
import os
import sys
import time
from pathlib import Path
from typing import Optional
from typing import Optional, Tuple
from libretime_api_client.v1 import ApiClient as LegacyClient
from loguru import logger
from jinja2 import Template
from libretime_shared.config import AudioFormat, IcecastOutput, SystemOutput
from ..config import Config
from .models import Info, StreamPreferences
here = Path(__file__).parent
entrypoint_template_path = here / "entrypoint.liq.j2"
entrypoint_template = Template(
entrypoint_template_path.read_text(encoding="utf-8"),
keep_trailing_newline=True,
)
# Liquidsoap has 4 hardcoded output stream set of variables, so we need to
# fill the missing stream outputs with placeholders so Liquidsoap does
# not fail with missing variables in the entrypoint.
_icecast_placeholder = IcecastOutput(
enabled=False,
mount="",
source_password="",
audio=dict(format="ogg", bitrate=256),
)
_system_placeholder = SystemOutput()
def generate_liquidsoap_config(ss, log_filepath: Optional[Path]):
data = ss["msg"]
fh = open("/etc/libretime/liquidsoap.cfg", "w")
fh.write("################################################\n")
fh.write("# THIS FILE IS AUTO GENERATED. DO NOT CHANGE!! #\n")
fh.write("################################################\n")
fh.write("# The ignore() lines are to squash unused variable warnings\n")
def generate_entrypoint(
entrypoint_filepath: Path,
log_filepath: Optional[Path],
config: Config,
preferences: StreamPreferences,
info: Info,
version: Tuple[int, int, int],
):
paths = {}
paths["auth_filepath"] = here / "liquidsoap_auth.py"
paths["lib_filepath"] = here / f"{version[0]}.{version[1]}/ls_script.liq"
for key, value in data.items():
try:
if not "port" in key and not "bitrate" in key: # Stupid hack
raise ValueError()
str_buffer = f"{key} = {int(value)}\n"
except ValueError:
try: # Is it a boolean?
if value == "true" or value == "false":
str_buffer = f"{key} = {value.lower()}\n"
else:
raise ValueError() # Just drop into the except below
except: # Everything else is a string
str_buffer = f'{key} = "{value}"\n'
if log_filepath is not None:
paths["log_filepath"] = log_filepath.resolve()
fh.write(str_buffer)
# ignore squashes unused variable errors from Liquidsoap
fh.write("ignore(%s)\n" % key)
config = config.copy()
missing_outputs = [_icecast_placeholder] * (4 - len(config.stream.outputs.merged))
config.stream.outputs.icecast.extend(missing_outputs)
auth_path = os.path.dirname(os.path.realpath(__file__))
log_file = log_filepath.resolve() if log_filepath is not None else ""
if not config.stream.outputs.system:
config.stream.outputs.system.append(_system_placeholder)
fh.write(f'log_file = "{log_file}"\n')
fh.write('auth_path = "%s/liquidsoap_auth.py"\n' % auth_path)
fh.close()
# Global icecast_vorbis_metadata until it is
# handled per output
icecast_vorbis_metadata = any(
o.enabled and o.audio.format == AudioFormat.OGG and o.audio.enable_metadata # type: ignore
for o in config.stream.outputs.icecast
)
def generate_entrypoint(log_filepath: Optional[Path]):
attempts = 0
max_attempts = 10
successful = False
while not successful:
try:
legacy_client = LegacyClient(logger)
ss = legacy_client.get_stream_setting()
generate_liquidsoap_config(ss, log_filepath)
successful = True
except Exception:
logger.exception("Unable to connect to the Airtime server")
if attempts == max_attempts:
logger.error("giving up and exiting...")
sys.exit(1)
else:
logger.info("Retrying in 3 seconds...")
time.sleep(3)
attempts += 1
entrypoint_filepath.write_text(
entrypoint_template.render(
config=config,
preferences=preferences,
info=info,
paths=paths,
version=version,
icecast_vorbis_metadata=icecast_vorbis_metadata,
),
encoding="utf-8",
)

View File

@ -3,12 +3,15 @@ from pathlib import Path
from typing import Optional
import click
from libretime_shared.cli import cli_logging_options
from libretime_api_client.v2 import ApiClient
from libretime_shared.cli import cli_config_options, cli_logging_options
from libretime_shared.config import DEFAULT_ENV_PREFIX
from libretime_shared.logging import level_from_name, setup_logger
from loguru import logger
from ..config import Config
from .entrypoint import generate_entrypoint
from .models import Info, StreamPreferences
from .version import get_liquidsoap_version
here = Path(__file__).parent
@ -16,25 +19,42 @@ here = Path(__file__).parent
@click.command(context_settings={"auto_envvar_prefix": DEFAULT_ENV_PREFIX})
@cli_logging_options()
def cli(log_level: str, log_filepath: Optional[Path]):
@cli_config_options()
def cli(log_level: str, log_filepath: Optional[Path], config_filepath: Optional[Path]):
"""
Run liquidsoap.
"""
logger_level, _ = setup_logger(level_from_name(log_level), log_filepath)
config = Config(config_filepath)
generate_entrypoint(log_filepath)
api_client = ApiClient(
base_url=config.general.public_url,
api_key=config.general.api_key,
)
version = get_liquidsoap_version()
script_path = here / f"{version[0]}.{version[1]}/ls_script.liq"
info = Info(**api_client.get_info().json())
preferences = StreamPreferences(**api_client.get_stream_preferences().json())
entrypoint_filepath = Path.cwd() / "radio.liq"
generate_entrypoint(
entrypoint_filepath,
log_filepath,
config,
preferences,
info,
version,
)
exec_args = [
"/usr/bin/liquidsoap",
"libretime-liquidsoap",
"--verbose",
str(script_path),
str(entrypoint_filepath),
]
if logger_level.is_debug():
exec_args.append("--debug")
logger.debug(f"Liquidsoap {version} using script: {script_path}")
logger.debug(f"liquidsoap {version} using script: {entrypoint_filepath}")
os.execl(*exec_args)

View File

@ -0,0 +1,27 @@
from enum import Enum
from pydantic import BaseModel
class Info(BaseModel):
station_name: str
class MessageFormatKind(int, Enum):
ARTIST_TITLE = 0
SHOW_ARTIST_TITLE = 1
RADIO_SHOW = 2
class StreamPreferences(BaseModel):
input_fade_transition: float
message_format: MessageFormatKind
message_offline: str
class StreamState(BaseModel):
input_main_connected: bool
input_main_streaming: bool
input_show_connected: bool
input_show_streaming: bool
schedule_streaming: bool

View File

@ -2,6 +2,7 @@
# This file is auto-generated by tools/extract_requirements.py.
backports.zoneinfo>=0.2.1,<0.3;python_version<'3.9'
dataclasses>=0.8,<0.9;python_version<'3.7'
jinja2>=3.0.3,<3.2
kombu==4.6.11
lxml>=4.5.0,<4.10.0
mutagen>=1.45.1,<1.46

View File

@ -13,7 +13,7 @@ setup(
},
license="AGPLv3",
packages=find_packages(exclude=["*tests*", "*fixtures*"]),
package_data={"": ["**/*.liq", "*.types"]},
package_data={"": ["**/*.liq", "**/*.liq.j2", "*.types"]},
entry_points={
"console_scripts": [
"libretime-playout=libretime_playout.main:cli",
@ -25,6 +25,7 @@ setup(
install_requires=[
"backports.zoneinfo>=0.2.1,<0.3;python_version<'3.9'",
"dataclasses>=0.8,<0.9;python_version<'3.7'",
"jinja2>=3.0.3,<3.2",
"kombu==4.6.11",
"lxml>=4.5.0,<4.10.0",
"mutagen>=1.45.1,<1.46",

37
playout/tests/conftest.py Normal file
View File

@ -0,0 +1,37 @@
import pytest
from libretime_playout.config import Config
@pytest.fixture(scope="session")
def config():
return Config(
**{
"general": {
"public_url": "http://localhost:8080",
"api_key": "some_api_key",
},
"stream": {
"outputs": {
"icecast": [
{
"enabled": True,
"mount": "main",
"source_password": "hackme",
"audio": {"format": "ogg", "bitrate": 256},
"name": "LibreTime!",
"description": "LibreTime Radio! Stream #1",
"website": "https://libretime.org",
"genre": "various",
},
{
"enabled": True,
"mount": "second",
"source_password": "hackme",
"audio": {"format": "mp3", "bitrate": 256},
},
]
}
},
}
)

View File

@ -1,6 +1,11 @@
from pathlib import Path
fixture_path = Path(__file__).parent
fixtures_path = Path(__file__).parent
icecast_stats = fixture_path / "icecast_stats.xml"
shoutcast_admin = fixture_path / "shoutcast_admin.xml"
icecast_stats = fixtures_path / "icecast_stats.xml"
shoutcast_admin = fixtures_path / "shoutcast_admin.xml"
entrypoint_1_1 = fixtures_path / "entrypoint-1.1.liq"
entrypoint_1_1_snapshot = entrypoint_1_1.read_text(encoding="utf-8")
entrypoint_1_4 = fixtures_path / "entrypoint-1.4.liq"
entrypoint_1_4_snapshot = entrypoint_1_4.read_text(encoding="utf-8")

View File

@ -0,0 +1,105 @@
# THIS FILE IS AUTO GENERATED. PLEASE DO NOT EDIT!
###########################################################
# The ignore() lines are to squash unused variable warnings
# Inputs
master_live_stream_mp = "main"
master_live_stream_port = 8001
dj_live_stream_mp = "show"
dj_live_stream_port = 8002
# Output s1
s1_enable = true
s1_output = "icecast"
s1_host = "localhost"
s1_port = 8000
s1_mount = "main"
s1_user = "source"
s1_pass = "hackme"
s1_channels = "stereo"
s1_type = "ogg"
s1_bitrate = 256
s1_name = "LibreTime!"
s1_description = "LibreTime Radio! Stream #1"
s1_genre = "various"
s1_url = "https://libretime.org"
# Output s2
s2_enable = true
s2_output = "icecast"
s2_host = "localhost"
s2_port = 8000
s2_mount = "second"
s2_user = "source"
s2_pass = "hackme"
s2_channels = "stereo"
s2_type = "mp3"
s2_bitrate = 256
s2_name = ""
s2_description = ""
s2_genre = ""
s2_url = ""
# Output s3
s3_enable = false
s3_output = "icecast"
s3_host = "localhost"
s3_port = 8000
s3_mount = ""
s3_user = "source"
s3_pass = ""
s3_channels = "stereo"
s3_type = "ogg"
s3_bitrate = 256
s3_name = ""
s3_description = ""
s3_genre = ""
s3_url = ""
# Output s4
s4_enable = false
s4_output = "icecast"
s4_host = "localhost"
s4_port = 8000
s4_mount = ""
s4_user = "source"
s4_pass = ""
s4_channels = "stereo"
s4_type = "ogg"
s4_bitrate = 256
s4_name = ""
s4_description = ""
s4_genre = ""
s4_url = ""
icecast_vorbis_metadata = false
# System output
output_sound_device = false
output_sound_device_type = "alsa"
# Settings
auth_path = "/fake/liquidsoap_auth.py"
set("log.file.path", "/var/log/radio.log")
set("server.telnet", true)
set("server.telnet.port", 1234)
set("harbor.bind_addr", "0.0.0.0")
station_name = ref "LibreTime"
off_air_meta = "LibreTime - offline"
stream_metadata_type = ref 0
default_dj_fade = ref 0.0
%include "/fake/1.1/ls_script.liq"

View File

@ -0,0 +1,105 @@
# THIS FILE IS AUTO GENERATED. PLEASE DO NOT EDIT!
###########################################################
# The ignore() lines are to squash unused variable warnings
# Inputs
master_live_stream_mp = "main"
master_live_stream_port = 8001
dj_live_stream_mp = "show"
dj_live_stream_port = 8002
# Output s1
s1_enable = true
s1_output = "icecast"
s1_host = "localhost"
s1_port = 8000
s1_mount = "main"
s1_user = "source"
s1_pass = "hackme"
s1_channels = "stereo"
s1_type = "ogg"
s1_bitrate = 256
s1_name = "LibreTime!"
s1_description = "LibreTime Radio! Stream #1"
s1_genre = "various"
s1_url = "https://libretime.org"
# Output s2
s2_enable = true
s2_output = "icecast"
s2_host = "localhost"
s2_port = 8000
s2_mount = "second"
s2_user = "source"
s2_pass = "hackme"
s2_channels = "stereo"
s2_type = "mp3"
s2_bitrate = 256
s2_name = ""
s2_description = ""
s2_genre = ""
s2_url = ""
# Output s3
s3_enable = false
s3_output = "icecast"
s3_host = "localhost"
s3_port = 8000
s3_mount = ""
s3_user = "source"
s3_pass = ""
s3_channels = "stereo"
s3_type = "ogg"
s3_bitrate = 256
s3_name = ""
s3_description = ""
s3_genre = ""
s3_url = ""
# Output s4
s4_enable = false
s4_output = "icecast"
s4_host = "localhost"
s4_port = 8000
s4_mount = ""
s4_user = "source"
s4_pass = ""
s4_channels = "stereo"
s4_type = "ogg"
s4_bitrate = 256
s4_name = ""
s4_description = ""
s4_genre = ""
s4_url = ""
icecast_vorbis_metadata = false
# System output
output_sound_device = false
output_sound_device_type = "alsa"
# Settings
auth_path = "/fake/liquidsoap_auth.py"
set("log.file.path", "/var/log/radio.log")
set("server.telnet", true)
set("server.telnet.port", 1234)
set("harbor.bind_addrs", ["0.0.0.0"])
station_name = ref "LibreTime"
off_air_meta = "LibreTime - offline"
stream_metadata_type = ref 0
default_dj_fade = ref 0.0
%include "/fake/1.4/ls_script.liq"

View File

@ -0,0 +1,43 @@
from pathlib import Path
from unittest import mock
import pytest
from libretime_playout.config import Config
from libretime_playout.liquidsoap.entrypoint import generate_entrypoint
from libretime_playout.liquidsoap.models import Info, StreamPreferences
from ..fixtures import entrypoint_1_1_snapshot, entrypoint_1_4_snapshot
@pytest.mark.parametrize(
"version, expected",
[
pytest.param((1, 1, 1), entrypoint_1_1_snapshot, id="snapshot_1.1"),
pytest.param((1, 4, 4), entrypoint_1_4_snapshot, id="snapshot_1.4"),
],
)
def test_generate_entrypoint(tmp_path: Path, config: Config, version, expected):
entrypoint_filepath = tmp_path / "radio.liq"
with mock.patch(
"libretime_playout.liquidsoap.entrypoint.here",
Path("/fake"),
):
generate_entrypoint(
entrypoint_filepath,
log_filepath=Path("/var/log/radio.log"),
config=config,
preferences=StreamPreferences(
input_fade_transition=0.0,
message_format=0,
message_offline="LibreTime - offline",
),
info=Info(
station_name="LibreTime",
),
version=version,
)
found = entrypoint_filepath.read_text(encoding="utf-8")
assert found == expected