Move python_apps/pypo/ to playout/

This commit is contained in:
jo 2021-10-17 02:52:31 +02:00 committed by Kyle Robbertze
parent d97ecdfba7
commit ca12ca5c41
50 changed files with 0 additions and 0 deletions

11
playout/Makefile Normal file
View file

@ -0,0 +1,11 @@
all: lint
include ../../tools/python.mk
PIP_INSTALL := --editable .
PYLINT_ARG := liquidsoap pypo
MYPY_ARG := liquidsoap pypo
format: .format
lint: .pylint .mypy
clean: .clean

15
playout/README.md Normal file
View file

@ -0,0 +1,15 @@
# LibreTime Playout
## History
This tool was born out of a collaboration between Open Broadcast and Sourcefabric.
The authors of the code are:
Original Authors:
- Jonas Ohrstrom <jonas@digris.ch>
- Paul Baranowski <paul.baranowski@sourcefabric.org>
- James Moon <james.moon@sourcefabric.org>
Almost a complete refactor/rewrite by: Martin Konecny <martin.konecny@gmail.com>

View file

@ -0,0 +1,6 @@
#!/usr/bin/env python3
import runpy
# Run the liquidsoap python module
runpy.run_module("liquidsoap")

5
playout/bin/libretime-playout Executable file
View file

@ -0,0 +1,5 @@
#!/usr/bin/env python3
import runpy
runpy.run_module("pypo", run_name="__main__")

View file

@ -0,0 +1,206 @@
#!/usr/bin/env python3
import traceback
"""
Python part of radio playout (pypo)
This function acts as a gateway between liquidsoap and the server API.
Mainly used to tell the platform what pypo/liquidsoap does.
Main case:
- whenever LS starts playing a new track, its on_metadata callback calls
a function in ls (notify(m)) which then calls the python script here
with the currently starting filename as parameter
- this python script takes this parameter, tries to extract the actual
media id from it, and then calls back to the API to tell about it about it.
"""
import json
import logging.config
import sys
from optparse import OptionParser
# custom imports
# from util import *
from api_clients import version1 as api_client
# additional modules (should be checked)
from configobj import ConfigObj
LOG_LEVEL = logging.INFO
LOG_PATH = "/var/log/airtime/pypo/notify.log"
# help screeen / info
usage = "%prog [options]" + " - notification gateway"
parser = OptionParser(usage=usage)
# Options
parser.add_option(
"-d",
"--data",
help="Pass JSON data from Liquidsoap into this script.",
metavar="data",
)
parser.add_option(
"-m",
"--media-id",
help="ID of the file that is currently playing.",
metavar="media_id",
)
parser.add_option(
"-e",
"--error",
action="store",
dest="error",
type="string",
help="Liquidsoap error msg.",
metavar="error_msg",
)
parser.add_option("-s", "--stream-id", help="ID stream", metavar="stream_id")
parser.add_option(
"-c",
"--connect",
help="Liquidsoap connected",
action="store_true",
metavar="connect",
)
parser.add_option(
"-t",
"--time",
help="Liquidsoap boot up time",
action="store",
dest="time",
metavar="time",
type="string",
)
parser.add_option(
"-x", "--source-name", help="source connection name", metavar="source_name"
)
parser.add_option(
"-y", "--source-status", help="source connection status", metavar="source_status"
)
parser.add_option(
"-w",
"--webstream",
help="JSON metadata associated with webstream",
metavar="json_data",
)
parser.add_option(
"-n",
"--liquidsoap-started",
help="notify liquidsoap started",
metavar="json_data",
action="store_true",
default=False,
)
# parse options
(options, args) = parser.parse_args()
# Set up logging
logging.captureWarnings(True)
logFormatter = logging.Formatter(
"%(asctime)s [%(module)s] [%(levelname)-5.5s] %(message)s"
)
rootLogger = logging.getLogger()
rootLogger.setLevel(LOG_LEVEL)
fileHandler = logging.handlers.RotatingFileHandler(
filename=LOG_PATH, maxBytes=1024 * 1024 * 30, backupCount=8
)
fileHandler.setFormatter(logFormatter)
rootLogger.addHandler(fileHandler)
consoleHandler = logging.StreamHandler()
consoleHandler.setFormatter(logFormatter)
rootLogger.addHandler(consoleHandler)
logger = rootLogger
# need to wait for Python 2.7 for this..
# logging.captureWarnings(True)
# loading config file
try:
config = ConfigObj("/etc/airtime/airtime.conf")
except Exception as e:
logger.error("Error loading config file: %s", e)
sys.exit()
class Notify:
def __init__(self):
self.api_client = api_client.AirtimeApiClient(logger=logger)
def notify_liquidsoap_started(self):
logger.debug("Notifying server that Liquidsoap has started")
self.api_client.notify_liquidsoap_started()
def notify_media_start_playing(self, media_id):
logger.debug("#################################################")
logger.debug("# Calling server to update about what's playing #")
logger.debug("#################################################")
response = self.api_client.notify_media_item_start_playing(media_id)
logger.debug("Response: " + json.dumps(response))
# @pram time: time that LS started
def notify_liquidsoap_status(self, msg, stream_id, time):
logger.info("#################################################")
logger.info("# Calling server to update liquidsoap status #")
logger.info("#################################################")
logger.info("msg = " + str(msg))
response = self.api_client.notify_liquidsoap_status(msg, stream_id, time)
logger.info("Response: " + json.dumps(response))
def notify_source_status(self, source_name, status):
logger.debug("#################################################")
logger.debug("# Calling server to update source status #")
logger.debug("#################################################")
logger.debug("msg = " + str(source_name) + " : " + str(status))
response = self.api_client.notify_source_status(source_name, status)
logger.debug("Response: " + json.dumps(response))
def notify_webstream_data(self, data, media_id):
logger.debug("#################################################")
logger.debug("# Calling server to update webstream data #")
logger.debug("#################################################")
response = self.api_client.notify_webstream_data(data, media_id)
logger.debug("Response: " + json.dumps(response))
def run_with_options(self, options):
if options.error and options.stream_id:
self.notify_liquidsoap_status(
options.error, options.stream_id, options.time
)
elif options.connect and options.stream_id:
self.notify_liquidsoap_status("OK", options.stream_id, options.time)
elif options.source_name and options.source_status:
self.notify_source_status(options.source_name, options.source_status)
elif options.webstream:
self.notify_webstream_data(options.webstream, options.media_id)
elif options.media_id:
self.notify_media_start_playing(options.media_id)
elif options.liquidsoap_started:
self.notify_liquidsoap_started()
else:
logger.debug(
"Unrecognized option in options({}). Doing nothing".format(options)
)
if __name__ == "__main__":
print()
print("#########################################")
print("# *** pypo *** #")
print("# pypo notification gateway #")
print("#########################################")
# initialize
try:
n = Notify()
n.run_with_options(options)
except Exception as e:
print(traceback.format_exc())

View file

@ -0,0 +1,11 @@
[Unit]
Description=Libretime Liquidsoap Service
[Service]
ExecStart=/usr/local/bin/libretime-liquidsoap
User=libretime-playout
Group=libretime-playout
Restart=always
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,12 @@
[Unit]
Description=Libretime Playout Service
After=network-online.target
[Service]
ExecStart=/usr/local/bin/libretime-playout
User=libretime-pypo
Group=libretime-pypo
Restart=always
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,23 @@
if bitrate == 24 then
ignore(output_stereo(%fdkaac(bitrate = 24, aot="mpeg4_he_aac_v2", afterburner=false, sbr_mode=true), !source))
elsif bitrate == 32 then
ignore(output_stereo(%fdkaac(bitrate = 32, aot="mpeg4_he_aac_v2", afterburner=false, sbr_mode=true), !source))
elsif bitrate == 48 then
ignore(output_stereo(%fdkaac(bitrate = 48, aot="mpeg4_he_aac_v2", afterburner=false, sbr_mode=true), !source))
elsif bitrate == 64 then
ignore(output_stereo(%fdkaac(bitrate = 64, aot="mpeg4_he_aac_v2", afterburner=false, sbr_mode=true), !source))
elsif bitrate == 96 then
ignore(output_stereo(%fdkaac(bitrate = 96, aot="mpeg4_aac_lc", afterburner=false, sbr_mode=true), !source))
elsif bitrate == 128 then
ignore(output_stereo(%fdkaac(bitrate = 128, aot="mpeg4_aac_lc", afterburner=false, sbr_mode=true), !source))
elsif bitrate == 160 then
ignore(output_stereo(%fdkaac(bitrate = 160, aot="mpeg4_aac_lc", afterburner=true, sbr_mode=true), !source))
elsif bitrate == 192 then
ignore(output_stereo(%fdkaac(bitrate = 192, aot="mpeg4_aac_lc", afterburner=true, sbr_mode=true), !source))
elsif bitrate == 224 then
ignore(output_stereo(%fdkaac(bitrate = 224, aot="mpeg4_aac_lc", afterburner=true, sbr_mode=true), !source))
elsif bitrate == 256 then
ignore(output_stereo(%fdkaac(bitrate = 256, aot="mpeg4_aac_lc", afterburner=true, sbr_mode=true), !source))
elsif bitrate == 320 then
ignore(output_stereo(%fdkaac(bitrate = 320, aot="mpeg4_aac_lc", afterburner=true, sbr_mode=true), !source))
end

View file

@ -0,0 +1,399 @@
def notify(m)
command = "timeout --signal=KILL 45 libretime-playout-notify --media-id=#{m['schedule_table_id']} &"
log(command)
system(command)
end
def notify_queue(m)
f = !dynamic_metadata_callback
ignore(f(m))
notify(m)
end
def notify_stream(m)
json_str = string.replace(pattern="\n",(fun (s) -> ""), json_of(m))
#if a string has a single apostrophe in it, let's comment it out by ending the string before right before it
#escaping the apostrophe, and then starting a new string right after it. This is why we use 3 apostrophes.
json_str = string.replace(pattern="'",(fun (s) -> "'\''"), json_str)
command = "timeout --signal=KILL 45 libretime-playout-notify --webstream='#{json_str}' --media-id=#{!current_dyn_id} &"
if !current_dyn_id != "-1" then
log(command)
system(command)
end
end
# A function applied to each metadata chunk
def append_title(m) =
log("Using stream_format #{!stream_metadata_type}")
if list.mem_assoc("mapped", m) then
#protection against applying this function twice. It shouldn't be happening
#and bug file with Liquidsoap.
m
else
if !stream_metadata_type == 1 then
[("title", "#{!show_name} - #{m['artist']} - #{m['title']}"), ("mapped", "true")]
elsif !stream_metadata_type == 2 then
[("title", "#{!station_name} - #{!show_name}"), ("mapped", "true")]
else
if "#{m['artist']}" == "" then
[("title", "#{m['title']}"), ("mapped", "true")]
else
[("title", "#{m['artist']} - #{m['title']}"), ("mapped", "true")]
end
end
end
end
def crossfade_airtime(s)
#duration is automatically overwritten by metadata fields passed in
#with audio
s = fade.in(type="log", duration=0., s)
s = fade.out(type="log", duration=0., s)
fader = fun (a,b) -> add(normalize=false,[b,a])
cross(fader,s)
end
def transition(a,b) =
log("transition called...")
add(normalize=false,
[ sequence([ blank(duration=0.01),
fade.initial(duration=!default_dj_fade, b) ]),
fade.final(duration=!default_dj_fade, a) ])
end
# we need this function for special transition case(from default to queue)
# we don't want the trasition fade to have effect on the first song that would
# be played switching out of the default(silent) source
def transition_default(a,b) =
log("transition called...")
if !just_switched then
just_switched := false
add(normalize=false,
[ sequence([ blank(duration=0.01),
fade.initial(duration=!default_dj_fade, b) ]),
fade.final(duration=!default_dj_fade, a) ])
else
just_switched := false
b
end
end
# Define a transition that fades out the
# old source, adds a single, and then
# plays the new source
def to_live(old,new) =
# Fade out old source
old = fade.final(old)
# Compose this in sequence with
# the new source
sequence([old,new])
end
def output_to(output_type, type, bitrate, host, port, pass, mount_point, url, description, genre, user, s, stream, connected, name, channels) =
source = ref s
def on_error(msg)
connected := "false"
command = "timeout --signal=KILL 45 libretime-playout-notify --error='#{msg}' --stream-id=#{stream} --time=#{!time} &"
system(command)
log(command)
5.
end
def on_connect()
connected := "true"
command = "timeout --signal=KILL 45 libretime-playout-notify --connect --stream-id=#{stream} --time=#{!time} &"
system(command)
log(command)
end
stereo = (channels == "stereo")
if output_type == "icecast" then
user_ref = ref user
if user == "" then
user_ref := "source"
end
output_mono = output.icecast(host = host,
port = port,
password = pass,
mount = mount_point,
fallible = true,
url = url,
description = description,
name = name,
genre = genre,
user = !user_ref,
on_error = on_error,
on_connect = on_connect)
output_stereo = output.icecast(host = host,
port = port,
password = pass,
mount = mount_point,
fallible = true,
url = url,
description = description,
name = name,
genre = genre,
user = !user_ref,
on_error = on_error,
on_connect = on_connect)
if type == "mp3" then
%include "mp3.liq"
end
if type == "ogg" then
%include "ogg.liq"
end
%ifencoder %opus
if type == "opus" then
%include "opus.liq"
end
%endif
%ifencoder %fdkaac
if type == "aac" then
%include "fdkaac.liq"
end
%endif
else
user_ref = ref user
if user == "" then
user_ref := "source"
end
output_mono = output.shoutcast(id = "shoutcast_stream_#{stream}",
host = host,
port = port,
password = pass,
fallible = true,
url = url,
genre = genre,
name = description,
user = !user_ref,
on_error = on_error,
on_connect = on_connect)
output_stereo = output.shoutcast(id = "shoutcast_stream_#{stream}",
host = host,
port = port,
password = pass,
fallible = true,
url = url,
genre = genre,
name = description,
user = !user_ref,
on_error = on_error,
on_connect = on_connect)
if type == "mp3" then
%include "mp3.liq"
end
%ifencoder %fdkaac
if type == "aac" then
%include "fdkaac.liq"
end
%endif
end
end
# Add a skip function to a source
# when it does not have one
# by default
#def add_skip_command(s)
# # A command to skip
# def skip(_)
# # get playing (active) queue and flush it
# l = list.hd(server.execute("queue.secondary_queue"))
# l = string.split(separator=" ",l)
# list.iter(fun (rid) -> ignore(server.execute("queue.remove #{rid}")), l)
#
# l = list.hd(server.execute("queue.primary_queue"))
# l = string.split(separator=" ", l)
# if list.length(l) > 0 then
# source.skip(s)
# "Skipped"
# else
# "Not skipped"
# end
# end
# # Register the command:
# server.register(namespace="source",
# usage="skip",
# description="Skip the current song.",
# "skip",fun(s) -> begin log("source.skip") skip(s) end)
#end
def clear_queue(s)
source.skip(s)
end
def set_dynamic_source_id(id) =
current_dyn_id := id
string_of(!current_dyn_id)
end
def get_dynamic_source_id() =
string_of(!current_dyn_id)
end
#cc-4633
# NOTE
# A few values are hardcoded and may be dependent:
# - the delay in gracetime is linked with the buffer duration of input.http
# (delay should be a bit less than buffer)
# - crossing duration should be less than buffer length
# (at best, a higher duration will be ineffective)
# HTTP input with "restart" command that waits for "stop" to be effected
# before "start" command is issued. Optionally it takes a new URL to play,
# which makes it a convenient replacement for "url".
# In the future, this may become a core feature of the HTTP input.
# TODO If we stop and restart quickly several times in a row,
# the data bursts accumulate and create buffer overflow.
# Flushing the buffer on restart could be a good idea, but
# it would also create an interruptions while the buffer is
# refilling... on the other hand, this would avoid having to
# fade using both cross() and switch().
def input.http_restart(~id,~initial_url="http://dummy/url")
source = audio_to_stereo(input.http(buffer=5.,max=15.,id=id,autostart=false,initial_url))
def stopped()
"stopped" == list.hd(server.execute("#{id}.status"))
end
server.register(namespace=id,
"restart",
usage="restart [url]",
fun (url) -> begin
if url != "" then
log(string_of(server.execute("#{id}.url #{url}")))
end
log(string_of(server.execute("#{id}.stop")))
add_timeout(0.5,
{ if stopped() then
log(string_of(server.execute("#{id}.start"))) ;
(-1.)
else 0.5 end})
"OK"
end)
# Dummy output should be useless if HTTP stream is meant
# to be listened to immediately. Otherwise, apply it.
#
# output.dummy(fallible=true,source)
source
end
# Transitions between URL changes in HTTP streams.
def cross_http(~debug=true,~http_input_id,source)
id = http_input_id
last_url = ref ""
change = ref false
def on_m(m)
notify_stream(m)
changed = m["source_url"] != !last_url
log("URL now #{m['source_url']} (change: #{changed})")
if changed then
if !last_url != "" then change := true end
last_url := m["source_url"]
end
end
# We use both metadata and status to know about the current URL.
# Using only metadata may be more precise is crazy corner cases,
# but it's also asking too much: the metadata may not pass through
# before the crosser is instantiated.
# Using only status in crosser misses some info, eg. on first URL.
source = on_metadata(on_m,source)
cross_d = 3.
def crosser(a,b)
url = list.hd(server.execute('#{id}.url'))
status = list.hd(server.execute('#{id}.status'))
on_m([("source_url",url)])
if debug then
log("New track inside HTTP stream")
log(" status: #{status}")
log(" need to cross: #{!change}")
log(" remaining #{source.remaining(a)} sec before, \
#{source.remaining(b)} sec after")
end
if !change then
change := false
# In principle one should avoid crossing on a live stream
# it'd be okay to do it here (eg. use add instead of sequence)
# because it's only once per URL, but be cautious.
sequence([fade.out(duration=cross_d,a),fade.in(b)])
else
# This is done on tracks inside a single stream.
# Do NOT cross here or you'll gradually empty the buffer!
sequence([a,b])
end
end
# Setting conservative=true would mess with the delayed switch below
cross(duration=cross_d,conservative=false,crosser,source)
end
# Custom fallback between http and default source with fading of
# beginning and end of HTTP stream.
# It does not take potential URL changes into account, as long as
# they do not interrupt streaming (thanks to the HTTP buffer).
def http_fallback(~http_input_id,~http,~default)
id = http_input_id
# We use a custom switching predicate to trigger switching (and thus,
# transitions) before the end of a track (rather, end of HTTP stream).
# It is complexified because we don't want to trigger switching when
# HTTP disconnects for just an instant, when changing URL: for that
# we use gracetime below.
def gracetime(~delay=3.,f)
last_true = ref 0.
{ if f() then
last_true := gettimeofday()
true
else
gettimeofday() < !last_true+delay
end }
end
def connected()
status = list.hd(server.execute("#{id}.status"))
not(list.mem(status,["polling","stopped"]))
end
connected = gracetime(connected)
def to_live(a,b) =
log("TRANSITION to live")
add(normalize=false,
[fade.initial(b),fade.final(a)])
end
def to_static(a,b) =
log("TRANSITION to static")
sequence([fade.out(a),fade.initial(b)])
end
switch(
track_sensitive=false,
transitions=[to_live,to_static],
[(# make sure it is connected, and not buffering
{connected() and source.is_ready(http) and !webstream_enabled}, http),
({true},default)])
end

View file

@ -0,0 +1,443 @@
%include "/etc/airtime/liquidsoap.cfg"
set("log.file.path", log_file)
set("server.telnet", true)
set("server.telnet.port", 1234)
#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'
pypo_data = ref '0'
stream_metadata_type = ref 0
default_dj_fade = ref 0.
station_name = ref ''
show_name = ref ''
dynamic_metadata_callback = ref fun (s) -> begin () end
s1_connected = ref ''
s2_connected = ref ''
s3_connected = ref ''
s4_connected = ref ''
s1_namespace = ref ''
s2_namespace = ref ''
s3_namespace = ref ''
just_switched = ref false
%include "ls_lib.liq"
sources = ref []
source_id = ref 0
def check_version(~version=liquidsoap.version, major, minor) =
v = list.map(int_of_string, string.split(separator="\.", version))
list.nth(v,0) > major or list.nth(v,0) == major and list.nth(v,1) >= minor
end
# cue cut fix for liquidsoap <1.2.2
#
# This was most likely broken on 1.1.1 (debian) as well.
#
# adapted from https://github.com/savonet/liquidsoap/issues/390#issuecomment-277562081
#
def fix_cue_in(~cue_in_metadata='liq_cue_in', m) =
# 0.04 might need to be adjusted according to your frame size
if float_of_string(m[cue_in_metadata]) < 0.04 then
[(cue_in_metadata, "0")]
else
[]
end
end
def create_source()
l = request.equeue(id="s#{!source_id}", length=0.5)
l = audio_to_stereo(id="queue_src", l)
l = if not check_version(1, 3) then
map_metadata(fix_cue_in, l)
else
l
end
l = cue_cut(l)
l = amplify(1., override="replay_gain", l)
# the crossfade function controls fade in/out
l = crossfade_airtime(l)
l = on_metadata(notify_queue, l)
sources := list.append([l], !sources)
server.register(namespace="queues",
"s#{!source_id}_skip",
fun (s) -> begin log("queues.s#{!source_id}_skip")
clear_queue(l)
"Done"
end)
source_id := !source_id + 1
end
create_source()
create_source()
create_source()
create_source()
create_source()
create_source()
create_source()
create_source()
queue = add(!sources, normalize=false)
pair = insert_metadata(queue)
dynamic_metadata_callback := fst(pair)
queue = snd(pair)
output.dummy(fallible=true, queue)
http = input.http_restart(id="http")
http = cross_http(http_input_id="http",http)
output.dummy(fallible=true, http)
stream_queue = http_fallback(http_input_id="http", http=http, default=queue)
stream_queue = map_metadata(update=false, append_title, stream_queue)
ignore(output.dummy(stream_queue, fallible=true))
server.register(namespace="vars",
"pypo_data",
fun (s) -> begin log("vars.pypo_data") pypo_data := s "Done" end)
server.register(namespace="vars",
"stream_metadata_type",
fun (s) -> begin log("vars.stream_metadata_type") stream_metadata_type := int_of_string(s) s end)
server.register(namespace="vars",
"show_name",
fun (s) -> begin log("vars.show_name") show_name := s s end)
server.register(namespace="vars",
"station_name",
fun (s) -> begin log("vars.station_name") station_name := s s end)
server.register(namespace="vars",
"bootup_time",
fun (s) -> begin log("vars.bootup_time") time := s s end)
server.register(namespace="streams",
"connection_status",
fun (s) -> begin log("streams.connection_status") "1:#{!s1_connected},2:#{!s2_connected},3:#{!s3_connected},4:#{!s4_connected}" end)
server.register(namespace="vars",
"default_dj_fade",
fun (s) -> begin log("vars.default_dj_fade") default_dj_fade := float_of_string(s) s end)
server.register(namespace="dynamic_source",
description="Enable webstream output",
usage='start',
"output_start",
fun (s) -> begin log("dynamic_source.output_start")
notify([("schedule_table_id", !current_dyn_id)])
webstream_enabled := true "enabled" end)
server.register(namespace="dynamic_source",
description="Enable webstream output",
usage='stop',
"output_stop",
fun (s) -> begin log("dynamic_source.output_stop") webstream_enabled := false "disabled" end)
server.register(namespace="dynamic_source",
description="Set the streams cc_schedule row id",
usage="id <id>",
"id",
fun (s) -> begin log("dynamic_source.id") set_dynamic_source_id(s) end)
server.register(namespace="dynamic_source",
description="Get the streams cc_schedule row id",
usage="get_id",
"get_id",
fun (s) -> begin log("dynamic_source.get_id") get_dynamic_source_id() end)
#server.register(namespace="dynamic_source",
# description="Start a new dynamic source.",
# usage="start <uri>",
# "read_start",
# fun (uri) -> begin log("dynamic_source.read_start") begin_stream_read(uri) end)
#server.register(namespace="dynamic_source",
# description="Stop a dynamic source.",
# usage="stop <id>",
# "read_stop",
# fun (s) -> begin log("dynamic_source.read_stop") stop_stream_read(s) end)
#server.register(namespace="dynamic_source",
# description="Stop a dynamic source.",
# usage="stop <id>",
# "read_stop_all",
# fun (s) -> begin log("dynamic_source.read_stop") destroy_dynamic_source_all() end)
default = amplify(id="silence_src", 0.00001, noise())
ref_off_air_meta = ref off_air_meta
if !ref_off_air_meta == "" then
ref_off_air_meta := "LibreTime - offline"
end
default = rewrite_metadata([("title", !ref_off_air_meta)], default)
ignore(output.dummy(default, fallible=true))
master_dj_enabled = ref false
live_dj_enabled = ref false
scheduled_play_enabled = ref false
def make_master_dj_available()
master_dj_enabled := true
end
def make_master_dj_unavailable()
master_dj_enabled := false
end
def make_live_dj_available()
live_dj_enabled := true
end
def make_live_dj_unavailable()
live_dj_enabled := false
end
def make_scheduled_play_available()
scheduled_play_enabled := true
just_switched := true
end
def make_scheduled_play_unavailable()
scheduled_play_enabled := false
end
def update_source_status(sourcename, status) =
command = "timeout --signal=KILL 45 libretime-playout-notify --source-name=#{sourcename} --source-status=#{status} &"
system(command)
log(command)
end
def live_dj_connect(header) =
update_source_status("live_dj", true)
end
def live_dj_disconnect() =
update_source_status("live_dj", false)
end
def master_dj_connect(header) =
update_source_status("master_dj", true)
end
def master_dj_disconnect() =
update_source_status("master_dj", false)
end
#auth function for live stream
def check_master_dj_client(user,password) =
log("master connected")
#get the output of the php script
ret = get_process_lines("python3 #{auth_path} --master #{user} #{password}")
#ret has now the value of the live client (dj1,dj2, or djx), or "ERROR"/"unknown" ...
ret = list.hd(ret)
#return true to let the client transmit data, or false to tell harbor to decline
ret == "True"
end
def check_dj_client(user,password) =
log("live dj connected")
#get the output of the php script
ret = get_process_lines("python3 #{auth_path} --dj #{user} #{password}")
#ret has now the value of the live client (dj1,dj2, or djx), or "ERROR"/"unknown" ...
hd = list.hd(ret)
log("Live DJ authenticated: #{hd}")
hd == "True"
end
s = switch(id="schedule_noise_switch",
track_sensitive=false,
transitions=[transition_default, transition],
[({!scheduled_play_enabled}, stream_queue), ({true}, default)]
)
s = if dj_live_stream_port != 0 and dj_live_stream_mp != "" then
dj_live =
audio_to_stereo(
input.harbor(id="live_dj_harbor",
dj_live_stream_mp,
port=dj_live_stream_port,
auth=check_dj_client,
max=40.,
on_connect=live_dj_connect,
on_disconnect=live_dj_disconnect))
ignore(output.dummy(dj_live, fallible=true))
switch(id="show_schedule_noise_switch",
track_sensitive=false,
transitions=[transition, transition],
[({!live_dj_enabled}, dj_live), ({true}, s)]
)
else
s
end
s = if master_live_stream_port != 0 and master_live_stream_mp != "" then
master_dj =
audio_to_stereo(
input.harbor(id="master_harbor",
master_live_stream_mp,
port=master_live_stream_port,
auth=check_master_dj_client,
max=40.,
on_connect=master_dj_connect,
on_disconnect=master_dj_disconnect))
ignore(output.dummy(master_dj, fallible=true))
switch(id="master_show_schedule_noise_switch",
track_sensitive=false,
transitions=[transition, transition],
[({!master_dj_enabled}, master_dj), ({true}, s)]
)
else
s
end
# Attach a skip command to the source s:
#add_skip_command(s)
server.register(namespace="streams",
description="Stop Master DJ source.",
usage="master_dj_stop",
"master_dj_stop",
fun (s) -> begin log("streams.master_dj_stop") make_master_dj_unavailable() "Done." end)
server.register(namespace="streams",
description="Start Master DJ source.",
usage="master_dj_start",
"master_dj_start",
fun (s) -> begin log("streams.master_dj_start") make_master_dj_available() "Done." end)
server.register(namespace="streams",
description="Stop Live DJ source.",
usage="live_dj_stop",
"live_dj_stop",
fun (s) -> begin log("streams.live_dj_stop") make_live_dj_unavailable() "Done." end)
server.register(namespace="streams",
description="Start Live DJ source.",
usage="live_dj_start",
"live_dj_start",
fun (s) -> begin log("streams.live_dj_start") make_live_dj_available() "Done." end)
server.register(namespace="streams",
description="Stop Scheduled Play source.",
usage="scheduled_play_stop",
"scheduled_play_stop",
fun (s) -> begin log("streams.scheduled_play_stop") make_scheduled_play_unavailable() "Done." end)
server.register(namespace="streams",
description="Start Scheduled Play source.",
usage="scheduled_play_start",
"scheduled_play_start",
fun (s) -> begin log("streams.scheduled_play_start") make_scheduled_play_available() "Done." end)
if output_sound_device then
success = ref false
log(output_sound_device_type)
%ifdef output.alsa
if output_sound_device_type == "ALSA" then
ignore(output.alsa(s))
success := true
end
%endif
%ifdef output.ao
if output_sound_device_type == "AO" then
ignore(output.ao(s))
success := true
end
%endif
%ifdef output.oss
if output_sound_device_type == "OSS" then
ignore(output.oss(s))
success := true
end
%endif
%ifdef output.portaudio
if output_sound_device_type == "Portaudio" then
ignore(output.portaudio(s))
success := true
end
%endif
%ifdef output.pulseaudio
if output_sound_device_type == "Pulseaudio" then
ignore(output.pulseaudio(s))
success := true
end
%endif
if (!success == false) then
ignore(output.prefered(s))
end
end
if s1_enable == true then
if s1_output == 'shoutcast' then
s1_namespace := "shoutcast_stream_1"
else
s1_namespace := s1_mount
end
server.register(namespace=!s1_namespace, "connected", fun (s) -> begin log("#{!s1_namespace}.connected") !s1_connected end)
output_to(s1_output, s1_type, s1_bitrate, s1_host, s1_port, s1_pass,
s1_mount, s1_url, s1_description, s1_genre, s1_user, s, "1",
s1_connected, s1_name, s1_channels)
end
if s2_enable == true then
if s2_output == 'shoutcast' then
s2_namespace := "shoutcast_stream_2"
else
s2_namespace := s2_mount
end
server.register(namespace=!s2_namespace, "connected", fun (s) -> begin log("#{!s2_namespace}.connected") !s2_connected end)
output_to(s2_output, s2_type, s2_bitrate, s2_host, s2_port, s2_pass,
s2_mount, s2_url, s2_description, s2_genre, s2_user, s, "2",
s2_connected, s2_name, s2_channels)
end
if s3_enable == true then
if s3_output == 'shoutcast' then
s3_namespace := "shoutcast_stream_3"
else
s3_namespace := s3_mount
end
server.register(namespace=!s3_namespace, "connected", fun (s) -> begin log("#{!s3_namespace}.connected") !s3_connected end)
output_to(s3_output, s3_type, s3_bitrate, s3_host, s3_port, s3_pass,
s3_mount, s3_url, s3_description, s3_genre, s3_user, s, "3",
s3_connected, s3_name, s3_channels)
end
s4_namespace = ref ''
if s4_enable == true then
log("Stream 4 Enabled")
if s4_output == 'shoutcast' then
s4_namespace := "shoutcast_stream_4"
else
s4_namespace := s4_mount
end
server.register(namespace=!s4_namespace, "connected", fun (s) -> begin log("#{!s4_namespace}.connected") !s4_connected end)
output_to(s4_output, s4_type, s4_bitrate, s4_host, s4_port, s4_pass,
s4_mount, s4_url, s4_name, s4_genre, s4_user, s, "4",
s4_connected, s4_description, s4_channels)
end
command = "timeout --signal=KILL 45 libretime-playout-notify --liquidsoap-started &"
log(command)
system(command)

View file

@ -0,0 +1,67 @@
if bitrate == 24 then
if stereo then
ignore(output_stereo(%mp3(bitrate = 24, stereo = true), !source))
else
ignore(output_mono(%mp3(bitrate = 24, stereo = false), mean(!source)))
end
elsif bitrate == 32 then
if stereo then
ignore(output_stereo(%mp3(bitrate = 32, stereo = true), !source))
else
ignore(output_mono(%mp3(bitrate = 32, stereo = false), mean(!source)))
end
elsif bitrate == 48 then
if stereo then
ignore(output_stereo(%mp3(bitrate = 48, stereo = true), !source))
else
ignore(output_mono(%mp3(bitrate = 48, stereo = false), mean(!source)))
end
elsif bitrate == 64 then
if stereo then
ignore(output_stereo(%mp3(bitrate = 64, stereo = true), !source))
else
ignore(output_mono(%mp3(bitrate = 64, stereo = false), mean(!source)))
end
elsif bitrate == 96 then
if stereo then
ignore(output_stereo(%mp3(bitrate = 96, stereo = true), !source))
else
ignore(output_mono(%mp3(bitrate = 96, stereo = false), mean(!source)))
end
elsif bitrate == 128 then
if stereo then
ignore(output_stereo(%mp3(bitrate = 128, stereo = true), !source))
else
ignore(output_mono(%mp3(bitrate = 128, stereo = false), mean(!source)))
end
elsif bitrate == 160 then
if stereo then
ignore(output_stereo(%mp3(bitrate = 160, stereo = true), !source))
else
ignore(output_mono(%mp3(bitrate = 160, stereo = false), mean(!source)))
end
elsif bitrate == 192 then
if stereo then
ignore(output_stereo(%mp3(bitrate = 192, stereo = true), !source))
else
ignore(output_mono(%mp3(bitrate = 192, stereo = false), mean(!source)))
end
elsif bitrate == 224 then
if stereo then
ignore(output_stereo(%mp3(bitrate = 224, stereo = true), !source))
else
ignore(output_mono(%mp3(bitrate = 224, stereo = false), mean(!source)))
end
elsif bitrate == 256 then
if stereo then
ignore(output_stereo(%mp3(bitrate = 256, stereo = true), !source))
else
ignore(output_mono(%mp3(bitrate = 256, stereo = false), mean(!source)))
end
elsif bitrate == 320 then
if stereo then
ignore(output_stereo(%mp3(bitrate = 320, stereo = true), !source))
else
ignore(output_mono(%mp3(bitrate = 320, stereo = false), mean(!source)))
end
end

View file

@ -0,0 +1,59 @@
if not icecast_vorbis_metadata then
source := add(normalize=false, [amplify(0.00001, noise()), !source])
end
if bitrate == 24 or bitrate == 32 or bitrate == 48 then
if stereo then
ignore(output_stereo(%vorbis(quality=-0.1, channels = 2), !source))
else
ignore(output_mono(%vorbis(quality=-0.1, channels = 1), mean(!source)))
end
elsif bitrate == 64 then
if stereo then
ignore(output_stereo(%vorbis(quality=0, channels = 2), !source))
else
ignore(output_mono(%vorbis(quality=0, channels = 1), mean(!source)))
end
elsif bitrate == 96 then
if stereo then
ignore(output_stereo(%vorbis(quality=0.2, channels = 2), !source))
else
ignore(output_mono(%vorbis(quality=0.2, channels = 1), mean(!source)))
end
elsif bitrate == 128 then
if stereo then
ignore(output_stereo(%vorbis(quality=0.4, channels = 2), !source))
else
ignore(output_mono(%vorbis(quality=0.4, channels = 1), mean(!source)))
end
elsif bitrate == 160 then
if stereo then
ignore(output_stereo(%vorbis(quality=0.5, channels = 2), !source))
else
ignore(output_mono(%vorbis(quality=0.5, channels = 1), mean(!source)))
end
elsif bitrate == 192 then
if stereo then
ignore(output_stereo(%vorbis(quality=0.6, channels = 2), !source))
else
ignore(output_mono(%vorbis(quality=0.6, channels = 1), mean(!source)))
end
elsif bitrate == 224 then
if stereo then
ignore(output_stereo(%vorbis(quality=0.7, channels = 2), !source))
else
ignore(output_mono(%vorbis(quality=0.7, channels = 1), mean(!source)))
end
elsif bitrate == 256 then
if stereo then
ignore(output_stereo(%vorbis(quality=0.8, channels = 2), !source))
else
ignore(output_mono(%vorbis(quality=0.8, channels = 1), mean(!source)))
end
elsif bitrate == 320 then
if stereo then
ignore(output_stereo(%vorbis(quality=0.9, channels = 2), !source))
else
ignore(output_mono(%vorbis(quality=0.9, channels = 1), mean(!source)))
end
end

View file

@ -0,0 +1,67 @@
if bitrate == 24 then
if stereo then
ignore(output_stereo(%opus(bitrate = 24, channels = 2, signal="music", application="audio", complexity=10, vbr="constrained"), !source))
else
ignore(output_mono(%opus(bitrate = 24, channels = 1, signal="music", application="audio", complexity=10, vbr="constrained"), mean(!source)))
end
elsif bitrate == 32 then
if stereo then
ignore(output_stereo(%opus(bitrate = 32, channels = 2, signal="music", application="audio", complexity=10, vbr="constrained"), !source))
else
ignore(output_mono(%opus(bitrate = 32, channels = 1, signal="music", application="audio", complexity=10, vbr="constrained"), mean(!source)))
end
elsif bitrate == 48 then
if stereo then
ignore(output_stereo(%opus(bitrate = 48, channels = 2, signal="music", application="audio", complexity=10, vbr="constrained"), !source))
else
ignore(output_mono(%opus(bitrate = 48, channels = 1, signal="music", application="audio", complexity=10, vbr="constrained"), mean(!source)))
end
elsif bitrate == 64 then
if stereo then
ignore(output_stereo(%opus(bitrate = 64, channels = 2, signal="music", application="audio", complexity=10, vbr="constrained"), !source))
else
ignore(output_mono(%opus(bitrate = 64, channels = 1, signal="music", application="audio", complexity=10, vbr="constrained"), mean(!source)))
end
elsif bitrate == 96 then
if stereo then
ignore(output_stereo(%opus(bitrate = 96, channels = 2, signal="music", application="audio", complexity=10, vbr="constrained"), !source))
else
ignore(output_mono(%opus(bitrate = 96, channels = 1, signal="music", application="audio", complexity=10, vbr="constrained"), mean(!source)))
end
elsif bitrate == 128 then
if stereo then
ignore(output_stereo(%opus(bitrate = 128, channels = 2, signal="music", application="audio", complexity=10, vbr="constrained"), !source))
else
ignore(output_mono(%opus(bitrate = 128, channels = 1, signal="music", application="audio", complexity=10, vbr="constrained"), mean(!source)))
end
elsif bitrate == 160 then
if stereo then
ignore(output_stereo(%opus(bitrate = 160, channels = 2, signal="music", application="audio", complexity=10, vbr="constrained"), !source))
else
ignore(output_mono(%opus(bitrate = 160, channels = 1, signal="music", application="audio", complexity=10, vbr="constrained"), mean(!source)))
end
elsif bitrate == 192 then
if stereo then
ignore(output_stereo(%opus(bitrate = 192, channels = 2, signal="music", application="audio", complexity=10, vbr="constrained"), !source))
else
ignore(output_mono(%opus(bitrate = 192, channels = 1, signal="music", application="audio", complexity=10, vbr="constrained"), mean(!source)))
end
elsif bitrate == 224 then
if stereo then
ignore(output_stereo(%opus(bitrate = 224, channels = 2, signal="music", application="audio", complexity=10, vbr="constrained"), !source))
else
ignore(output_mono(%opus(bitrate = 224, channels = 1, signal="music", application="audio", complexity=10, vbr="constrained"), mean(!source)))
end
elsif bitrate == 256 then
if stereo then
ignore(output_stereo(%opus(bitrate = 256, channels = 2, signal="music", application="audio", complexity=10, vbr="constrained"), !source))
else
ignore(output_mono(%opus(bitrate = 256, channels = 1, signal="music", application="audio", complexity=10, vbr="constrained"), mean(!source)))
end
elsif bitrate == 320 then
if stereo then
ignore(output_stereo(%opus(bitrate = 320, channels = 2, signal="music", application="audio", complexity=10, vbr="constrained"), !source))
else
ignore(output_mono(%opus(bitrate = 320, channels = 1, signal="music", application="audio", complexity=10, vbr="constrained"), mean(!source)))
end
end

View file

@ -0,0 +1,23 @@
if bitrate == 24 then
ignore(output_stereo(%fdkaac(bitrate = 24, aot="mpeg4_he_aac_v2", afterburner=false, sbr_mode=true), !source))
elsif bitrate == 32 then
ignore(output_stereo(%fdkaac(bitrate = 32, aot="mpeg4_he_aac_v2", afterburner=false, sbr_mode=true), !source))
elsif bitrate == 48 then
ignore(output_stereo(%fdkaac(bitrate = 48, aot="mpeg4_he_aac_v2", afterburner=false, sbr_mode=true), !source))
elsif bitrate == 64 then
ignore(output_stereo(%fdkaac(bitrate = 64, aot="mpeg4_he_aac_v2", afterburner=false, sbr_mode=true), !source))
elsif bitrate == 96 then
ignore(output_stereo(%fdkaac(bitrate = 96, aot="mpeg4_aac_lc", afterburner=false, sbr_mode=true), !source))
elsif bitrate == 128 then
ignore(output_stereo(%fdkaac(bitrate = 128, aot="mpeg4_aac_lc", afterburner=false, sbr_mode=true), !source))
elsif bitrate == 160 then
ignore(output_stereo(%fdkaac(bitrate = 160, aot="mpeg4_aac_lc", afterburner=true, sbr_mode=true), !source))
elsif bitrate == 192 then
ignore(output_stereo(%fdkaac(bitrate = 192, aot="mpeg4_aac_lc", afterburner=true, sbr_mode=true), !source))
elsif bitrate == 224 then
ignore(output_stereo(%fdkaac(bitrate = 224, aot="mpeg4_aac_lc", afterburner=true, sbr_mode=true), !source))
elsif bitrate == 256 then
ignore(output_stereo(%fdkaac(bitrate = 256, aot="mpeg4_aac_lc", afterburner=true, sbr_mode=true), !source))
elsif bitrate == 320 then
ignore(output_stereo(%fdkaac(bitrate = 320, aot="mpeg4_aac_lc", afterburner=true, sbr_mode=true), !source))
end

View file

@ -0,0 +1,399 @@
def notify(m)
command = "timeout --signal=KILL 45 libretime-playout-notify --media-id=#{m['schedule_table_id']} &"
log(command)
system(command)
end
def notify_queue(m)
f = !dynamic_metadata_callback
ignore(f(m))
notify(m)
end
def notify_stream(m)
json_str = string.replace(pattern="\n",(fun (s) -> ""), json_of(m))
#if a string has a single apostrophe in it, let's comment it out by ending the string before right before it
#escaping the apostrophe, and then starting a new string right after it. This is why we use 3 apostrophes.
json_str = string.replace(pattern="'",(fun (s) -> "'\''"), json_str)
command = "timeout --signal=KILL 45 libretime-playout-notify --webstream='#{json_str}' --media-id=#{!current_dyn_id} &"
if !current_dyn_id != "-1" then
log(command)
system(command)
end
end
# A function applied to each metadata chunk
def append_title(m) =
log("Using stream_format #{!stream_metadata_type}")
if list.mem_assoc("mapped", m) then
#protection against applying this function twice. It shouldn't be happening
#and bug file with Liquidsoap.
m
else
if !stream_metadata_type == 1 then
[("title", "#{!show_name} - #{m['artist']} - #{m['title']}"), ("mapped", "true")]
elsif !stream_metadata_type == 2 then
[("title", "#{!station_name} - #{!show_name}"), ("mapped", "true")]
else
if "#{m['artist']}" == "" then
[("title", "#{m['title']}"), ("mapped", "true")]
else
[("title", "#{m['artist']} - #{m['title']}"), ("mapped", "true")]
end
end
end
end
def crossfade_airtime(s)
#duration is automatically overwritten by metadata fields passed in
#with audio
s = fade.in(type="log", duration=0., s)
s = fade.out(type="log", duration=0., s)
fader = fun (a,b) -> add(normalize=false,[b,a])
cross(fader,s)
end
def transition(a,b) =
log("transition called...")
add(normalize=false,
[ sequence([ blank(duration=0.01),
fade.initial(duration=!default_dj_fade, b) ]),
fade.final(duration=!default_dj_fade, a) ])
end
# we need this function for special transition case(from default to queue)
# we don't want the trasition fade to have effect on the first song that would
# be played switching out of the default(silent) source
def transition_default(a,b) =
log("transition called...")
if !just_switched then
just_switched := false
add(normalize=false,
[ sequence([ blank(duration=0.01),
fade.initial(duration=!default_dj_fade, b) ]),
fade.final(duration=!default_dj_fade, a) ])
else
just_switched := false
b
end
end
# Define a transition that fades out the
# old source, adds a single, and then
# plays the new source
def to_live(old,new) =
# Fade out old source
old = fade.final(old)
# Compose this in sequence with
# the new source
sequence([old,new])
end
def output_to(output_type, type, bitrate, host, port, pass, mount_point, url, description, genre, user, s, stream, connected, name, channels) =
source = ref s
def on_error(msg)
connected := "false"
command = "timeout --signal=KILL 45 libretime-playout-notify --error='#{msg}' --stream-id=#{stream} --time=#{!time} &"
system(command)
log(command)
5.
end
def on_connect()
connected := "true"
command = "timeout --signal=KILL 45 libretime-playout-notify --connect --stream-id=#{stream} --time=#{!time} &"
system(command)
log(command)
end
stereo = (channels == "stereo")
if output_type == "icecast" then
user_ref = ref user
if user == "" then
user_ref := "source"
end
output_mono = output.icecast(host = host,
port = port,
password = pass,
mount = mount_point,
fallible = true,
url = url,
description = description,
name = name,
genre = genre,
user = !user_ref,
on_error = on_error,
on_connect = on_connect)
output_stereo = output.icecast(host = host,
port = port,
password = pass,
mount = mount_point,
fallible = true,
url = url,
description = description,
name = name,
genre = genre,
user = !user_ref,
on_error = on_error,
on_connect = on_connect)
if type == "mp3" then
%include "mp3.liq"
end
if type == "ogg" then
%include "ogg.liq"
end
%ifencoder %opus
if type == "opus" then
%include "opus.liq"
end
%endif
%ifencoder %fdkaac
if type == "aac" then
%include "fdkaac.liq"
end
%endif
else
user_ref = ref user
if user == "" then
user_ref := "source"
end
output_mono = output.shoutcast(id = "shoutcast_stream_#{stream}",
host = host,
port = port,
password = pass,
fallible = true,
url = url,
genre = genre,
name = description,
user = !user_ref,
on_error = on_error,
on_connect = on_connect)
output_stereo = output.shoutcast(id = "shoutcast_stream_#{stream}",
host = host,
port = port,
password = pass,
fallible = true,
url = url,
genre = genre,
name = description,
user = !user_ref,
on_error = on_error,
on_connect = on_connect)
if type == "mp3" then
%include "mp3.liq"
end
%ifencoder %fdkaac
if type == "aac" then
%include "fdkaac.liq"
end
%endif
end
end
# Add a skip function to a source
# when it does not have one
# by default
#def add_skip_command(s)
# # A command to skip
# def skip(_)
# # get playing (active) queue and flush it
# l = list.hd(server.execute("queue.secondary_queue"))
# l = string.split(separator=" ",l)
# list.iter(fun (rid) -> ignore(server.execute("queue.remove #{rid}")), l)
#
# l = list.hd(server.execute("queue.primary_queue"))
# l = string.split(separator=" ", l)
# if list.length(l) > 0 then
# source.skip(s)
# "Skipped"
# else
# "Not skipped"
# end
# end
# # Register the command:
# server.register(namespace="source",
# usage="skip",
# description="Skip the current song.",
# "skip",fun(s) -> begin log("source.skip") skip(s) end)
#end
def clear_queue(s)
source.skip(s)
end
def set_dynamic_source_id(id) =
current_dyn_id := id
string_of(!current_dyn_id)
end
def get_dynamic_source_id() =
string_of(!current_dyn_id)
end
#cc-4633
# NOTE
# A few values are hardcoded and may be dependent:
# - the delay in gracetime is linked with the buffer duration of input.http
# (delay should be a bit less than buffer)
# - crossing duration should be less than buffer length
# (at best, a higher duration will be ineffective)
# HTTP input with "restart" command that waits for "stop" to be effected
# before "start" command is issued. Optionally it takes a new URL to play,
# which makes it a convenient replacement for "url".
# In the future, this may become a core feature of the HTTP input.
# TODO If we stop and restart quickly several times in a row,
# the data bursts accumulate and create buffer overflow.
# Flushing the buffer on restart could be a good idea, but
# it would also create an interruptions while the buffer is
# refilling... on the other hand, this would avoid having to
# fade using both cross() and switch().
def input.http_restart(~id,~initial_url="http://dummy/url")
source = audio_to_stereo(input.http(buffer=5.,max=15.,id=id,autostart=false,initial_url))
def stopped()
"stopped" == list.hd(server.execute("#{id}.status"), default="")
end
server.register(namespace=id,
"restart",
usage="restart [url]",
fun (url) -> begin
if url != "" then
log(string_of(server.execute("#{id}.url #{url}")))
end
log(string_of(server.execute("#{id}.stop")))
add_timeout(0.5,
{ if stopped() then
log(string_of(server.execute("#{id}.start"))) ;
(-1.)
else 0.5 end})
"OK"
end)
# Dummy output should be useless if HTTP stream is meant
# to be listened to immediately. Otherwise, apply it.
#
# output.dummy(fallible=true,source)
source
end
# Transitions between URL changes in HTTP streams.
def cross_http(~debug=true,~http_input_id,source)
id = http_input_id
last_url = ref ""
change = ref false
def on_m(m)
notify_stream(m)
changed = m["source_url"] != !last_url
log("URL now #{m['source_url']} (change: #{changed})")
if changed then
if !last_url != "" then change := true end
last_url := m["source_url"]
end
end
# We use both metadata and status to know about the current URL.
# Using only metadata may be more precise is crazy corner cases,
# but it's also asking too much: the metadata may not pass through
# before the crosser is instantiated.
# Using only status in crosser misses some info, eg. on first URL.
source = on_metadata(on_m,source)
cross_d = 3.
def crosser(a,b)
url = list.hd(server.execute('#{id}.url'), default="")
status = list.hd(server.execute('#{id}.status'))
on_m([("source_url",url)])
if debug then
log("New track inside HTTP stream")
log(" status: #{status}")
log(" need to cross: #{!change}")
log(" remaining #{source.remaining(a)} sec before, \
#{source.remaining(b)} sec after")
end
if !change then
change := false
# In principle one should avoid crossing on a live stream
# it'd be okay to do it here (eg. use add instead of sequence)
# because it's only once per URL, but be cautious.
sequence([fade.out(duration=cross_d,a),fade.in(b)])
else
# This is done on tracks inside a single stream.
# Do NOT cross here or you'll gradually empty the buffer!
sequence([a,b])
end
end
# Setting conservative=true would mess with the delayed switch below
cross(duration=cross_d,conservative=false,crosser,source)
end
# Custom fallback between http and default source with fading of
# beginning and end of HTTP stream.
# It does not take potential URL changes into account, as long as
# they do not interrupt streaming (thanks to the HTTP buffer).
def http_fallback(~http_input_id,~http,~default)
id = http_input_id
# We use a custom switching predicate to trigger switching (and thus,
# transitions) before the end of a track (rather, end of HTTP stream).
# It is complexified because we don't want to trigger switching when
# HTTP disconnects for just an instant, when changing URL: for that
# we use gracetime below.
def gracetime(~delay=3.,f)
last_true = ref 0.
{ if f() then
last_true := gettimeofday()
true
else
gettimeofday() < !last_true+delay
end }
end
def connected()
status = list.hd(server.execute("#{id}.status"), default="")
not(list.mem(status,["polling","stopped"]))
end
connected = gracetime(connected)
def to_live(a,b) =
log("TRANSITION to live")
add(normalize=false,
[fade.initial(b),fade.final(a)])
end
def to_static(a,b) =
log("TRANSITION to static")
sequence([fade.out(a),fade.initial(b)])
end
switch(
track_sensitive=false,
transitions=[to_live,to_static],
[(# make sure it is connected, and not buffering
{connected() and source.is_ready(http) and !webstream_enabled}, http),
({true},default)])
end

View file

@ -0,0 +1,457 @@
%include "/etc/airtime/liquidsoap.cfg"
set("log.file.path", log_file)
set("server.telnet", true)
set("server.telnet.port", 1234)
#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'
pypo_data = ref '0'
stream_metadata_type = ref 0
default_dj_fade = ref 0.
station_name = ref ''
show_name = ref ''
dynamic_metadata_callback = ref fun (s) -> begin () end
s1_connected = ref ''
s2_connected = ref ''
s3_connected = ref ''
s4_connected = ref ''
s1_namespace = ref ''
s2_namespace = ref ''
s3_namespace = ref ''
just_switched = ref false
%include "ls_lib.liq"
sources = ref []
source_id = ref 0
def check_version(~version=liquidsoap.version, major, minor) =
v = list.map(int_of_string, string.split(separator="\.", version))
list.nth(v,0,default=0) > major or list.nth(v,0,default=0) == major and list.nth(v,1,default=0) >= minor
end
# cue cut fix for liquidsoap <1.2.2
#
# This was most likely broken on 1.1.1 (debian) as well.
#
# adapted from https://github.com/savonet/liquidsoap/issues/390#issuecomment-277562081
#
def fix_cue_in(~cue_in_metadata='liq_cue_in', m) =
# 0.04 might need to be adjusted according to your frame size
if float_of_string(m[cue_in_metadata]) < 0.04 then
[(cue_in_metadata, "0")]
else
[]
end
end
def create_source()
l = request.equeue(id="s#{!source_id}", length=0.5)
l = audio_to_stereo(id="queue_src", l)
l = if not check_version(1, 3) then
map_metadata(fix_cue_in, l)
else
l
end
l = cue_cut(l)
l = amplify(1., override="replay_gain", l)
# the crossfade function controls fade in/out
l = crossfade_airtime(l)
l = on_metadata(notify_queue, l)
sources := list.append([l], !sources)
server.register(namespace="queues",
"s#{!source_id}_skip",
fun (s) -> begin log("queues.s#{!source_id}_skip")
clear_queue(l)
"Done"
end)
source_id := !source_id + 1
end
create_source()
create_source()
create_source()
create_source()
create_source()
create_source()
create_source()
create_source()
queue = add(!sources, normalize=false)
pair = insert_metadata(queue)
dynamic_metadata_callback := fst(pair)
queue = snd(pair)
output.dummy(fallible=true, queue)
http = input.http_restart(id="http")
http = cross_http(http_input_id="http",http)
output.dummy(fallible=true, http)
stream_queue = http_fallback(http_input_id="http", http=http, default=queue)
stream_queue = map_metadata(update=false, append_title, stream_queue)
ignore(output.dummy(stream_queue, fallible=true))
server.register(namespace="vars",
"pypo_data",
fun (s) -> begin log("vars.pypo_data") pypo_data := s "Done" end)
server.register(namespace="vars",
"stream_metadata_type",
fun (s) -> begin log("vars.stream_metadata_type") stream_metadata_type := int_of_string(s) s end)
server.register(namespace="vars",
"show_name",
fun (s) -> begin log("vars.show_name") show_name := s s end)
server.register(namespace="vars",
"station_name",
fun (s) -> begin log("vars.station_name") station_name := s s end)
server.register(namespace="vars",
"bootup_time",
fun (s) -> begin log("vars.bootup_time") time := s s end)
server.register(namespace="streams",
"connection_status",
fun (s) -> begin log("streams.connection_status") "1:#{!s1_connected},2:#{!s2_connected},3:#{!s3_connected},4:#{!s4_connected}" end)
server.register(namespace="vars",
"default_dj_fade",
fun (s) -> begin log("vars.default_dj_fade") default_dj_fade := float_of_string(s) s end)
server.register(namespace="dynamic_source",
description="Enable webstream output",
usage='start',
"output_start",
fun (s) -> begin log("dynamic_source.output_start")
notify([("schedule_table_id", !current_dyn_id)])
webstream_enabled := true "enabled" end)
server.register(namespace="dynamic_source",
description="Enable webstream output",
usage='stop',
"output_stop",
fun (s) -> begin log("dynamic_source.output_stop") webstream_enabled := false "disabled" end)
server.register(namespace="dynamic_source",
description="Set the streams cc_schedule row id",
usage="id <id>",
"id",
fun (s) -> begin log("dynamic_source.id") set_dynamic_source_id(s) end)
server.register(namespace="dynamic_source",
description="Get the streams cc_schedule row id",
usage="get_id",
"get_id",
fun (s) -> begin log("dynamic_source.get_id") get_dynamic_source_id() end)
#server.register(namespace="dynamic_source",
# description="Start a new dynamic source.",
# usage="start <uri>",
# "read_start",
# fun (uri) -> begin log("dynamic_source.read_start") begin_stream_read(uri) end)
#server.register(namespace="dynamic_source",
# description="Stop a dynamic source.",
# usage="stop <id>",
# "read_stop",
# fun (s) -> begin log("dynamic_source.read_stop") stop_stream_read(s) end)
#server.register(namespace="dynamic_source",
# description="Stop a dynamic source.",
# usage="stop <id>",
# "read_stop_all",
# fun (s) -> begin log("dynamic_source.read_stop") destroy_dynamic_source_all() end)
default = amplify(id="silence_src", 0.00001, noise())
ref_off_air_meta = ref off_air_meta
if !ref_off_air_meta == "" then
ref_off_air_meta := "LibreTime - offline"
end
default = rewrite_metadata([("title", !ref_off_air_meta)], default)
ignore(output.dummy(default, fallible=true))
master_dj_enabled = ref false
live_dj_enabled = ref false
scheduled_play_enabled = ref false
def make_master_dj_available()
master_dj_enabled := true
end
def make_master_dj_unavailable()
master_dj_enabled := false
end
def make_live_dj_available()
live_dj_enabled := true
end
def make_live_dj_unavailable()
live_dj_enabled := false
end
def make_scheduled_play_available()
scheduled_play_enabled := true
just_switched := true
end
def make_scheduled_play_unavailable()
scheduled_play_enabled := false
end
def update_source_status(sourcename, status) =
command = "timeout --signal=KILL 45 libretime-playout-notify --source-name=#{sourcename} --source-status=#{status} &"
system(command)
log(command)
end
def live_dj_connect(header) =
update_source_status("live_dj", true)
end
def live_dj_disconnect() =
update_source_status("live_dj", false)
end
def master_dj_connect(header) =
update_source_status("master_dj", true)
end
def master_dj_disconnect() =
update_source_status("master_dj", false)
end
# Auth function for live stream
# @Category LiveStream
# @param user Username to check against LibreTime API
# @param password Password to check against LibreTime API
# @param ~type Type of password to check, "dj" or "master, default: "master"
def check_auth(user="", password="", ~type="master") =
log("#{type} user #{user} connected",label="#{type}_source")
# Check auth based on return value from auth script
ret = snd(snd(run_process("python3 #{auth_path} --#{type} #{user} #{password}"))) == "0"
if ret then
log("#{type} user #{user} authenticated",label="#{type}_source")
else
log("#{type} user #{user} auth failed",label="#{type}_source",level=2)
end
ret
end
# Check master source auth
# @Category LiveStream
# @param user Username to check against LibreTime API
# @param password Password to check against LibreTime API
def check_master_dj_client(user, password) =
check_auth(user, password)
end
# Check dj/show source auth
# @Category LiveStream
# @param user Username to check against LibreTime API
# @param password Password to check against LibreTime API
def check_dj_client(user, password) =
check_auth(user, password, type="dj")
end
s = switch(id="schedule_noise_switch",
track_sensitive=false,
transitions=[transition_default, transition],
[({!scheduled_play_enabled}, stream_queue), ({true}, default)]
)
s = if dj_live_stream_port != 0 and dj_live_stream_mp != "" then
dj_live =
audio_to_stereo(
input.harbor(id="live_dj_harbor",
dj_live_stream_mp,
port=dj_live_stream_port,
auth=check_dj_client,
max=40.,
on_connect=live_dj_connect,
on_disconnect=live_dj_disconnect))
ignore(output.dummy(dj_live, fallible=true))
switch(id="show_schedule_noise_switch",
track_sensitive=false,
transitions=[transition, transition],
[({!live_dj_enabled}, dj_live), ({true}, s)]
)
else
s
end
s = if master_live_stream_port != 0 and master_live_stream_mp != "" then
master_dj =
audio_to_stereo(
input.harbor(id="master_harbor",
master_live_stream_mp,
port=master_live_stream_port,
auth=check_master_dj_client,
max=40.,
on_connect=master_dj_connect,
on_disconnect=master_dj_disconnect))
ignore(output.dummy(master_dj, fallible=true))
switch(id="master_show_schedule_noise_switch",
track_sensitive=false,
transitions=[transition, transition],
[({!master_dj_enabled}, master_dj), ({true}, s)]
)
else
s
end
# Attach a skip command to the source s:
#add_skip_command(s)
server.register(namespace="streams",
description="Stop Master DJ source.",
usage="master_dj_stop",
"master_dj_stop",
fun (s) -> begin log("streams.master_dj_stop") make_master_dj_unavailable() "Done." end)
server.register(namespace="streams",
description="Start Master DJ source.",
usage="master_dj_start",
"master_dj_start",
fun (s) -> begin log("streams.master_dj_start") make_master_dj_available() "Done." end)
server.register(namespace="streams",
description="Stop Live DJ source.",
usage="live_dj_stop",
"live_dj_stop",
fun (s) -> begin log("streams.live_dj_stop") make_live_dj_unavailable() "Done." end)
server.register(namespace="streams",
description="Start Live DJ source.",
usage="live_dj_start",
"live_dj_start",
fun (s) -> begin log("streams.live_dj_start") make_live_dj_available() "Done." end)
server.register(namespace="streams",
description="Stop Scheduled Play source.",
usage="scheduled_play_stop",
"scheduled_play_stop",
fun (s) -> begin log("streams.scheduled_play_stop") make_scheduled_play_unavailable() "Done." end)
server.register(namespace="streams",
description="Start Scheduled Play source.",
usage="scheduled_play_start",
"scheduled_play_start",
fun (s) -> begin log("streams.scheduled_play_start") make_scheduled_play_available() "Done." end)
if output_sound_device then
success = ref false
log(output_sound_device_type)
%ifdef output.alsa
if output_sound_device_type == "ALSA" then
ignore(output.alsa(s))
success := true
end
%endif
%ifdef output.ao
if output_sound_device_type == "AO" then
ignore(output.ao(s))
success := true
end
%endif
%ifdef output.oss
if output_sound_device_type == "OSS" then
ignore(output.oss(s))
success := true
end
%endif
%ifdef output.portaudio
if output_sound_device_type == "Portaudio" then
ignore(output.portaudio(s))
success := true
end
%endif
%ifdef output.pulseaudio
if output_sound_device_type == "Pulseaudio" then
ignore(output.pulseaudio(s))
success := true
end
%endif
if (!success == false) then
ignore(output.prefered(s))
end
end
if s1_enable == true then
if s1_output == 'shoutcast' then
s1_namespace := "shoutcast_stream_1"
else
s1_namespace := s1_mount
end
server.register(namespace=!s1_namespace, "connected", fun (s) -> begin log("#{!s1_namespace}.connected") !s1_connected end)
output_to(s1_output, s1_type, s1_bitrate, s1_host, s1_port, s1_pass,
s1_mount, s1_url, s1_description, s1_genre, s1_user, s, "1",
s1_connected, s1_name, s1_channels)
end
if s2_enable == true then
if s2_output == 'shoutcast' then
s2_namespace := "shoutcast_stream_2"
else
s2_namespace := s2_mount
end
server.register(namespace=!s2_namespace, "connected", fun (s) -> begin log("#{!s2_namespace}.connected") !s2_connected end)
output_to(s2_output, s2_type, s2_bitrate, s2_host, s2_port, s2_pass,
s2_mount, s2_url, s2_description, s2_genre, s2_user, s, "2",
s2_connected, s2_name, s2_channels)
end
if s3_enable == true then
if s3_output == 'shoutcast' then
s3_namespace := "shoutcast_stream_3"
else
s3_namespace := s3_mount
end
server.register(namespace=!s3_namespace, "connected", fun (s) -> begin log("#{!s3_namespace}.connected") !s3_connected end)
output_to(s3_output, s3_type, s3_bitrate, s3_host, s3_port, s3_pass,
s3_mount, s3_url, s3_description, s3_genre, s3_user, s, "3",
s3_connected, s3_name, s3_channels)
end
s4_namespace = ref ''
if s4_enable == true then
log("Stream 4 Enabled")
if s4_output == 'shoutcast' then
s4_namespace := "shoutcast_stream_4"
else
s4_namespace := s4_mount
end
server.register(namespace=!s4_namespace, "connected", fun (s) -> begin log("#{!s4_namespace}.connected") !s4_connected end)
output_to(s4_output, s4_type, s4_bitrate, s4_host, s4_port, s4_pass,
s4_mount, s4_url, s4_name, s4_genre, s4_user, s, "4",
s4_connected, s4_description, s4_channels)
end
command = "timeout --signal=KILL 45 libretime-playout-notify --liquidsoap-started &"
log(command)
system(command)

View file

@ -0,0 +1,67 @@
if bitrate == 24 then
if stereo then
ignore(output_stereo(%mp3(bitrate = 24, stereo = true), !source))
else
ignore(output_mono(%mp3(bitrate = 24, stereo = false), mean(!source)))
end
elsif bitrate == 32 then
if stereo then
ignore(output_stereo(%mp3(bitrate = 32, stereo = true), !source))
else
ignore(output_mono(%mp3(bitrate = 32, stereo = false), mean(!source)))
end
elsif bitrate == 48 then
if stereo then
ignore(output_stereo(%mp3(bitrate = 48, stereo = true), !source))
else
ignore(output_mono(%mp3(bitrate = 48, stereo = false), mean(!source)))
end
elsif bitrate == 64 then
if stereo then
ignore(output_stereo(%mp3(bitrate = 64, stereo = true), !source))
else
ignore(output_mono(%mp3(bitrate = 64, stereo = false), mean(!source)))
end
elsif bitrate == 96 then
if stereo then
ignore(output_stereo(%mp3(bitrate = 96, stereo = true), !source))
else
ignore(output_mono(%mp3(bitrate = 96, stereo = false), mean(!source)))
end
elsif bitrate == 128 then
if stereo then
ignore(output_stereo(%mp3(bitrate = 128, stereo = true), !source))
else
ignore(output_mono(%mp3(bitrate = 128, stereo = false), mean(!source)))
end
elsif bitrate == 160 then
if stereo then
ignore(output_stereo(%mp3(bitrate = 160, stereo = true), !source))
else
ignore(output_mono(%mp3(bitrate = 160, stereo = false), mean(!source)))
end
elsif bitrate == 192 then
if stereo then
ignore(output_stereo(%mp3(bitrate = 192, stereo = true), !source))
else
ignore(output_mono(%mp3(bitrate = 192, stereo = false), mean(!source)))
end
elsif bitrate == 224 then
if stereo then
ignore(output_stereo(%mp3(bitrate = 224, stereo = true), !source))
else
ignore(output_mono(%mp3(bitrate = 224, stereo = false), mean(!source)))
end
elsif bitrate == 256 then
if stereo then
ignore(output_stereo(%mp3(bitrate = 256, stereo = true), !source))
else
ignore(output_mono(%mp3(bitrate = 256, stereo = false), mean(!source)))
end
elsif bitrate == 320 then
if stereo then
ignore(output_stereo(%mp3(bitrate = 320, stereo = true), !source))
else
ignore(output_mono(%mp3(bitrate = 320, stereo = false), mean(!source)))
end
end

View file

@ -0,0 +1,59 @@
if not icecast_vorbis_metadata then
source := add(normalize=false, [amplify(0.00001, noise()), !source])
end
if bitrate == 24 or bitrate == 32 or bitrate == 48 then
if stereo then
ignore(output_stereo(%vorbis(quality=-0.1, channels = 2), !source))
else
ignore(output_mono(%vorbis(quality=-0.1, channels = 1), mean(!source)))
end
elsif bitrate == 64 then
if stereo then
ignore(output_stereo(%vorbis(quality=0, channels = 2), !source))
else
ignore(output_mono(%vorbis(quality=0, channels = 1), mean(!source)))
end
elsif bitrate == 96 then
if stereo then
ignore(output_stereo(%vorbis(quality=0.2, channels = 2), !source))
else
ignore(output_mono(%vorbis(quality=0.2, channels = 1), mean(!source)))
end
elsif bitrate == 128 then
if stereo then
ignore(output_stereo(%vorbis(quality=0.4, channels = 2), !source))
else
ignore(output_mono(%vorbis(quality=0.4, channels = 1), mean(!source)))
end
elsif bitrate == 160 then
if stereo then
ignore(output_stereo(%vorbis(quality=0.5, channels = 2), !source))
else
ignore(output_mono(%vorbis(quality=0.5, channels = 1), mean(!source)))
end
elsif bitrate == 192 then
if stereo then
ignore(output_stereo(%vorbis(quality=0.6, channels = 2), !source))
else
ignore(output_mono(%vorbis(quality=0.6, channels = 1), mean(!source)))
end
elsif bitrate == 224 then
if stereo then
ignore(output_stereo(%vorbis(quality=0.7, channels = 2), !source))
else
ignore(output_mono(%vorbis(quality=0.7, channels = 1), mean(!source)))
end
elsif bitrate == 256 then
if stereo then
ignore(output_stereo(%vorbis(quality=0.8, channels = 2), !source))
else
ignore(output_mono(%vorbis(quality=0.8, channels = 1), mean(!source)))
end
elsif bitrate == 320 then
if stereo then
ignore(output_stereo(%vorbis(quality=0.9, channels = 2), !source))
else
ignore(output_mono(%vorbis(quality=0.9, channels = 1), mean(!source)))
end
end

View file

@ -0,0 +1,67 @@
if bitrate == 24 then
if stereo then
ignore(output_stereo(%opus(bitrate = 24, channels = 2, signal="music", application="audio", complexity=10, vbr="constrained"), !source))
else
ignore(output_mono(%opus(bitrate = 24, channels = 1, signal="music", application="audio", complexity=10, vbr="constrained"), mean(!source)))
end
elsif bitrate == 32 then
if stereo then
ignore(output_stereo(%opus(bitrate = 32, channels = 2, signal="music", application="audio", complexity=10, vbr="constrained"), !source))
else
ignore(output_mono(%opus(bitrate = 32, channels = 1, signal="music", application="audio", complexity=10, vbr="constrained"), mean(!source)))
end
elsif bitrate == 48 then
if stereo then
ignore(output_stereo(%opus(bitrate = 48, channels = 2, signal="music", application="audio", complexity=10, vbr="constrained"), !source))
else
ignore(output_mono(%opus(bitrate = 48, channels = 1, signal="music", application="audio", complexity=10, vbr="constrained"), mean(!source)))
end
elsif bitrate == 64 then
if stereo then
ignore(output_stereo(%opus(bitrate = 64, channels = 2, signal="music", application="audio", complexity=10, vbr="constrained"), !source))
else
ignore(output_mono(%opus(bitrate = 64, channels = 1, signal="music", application="audio", complexity=10, vbr="constrained"), mean(!source)))
end
elsif bitrate == 96 then
if stereo then
ignore(output_stereo(%opus(bitrate = 96, channels = 2, signal="music", application="audio", complexity=10, vbr="constrained"), !source))
else
ignore(output_mono(%opus(bitrate = 96, channels = 1, signal="music", application="audio", complexity=10, vbr="constrained"), mean(!source)))
end
elsif bitrate == 128 then
if stereo then
ignore(output_stereo(%opus(bitrate = 128, channels = 2, signal="music", application="audio", complexity=10, vbr="constrained"), !source))
else
ignore(output_mono(%opus(bitrate = 128, channels = 1, signal="music", application="audio", complexity=10, vbr="constrained"), mean(!source)))
end
elsif bitrate == 160 then
if stereo then
ignore(output_stereo(%opus(bitrate = 160, channels = 2, signal="music", application="audio", complexity=10, vbr="constrained"), !source))
else
ignore(output_mono(%opus(bitrate = 160, channels = 1, signal="music", application="audio", complexity=10, vbr="constrained"), mean(!source)))
end
elsif bitrate == 192 then
if stereo then
ignore(output_stereo(%opus(bitrate = 192, channels = 2, signal="music", application="audio", complexity=10, vbr="constrained"), !source))
else
ignore(output_mono(%opus(bitrate = 192, channels = 1, signal="music", application="audio", complexity=10, vbr="constrained"), mean(!source)))
end
elsif bitrate == 224 then
if stereo then
ignore(output_stereo(%opus(bitrate = 224, channels = 2, signal="music", application="audio", complexity=10, vbr="constrained"), !source))
else
ignore(output_mono(%opus(bitrate = 224, channels = 1, signal="music", application="audio", complexity=10, vbr="constrained"), mean(!source)))
end
elsif bitrate == 256 then
if stereo then
ignore(output_stereo(%opus(bitrate = 256, channels = 2, signal="music", application="audio", complexity=10, vbr="constrained"), !source))
else
ignore(output_mono(%opus(bitrate = 256, channels = 1, signal="music", application="audio", complexity=10, vbr="constrained"), mean(!source)))
end
elsif bitrate == 320 then
if stereo then
ignore(output_stereo(%opus(bitrate = 320, channels = 2, signal="music", application="audio", complexity=10, vbr="constrained"), !source))
else
ignore(output_mono(%opus(bitrate = 320, channels = 1, signal="music", application="audio", complexity=10, vbr="constrained"), mean(!source)))
end
end

View file

@ -0,0 +1,23 @@
if bitrate == 24 then
ignore(output_stereo(%fdkaac(bitrate = 24, aot="mpeg4_he_aac_v2", afterburner=false, sbr_mode=true), !source))
elsif bitrate == 32 then
ignore(output_stereo(%fdkaac(bitrate = 32, aot="mpeg4_he_aac_v2", afterburner=false, sbr_mode=true), !source))
elsif bitrate == 48 then
ignore(output_stereo(%fdkaac(bitrate = 48, aot="mpeg4_he_aac_v2", afterburner=false, sbr_mode=true), !source))
elsif bitrate == 64 then
ignore(output_stereo(%fdkaac(bitrate = 64, aot="mpeg4_he_aac_v2", afterburner=false, sbr_mode=true), !source))
elsif bitrate == 96 then
ignore(output_stereo(%fdkaac(bitrate = 96, aot="mpeg4_aac_lc", afterburner=false, sbr_mode=true), !source))
elsif bitrate == 128 then
ignore(output_stereo(%fdkaac(bitrate = 128, aot="mpeg4_aac_lc", afterburner=false, sbr_mode=true), !source))
elsif bitrate == 160 then
ignore(output_stereo(%fdkaac(bitrate = 160, aot="mpeg4_aac_lc", afterburner=true, sbr_mode=true), !source))
elsif bitrate == 192 then
ignore(output_stereo(%fdkaac(bitrate = 192, aot="mpeg4_aac_lc", afterburner=true, sbr_mode=true), !source))
elsif bitrate == 224 then
ignore(output_stereo(%fdkaac(bitrate = 224, aot="mpeg4_aac_lc", afterburner=true, sbr_mode=true), !source))
elsif bitrate == 256 then
ignore(output_stereo(%fdkaac(bitrate = 256, aot="mpeg4_aac_lc", afterburner=true, sbr_mode=true), !source))
elsif bitrate == 320 then
ignore(output_stereo(%fdkaac(bitrate = 320, aot="mpeg4_aac_lc", afterburner=true, sbr_mode=true), !source))
end

View file

@ -0,0 +1,389 @@
def notify(m)
command = "timeout --signal=KILL 45 libretime-playout-notify --media-id=#{m['schedule_table_id']} &"
log(command)
system(command)
end
def notify_queue(m)
f = !dynamic_metadata_callback
ignore(f(m))
notify(m)
end
def notify_stream(m)
json_str = string.replace(pattern="\n",(fun (s) -> ""), json_of(m))
#if a string has a single apostrophe in it, let's comment it out by ending the string before right before it
#escaping the apostrophe, and then starting a new string right after it. This is why we use 3 apostrophes.
json_str = string.replace(pattern="'",(fun (s) -> "'\''"), json_str)
command = "timeout --signal=KILL 45 libretime-playout-notify --webstream='#{json_str}' --media-id=#{!current_dyn_id} &"
if !current_dyn_id != "-1" then
log(command)
system(command)
end
end
# A function applied to each metadata chunk
def append_title(m) =
log("Using stream_format #{!stream_metadata_type}")
if list.mem_assoc("mapped", m) then
#protection against applying this function twice. It shouldn't be happening
#and bug file with Liquidsoap.
m
else
if !stream_metadata_type == 1 then
[("title", "#{!show_name} - #{m['artist']} - #{m['title']}"), ("mapped", "true")]
elsif !stream_metadata_type == 2 then
[("title", "#{!station_name} - #{!show_name}"), ("mapped", "true")]
else
if "#{m['artist']}" == "" then
[("title", "#{m['title']}"), ("mapped", "true")]
else
[("title", "#{m['artist']} - #{m['title']}"), ("mapped", "true")]
end
end
end
end
def transition(a,b) =
log("transition called...")
add(normalize=false,
[ sequence([ blank(duration=0.01),
fade.initial(duration=!default_dj_fade, b) ]),
fade.final(duration=!default_dj_fade, a) ])
end
# we need this function for special transition case(from default to queue)
# we don't want the trasition fade to have effect on the first song that would
# be played switching out of the default(silent) source
def transition_default(a,b) =
log("transition called...")
if !just_switched then
just_switched := false
add(normalize=false,
[ sequence([ blank(duration=0.01),
fade.initial(duration=!default_dj_fade, b) ]),
fade.final(duration=!default_dj_fade, a) ])
else
just_switched := false
b
end
end
# Define a transition that fades out the
# old source, adds a single, and then
# plays the new source
def to_live(old,new) =
# Fade out old source
old = fade.final(old)
# Compose this in sequence with
# the new source
sequence([old,new])
end
def output_to(output_type, type, bitrate, host, port, pass, mount_point, url, description, genre, user, s, stream, connected, name, channels) =
source = ref s
def on_error(msg)
connected := "false"
command = "timeout --signal=KILL 45 libretime-playout-notify --error='#{msg}' --stream-id=#{stream} --time=#{!time} &"
system(command)
log(command)
5.
end
def on_connect()
connected := "true"
command = "timeout --signal=KILL 45 libretime-playout-notify --connect --stream-id=#{stream} --time=#{!time} &"
system(command)
log(command)
end
stereo = (channels == "stereo")
if output_type == "icecast" then
user_ref = ref user
if user == "" then
user_ref := "source"
end
output_mono = output.icecast(host = host,
port = port,
password = pass,
mount = mount_point,
fallible = true,
url = url,
description = description,
name = name,
genre = genre,
user = !user_ref,
on_error = on_error,
on_connect = on_connect)
output_stereo = output.icecast(host = host,
port = port,
password = pass,
mount = mount_point,
fallible = true,
url = url,
description = description,
name = name,
genre = genre,
user = !user_ref,
on_error = on_error,
on_connect = on_connect)
if type == "mp3" then
%include "mp3.liq"
end
if type == "ogg" then
%include "ogg.liq"
end
%ifencoder %opus
if type == "opus" then
%include "opus.liq"
end
%endif
%ifencoder %fdkaac
if type == "aac" then
%include "fdkaac.liq"
end
%endif
else
user_ref = ref user
if user == "" then
user_ref := "source"
end
output_mono = output.shoutcast(id = "shoutcast_stream_#{stream}",
host = host,
port = port,
password = pass,
fallible = true,
url = url,
genre = genre,
name = description,
user = !user_ref,
on_error = on_error,
on_connect = on_connect)
output_stereo = output.shoutcast(id = "shoutcast_stream_#{stream}",
host = host,
port = port,
password = pass,
fallible = true,
url = url,
genre = genre,
name = description,
user = !user_ref,
on_error = on_error,
on_connect = on_connect)
if type == "mp3" then
%include "mp3.liq"
end
%ifencoder %fdkaac
if type == "aac" then
%include "fdkaac.liq"
end
%endif
end
end
# Add a skip function to a source
# when it does not have one
# by default
#def add_skip_command(s)
# # A command to skip
# def skip(_)
# # get playing (active) queue and flush it
# l = list.hd(server.execute("queue.secondary_queue"))
# l = string.split(separator=" ",l)
# list.iter(fun (rid) -> ignore(server.execute("queue.remove #{rid}")), l)
#
# l = list.hd(server.execute("queue.primary_queue"))
# l = string.split(separator=" ", l)
# if list.length(l) > 0 then
# source.skip(s)
# "Skipped"
# else
# "Not skipped"
# end
# end
# # Register the command:
# server.register(namespace="source",
# usage="skip",
# description="Skip the current song.",
# "skip",fun(s) -> begin log("source.skip") skip(s) end)
#end
def clear_queue(s)
source.skip(s)
end
def set_dynamic_source_id(id) =
current_dyn_id := id
string_of(!current_dyn_id)
end
def get_dynamic_source_id() =
string_of(!current_dyn_id)
end
#cc-4633
# NOTE
# A few values are hardcoded and may be dependent:
# - the delay in gracetime is linked with the buffer duration of input.http
# (delay should be a bit less than buffer)
# - crossing duration should be less than buffer length
# (at best, a higher duration will be ineffective)
# HTTP input with "restart" command that waits for "stop" to be effected
# before "start" command is issued. Optionally it takes a new URL to play,
# which makes it a convenient replacement for "url".
# In the future, this may become a core feature of the HTTP input.
# TODO If we stop and restart quickly several times in a row,
# the data bursts accumulate and create buffer overflow.
# Flushing the buffer on restart could be a good idea, but
# it would also create an interruptions while the buffer is
# refilling... on the other hand, this would avoid having to
# fade using both cross() and switch().
def input.http_restart(~id,~initial_url="http://dummy/url")
source = audio_to_stereo(input.http(buffer=5.,max=15.,id=id,autostart=false,initial_url))
def stopped()
"stopped" == list.hd(server.execute("#{id}.status"), default="")
end
server.register(namespace=id,
"restart",
usage="restart [url]",
fun (url) -> begin
if url != "" then
log(string_of(server.execute("#{id}.url #{url}")))
end
log(string_of(server.execute("#{id}.stop")))
add_timeout(0.5,
{ if stopped() then
log(string_of(server.execute("#{id}.start"))) ;
(-1.)
else 0.5 end})
"OK"
end)
# Dummy output should be useless if HTTP stream is meant
# to be listened to immediately. Otherwise, apply it.
#
# output.dummy(fallible=true,source)
source
end
# Transitions between URL changes in HTTP streams.
def cross_http(~debug=true,~http_input_id,source)
id = http_input_id
last_url = ref ""
change = ref false
def on_m(m)
notify_stream(m)
changed = m["source_url"] != !last_url
log("URL now #{m['source_url']} (change: #{changed})")
if changed then
if !last_url != "" then change := true end
last_url := m["source_url"]
end
end
# We use both metadata and status to know about the current URL.
# Using only metadata may be more precise is crazy corner cases,
# but it's also asking too much: the metadata may not pass through
# before the crosser is instantiated.
# Using only status in crosser misses some info, eg. on first URL.
source = on_metadata(on_m,source)
cross_d = 3.
def crosser(a,b,ma,mb,sa,sb)
url = list.hd(server.execute('#{id}.url'), default="")
status = list.hd(server.execute('#{id}.status'))
on_m([("source_url",url)])
if debug then
log("New track inside HTTP stream")
log(" status: #{status}")
log(" need to cross: #{!change}")
log(" remaining #{source.remaining(sa)} sec before, \
#{source.remaining(sb)} sec after")
end
if !change then
change := false
# In principle one should avoid crossing on a live stream
# it'd be okay to do it here (eg. use add instead of sequence)
# because it's only once per URL, but be cautious.
sequence([fade.out(duration=cross_d,sa),fade.in(sb)])
else
# This is done on tracks inside a single stream.
# Do NOT cross here or you'll gradually empty the buffer!
sequence([sa,sb])
end
end
# Setting conservative=true would mess with the delayed switch below
cross(duration=cross_d,conservative=false,crosser,source)
end
# Custom fallback between http and default source with fading of
# beginning and end of HTTP stream.
# It does not take potential URL changes into account, as long as
# they do not interrupt streaming (thanks to the HTTP buffer).
def http_fallback(~http_input_id,~http,~default)
id = http_input_id
# We use a custom switching predicate to trigger switching (and thus,
# transitions) before the end of a track (rather, end of HTTP stream).
# It is complexified because we don't want to trigger switching when
# HTTP disconnects for just an instant, when changing URL: for that
# we use gracetime below.
def gracetime(~delay=3.,f)
last_true = ref 0.
{ if f() then
last_true := gettimeofday()
true
else
gettimeofday() < !last_true+delay
end }
end
def connected()
status = list.hd(server.execute("#{id}.status"), default="")
not(list.mem(status,["polling","stopped"]))
end
connected = gracetime(connected)
def to_live(a,b) =
log("TRANSITION to live")
add(normalize=false,
[fade.initial(b),fade.final(a)])
end
def to_static(a,b) =
log("TRANSITION to static")
sequence([fade.out(a),fade.initial(b)])
end
switch(
track_sensitive=false,
transitions=[to_live,to_static],
[(# make sure it is connected, and not buffering
{connected() and source.is_ready(http) and !webstream_enabled}, http),
({true},default)])
end

View file

@ -0,0 +1,460 @@
%include "/etc/airtime/liquidsoap.cfg"
set("log.file.path", log_file)
set("server.telnet", true)
set("server.telnet.port", 1234)
#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'
pypo_data = ref '0'
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
s1_connected = ref ''
s2_connected = ref ''
s3_connected = ref ''
s4_connected = ref ''
s1_namespace = ref ''
s2_namespace = ref ''
s3_namespace = ref ''
just_switched = ref false
%include "ls_lib.liq"
sources = ref []
source_id = ref 0
def check_version(~version=liquidsoap.version, major, minor) =
v = list.map(int_of_string, string.split(separator="\.", version))
list.nth(v,0,default=0) > major or list.nth(v,0,default=0) == major and list.nth(v,1,default=0) >= minor
end
# cue cut fix for liquidsoap <1.2.2
#
# This was most likely broken on 1.1.1 (debian) as well.
#
# adapted from https://github.com/savonet/liquidsoap/issues/390#issuecomment-277562081
#
def fix_cue_in(~cue_in_metadata='liq_cue_in', m) =
# 0.04 might need to be adjusted according to your frame size
if float_of_string(m[cue_in_metadata]) < 0.04 then
[(cue_in_metadata, "0")]
else
[]
end
end
def create_source()
l = request.equeue(id="s#{!source_id}", length=0.5)
l = audio_to_stereo(id="queue_src", l)
l = if not check_version(1, 3) then
map_metadata(fix_cue_in, l)
else
l
end
l = cue_cut(l)
l = amplify(1., override="replay_gain", l)
# the crossfade function controls fade in/out
l = crossfade(duration=0., smart=true, l)
l = on_metadata(notify_queue, l)
sources := list.append([l], !sources)
server.register(namespace="queues",
"s#{!source_id}_skip",
fun (s) -> begin log("queues.s#{!source_id}_skip")
clear_queue(l)
"Done"
end)
source_id := !source_id + 1
end
create_source()
create_source()
create_source()
create_source()
create_source()
create_source()
create_source()
create_source()
queue = add(!sources, normalize=false)
pair = insert_metadata(queue)
dynamic_metadata_callback := fst(pair)
queue = snd(pair)
output.dummy(fallible=true, queue)
http = input.http_restart(id="http")
http = cross_http(http_input_id="http",http)
output.dummy(fallible=true, http)
stream_queue = http_fallback(http_input_id="http", http=http, default=queue)
stream_queue = map_metadata(update=false, append_title, stream_queue)
ignore(output.dummy(stream_queue, fallible=true))
server.register(namespace="vars",
"pypo_data",
fun (s) -> begin log("vars.pypo_data") pypo_data := s "Done" end)
server.register(namespace="vars",
"stream_metadata_type",
fun (s) -> begin log("vars.stream_metadata_type") stream_metadata_type := int_of_string(s) s end)
server.register(namespace="vars",
"show_name",
fun (s) -> begin log("vars.show_name") show_name := s s end)
server.register(namespace="vars",
"station_name",
fun (s) -> begin log("vars.station_name") station_name := s s end)
server.register(namespace="vars",
"bootup_time",
fun (s) -> begin log("vars.bootup_time") time := s s end)
server.register(namespace="streams",
"connection_status",
fun (s) -> begin log("streams.connection_status") "1:#{!s1_connected},2:#{!s2_connected},3:#{!s3_connected},4:#{!s4_connected}" end)
server.register(namespace="vars",
"default_dj_fade",
fun (s) -> begin log("vars.default_dj_fade") default_dj_fade := float_of_string(s) s end)
server.register(namespace="dynamic_source",
description="Enable webstream output",
usage='start',
"output_start",
fun (s) -> begin log("dynamic_source.output_start")
notify([("schedule_table_id", !current_dyn_id)])
webstream_enabled := true "enabled" end)
server.register(namespace="dynamic_source",
description="Enable webstream output",
usage='stop',
"output_stop",
fun (s) -> begin log("dynamic_source.output_stop") webstream_enabled := false "disabled" end)
server.register(namespace="dynamic_source",
description="Set the streams cc_schedule row id",
usage="id <id>",
"id",
fun (s) -> begin log("dynamic_source.id") set_dynamic_source_id(s) end)
server.register(namespace="dynamic_source",
description="Get the streams cc_schedule row id",
usage="get_id",
"get_id",
fun (s) -> begin log("dynamic_source.get_id") get_dynamic_source_id() end)
#server.register(namespace="dynamic_source",
# description="Start a new dynamic source.",
# usage="start <uri>",
# "read_start",
# fun (uri) -> begin log("dynamic_source.read_start") begin_stream_read(uri) end)
#server.register(namespace="dynamic_source",
# description="Stop a dynamic source.",
# usage="stop <id>",
# "read_stop",
# fun (s) -> begin log("dynamic_source.read_stop") stop_stream_read(s) end)
#server.register(namespace="dynamic_source",
# description="Stop a dynamic source.",
# usage="stop <id>",
# "read_stop_all",
# fun (s) -> begin log("dynamic_source.read_stop") destroy_dynamic_source_all() end)
default = amplify(id="silence_src", 0.00001, noise())
ref_off_air_meta = ref off_air_meta
if !ref_off_air_meta == "" then
ref_off_air_meta := "LibreTime - offline"
end
def map_off_air_meta(m) =
[("title", !ref_off_air_meta)]
end
default = map_metadata(map_off_air_meta, default)
ignore(output.dummy(default, fallible=true))
master_dj_enabled = ref false
live_dj_enabled = ref false
scheduled_play_enabled = ref false
def make_master_dj_available()
master_dj_enabled := true
end
def make_master_dj_unavailable()
master_dj_enabled := false
end
def make_live_dj_available()
live_dj_enabled := true
end
def make_live_dj_unavailable()
live_dj_enabled := false
end
def make_scheduled_play_available()
scheduled_play_enabled := true
just_switched := true
end
def make_scheduled_play_unavailable()
scheduled_play_enabled := false
end
def update_source_status(sourcename, status) =
command = "timeout --signal=KILL 45 libretime-playout-notify --source-name=#{sourcename} --source-status=#{status} &"
system(command)
log(command)
end
def live_dj_connect(header) =
update_source_status("live_dj", true)
end
def live_dj_disconnect() =
update_source_status("live_dj", false)
end
def master_dj_connect(header) =
update_source_status("master_dj", true)
end
def master_dj_disconnect() =
update_source_status("master_dj", false)
end
# Auth function for live stream
# @Category LiveStream
# @param user Username to check against LibreTime API
# @param password Password to check against LibreTime API
# @param ~type Type of password to check, "dj" or "master, default: "master"
def check_auth(user="", password="", ~type="master") =
log("#{type} user #{user} connected",label="#{type}_source")
# Check auth based on return value from auth script
ret = test_process("python3 #{auth_path} --#{type} #{user} #{password}")
if ret then
log("#{type} user #{user} authenticated",label="#{type}_source")
else
log("#{type} user #{user} auth failed",label="#{type}_source",level=2)
end
ret
end
# Check master source auth
# @Category LiveStream
# @param user Username to check against LibreTime API
# @param password Password to check against LibreTime API
def check_master_dj_client(user, password) =
check_auth(user, password)
end
# Check dj/show source auth
# @Category LiveStream
# @param user Username to check against LibreTime API
# @param password Password to check against LibreTime API
def check_dj_client(user, password) =
check_auth(user, password, type="dj")
end
s = switch(id="schedule_noise_switch",
track_sensitive=false,
transitions=[transition_default, transition],
[({!scheduled_play_enabled}, stream_queue), ({true}, default)]
)
s = if dj_live_stream_port != 0 and dj_live_stream_mp != "" then
dj_live =
audio_to_stereo(
input.harbor(id="live_dj_harbor",
dj_live_stream_mp,
port=dj_live_stream_port,
auth=check_dj_client,
max=40.,
on_connect=live_dj_connect,
on_disconnect=live_dj_disconnect))
ignore(output.dummy(dj_live, fallible=true))
switch(id="show_schedule_noise_switch",
track_sensitive=false,
transitions=[transition, transition],
[({!live_dj_enabled}, dj_live), ({true}, s)]
)
else
s
end
s = if master_live_stream_port != 0 and master_live_stream_mp != "" then
master_dj =
audio_to_stereo(
input.harbor(id="master_harbor",
master_live_stream_mp,
port=master_live_stream_port,
auth=check_master_dj_client,
max=40.,
on_connect=master_dj_connect,
on_disconnect=master_dj_disconnect))
ignore(output.dummy(master_dj, fallible=true))
switch(id="master_show_schedule_noise_switch",
track_sensitive=false,
transitions=[transition, transition],
[({!master_dj_enabled}, master_dj), ({true}, s)]
)
else
s
end
# Attach a skip command to the source s:
#add_skip_command(s)
server.register(namespace="streams",
description="Stop Master DJ source.",
usage="master_dj_stop",
"master_dj_stop",
fun (s) -> begin log("streams.master_dj_stop") make_master_dj_unavailable() "Done." end)
server.register(namespace="streams",
description="Start Master DJ source.",
usage="master_dj_start",
"master_dj_start",
fun (s) -> begin log("streams.master_dj_start") make_master_dj_available() "Done." end)
server.register(namespace="streams",
description="Stop Live DJ source.",
usage="live_dj_stop",
"live_dj_stop",
fun (s) -> begin log("streams.live_dj_stop") make_live_dj_unavailable() "Done." end)
server.register(namespace="streams",
description="Start Live DJ source.",
usage="live_dj_start",
"live_dj_start",
fun (s) -> begin log("streams.live_dj_start") make_live_dj_available() "Done." end)
server.register(namespace="streams",
description="Stop Scheduled Play source.",
usage="scheduled_play_stop",
"scheduled_play_stop",
fun (s) -> begin log("streams.scheduled_play_stop") make_scheduled_play_unavailable() "Done." end)
server.register(namespace="streams",
description="Start Scheduled Play source.",
usage="scheduled_play_start",
"scheduled_play_start",
fun (s) -> begin log("streams.scheduled_play_start") make_scheduled_play_available() "Done." end)
if output_sound_device then
success = ref false
log(output_sound_device_type)
%ifdef output.alsa
if output_sound_device_type == "ALSA" then
ignore(output.alsa(s))
success := true
end
%endif
%ifdef output.ao
if output_sound_device_type == "AO" then
ignore(output.ao(s))
success := true
end
%endif
%ifdef output.oss
if output_sound_device_type == "OSS" then
ignore(output.oss(s))
success := true
end
%endif
%ifdef output.portaudio
if output_sound_device_type == "Portaudio" then
ignore(output.portaudio(s))
success := true
end
%endif
%ifdef output.pulseaudio
if output_sound_device_type == "Pulseaudio" then
ignore(output.pulseaudio(s))
success := true
end
%endif
if (!success == false) then
ignore(output.prefered(s))
end
end
if s1_enable == true then
if s1_output == 'shoutcast' then
s1_namespace := "shoutcast_stream_1"
else
s1_namespace := s1_mount
end
server.register(namespace=!s1_namespace, "connected", fun (s) -> begin log("#{!s1_namespace}.connected") !s1_connected end)
output_to(s1_output, s1_type, s1_bitrate, s1_host, s1_port, s1_pass,
s1_mount, s1_url, s1_description, s1_genre, s1_user, s, "1",
s1_connected, s1_name, s1_channels)
end
if s2_enable == true then
if s2_output == 'shoutcast' then
s2_namespace := "shoutcast_stream_2"
else
s2_namespace := s2_mount
end
server.register(namespace=!s2_namespace, "connected", fun (s) -> begin log("#{!s2_namespace}.connected") !s2_connected end)
output_to(s2_output, s2_type, s2_bitrate, s2_host, s2_port, s2_pass,
s2_mount, s2_url, s2_description, s2_genre, s2_user, s, "2",
s2_connected, s2_name, s2_channels)
end
if s3_enable == true then
if s3_output == 'shoutcast' then
s3_namespace := "shoutcast_stream_3"
else
s3_namespace := s3_mount
end
server.register(namespace=!s3_namespace, "connected", fun (s) -> begin log("#{!s3_namespace}.connected") !s3_connected end)
output_to(s3_output, s3_type, s3_bitrate, s3_host, s3_port, s3_pass,
s3_mount, s3_url, s3_description, s3_genre, s3_user, s, "3",
s3_connected, s3_name, s3_channels)
end
s4_namespace = ref ''
if s4_enable == true then
log("Stream 4 Enabled")
if s4_output == 'shoutcast' then
s4_namespace := "shoutcast_stream_4"
else
s4_namespace := s4_mount
end
server.register(namespace=!s4_namespace, "connected", fun (s) -> begin log("#{!s4_namespace}.connected") !s4_connected end)
output_to(s4_output, s4_type, s4_bitrate, s4_host, s4_port, s4_pass,
s4_mount, s4_url, s4_name, s4_genre, s4_user, s, "4",
s4_connected, s4_description, s4_channels)
end
command = "timeout --signal=KILL 45 libretime-playout-notify --liquidsoap-started &"
log(command)
system(command)

View file

@ -0,0 +1,67 @@
if bitrate == 24 then
if stereo then
ignore(output_stereo(%mp3(bitrate = 24, stereo = true), !source))
else
ignore(output_mono(%mp3(bitrate = 24, stereo = false), mean(!source)))
end
elsif bitrate == 32 then
if stereo then
ignore(output_stereo(%mp3(bitrate = 32, stereo = true), !source))
else
ignore(output_mono(%mp3(bitrate = 32, stereo = false), mean(!source)))
end
elsif bitrate == 48 then
if stereo then
ignore(output_stereo(%mp3(bitrate = 48, stereo = true), !source))
else
ignore(output_mono(%mp3(bitrate = 48, stereo = false), mean(!source)))
end
elsif bitrate == 64 then
if stereo then
ignore(output_stereo(%mp3(bitrate = 64, stereo = true), !source))
else
ignore(output_mono(%mp3(bitrate = 64, stereo = false), mean(!source)))
end
elsif bitrate == 96 then
if stereo then
ignore(output_stereo(%mp3(bitrate = 96, stereo = true), !source))
else
ignore(output_mono(%mp3(bitrate = 96, stereo = false), mean(!source)))
end
elsif bitrate == 128 then
if stereo then
ignore(output_stereo(%mp3(bitrate = 128, stereo = true), !source))
else
ignore(output_mono(%mp3(bitrate = 128, stereo = false), mean(!source)))
end
elsif bitrate == 160 then
if stereo then
ignore(output_stereo(%mp3(bitrate = 160, stereo = true), !source))
else
ignore(output_mono(%mp3(bitrate = 160, stereo = false), mean(!source)))
end
elsif bitrate == 192 then
if stereo then
ignore(output_stereo(%mp3(bitrate = 192, stereo = true), !source))
else
ignore(output_mono(%mp3(bitrate = 192, stereo = false), mean(!source)))
end
elsif bitrate == 224 then
if stereo then
ignore(output_stereo(%mp3(bitrate = 224, stereo = true), !source))
else
ignore(output_mono(%mp3(bitrate = 224, stereo = false), mean(!source)))
end
elsif bitrate == 256 then
if stereo then
ignore(output_stereo(%mp3(bitrate = 256, stereo = true), !source))
else
ignore(output_mono(%mp3(bitrate = 256, stereo = false), mean(!source)))
end
elsif bitrate == 320 then
if stereo then
ignore(output_stereo(%mp3(bitrate = 320, stereo = true), !source))
else
ignore(output_mono(%mp3(bitrate = 320, stereo = false), mean(!source)))
end
end

View file

@ -0,0 +1,59 @@
if not icecast_vorbis_metadata then
source := add(normalize=false, [amplify(0.00001, noise()), !source])
end
if bitrate == 24 or bitrate == 32 or bitrate == 48 then
if stereo then
ignore(output_stereo(%vorbis(quality=-0.1, channels = 2), !source))
else
ignore(output_mono(%vorbis(quality=-0.1, channels = 1), mean(!source)))
end
elsif bitrate == 64 then
if stereo then
ignore(output_stereo(%vorbis(quality=0, channels = 2), !source))
else
ignore(output_mono(%vorbis(quality=0, channels = 1), mean(!source)))
end
elsif bitrate == 96 then
if stereo then
ignore(output_stereo(%vorbis(quality=0.2, channels = 2), !source))
else
ignore(output_mono(%vorbis(quality=0.2, channels = 1), mean(!source)))
end
elsif bitrate == 128 then
if stereo then
ignore(output_stereo(%vorbis(quality=0.4, channels = 2), !source))
else
ignore(output_mono(%vorbis(quality=0.4, channels = 1), mean(!source)))
end
elsif bitrate == 160 then
if stereo then
ignore(output_stereo(%vorbis(quality=0.5, channels = 2), !source))
else
ignore(output_mono(%vorbis(quality=0.5, channels = 1), mean(!source)))
end
elsif bitrate == 192 then
if stereo then
ignore(output_stereo(%vorbis(quality=0.6, channels = 2), !source))
else
ignore(output_mono(%vorbis(quality=0.6, channels = 1), mean(!source)))
end
elsif bitrate == 224 then
if stereo then
ignore(output_stereo(%vorbis(quality=0.7, channels = 2), !source))
else
ignore(output_mono(%vorbis(quality=0.7, channels = 1), mean(!source)))
end
elsif bitrate == 256 then
if stereo then
ignore(output_stereo(%vorbis(quality=0.8, channels = 2), !source))
else
ignore(output_mono(%vorbis(quality=0.8, channels = 1), mean(!source)))
end
elsif bitrate == 320 then
if stereo then
ignore(output_stereo(%vorbis(quality=0.9, channels = 2), !source))
else
ignore(output_mono(%vorbis(quality=0.9, channels = 1), mean(!source)))
end
end

View file

@ -0,0 +1,67 @@
if bitrate == 24 then
if stereo then
ignore(output_stereo(%opus(bitrate = 24, channels = 2, signal="music", application="audio", complexity=10, vbr="constrained"), !source))
else
ignore(output_mono(%opus(bitrate = 24, channels = 1, signal="music", application="audio", complexity=10, vbr="constrained"), mean(!source)))
end
elsif bitrate == 32 then
if stereo then
ignore(output_stereo(%opus(bitrate = 32, channels = 2, signal="music", application="audio", complexity=10, vbr="constrained"), !source))
else
ignore(output_mono(%opus(bitrate = 32, channels = 1, signal="music", application="audio", complexity=10, vbr="constrained"), mean(!source)))
end
elsif bitrate == 48 then
if stereo then
ignore(output_stereo(%opus(bitrate = 48, channels = 2, signal="music", application="audio", complexity=10, vbr="constrained"), !source))
else
ignore(output_mono(%opus(bitrate = 48, channels = 1, signal="music", application="audio", complexity=10, vbr="constrained"), mean(!source)))
end
elsif bitrate == 64 then
if stereo then
ignore(output_stereo(%opus(bitrate = 64, channels = 2, signal="music", application="audio", complexity=10, vbr="constrained"), !source))
else
ignore(output_mono(%opus(bitrate = 64, channels = 1, signal="music", application="audio", complexity=10, vbr="constrained"), mean(!source)))
end
elsif bitrate == 96 then
if stereo then
ignore(output_stereo(%opus(bitrate = 96, channels = 2, signal="music", application="audio", complexity=10, vbr="constrained"), !source))
else
ignore(output_mono(%opus(bitrate = 96, channels = 1, signal="music", application="audio", complexity=10, vbr="constrained"), mean(!source)))
end
elsif bitrate == 128 then
if stereo then
ignore(output_stereo(%opus(bitrate = 128, channels = 2, signal="music", application="audio", complexity=10, vbr="constrained"), !source))
else
ignore(output_mono(%opus(bitrate = 128, channels = 1, signal="music", application="audio", complexity=10, vbr="constrained"), mean(!source)))
end
elsif bitrate == 160 then
if stereo then
ignore(output_stereo(%opus(bitrate = 160, channels = 2, signal="music", application="audio", complexity=10, vbr="constrained"), !source))
else
ignore(output_mono(%opus(bitrate = 160, channels = 1, signal="music", application="audio", complexity=10, vbr="constrained"), mean(!source)))
end
elsif bitrate == 192 then
if stereo then
ignore(output_stereo(%opus(bitrate = 192, channels = 2, signal="music", application="audio", complexity=10, vbr="constrained"), !source))
else
ignore(output_mono(%opus(bitrate = 192, channels = 1, signal="music", application="audio", complexity=10, vbr="constrained"), mean(!source)))
end
elsif bitrate == 224 then
if stereo then
ignore(output_stereo(%opus(bitrate = 224, channels = 2, signal="music", application="audio", complexity=10, vbr="constrained"), !source))
else
ignore(output_mono(%opus(bitrate = 224, channels = 1, signal="music", application="audio", complexity=10, vbr="constrained"), mean(!source)))
end
elsif bitrate == 256 then
if stereo then
ignore(output_stereo(%opus(bitrate = 256, channels = 2, signal="music", application="audio", complexity=10, vbr="constrained"), !source))
else
ignore(output_mono(%opus(bitrate = 256, channels = 1, signal="music", application="audio", complexity=10, vbr="constrained"), mean(!source)))
end
elsif bitrate == 320 then
if stereo then
ignore(output_stereo(%opus(bitrate = 320, channels = 2, signal="music", application="audio", complexity=10, vbr="constrained"), !source))
else
ignore(output_mono(%opus(bitrate = 320, channels = 1, signal="music", application="audio", complexity=10, vbr="constrained"), mean(!source)))
end
end

View file

View file

@ -0,0 +1,50 @@
""" Runs Airtime liquidsoap
"""
import argparse
import logging
import os
import subprocess
from pypo import pure
from . import generate_liquidsoap_cfg
PYPO_HOME = "/var/tmp/airtime/pypo/"
def run():
"""Entry-point for this application"""
print("Airtime Liquidsoap")
parser = argparse.ArgumentParser()
parser.add_argument("-d", "--debug", help="run in debug mode", action="store_true")
args = parser.parse_args()
os.environ["HOME"] = PYPO_HOME
if args.debug:
logging.basicConfig(level=getattr(logging, "DEBUG", None))
generate_liquidsoap_cfg.run()
""" check liquidsoap version so we can run a scripts matching the liquidsoap minor version """
liquidsoap_version = subprocess.check_output(
"liquidsoap --force-start 'print(liquidsoap.version) shutdown()'",
shell=True,
universal_newlines=True,
)[0:3]
script_path = os.path.join(
os.path.dirname(__file__), liquidsoap_version, "ls_script.liq"
)
exec_args = [
"/usr/bin/liquidsoap",
"libretime-liquidsoap",
script_path,
"--verbose",
"-f",
]
if args.debug:
print(f"Liquidsoap {liquidsoap_version} using script: {script_path}")
exec_args.append("--debug")
os.execl(*exec_args)
run()

View file

@ -0,0 +1,64 @@
import logging
import os
import sys
import time
import traceback
from api_clients.version1 import AirtimeApiClient
def generate_liquidsoap_config(ss):
data = ss["msg"]
fh = open("/etc/airtime/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")
for key, value in data.items():
try:
if not "port" in key and not "bitrate" in key: # Stupid hack
raise ValueError()
str_buffer = "%s = %s\n" % (key, int(value))
except ValueError:
try: # Is it a boolean?
if value == "true" or value == "false":
str_buffer = "%s = %s\n" % (key, value.lower())
else:
raise ValueError() # Just drop into the except below
except: # Everything else is a string
str_buffer = '%s = "%s"\n' % (key, value)
fh.write(str_buffer)
# ignore squashes unused variable errors from Liquidsoap
fh.write("ignore(%s)\n" % key)
auth_path = os.path.dirname(os.path.realpath(__file__))
fh.write('log_file = "/var/log/airtime/pypo-liquidsoap/<script>.log"\n')
fh.write('auth_path = "%s/liquidsoap_auth.py"\n' % auth_path)
fh.close()
def run():
logging.basicConfig(format="%(message)s")
attempts = 0
max_attempts = 10
successful = False
while not successful:
try:
ac = AirtimeApiClient(logging.getLogger())
ss = ac.get_stream_setting()
generate_liquidsoap_config(ss)
successful = True
except Exception as e:
print("Unable to connect to the Airtime server.")
logging.error(str(e))
logging.error("traceback: %s", traceback.format_exc())
if attempts == max_attempts:
logging.error("giving up and exiting...")
sys.exit(1)
else:
logging.info("Retrying in 3 seconds...")
time.sleep(3)
attempts += 1

View file

@ -0,0 +1,81 @@
###########################################
# Liquidsoap config file #
###########################################
###########################################
# Output settings #
###########################################
output_sound_device = false
output_sound_device_type = "ALSA"
s1_output = "icecast"
s2_output = "icecast"
s3_output = "icecast"
s4_output = "icecast"
s1_enable = true
s2_enable = false
s3_enable = false
s4_enable = false
s1_type = "ogg"
s2_type = "ogg"
s3_type = "mp3"
s4_type = "mp3"
s1_bitrate = 128
s2_bitrate = 128
s3_bitrate = 160
s4_bitrate = 160
###########################################
# Logging settings #
###########################################
log_file = "/var/log/airtime/pypo-liquidsoap/<script>.log"
#log_level = 3
###########################################
# Icecast Stream settings #
###########################################
s1_host = "127.0.0.1"
s2_host = "127.0.0.1"
s3_host = "127.0.0.1"
s4_host = "127.0.0.1"
s1_port = 8000
s2_port = 8000
s3_port = 8000
s4_port = 8000
s1_user = ""
s2_user = ""
s3_user = ""
s4_user = ""
s1_pass = "hackme"
s2_pass = "hackme"
s3_pass = "hackme"
s4_pass = "hackme"
# Icecast mountpoint names
s1_mount = "airtime_128.ogg"
s2_mount = "airtime_128.ogg"
s3_mount = "airtime_160.mp3"
s4_mount = "airtime_160.mp3"
# Webstream metadata settings
s1_url = "https://libretime.org"
s2_url = "https://libretime.org"
s3_url = "https://libretime.org"
s4_url = "https://libretime.org"
s1_description = "LibreTime Radio! Stream 1"
s2_description = "LibreTime Radio! Stream 2"
s3_description = "LibreTime Radio! Stream 3"
s4_description = "LibreTime Radio! Stream 4"
s1_genre = "genre"
s2_genre = "genre"
s3_genre = "genre"
s4_genre = "genre"
# Audio stream metadata for vorbis/ogg is disabled by default
# due to a number of client media players that disconnect
# when the metadata changes to a new track. Some versions of
# mplayer and VLC have this problem. Enable this option at your
# own risk!
icecast_vorbis_metadata = false

View file

@ -0,0 +1,24 @@
import sys
from api_clients import version1 as api_client
api_clients = api_client.AirtimeApiClient()
dj_type = sys.argv[1]
username = sys.argv[2]
password = sys.argv[3]
source_type = ""
if dj_type == "--master":
source_type = "master"
elif dj_type == "--dj":
source_type = "dj"
response = api_clients.check_live_stream_auth(username, password, source_type)
if "msg" in response and response["msg"] == True:
print(response["msg"])
sys.exit(0)
else:
print(False)
sys.exit(1)

View file

@ -0,0 +1,19 @@
import sys
import telnetlib
from configobj import ConfigObj
try:
config = ConfigObj("/etc/airtime/airtime.conf")
LS_HOST = config["pypo"]["ls_host"]
LS_PORT = config["pypo"]["ls_port"]
tn = telnetlib.Telnet(LS_HOST, LS_PORT)
tn.write("master_harbor.stop\n")
tn.write("live_dj_harbor.stop\n")
tn.write("exit\n")
tn.read_all()
except Exception as e:
print("Error loading config file: {}".format(e))
sys.exit()

View file

@ -0,0 +1,11 @@
/var/log/airtime/pypo-liquidsoap/ls_script.log {
compress
rotate 10
size 1000k
missingok
notifempty
sharedscripts
postrotate
systemctl kill --signal=SIGUSR1 libretime-liquidsoap >/dev/null 2>&1 || true
endscript
}

32
playout/packages.ini Normal file
View file

@ -0,0 +1,32 @@
# This file contains a list of package dependencies.
[common]
python3 = buster, bullseye, bionic, focal
python3-pip = buster, bullseye, bionic, focal
python3-venv = buster, bullseye, bionic, focal
[liquidsoap]
# https://github.com/savonet/liquidsoap/blob/main/CHANGES.md
liquidsoap-plugin-alsa = bionic
liquidsoap-plugin-ao = bionic
liquidsoap-plugin-ogg = bionic
liquidsoap-plugin-portaudio = bionic
# Already recommended packages in bionic
# See `apt show liquidsoap`
; liquidsoap-plugin-faad = bionic
; liquidsoap-plugin-flac = bionic
; liquidsoap-plugin-icecast = bionic
; liquidsoap-plugin-lame = bionic
; liquidsoap-plugin-mad = bionic
; liquidsoap-plugin-pulseaudio = bionic
; liquidsoap-plugin-taglib = bionic
; liquidsoap-plugin-voaacenc = bionic
; liquidsoap-plugin-vorbis = bionic
liquidsoap = buster, bullseye, bionic, focal
[recorder]
ecasound = buster, bullseye, bionic, focal
[misc]
# Used by pypofetch to check if a file is open.
# TODO: consider using a python library
lsof = buster, bullseye, bionic, focal

0
playout/pypo/__init__.py Normal file
View file

299
playout/pypo/__main__.py Normal file
View file

@ -0,0 +1,299 @@
"""
Python part of radio playout (pypo)
"""
import importlib
import locale
import logging
import os
import re
import signal
import sys
import telnetlib
import time
from datetime import datetime
from optparse import OptionParser
from api_clients import version1 as api_client
from configobj import ConfigObj
try:
from queue import Queue
except ImportError: # Python 2.7.5 (CentOS 7)
from queue import Queue
from threading import Lock
from . import pure
from .listenerstat import ListenerStat
from .pypofetch import PypoFetch
from .pypofile import PypoFile
from .pypoliquidsoap import PypoLiquidsoap
from .pypomessagehandler import PypoMessageHandler
from .pypopush import PypoPush
from .recorder import Recorder
from .timeout import ls_timeout
LOG_PATH = "/var/log/airtime/pypo/pypo.log"
LOG_LEVEL = logging.INFO
logging.captureWarnings(True)
# Set up command-line options
parser = OptionParser()
# help screen / info
usage = "%prog [options]" + " - python playout system"
parser = OptionParser(usage=usage)
# Options
parser.add_option(
"-v",
"--compat",
help="Check compatibility with server API version",
default=False,
action="store_true",
dest="check_compat",
)
parser.add_option(
"-t",
"--test",
help="Do a test to make sure everything is working properly.",
default=False,
action="store_true",
dest="test",
)
parser.add_option(
"-b",
"--cleanup",
help="Cleanup",
default=False,
action="store_true",
dest="cleanup",
)
parser.add_option(
"-c",
"--check",
help="Check the cached schedule and exit",
default=False,
action="store_true",
dest="check",
)
# parse options
(options, args) = parser.parse_args()
LIQUIDSOAP_MIN_VERSION = "1.1.1"
PYPO_HOME = "/var/tmp/airtime/pypo/"
def configure_environment():
os.environ["HOME"] = PYPO_HOME
os.environ["TERM"] = "xterm"
configure_environment()
# need to wait for Python 2.7 for this..
logging.captureWarnings(True)
# configure logging
try:
# Set up logging
logFormatter = logging.Formatter(
"%(asctime)s [%(module)s] [%(levelname)-5.5s] %(message)s"
)
rootLogger = logging.getLogger()
rootLogger.setLevel(LOG_LEVEL)
logger = rootLogger
fileHandler = logging.handlers.RotatingFileHandler(
filename=LOG_PATH, maxBytes=1024 * 1024 * 30, backupCount=8
)
fileHandler.setFormatter(logFormatter)
rootLogger.addHandler(fileHandler)
consoleHandler = logging.StreamHandler()
consoleHandler.setFormatter(logFormatter)
rootLogger.addHandler(consoleHandler)
except Exception as e:
print("Couldn't configure logging: {}".format(e))
sys.exit(1)
# loading config file
try:
config = ConfigObj("/etc/airtime/airtime.conf")
except Exception as e:
logger.error("Error loading config file: %s", e)
sys.exit(1)
class Global:
def __init__(self, api_client):
self.api_client = api_client
def selfcheck(self):
return self.api_client.is_server_compatible()
def test_api(self):
self.api_client.test()
def keyboardInterruptHandler(signum, frame):
logger = logging.getLogger()
logger.info("\nKeyboard Interrupt\n")
sys.exit(0)
@ls_timeout
def liquidsoap_get_info(telnet_lock, host, port, logger):
logger.debug("Checking to see if Liquidsoap is running")
try:
telnet_lock.acquire()
tn = telnetlib.Telnet(host, port)
msg = "version\n"
tn.write(msg.encode("utf-8"))
tn.write("exit\n".encode("utf-8"))
response = tn.read_all().decode("utf-8")
except Exception as e:
logger.error(e)
return None
finally:
telnet_lock.release()
return get_liquidsoap_version(response)
def get_liquidsoap_version(version_string):
m = re.match(r"Liquidsoap (\d+.\d+.\d+)", version_string)
if m:
return m.group(1)
else:
return None
if m:
current_version = m.group(1)
return pure.version_cmp(current_version, LIQUIDSOAP_MIN_VERSION) >= 0
return False
def liquidsoap_startup_test():
liquidsoap_version_string = liquidsoap_get_info(
telnet_lock, ls_host, ls_port, logger
)
while not liquidsoap_version_string:
logger.warning(
"Liquidsoap doesn't appear to be running!, " + "Sleeping and trying again"
)
time.sleep(1)
liquidsoap_version_string = liquidsoap_get_info(
telnet_lock, ls_host, ls_port, logger
)
while pure.version_cmp(liquidsoap_version_string, LIQUIDSOAP_MIN_VERSION) < 0:
logger.warning(
"Liquidsoap is running but in incorrect version! "
+ "Make sure you have at least Liquidsoap %s installed"
% LIQUIDSOAP_MIN_VERSION
)
time.sleep(1)
liquidsoap_version_string = liquidsoap_get_info(
telnet_lock, ls_host, ls_port, logger
)
logger.info("Liquidsoap version string found %s" % liquidsoap_version_string)
if __name__ == "__main__":
logger.info("###########################################")
logger.info("# *** pypo *** #")
logger.info("# Liquidsoap Scheduled Playout System #")
logger.info("###########################################")
# Although all of our calculations are in UTC, it is useful to know what timezone
# the local machine is, so that we have a reference for what time the actual
# log entries were made
logger.info("Timezone: %s" % str(time.tzname))
logger.info("UTC time: %s" % str(datetime.utcnow()))
signal.signal(signal.SIGINT, keyboardInterruptHandler)
api_client = api_client.AirtimeApiClient()
g = Global(api_client)
while not g.selfcheck():
time.sleep(5)
success = False
while not success:
try:
api_client.register_component("pypo")
success = True
except Exception as e:
logger.error(str(e))
time.sleep(10)
telnet_lock = Lock()
ls_host = config["pypo"]["ls_host"]
ls_port = config["pypo"]["ls_port"]
liquidsoap_startup_test()
if options.test:
g.test_api()
sys.exit(0)
pypoFetch_q = Queue()
recorder_q = Queue()
pypoPush_q = Queue()
pypo_liquidsoap = PypoLiquidsoap(logger, telnet_lock, ls_host, ls_port)
"""
This queue is shared between pypo-fetch and pypo-file, where pypo-file
is the consumer. Pypo-fetch will send every schedule it gets to pypo-file
and pypo will parse this schedule to determine which file has the highest
priority, and retrieve it.
"""
media_q = Queue()
# Pass only the configuration sections needed; PypoMessageHandler only needs rabbitmq settings
pmh = PypoMessageHandler(pypoFetch_q, recorder_q, config["rabbitmq"])
pmh.daemon = True
pmh.start()
pfile = PypoFile(media_q, config["pypo"])
pfile.daemon = True
pfile.start()
pf = PypoFetch(
pypoFetch_q, pypoPush_q, media_q, telnet_lock, pypo_liquidsoap, config["pypo"]
)
pf.daemon = True
pf.start()
pp = PypoPush(pypoPush_q, telnet_lock, pypo_liquidsoap, config["pypo"])
pp.daemon = True
pp.start()
recorder = Recorder(recorder_q)
recorder.daemon = True
recorder.start()
stat = ListenerStat(config)
stat.daemon = True
stat.start()
# Just sleep the main thread, instead of blocking on pf.join().
# This allows CTRL-C to work!
while True:
time.sleep(1)
logger.info("System exit")

View file

@ -0,0 +1,6 @@
FILE = "file"
EVENT = "event"
STREAM_BUFFER_START = "stream_buffer_start"
STREAM_OUTPUT_START = "stream_output_start"
STREAM_BUFFER_END = "stream_buffer_end"
STREAM_OUTPUT_END = "stream_output_end"

View file

@ -0,0 +1,181 @@
import base64
import logging
import time
import traceback
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime
from threading import Thread
import defusedxml.minidom
from api_clients import version1 as api_client
class ListenerStat(Thread):
HTTP_REQUEST_TIMEOUT = 30 # 30 second HTTP request timeout
def __init__(self, config, logger=None):
Thread.__init__(self)
self.config = config
self.api_client = api_client.AirtimeApiClient()
if logger is None:
self.logger = logging.getLogger()
else:
self.logger = logger
def get_node_text(self, nodelist):
rc = []
for node in nodelist:
if node.nodeType == node.TEXT_NODE:
rc.append(node.data)
return "".join(rc)
def get_stream_parameters(self):
# [{"user":"", "password":"", "url":"", "port":""},{},{}]
return self.api_client.get_stream_parameters()
def get_stream_server_xml(self, ip, url, is_shoutcast=False):
auth_string = "%(admin_user)s:%(admin_pass)s" % ip
encoded = base64.b64encode(auth_string.encode("utf-8"))
header = {"Authorization": "Basic %s" % encoded.decode("ascii")}
if is_shoutcast:
# user agent is required for shoutcast auth, otherwise it returns 404.
user_agent = "Mozilla/5.0 (Linux; rv:22.0) Gecko/20130405 Firefox/22.0"
header["User-Agent"] = user_agent
req = urllib.request.Request(
# assuming that the icecast stats path is /admin/stats.xml
# need to fix this
url=url,
headers=header,
)
f = urllib.request.urlopen(req, timeout=ListenerStat.HTTP_REQUEST_TIMEOUT)
document = f.read()
return document
def get_icecast_stats(self, ip):
document = None
if "airtime.pro" in ip["host"].lower():
url = "http://%(host)s:%(port)s/stats.xsl" % ip
document = self.get_stream_server_xml(ip, url)
else:
url = "http://%(host)s:%(port)s/admin/stats.xml" % ip
document = self.get_stream_server_xml(ip, url)
dom = defusedxml.minidom.parseString(document)
sources = dom.getElementsByTagName("source")
mount_stats = None
for s in sources:
# drop the leading '/' character
mount_name = s.getAttribute("mount")[1:]
if mount_name == ip["mount"]:
timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
listeners = s.getElementsByTagName("listeners")
num_listeners = 0
if len(listeners):
num_listeners = self.get_node_text(listeners[0].childNodes)
mount_stats = {
"timestamp": timestamp,
"num_listeners": num_listeners,
"mount_name": mount_name,
}
return mount_stats
def get_shoutcast_stats(self, ip):
url = "http://%(host)s:%(port)s/admin.cgi?sid=1&mode=viewxml" % ip
document = self.get_stream_server_xml(ip, url, is_shoutcast=True)
dom = defusedxml.minidom.parseString(document)
current_listeners = dom.getElementsByTagName("CURRENTLISTENERS")
timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
num_listeners = 0
if len(current_listeners):
num_listeners = self.get_node_text(current_listeners[0].childNodes)
mount_stats = {
"timestamp": timestamp,
"num_listeners": num_listeners,
"mount_name": "shoutcast",
}
return mount_stats
def get_stream_stats(self, stream_parameters):
stats = []
# iterate over stream_parameters which is a list of dicts. Each dict
# represents one Airtime stream (currently this limit is 3).
# Note that there can be optimizations done, since if all three
# streams are the same server, we will still initiate 3 separate
# connections
for k, v in stream_parameters.items():
if v["enable"] == "true":
try:
if v["output"] == "icecast":
mount_stats = self.get_icecast_stats(v)
if mount_stats:
stats.append(mount_stats)
else:
stats.append(self.get_shoutcast_stats(v))
self.update_listener_stat_error(v["mount"], "OK")
except Exception as e:
try:
self.update_listener_stat_error(v["mount"], str(e))
except Exception as e:
self.logger.error("Exception: %s", e)
return stats
def push_stream_stats(self, stats):
self.api_client.push_stream_stats(stats)
def update_listener_stat_error(self, stream_id, error):
keyname = "%s_listener_stat_error" % stream_id
data = {keyname: error}
self.api_client.update_stream_setting_table(data)
def run(self):
# Wake up every 120 seconds and gather icecast statistics. Note that we
# are currently querying the server every 2 minutes for list of
# mountpoints as well. We could remove this query if we hooked into
# rabbitmq events, and listened for these changes instead.
while True:
try:
stream_parameters = self.get_stream_parameters()
stats = self.get_stream_stats(stream_parameters["stream_params"])
if stats:
self.push_stream_stats(stats)
except Exception as e:
self.logger.error("Exception: %s", e)
time.sleep(120)
self.logger.info("ListenerStat thread exiting")
if __name__ == "__main__":
# create logger
logger = logging.getLogger("std_out")
logger.setLevel(logging.DEBUG)
# create console handler and set level to debug
# ch = logging.StreamHandler()
# ch.setLevel(logging.DEBUG)
# create formatter
formatter = logging.Formatter(
"%(asctime)s - %(name)s - %(lineno)s - %(levelname)s - %(message)s"
)
# add formatter to ch
# ch.setFormatter(formatter)
# add ch to logger
# logger.addHandler(ch)
# ls = ListenerStat(logger=logger)
# ls.run()

18
playout/pypo/mime.types Normal file
View file

@ -0,0 +1,18 @@
audio/ogg ogg
application/ogg ogg
audio/vorbis ogg
audio/mp3 mp3
audio/mpeg mp3
audio/mpeg3 mp3
audio/x-aac aac
audio/aac aac
audio/aacp aac
audio/mp4 m4a
audio/x-flac flac
audio/flac flac
audio/wav wav
audio/x-wav wav
audio/mp2 mp2
audio/mp1 mp1
audio/x-ms-wma wma
audio/basic au

25
playout/pypo/pure.py Normal file
View file

@ -0,0 +1,25 @@
import re
from packaging.version import Version, parse
def version_cmp(version1, version2):
version1 = parse(version1)
version2 = parse(version2)
if version1 > version2:
return 1
if version1 == version2:
return 0
return -1
def date_interval_to_seconds(interval):
"""
Convert timedelta object into int representing the number of seconds. If
number of seconds is less than 0, then return 0.
"""
seconds = (
interval.microseconds + (interval.seconds + interval.days * 24 * 3600) * 10 ** 6
) / float(10 ** 6)
return seconds

547
playout/pypo/pypofetch.py Normal file
View file

@ -0,0 +1,547 @@
import copy
import json
import logging.config
import mimetypes
import os
import signal
import subprocess
import sys
import telnetlib
import time
from datetime import datetime
from queue import Empty
from subprocess import PIPE, Popen
from threading import Thread, Timer
from api_clients import version1 as v1_api_client
from api_clients import version2 as api_client
from . import pure
from .timeout import ls_timeout
def keyboardInterruptHandler(signum, frame):
logger = logging.getLogger()
logger.info("\nKeyboard Interrupt\n")
sys.exit(0)
signal.signal(signal.SIGINT, keyboardInterruptHandler)
logging.captureWarnings(True)
POLL_INTERVAL = 400
class PypoFetch(Thread):
def __init__(
self, pypoFetch_q, pypoPush_q, media_q, telnet_lock, pypo_liquidsoap, config
):
Thread.__init__(self)
# Hacky...
PypoFetch.ref = self
self.v1_api_client = v1_api_client.AirtimeApiClient()
self.api_client = api_client.AirtimeApiClient()
self.fetch_queue = pypoFetch_q
self.push_queue = pypoPush_q
self.media_prepare_queue = media_q
self.last_update_schedule_timestamp = time.time()
self.config = config
self.listener_timeout = POLL_INTERVAL
self.telnet_lock = telnet_lock
self.logger = logging.getLogger()
self.pypo_liquidsoap = pypo_liquidsoap
self.cache_dir = os.path.join(config["cache_dir"], "scheduler")
self.logger.debug("Cache dir %s", self.cache_dir)
try:
if not os.path.isdir(dir):
"""
We get here if path does not exist, or path does exist but
is a file. We are not handling the second case, but don't
think we actually care about handling it.
"""
self.logger.debug("Cache dir does not exist. Creating...")
os.makedirs(dir)
except Exception as e:
pass
self.schedule_data = []
self.logger.info("PypoFetch: init complete")
"""
Handle a message from RabbitMQ, put it into our yucky global var.
Hopefully there is a better way to do this.
"""
def handle_message(self, message):
try:
self.logger.info("Received event from Pypo Message Handler: %s" % message)
try:
message = message.decode()
except (UnicodeDecodeError, AttributeError):
pass
m = json.loads(message)
command = m["event_type"]
self.logger.info("Handling command: " + command)
if command == "update_schedule":
self.schedule_data = m["schedule"]
self.process_schedule(self.schedule_data)
elif command == "reset_liquidsoap_bootstrap":
self.set_bootstrap_variables()
elif command == "update_stream_setting":
self.logger.info("Updating stream setting...")
self.regenerate_liquidsoap_conf(m["setting"])
elif command == "update_stream_format":
self.logger.info("Updating stream format...")
self.update_liquidsoap_stream_format(m["stream_format"])
elif command == "update_station_name":
self.logger.info("Updating station name...")
self.update_liquidsoap_station_name(m["station_name"])
elif command == "update_transition_fade":
self.logger.info("Updating transition_fade...")
self.update_liquidsoap_transition_fade(m["transition_fade"])
elif command == "switch_source":
self.logger.info("switch_on_source show command received...")
self.pypo_liquidsoap.get_telnet_dispatcher().switch_source(
m["sourcename"], m["status"]
)
elif command == "disconnect_source":
self.logger.info("disconnect_on_source show command received...")
self.pypo_liquidsoap.get_telnet_dispatcher().disconnect_source(
m["sourcename"]
)
else:
self.logger.info("Unknown command: %s" % command)
# update timeout value
if command == "update_schedule":
self.listener_timeout = POLL_INTERVAL
else:
self.listener_timeout = (
self.last_update_schedule_timestamp - time.time() + POLL_INTERVAL
)
if self.listener_timeout < 0:
self.listener_timeout = 0
self.logger.info("New timeout: %s" % self.listener_timeout)
except Exception as e:
self.logger.exception("Exception in handling Message Handler message")
def switch_source_temp(self, sourcename, status):
self.logger.debug('Switching source: %s to "%s" status', sourcename, status)
command = "streams."
if sourcename == "master_dj":
command += "master_dj_"
elif sourcename == "live_dj":
command += "live_dj_"
elif sourcename == "scheduled_play":
command += "scheduled_play_"
if status == "on":
command += "start\n"
else:
command += "stop\n"
return command
"""
Initialize Liquidsoap environment
"""
def set_bootstrap_variables(self):
self.logger.debug("Getting information needed on bootstrap from Airtime")
try:
info = self.v1_api_client.get_bootstrap_info()
except Exception as e:
self.logger.exception("Unable to get bootstrap info.. Exiting pypo...")
self.logger.debug("info:%s", info)
commands = []
for k, v in info["switch_status"].items():
commands.append(self.switch_source_temp(k, v))
stream_format = info["stream_label"]
station_name = info["station_name"]
fade = info["transition_fade"]
commands.append(
("vars.stream_metadata_type %s\n" % stream_format).encode("utf-8")
)
commands.append(("vars.station_name %s\n" % station_name).encode("utf-8"))
commands.append(("vars.default_dj_fade %s\n" % fade).encode("utf-8"))
self.pypo_liquidsoap.get_telnet_dispatcher().telnet_send(commands)
self.pypo_liquidsoap.clear_all_queues()
self.pypo_liquidsoap.clear_queue_tracker()
def restart_liquidsoap(self):
try:
"""do not block - if we receive the lock then good - no other thread
will try communicating with Liquidsoap. If we don't receive, it may
mean some thread blocked and is still holding the lock. Restarting
Liquidsoap will cause that thread to release the lock as an Exception
will be thrown."""
self.telnet_lock.acquire(False)
self.logger.info("Restarting Liquidsoap")
subprocess.call(
"kill -9 `pidof libretime-liquidsoap`", shell=True, close_fds=True
)
# Wait here and poll Liquidsoap until it has started up
self.logger.info("Waiting for Liquidsoap to start")
while True:
try:
tn = telnetlib.Telnet(
self.config["ls_host"], self.config["ls_port"]
)
tn.write("exit\n".encode("utf-8"))
tn.read_all()
self.logger.info("Liquidsoap is up and running")
break
except Exception as e:
# sleep 0.5 seconds and try again
time.sleep(0.5)
except Exception as e:
self.logger.exception(e)
finally:
if self.telnet_lock.locked():
self.telnet_lock.release()
"""
NOTE: This function is quite short after it was refactored.
"""
def regenerate_liquidsoap_conf(self, setting):
self.restart_liquidsoap()
self.update_liquidsoap_connection_status()
@ls_timeout
def update_liquidsoap_connection_status(self):
"""
updates the status of Liquidsoap connection to the streaming server
This function updates the bootup time variable in Liquidsoap script
"""
try:
self.telnet_lock.acquire()
tn = telnetlib.Telnet(self.config["ls_host"], self.config["ls_port"])
# update the boot up time of Liquidsoap. Since Liquidsoap is not restarting,
# we are manually adjusting the bootup time variable so the status msg will get
# updated.
current_time = time.time()
boot_up_time_command = (
"vars.bootup_time " + str(current_time) + "\n"
).encode("utf-8")
self.logger.info(boot_up_time_command)
tn.write(boot_up_time_command)
connection_status = ("streams.connection_status\n").encode("utf-8")
self.logger.info(connection_status)
tn.write(connection_status)
tn.write("exit\n".encode("utf-8"))
output = tn.read_all()
except Exception as e:
self.logger.exception(e)
finally:
self.telnet_lock.release()
output_list = output.split("\r\n")
stream_info = output_list[2]
# streamin info is in the form of:
# eg. s1:true,2:true,3:false
streams = stream_info.split(",")
self.logger.info(streams)
fake_time = current_time + 1
for s in streams:
info = s.split(":")
stream_id = info[0]
status = info[1]
if status == "true":
self.v1_api_client.notify_liquidsoap_status(
"OK", stream_id, str(fake_time)
)
@ls_timeout
def update_liquidsoap_stream_format(self, stream_format):
# Push stream metadata to liquidsoap
# TODO: THIS LIQUIDSOAP STUFF NEEDS TO BE MOVED TO PYPO-PUSH!!!
try:
self.telnet_lock.acquire()
tn = telnetlib.Telnet(self.config["ls_host"], self.config["ls_port"])
command = ("vars.stream_metadata_type %s\n" % stream_format).encode("utf-8")
self.logger.info(command)
tn.write(command)
tn.write("exit\n".encode("utf-8"))
tn.read_all()
except Exception as e:
self.logger.exception(e)
finally:
self.telnet_lock.release()
@ls_timeout
def update_liquidsoap_transition_fade(self, fade):
# Push stream metadata to liquidsoap
# TODO: THIS LIQUIDSOAP STUFF NEEDS TO BE MOVED TO PYPO-PUSH!!!
try:
self.telnet_lock.acquire()
tn = telnetlib.Telnet(self.config["ls_host"], self.config["ls_port"])
command = ("vars.default_dj_fade %s\n" % fade).encode("utf-8")
self.logger.info(command)
tn.write(command)
tn.write("exit\n".encode("utf-8"))
tn.read_all()
except Exception as e:
self.logger.exception(e)
finally:
self.telnet_lock.release()
@ls_timeout
def update_liquidsoap_station_name(self, station_name):
# Push stream metadata to liquidsoap
# TODO: THIS LIQUIDSOAP STUFF NEEDS TO BE MOVED TO PYPO-PUSH!!!
try:
try:
self.telnet_lock.acquire()
tn = telnetlib.Telnet(self.config["ls_host"], self.config["ls_port"])
command = ("vars.station_name %s\n" % station_name).encode("utf-8")
self.logger.info(command)
tn.write(command)
tn.write("exit\n".encode("utf-8"))
tn.read_all()
except Exception as e:
self.logger.exception(e)
finally:
self.telnet_lock.release()
except Exception as e:
self.logger.exception(e)
"""
Process the schedule
- Reads the scheduled entries of a given range (actual time +/- "prepare_ahead" / "cache_for")
- Saves a serialized file of the schedule
- playlists are prepared. (brought to liquidsoap format) and, if not mounted via nsf, files are copied
to the cache dir (Folder-structure: cache/YYYY-MM-DD-hh-mm-ss)
- runs the cleanup routine, to get rid of unused cached files
"""
def process_schedule(self, schedule_data):
self.last_update_schedule_timestamp = time.time()
self.logger.debug(schedule_data)
media = schedule_data["media"]
media_filtered = {}
# Download all the media and put playlists in liquidsoap "annotate" format
try:
"""
Make sure cache_dir exists
"""
download_dir = self.cache_dir
try:
os.makedirs(download_dir)
except Exception as e:
pass
media_copy = {}
for key in media:
media_item = media[key]
if media_item["type"] == "file":
fileExt = self.sanity_check_media_item(media_item)
dst = os.path.join(download_dir, f'{media_item["id"]}{fileExt}')
media_item["dst"] = dst
media_item["file_ready"] = False
media_filtered[key] = media_item
media_item["start"] = datetime.strptime(
media_item["start"], "%Y-%m-%d-%H-%M-%S"
)
media_item["end"] = datetime.strptime(
media_item["end"], "%Y-%m-%d-%H-%M-%S"
)
media_copy[key] = media_item
self.media_prepare_queue.put(copy.copy(media_filtered))
except Exception as e:
self.logger.exception(e)
# Send the data to pypo-push
self.logger.debug("Pushing to pypo-push")
self.push_queue.put(media_copy)
# cleanup
try:
self.cache_cleanup(media)
except Exception as e:
self.logger.exception(e)
# do basic validation of file parameters. Useful for debugging
# purposes
def sanity_check_media_item(self, media_item):
start = datetime.strptime(media_item["start"], "%Y-%m-%d-%H-%M-%S")
end = datetime.strptime(media_item["end"], "%Y-%m-%d-%H-%M-%S")
mime = media_item["metadata"]["mime"]
mimetypes.init(["%s/mime.types" % os.path.dirname(os.path.realpath(__file__))])
mime_ext = mimetypes.guess_extension(mime, strict=False)
length1 = pure.date_interval_to_seconds(end - start)
length2 = media_item["cue_out"] - media_item["cue_in"]
if abs(length2 - length1) > 1:
self.logger.error("end - start length: %s", length1)
self.logger.error("cue_out - cue_in length: %s", length2)
self.logger.error("Two lengths are not equal!!!")
media_item["file_ext"] = mime_ext
return mime_ext
def is_file_opened(self, path):
# Capture stderr to avoid polluting py-interpreter.log
proc = Popen(["lsof", path], stdout=PIPE, stderr=PIPE)
out = proc.communicate()[0].strip()
return bool(out)
def cache_cleanup(self, media):
"""
Get list of all files in the cache dir and remove them if they aren't being used anymore.
Input dict() media, lists all files that are scheduled or currently playing. Not being in this
dict() means the file is safe to remove.
"""
cached_file_set = set(os.listdir(self.cache_dir))
scheduled_file_set = set()
for mkey in media:
media_item = media[mkey]
if media_item["type"] == "file":
if "file_ext" not in media_item.keys():
media_item["file_ext"] = mimetypes.guess_extension(
media_item["metadata"]["mime"], strict=False
)
scheduled_file_set.add(
"{}{}".format(media_item["id"], media_item["file_ext"])
)
expired_files = cached_file_set - scheduled_file_set
self.logger.debug("Files to remove " + str(expired_files))
for f in expired_files:
try:
path = os.path.join(self.cache_dir, f)
self.logger.debug("Removing %s" % path)
# check if this file is opened (sometimes Liquidsoap is still
# playing the file due to our knowledge of the track length
# being incorrect!)
if not self.is_file_opened(path):
os.remove(path)
self.logger.info("File '%s' removed" % path)
else:
self.logger.info("File '%s' not removed. Still busy!" % path)
except Exception as e:
self.logger.exception("Problem removing file '%s'" % f)
def manual_schedule_fetch(self):
try:
self.schedule_data = self.api_client.get_schedule()
self.process_schedule(self.schedule_data)
return True
except Exception as e:
self.logger.error("Unable to fetch schedule")
self.logger.exception(e)
return False
def persistent_manual_schedule_fetch(self, max_attempts=1):
success = False
num_attempts = 0
while not success and num_attempts < max_attempts:
success = self.manual_schedule_fetch()
num_attempts += 1
return success
# This function makes a request to Airtime to see if we need to
# push metadata to TuneIn. We have to do this because TuneIn turns
# off metadata if it does not receive a request every 5 minutes.
def update_metadata_on_tunein(self):
self.v1_api_client.update_metadata_on_tunein()
Timer(120, self.update_metadata_on_tunein).start()
def main(self):
# Make sure all Liquidsoap queues are empty. This is important in the
# case where we've just restarted the pypo scheduler, but Liquidsoap still
# is playing tracks. In this case let's just restart everything from scratch
# so that we can repopulate our dictionary that keeps track of what
# Liquidsoap is playing much more easily.
self.pypo_liquidsoap.clear_all_queues()
self.set_bootstrap_variables()
self.update_metadata_on_tunein()
# Bootstrap: since we are just starting up, we need to grab the
# most recent schedule. After that we fetch the schedule every 8
# minutes or wait for schedule updates to get pushed.
success = self.persistent_manual_schedule_fetch(max_attempts=5)
if success:
self.logger.info("Bootstrap schedule received: %s", self.schedule_data)
loops = 1
while True:
self.logger.info("Loop #%s", loops)
manual_fetch_needed = False
try:
"""
our simple_queue.get() requires a timeout, in which case we
fetch the Airtime schedule manually. It is important to fetch
the schedule periodically because if we didn't, we would only
get schedule updates via RabbitMq if the user was constantly
using the Airtime interface.
If the user is not using the interface, RabbitMq messages are not
sent, and we will have very stale (or non-existent!) data about the
schedule.
Currently we are checking every POLL_INTERVAL seconds
"""
message = self.fetch_queue.get(
block=True, timeout=self.listener_timeout
)
manual_fetch_needed = False
self.handle_message(message)
except Empty as e:
self.logger.info("Queue timeout. Fetching schedule manually")
manual_fetch_needed = True
except Exception as e:
self.logger.exception(e)
try:
if manual_fetch_needed:
self.persistent_manual_schedule_fetch(max_attempts=5)
except Exception as e:
self.logger.exception("Failed to manually fetch the schedule.")
loops += 1
def run(self):
"""
Entry point of the thread
"""
self.main()
self.logger.info("PypoFetch thread exiting")

223
playout/pypo/pypofile.py Normal file
View file

@ -0,0 +1,223 @@
import configparser
import hashlib
import json
import logging
import os
import shutil
import stat
import sys
import time
import traceback
from configparser import NoOptionError
from queue import Empty
from threading import Thread
import requests
from api_clients import version2 as api_client
from requests.exceptions import ConnectionError, HTTPError, Timeout
CONFIG_PATH = "/etc/airtime/airtime.conf"
logging.captureWarnings(True)
class PypoFile(Thread):
def __init__(self, schedule_queue, config):
Thread.__init__(self)
self.logger = logging.getLogger()
self.media_queue = schedule_queue
self.media = None
self.cache_dir = os.path.join(config["cache_dir"], "scheduler")
self._config = self.read_config_file(CONFIG_PATH)
self.api_client = api_client.AirtimeApiClient()
def copy_file(self, media_item):
"""
Copy media_item from local library directory to local cache directory.
"""
src = media_item["uri"]
dst = media_item["dst"]
src_size = media_item["filesize"]
dst_exists = True
try:
dst_size = os.path.getsize(dst)
if dst_size == 0:
dst_exists = False
except Exception as e:
dst_exists = False
do_copy = False
if dst_exists:
# TODO: Check if the locally cached variant of the file is sane.
# This used to be a filesize check that didn't end up working.
# Once we have watched folders updated files from them might
# become an issue here... This needs proper cache management.
# https://github.com/LibreTime/libretime/issues/756#issuecomment-477853018
# https://github.com/LibreTime/libretime/pull/845
self.logger.debug(
"file %s already exists in local cache as %s, skipping copying..."
% (src, dst)
)
else:
do_copy = True
media_item["file_ready"] = not do_copy
if do_copy:
self.logger.info("copying from %s to local cache %s" % (src, dst))
try:
with open(dst, "wb") as handle:
self.logger.info(media_item)
response = self.api_client.services.file_download_url(
id=media_item["id"]
)
if not response.ok:
self.logger.error(response)
raise Exception(
"%s - Error occurred downloading file"
% response.status_code
)
for chunk in response.iter_content(chunk_size=1024):
handle.write(chunk)
# make file world readable and owner writable
os.chmod(dst, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH)
if media_item["filesize"] == 0:
file_size = self.report_file_size_and_md5_to_api(
dst, media_item["id"]
)
media_item["filesize"] = file_size
media_item["file_ready"] = True
except Exception as e:
self.logger.error("Could not copy from %s to %s" % (src, dst))
self.logger.error(e)
def report_file_size_and_md5_to_api(self, file_path, file_id):
try:
file_size = os.path.getsize(file_path)
with open(file_path, "rb") as fh:
m = hashlib.md5()
while True:
data = fh.read(8192)
if not data:
break
m.update(data)
md5_hash = m.hexdigest()
except (OSError, IOError) as e:
file_size = 0
self.logger.error(
"Error getting file size and md5 hash for file id %s" % file_id
)
self.logger.error(e)
# Make PUT request to LibreTime to update the file size and hash
error_msg = (
"Could not update media file %s with file size and md5 hash:" % file_id
)
try:
payload = {"filesize": file_size, "md5": md5_hash}
response = self.api_client.update_file(file_id, payload)
except (ConnectionError, Timeout):
self.logger.error(error_msg)
except Exception as e:
self.logger.error(error_msg)
self.logger.error(e)
return file_size
def get_highest_priority_media_item(self, schedule):
"""
Get highest priority media_item in the queue. Currently the highest
priority is decided by how close the start time is to "now".
"""
if schedule is None or len(schedule) == 0:
return None
sorted_keys = sorted(schedule.keys())
if len(sorted_keys) == 0:
return None
highest_priority = sorted_keys[0]
media_item = schedule[highest_priority]
self.logger.debug("Highest priority item: %s" % highest_priority)
"""
Remove this media_item from the dictionary. On the next iteration
(from the main function) we won't consider it for prioritization
anymore. If on the next iteration we have received a new schedule,
it is very possible we will have to deal with the same media_items
again. In this situation, the worst possible case is that we try to
copy the file again and realize we already have it (thus aborting the copy).
"""
del schedule[highest_priority]
return media_item
def read_config_file(self, config_path):
"""Parse the application's config file located at config_path."""
config = configparser.SafeConfigParser(allow_no_value=True)
try:
config.readfp(open(config_path))
except IOError as e:
logging.debug(
"Failed to open config file at %s: %s" % (config_path, e.strerror)
)
sys.exit()
except Exception as e:
logging.debug(e.strerror)
sys.exit()
return config
def main(self):
while True:
try:
if self.media is None or len(self.media) == 0:
"""
We have no schedule, so we have nothing else to do. Let's
do a blocked wait on the queue
"""
self.media = self.media_queue.get(block=True)
else:
"""
We have a schedule we need to process, but we also want
to check if a newer schedule is available. In this case
do a non-blocking queue.get and in either case (we get something
or we don't), get back to work on preparing getting files.
"""
try:
self.media = self.media_queue.get_nowait()
except Empty as e:
pass
media_item = self.get_highest_priority_media_item(self.media)
if media_item is not None:
self.copy_file(media_item)
except Exception as e:
import traceback
top = traceback.format_exc()
self.logger.error(str(e))
self.logger.error(top)
raise
def run(self):
"""
Entry point of the thread
"""
try:
self.main()
except Exception as e:
top = traceback.format_exc()
self.logger.error("PypoFile Exception: %s", top)
time.sleep(5)
self.logger.info("PypoFile thread exiting")

View file

@ -0,0 +1,92 @@
import signal
import sys
import time
import traceback
from collections import deque
from datetime import datetime
from queue import Empty
from threading import Thread
def keyboardInterruptHandler(signum, frame):
logger = logging.getLogger()
logger.info("\nKeyboard Interrupt\n")
sys.exit(0)
signal.signal(signal.SIGINT, keyboardInterruptHandler)
class PypoLiqQueue(Thread):
def __init__(self, q, pypo_liquidsoap, logger):
Thread.__init__(self)
self.queue = q
self.logger = logger
self.pypo_liquidsoap = pypo_liquidsoap
def main(self):
time_until_next_play = None
schedule_deque = deque()
media_schedule = None
while True:
try:
if time_until_next_play is None:
self.logger.info("waiting indefinitely for schedule")
media_schedule = self.queue.get(block=True)
else:
self.logger.info(
"waiting %ss until next scheduled item" % time_until_next_play
)
media_schedule = self.queue.get(
block=True, timeout=time_until_next_play
)
except Empty as e:
# Time to push a scheduled item.
media_item = schedule_deque.popleft()
self.pypo_liquidsoap.play(media_item)
if len(schedule_deque):
time_until_next_play = self.date_interval_to_seconds(
schedule_deque[0]["start"] - datetime.utcnow()
)
if time_until_next_play < 0:
time_until_next_play = 0
else:
time_until_next_play = None
else:
self.logger.info("New schedule received: %s", media_schedule)
# new schedule received. Replace old one with this.
schedule_deque.clear()
keys = sorted(media_schedule.keys())
for i in keys:
schedule_deque.append(media_schedule[i])
if len(keys):
time_until_next_play = self.date_interval_to_seconds(
media_schedule[keys[0]]["start"] - datetime.utcnow()
)
else:
time_until_next_play = None
def date_interval_to_seconds(self, interval):
"""
Convert timedelta object into int representing the number of seconds. If
number of seconds is less than 0, then return 0.
"""
seconds = (
interval.microseconds
+ (interval.seconds + interval.days * 24 * 3600) * 10 ** 6
) / float(10 ** 6)
if seconds < 0:
seconds = 0
return seconds
def run(self):
try:
self.main()
except Exception as e:
self.logger.error("PypoLiqQueue Exception: %s", traceback.format_exc())

View file

@ -0,0 +1,255 @@
import time
from datetime import datetime, timedelta
from . import eventtypes
from .pypofetch import PypoFetch
from .telnetliquidsoap import TelnetLiquidsoap
class PypoLiquidsoap:
def __init__(self, logger, telnet_lock, host, port):
self.logger = logger
self.liq_queue_tracker = {
"s0": None,
"s1": None,
"s2": None,
"s3": None,
"s4": None,
}
self.telnet_liquidsoap = TelnetLiquidsoap(
telnet_lock, logger, host, port, list(self.liq_queue_tracker.keys())
)
def get_telnet_dispatcher(self):
return self.telnet_liquidsoap
def play(self, media_item):
if media_item["type"] == eventtypes.FILE:
self.handle_file_type(media_item)
elif media_item["type"] == eventtypes.EVENT:
self.handle_event_type(media_item)
elif media_item["type"] == eventtypes.STREAM_BUFFER_START:
self.telnet_liquidsoap.start_web_stream_buffer(media_item)
elif media_item["type"] == eventtypes.STREAM_OUTPUT_START:
if (
media_item["row_id"]
!= self.telnet_liquidsoap.current_prebuffering_stream_id
):
# this is called if the stream wasn't scheduled sufficiently ahead of time
# so that the prebuffering stage could take effect. Let's do the prebuffering now.
self.telnet_liquidsoap.start_web_stream_buffer(media_item)
self.telnet_liquidsoap.start_web_stream(media_item)
elif media_item["type"] == eventtypes.STREAM_BUFFER_END:
self.telnet_liquidsoap.stop_web_stream_buffer()
elif media_item["type"] == eventtypes.STREAM_OUTPUT_END:
self.telnet_liquidsoap.stop_web_stream_output()
else:
raise UnknownMediaItemType(str(media_item))
def handle_file_type(self, media_item):
"""
Wait 200 seconds (2000 iterations) for file to become ready,
otherwise give up on it.
"""
iter_num = 0
while not media_item["file_ready"] and iter_num < 2000:
time.sleep(0.1)
iter_num += 1
if media_item["file_ready"]:
available_queue = self.find_available_queue()
try:
self.telnet_liquidsoap.queue_push(available_queue, media_item)
self.liq_queue_tracker[available_queue] = media_item
except Exception as e:
self.logger.error(e)
raise
else:
self.logger.warn(
"File %s did not become ready in less than 5 seconds. Skipping...",
media_item["dst"],
)
def handle_event_type(self, media_item):
if media_item["event_type"] == "kick_out":
self.telnet_liquidsoap.disconnect_source("live_dj")
elif media_item["event_type"] == "switch_off":
self.telnet_liquidsoap.switch_source("live_dj", "off")
def is_media_item_finished(self, media_item):
if media_item is None:
return True
else:
return datetime.utcnow() > media_item["end"]
def find_available_queue(self):
available_queue = None
for i in self.liq_queue_tracker:
mi = self.liq_queue_tracker[i]
if mi == None or self.is_media_item_finished(mi):
# queue "i" is available. Push to this queue
available_queue = i
if available_queue == None:
raise NoQueueAvailableException()
return available_queue
def verify_correct_present_media(self, scheduled_now):
"""
verify whether Liquidsoap is currently playing the correct files.
if we find an item that Liquidsoap is not playing, then push it
into one of Liquidsoap's queues. If Liquidsoap is already playing
it do nothing. If Liquidsoap is playing a track that isn't in
currently_playing then stop it.
Check for Liquidsoap media we should source.skip
get liquidsoap items for each queue. Since each queue can only have one
item, we should have a max of 8 items.
2013-03-21-22-56-00_0: {
id: 1,
type: "stream_output_start",
row_id: 41,
uri: "http://stream2.radioblackout.org:80/blackout.ogg",
start: "2013-03-21-22-56-00",
end: "2013-03-21-23-26-00",
show_name: "Untitled Show",
independent_event: true
},
"""
try:
scheduled_now_files = [
x for x in scheduled_now if x["type"] == eventtypes.FILE
]
scheduled_now_webstream = [
x for x in scheduled_now if x["type"] == eventtypes.STREAM_OUTPUT_START
]
schedule_ids = set([x["row_id"] for x in scheduled_now_files])
row_id_map = {}
liq_queue_ids = set()
for i in self.liq_queue_tracker:
mi = self.liq_queue_tracker[i]
if not self.is_media_item_finished(mi):
liq_queue_ids.add(mi["row_id"])
row_id_map[mi["row_id"]] = mi
to_be_removed = set()
to_be_added = set()
# Iterate over the new files, and compare them to currently scheduled
# tracks. If already in liquidsoap queue still need to make sure they don't
# have different attributes
# if replay gain changes, it shouldn't change the amplification of the currently playing song
for i in scheduled_now_files:
if i["row_id"] in row_id_map:
mi = row_id_map[i["row_id"]]
correct = (
mi["start"] == i["start"]
and mi["end"] == i["end"]
and mi["row_id"] == i["row_id"]
)
if not correct:
# need to re-add
self.logger.info("Track %s found to have new attr." % i)
to_be_removed.add(i["row_id"])
to_be_added.add(i["row_id"])
to_be_removed.update(liq_queue_ids - schedule_ids)
to_be_added.update(schedule_ids - liq_queue_ids)
if to_be_removed:
self.logger.info(
"Need to remove items from Liquidsoap: %s" % to_be_removed
)
# remove files from Liquidsoap's queue
for i in self.liq_queue_tracker:
mi = self.liq_queue_tracker[i]
if mi is not None and mi["row_id"] in to_be_removed:
self.stop(i)
if to_be_added:
self.logger.info(
"Need to add items to Liquidsoap *now*: %s" % to_be_added
)
for i in scheduled_now_files:
if i["row_id"] in to_be_added:
self.modify_cue_point(i)
self.play(i)
# handle webstreams
current_stream_id = self.telnet_liquidsoap.get_current_stream_id()
if scheduled_now_webstream:
if int(current_stream_id) != int(scheduled_now_webstream[0]["row_id"]):
self.play(scheduled_now_webstream[0])
elif current_stream_id != "-1":
# something is playing and it shouldn't be.
self.telnet_liquidsoap.stop_web_stream_buffer()
self.telnet_liquidsoap.stop_web_stream_output()
except KeyError as e:
self.logger.error("Error: Malformed event in schedule. " + str(e))
def stop(self, queue):
self.telnet_liquidsoap.queue_remove(queue)
self.liq_queue_tracker[queue] = None
def is_file(self, media_item):
return media_item["type"] == eventtypes.FILE
def clear_queue_tracker(self):
for i in self.liq_queue_tracker.keys():
self.liq_queue_tracker[i] = None
def modify_cue_point(self, link):
assert self.is_file(link)
tnow = datetime.utcnow()
link_start = link["start"]
diff_td = tnow - link_start
diff_sec = self.date_interval_to_seconds(diff_td)
if diff_sec > 0:
self.logger.debug(
"media item was supposed to start %s ago. Preparing to start..",
diff_sec,
)
original_cue_in_td = timedelta(seconds=float(link["cue_in"]))
link["cue_in"] = (
self.date_interval_to_seconds(original_cue_in_td) + diff_sec
)
def date_interval_to_seconds(self, interval):
"""
Convert timedelta object into int representing the number of seconds. If
number of seconds is less than 0, then return 0.
"""
seconds = (
interval.microseconds
+ (interval.seconds + interval.days * 24 * 3600) * 10 ** 6
) / float(10 ** 6)
if seconds < 0:
seconds = 0
return seconds
def clear_all_queues(self):
self.telnet_liquidsoap.queue_clear_all()
class UnknownMediaItemType(Exception):
pass
class NoQueueAvailableException(Exception):
pass

View file

@ -0,0 +1,132 @@
import json
import logging
import os
import sys
import time
import traceback
from threading import Thread
from amqp.exceptions import AMQPError
# For RabbitMQ
from kombu.connection import Connection
from kombu.messaging import Exchange, Queue
from kombu.mixins import ConsumerMixin
from kombu.simple import SimpleQueue
logging.captureWarnings(True)
class RabbitConsumer(ConsumerMixin):
def __init__(self, connection, queues, handler):
self.connection = connection
self.queues = queues
self.handler = handler
def get_consumers(self, Consumer, channel):
return [
Consumer(self.queues, callbacks=[self.on_message], accept=["text/plain"]),
]
def on_message(self, body, message):
self.handler.handle_message(message.payload)
message.ack()
class PypoMessageHandler(Thread):
def __init__(self, pq, rq, config):
Thread.__init__(self)
self.logger = logging.getLogger("message_h")
self.pypo_queue = pq
self.recorder_queue = rq
self.config = config
def init_rabbit_mq(self):
self.logger.info("Initializing RabbitMQ stuff")
try:
schedule_exchange = Exchange(
"airtime-pypo", "direct", durable=True, auto_delete=True
)
schedule_queue = Queue("pypo-fetch", exchange=schedule_exchange, key="foo")
with Connection(
self.config["host"],
self.config["user"],
self.config["password"],
self.config["vhost"],
heartbeat=5,
) as connection:
rabbit = RabbitConsumer(connection, [schedule_queue], self)
rabbit.run()
except Exception as e:
self.logger.error(e)
"""
Handle a message from RabbitMQ, put it into our yucky global var.
Hopefully there is a better way to do this.
"""
def handle_message(self, message):
try:
self.logger.info("Received event from RabbitMQ: %s" % message)
try:
message = message.decode()
except (UnicodeDecodeError, AttributeError):
pass
m = json.loads(message)
command = m["event_type"]
self.logger.info("Handling command: " + command)
if command == "update_schedule":
self.logger.info("Updating schedule...")
self.pypo_queue.put(message)
elif command == "reset_liquidsoap_bootstrap":
self.logger.info("Resetting bootstrap vars...")
self.pypo_queue.put(message)
elif command == "update_stream_setting":
self.logger.info("Updating stream setting...")
self.pypo_queue.put(message)
elif command == "update_stream_format":
self.logger.info("Updating stream format...")
self.pypo_queue.put(message)
elif command == "update_station_name":
self.logger.info("Updating station name...")
self.pypo_queue.put(message)
elif command == "switch_source":
self.logger.info("switch_source command received...")
self.pypo_queue.put(message)
elif command == "update_transition_fade":
self.logger.info("Updating trasition fade...")
self.pypo_queue.put(message)
elif command == "disconnect_source":
self.logger.info("disconnect_source command received...")
self.pypo_queue.put(message)
elif command == "update_recorder_schedule":
self.recorder_queue.put(message)
elif command == "cancel_recording":
self.recorder_queue.put(message)
else:
self.logger.info("Unknown command: %s" % command)
except Exception as e:
self.logger.error("Exception in handling RabbitMQ message: %s", e)
def main(self):
try:
self.init_rabbit_mq()
except Exception as e:
self.logger.error("Exception: %s", e)
self.logger.error("traceback: %s", traceback.format_exc())
self.logger.error(
"Error connecting to RabbitMQ Server. Trying again in few seconds"
)
time.sleep(5)
"""
Main loop of the thread:
Wait for schedule updates from RabbitMQ, but in case there aren't any,
poll the server to get the upcoming schedule.
"""
def run(self):
while True:
self.main()

158
playout/pypo/pypopush.py Normal file
View file

@ -0,0 +1,158 @@
import calendar
import logging.config
import math
import os
import sys
import telnetlib
import time
import traceback
from datetime import datetime, timedelta
from queue import Empty, Queue
from threading import Thread
from api_clients import version1 as api_client
from configobj import ConfigObj
from .pypofetch import PypoFetch
from .pypoliqqueue import PypoLiqQueue
from .timeout import ls_timeout
logging.captureWarnings(True)
PUSH_INTERVAL = 2
def is_stream(media_item):
return media_item["type"] == "stream_output_start"
def is_file(media_item):
return media_item["type"] == "file"
class PypoPush(Thread):
def __init__(self, q, telnet_lock, pypo_liquidsoap, config):
Thread.__init__(self)
self.api_client = api_client.AirtimeApiClient()
self.queue = q
self.telnet_lock = telnet_lock
self.config = config
self.pushed_objects = {}
self.logger = logging.getLogger("push")
self.current_prebuffering_stream_id = None
self.queue_id = 0
self.future_scheduled_queue = Queue()
self.pypo_liquidsoap = pypo_liquidsoap
self.plq = PypoLiqQueue(
self.future_scheduled_queue, self.pypo_liquidsoap, self.logger
)
self.plq.daemon = True
self.plq.start()
def main(self):
loops = 0
heartbeat_period = math.floor(30 / PUSH_INTERVAL)
media_schedule = None
while True:
try:
media_schedule = self.queue.get(block=True)
except Exception as e:
self.logger.error(str(e))
raise
else:
self.logger.debug(media_schedule)
# separate media_schedule list into currently_playing and
# scheduled_for_future lists
currently_playing, scheduled_for_future = self.separate_present_future(
media_schedule
)
self.pypo_liquidsoap.verify_correct_present_media(currently_playing)
self.future_scheduled_queue.put(scheduled_for_future)
if loops % heartbeat_period == 0:
self.logger.info("heartbeat")
loops = 0
loops += 1
def separate_present_future(self, media_schedule):
tnow = datetime.utcnow()
present = []
future = {}
sorted_keys = sorted(media_schedule.keys())
for mkey in sorted_keys:
media_item = media_schedule[mkey]
# Ignore track that already ended
if media_item["end"] < tnow:
self.logger.debug(f"ignoring ended media_item: {media_item}")
continue
diff_td = tnow - media_item["start"]
diff_sec = self.date_interval_to_seconds(diff_td)
if diff_sec >= 0:
self.logger.debug(f"adding media_item to present: {media_item}")
present.append(media_item)
else:
self.logger.debug(f"adding media_item to future: {media_item}")
future[mkey] = media_item
return present, future
def date_interval_to_seconds(self, interval):
"""
Convert timedelta object into int representing the number of seconds. If
number of seconds is less than 0, then return 0.
"""
seconds = (
interval.microseconds
+ (interval.seconds + interval.days * 24 * 3600) * 10 ** 6
) / float(10 ** 6)
return seconds
@ls_timeout
def stop_web_stream_all(self):
try:
self.telnet_lock.acquire()
tn = telnetlib.Telnet(self.config["LS_HOST"], self.config["LS_PORT"])
# msg = 'dynamic_source.read_stop_all xxx\n'
msg = "http.stop\n"
self.logger.debug(msg)
tn.write(msg)
msg = "dynamic_source.output_stop\n"
self.logger.debug(msg)
tn.write(msg)
msg = "dynamic_source.id -1\n"
self.logger.debug(msg)
tn.write(msg)
tn.write("exit\n")
self.logger.debug(tn.read_all())
except Exception as e:
self.logger.error(str(e))
finally:
self.telnet_lock.release()
def run(self):
while True:
try:
self.main()
except Exception as e:
top = traceback.format_exc()
self.logger.error("Pypo Push Exception: %s", top)
time.sleep(5)
self.logger.info("PypoPush thread exiting")

380
playout/pypo/recorder.py Normal file
View file

@ -0,0 +1,380 @@
import datetime
import json
import logging
import math
import os
import re
import signal
import sys
import time
import traceback
from subprocess import PIPE, Popen
from threading import Thread
import mutagen
import pytz
from api_clients import version1 as v1_api_client
from api_clients import version2 as api_client
from configobj import ConfigObj
def api_client(logger):
"""
api_client returns the correct instance of AirtimeApiClient. Although there is only one
instance to choose from at the moment.
"""
return v1_api_client.AirtimeApiClient(logger)
# loading config file
try:
config = ConfigObj("/etc/airtime/airtime.conf")
except Exception as e:
print("Error loading config file: {}".format(e))
sys.exit()
# TODO : add docstrings everywhere in this module
def getDateTimeObj(time):
# TODO : clean up for this function later.
# - use tuples to parse result from split (instead of indices)
# - perhaps validate the input before doing dangerous casts?
# - rename this function to follow the standard convention
# - rename time to something else so that the module name does not get
# shadowed
# - add docstring to document all behaviour of this function
timeinfo = time.split(" ")
date = [int(x) for x in timeinfo[0].split("-")]
my_time = [int(x) for x in timeinfo[1].split(":")]
return datetime.datetime(
date[0], date[1], date[2], my_time[0], my_time[1], my_time[2], 0, None
)
PUSH_INTERVAL = 2
class ShowRecorder(Thread):
def __init__(self, show_instance, show_name, filelength, start_time):
Thread.__init__(self)
self.logger = logging.getLogger("recorder")
self.api_client = api_client(self.logger)
self.filelength = filelength
self.start_time = start_time
self.show_instance = show_instance
self.show_name = show_name
self.p = None
def record_show(self):
length = str(self.filelength)
filename = self.start_time
filename = filename.replace(" ", "-")
if config["pypo"]["record_file_type"] in ["mp3", "ogg"]:
filetype = config["pypo"]["record_file_type"]
else:
filetype = "ogg"
joined_path = os.path.join(config["pypo"]["base_recorded_files"], filename)
filepath = "%s.%s" % (joined_path, filetype)
br = config["pypo"]["record_bitrate"]
sr = config["pypo"]["record_samplerate"]
c = config["pypo"]["record_channels"]
ss = config["pypo"]["record_sample_size"]
# -f:16,2,44100
# -b:256
command = "ecasound -f:%s,%s,%s -i alsa -o %s,%s000 -t:%s" % (
ss,
c,
sr,
filepath,
br,
length,
)
args = command.split(" ")
self.logger.info("starting record")
self.logger.info("command " + command)
self.p = Popen(args, stdout=PIPE, stderr=PIPE)
# blocks at the following line until the child process
# quits
self.p.wait()
outmsgs = self.p.stdout.readlines()
for msg in outmsgs:
m = re.search("^ERROR", msg)
if not m == None:
self.logger.info("Recording error is found: %s", outmsgs)
self.logger.info("finishing record, return code %s", self.p.returncode)
code = self.p.returncode
self.p = None
return code, filepath
def cancel_recording(self):
# send signal interrupt (2)
self.logger.info("Show manually cancelled!")
if self.p is not None:
self.p.send_signal(signal.SIGINT)
# if self.p is defined, then the child process ecasound is recording
def is_recording(self):
return self.p is not None
def upload_file(self, filepath):
filename = os.path.split(filepath)[1]
# files is what requests actually expects
files = {
"file": open(filepath, "rb"),
"name": filename,
"show_instance": self.show_instance,
}
self.api_client.upload_recorded_show(files, self.show_instance)
def set_metadata_and_save(self, filepath):
"""
Writes song to 'filepath'. Uses metadata from:
self.start_time, self.show_name, self.show_instance
"""
try:
full_date, full_time = self.start_time.split(" ", 1)
# No idea why we translated - to : before
# full_time = full_time.replace(":","-")
self.logger.info("time: %s" % full_time)
artist = "Airtime Show Recorder"
# set some metadata for our file daemon
recorded_file = mutagen.File(filepath, easy=True)
recorded_file["artist"] = artist
recorded_file["date"] = full_date
recorded_file["title"] = "%s-%s-%s" % (self.show_name, full_date, full_time)
# You cannot pass ints into the metadata of a file. Even tracknumber needs to be a string
recorded_file["tracknumber"] = self.show_instance
recorded_file.save()
except Exception as e:
top = traceback.format_exc()
self.logger.error("Exception: %s", e)
self.logger.error("traceback: %s", top)
def run(self):
code, filepath = self.record_show()
if code == 0:
try:
self.logger.info("Preparing to upload %s" % filepath)
self.set_metadata_and_save(filepath)
self.upload_file(filepath)
os.remove(filepath)
except Exception as e:
self.logger.error(e)
else:
self.logger.info("problem recording show")
os.remove(filepath)
class Recorder(Thread):
def __init__(self, q):
Thread.__init__(self)
self.logger = logging.getLogger("recorder")
self.api_client = api_client(self.logger)
self.sr = None
self.shows_to_record = {}
self.server_timezone = ""
self.queue = q
self.loops = 0
self.logger.info("RecorderFetch: init complete")
success = False
while not success:
try:
self.api_client.register_component("show-recorder")
success = True
except Exception as e:
self.logger.error(str(e))
time.sleep(10)
def handle_message(self):
if not self.queue.empty():
message = self.queue.get()
try:
message = message.decode()
except (UnicodeDecodeError, AttributeError):
pass
msg = json.loads(message)
command = msg["event_type"]
self.logger.info("Received msg from Pypo Message Handler: %s", msg)
if command == "cancel_recording":
if self.currently_recording():
self.cancel_recording()
else:
self.process_recorder_schedule(msg)
self.loops = 0
if self.shows_to_record:
self.start_record()
def process_recorder_schedule(self, m):
self.logger.info("Parsing recording show schedules...")
temp_shows_to_record = {}
shows = m["shows"]
for show in shows:
show_starts = getDateTimeObj(show["starts"])
show_end = getDateTimeObj(show["ends"])
time_delta = show_end - show_starts
temp_shows_to_record[show["starts"]] = [
time_delta,
show["instance_id"],
show["name"],
m["server_timezone"],
]
self.shows_to_record = temp_shows_to_record
def get_time_till_next_show(self):
if len(self.shows_to_record) != 0:
tnow = datetime.datetime.utcnow()
sorted_show_keys = sorted(self.shows_to_record.keys())
start_time = sorted_show_keys[0]
next_show = getDateTimeObj(start_time)
delta = next_show - tnow
s = "%s.%s" % (delta.seconds, delta.microseconds)
out = float(s)
if out < 5:
self.logger.debug("Shows %s", self.shows_to_record)
self.logger.debug("Next show %s", next_show)
self.logger.debug("Now %s", tnow)
return out
def cancel_recording(self):
self.sr.cancel_recording()
self.sr = None
def currently_recording(self):
if self.sr is not None and self.sr.is_recording():
return True
else:
return False
def start_record(self):
if len(self.shows_to_record) == 0:
return None
try:
delta = self.get_time_till_next_show()
if delta < 5:
self.logger.debug("sleeping %s seconds until show", delta)
time.sleep(delta)
sorted_show_keys = sorted(self.shows_to_record.keys())
start_time = sorted_show_keys[0]
show_length = self.shows_to_record[start_time][0]
show_instance = self.shows_to_record[start_time][1]
show_name = self.shows_to_record[start_time][2]
server_timezone = self.shows_to_record[start_time][3]
T = pytz.timezone(server_timezone)
start_time_on_UTC = getDateTimeObj(start_time)
start_time_on_server = start_time_on_UTC.replace(
tzinfo=pytz.utc
).astimezone(T)
start_time_formatted = (
"%(year)d-%(month)02d-%(day)02d %(hour)02d:%(min)02d:%(sec)02d"
% {
"year": start_time_on_server.year,
"month": start_time_on_server.month,
"day": start_time_on_server.day,
"hour": start_time_on_server.hour,
"min": start_time_on_server.minute,
"sec": start_time_on_server.second,
}
)
seconds_waiting = 0
# avoiding CC-5299
while True:
if self.currently_recording():
self.logger.info("Previous record not finished, sleeping 100ms")
seconds_waiting = seconds_waiting + 0.1
time.sleep(0.1)
else:
show_length_seconds = show_length.seconds - seconds_waiting
self.sr = ShowRecorder(
show_instance,
show_name,
show_length_seconds,
start_time_formatted,
)
self.sr.start()
break
# remove show from shows to record.
del self.shows_to_record[start_time]
# self.time_till_next_show = self.get_time_till_next_show()
except Exception as e:
top = traceback.format_exc()
self.logger.error("Exception: %s", e)
self.logger.error("traceback: %s", top)
def run(self):
"""
Main loop of the thread:
Wait for schedule updates from RabbitMQ, but in case there aren't any,
poll the server to get the upcoming schedule.
"""
try:
self.logger.info("Started...")
# Bootstrap: since we are just starting up, we need to grab the
# most recent schedule. After that we can just wait for updates.
try:
temp = self.api_client.get_shows_to_record()
if temp is not None:
self.process_recorder_schedule(temp)
self.logger.info("Bootstrap recorder schedule received: %s", temp)
except Exception as e:
self.logger.error(traceback.format_exc())
self.logger.error(e)
self.logger.info("Bootstrap complete: got initial copy of the schedule")
self.loops = 0
heartbeat_period = math.floor(30 / PUSH_INTERVAL)
while True:
if self.loops * PUSH_INTERVAL > 3600:
self.loops = 0
"""
Fetch recorder schedule
"""
try:
temp = self.api_client.get_shows_to_record()
if temp is not None:
self.process_recorder_schedule(temp)
self.logger.info("updated recorder schedule received: %s", temp)
except Exception as e:
self.logger.error(traceback.format_exc())
self.logger.error(e)
try:
self.handle_message()
except Exception as e:
self.logger.error(traceback.format_exc())
self.logger.error("Pypo Recorder Exception: %s", e)
time.sleep(PUSH_INTERVAL)
self.loops += 1
except Exception as e:
top = traceback.format_exc()
self.logger.error("Exception: %s", e)
self.logger.error("traceback: %s", top)

View file

@ -0,0 +1,343 @@
import telnetlib
import traceback
from .timeout import ls_timeout
def create_liquidsoap_annotation(media):
# We need liq_start_next value in the annotate. That is the value that controls overlap duration of crossfade.
filename = media["dst"]
annotation = (
'annotate:media_id="%s",liq_start_next="0",liq_fade_in="%s",'
+ 'liq_fade_out="%s",liq_cue_in="%s",liq_cue_out="%s",'
+ 'schedule_table_id="%s",replay_gain="%s dB"'
) % (
media["id"],
float(media["fade_in"]) / 1000,
float(media["fade_out"]) / 1000,
float(media["cue_in"]),
float(media["cue_out"]),
media["row_id"],
media["replay_gain"],
)
# Override the the artist/title that Liquidsoap extracts from a file's metadata
# with the metadata we get from Airtime. (You can modify metadata in Airtime's library,
# which doesn't get saved back to the file.)
if "metadata" in media:
if "artist_name" in media["metadata"]:
artist_name = media["metadata"]["artist_name"]
if isinstance(artist_name, str):
annotation += ',artist="%s"' % (artist_name.replace('"', '\\"'))
if "track_title" in media["metadata"]:
track_title = media["metadata"]["track_title"]
if isinstance(track_title, str):
annotation += ',title="%s"' % (track_title.replace('"', '\\"'))
annotation += ":" + filename
return annotation
class TelnetLiquidsoap:
def __init__(self, telnet_lock, logger, ls_host, ls_port, queues):
self.telnet_lock = telnet_lock
self.ls_host = ls_host
self.ls_port = ls_port
self.logger = logger
self.queues = queues
self.current_prebuffering_stream_id = None
def __connect(self):
return telnetlib.Telnet(self.ls_host, self.ls_port)
def __is_empty(self, queue_id):
return True
tn = self.__connect()
msg = "%s.queue\nexit\n" % queue_id
tn.write(msg.encode("utf-8"))
output = tn.read_all().decode("utf-8").splitlines()
if len(output) == 3:
return len(output[0]) == 0
else:
raise Exception("Unexpected list length returned: %s" % output)
@ls_timeout
def queue_clear_all(self):
try:
self.telnet_lock.acquire()
tn = self.__connect()
for i in self.queues:
msg = "queues.%s_skip\n" % i
self.logger.debug(msg)
tn.write(msg.encode("utf-8"))
tn.write("exit\n".encode("utf-8"))
self.logger.debug(tn.read_all().decode("utf-8"))
except Exception:
raise
finally:
self.telnet_lock.release()
@ls_timeout
def queue_remove(self, queue_id):
try:
self.telnet_lock.acquire()
tn = self.__connect()
msg = "queues.%s_skip\n" % queue_id
self.logger.debug(msg)
tn.write(msg.encode("utf-8"))
tn.write("exit\n".encode("utf-8"))
self.logger.debug(tn.read_all().decode("utf-8"))
except Exception:
raise
finally:
self.telnet_lock.release()
@ls_timeout
def queue_push(self, queue_id, media_item):
try:
self.telnet_lock.acquire()
if not self.__is_empty(queue_id):
raise QueueNotEmptyException()
tn = self.__connect()
annotation = create_liquidsoap_annotation(media_item)
msg = "%s.push %s\n" % (queue_id, annotation)
self.logger.debug(msg)
tn.write(msg.encode("utf-8"))
show_name = media_item["show_name"]
msg = "vars.show_name %s\n" % show_name
tn.write(msg.encode("utf-8"))
self.logger.debug(msg)
tn.write("exit\n".encode("utf-8"))
self.logger.debug(tn.read_all().decode("utf-8"))
except Exception:
raise
finally:
self.telnet_lock.release()
@ls_timeout
def stop_web_stream_buffer(self):
try:
self.telnet_lock.acquire()
tn = telnetlib.Telnet(self.ls_host, self.ls_port)
# dynamic_source.stop http://87.230.101.24:80/top100station.mp3
msg = "http.stop\n"
self.logger.debug(msg)
tn.write(msg.encode("utf-8"))
msg = "dynamic_source.id -1\n"
self.logger.debug(msg)
tn.write(msg.encode("utf-8"))
tn.write("exit\n".encode("utf-8"))
self.logger.debug(tn.read_all().decode("utf-8"))
except Exception as e:
self.logger.error(str(e))
self.logger.error(traceback.format_exc())
finally:
self.telnet_lock.release()
@ls_timeout
def stop_web_stream_output(self):
try:
self.telnet_lock.acquire()
tn = telnetlib.Telnet(self.ls_host, self.ls_port)
# dynamic_source.stop http://87.230.101.24:80/top100station.mp3
msg = "dynamic_source.output_stop\n"
self.logger.debug(msg)
tn.write(msg.encode("utf-8"))
tn.write("exit\n".encode("utf-8"))
self.logger.debug(tn.read_all().decode("utf-8"))
except Exception as e:
self.logger.error(str(e))
self.logger.error(traceback.format_exc())
finally:
self.telnet_lock.release()
@ls_timeout
def start_web_stream(self, media_item):
try:
self.telnet_lock.acquire()
tn = telnetlib.Telnet(self.ls_host, self.ls_port)
# TODO: DO we need this?
msg = "streams.scheduled_play_start\n"
tn.write(msg.encode("utf-8"))
msg = "dynamic_source.output_start\n"
self.logger.debug(msg)
tn.write(msg.encode("utf-8"))
tn.write("exit\n".encode("utf-8"))
self.logger.debug(tn.read_all().decode("utf-8"))
self.current_prebuffering_stream_id = None
except Exception as e:
self.logger.error(str(e))
self.logger.error(traceback.format_exc())
finally:
self.telnet_lock.release()
@ls_timeout
def start_web_stream_buffer(self, media_item):
try:
self.telnet_lock.acquire()
tn = telnetlib.Telnet(self.ls_host, self.ls_port)
msg = "dynamic_source.id %s\n" % media_item["row_id"]
self.logger.debug(msg)
tn.write(msg.encode("utf-8"))
msg = "http.restart %s\n" % media_item["uri"]
self.logger.debug(msg)
tn.write(msg.encode("utf-8"))
tn.write("exit\n".encode("utf-8"))
self.logger.debug(tn.read_all().decode("utf-8"))
self.current_prebuffering_stream_id = media_item["row_id"]
except Exception as e:
self.logger.error(str(e))
self.logger.error(traceback.format_exc())
finally:
self.telnet_lock.release()
@ls_timeout
def get_current_stream_id(self):
try:
self.telnet_lock.acquire()
tn = telnetlib.Telnet(self.ls_host, self.ls_port)
msg = "dynamic_source.get_id\n"
self.logger.debug(msg)
tn.write(msg.encode("utf-8"))
tn.write("exit\n".encode("utf-8"))
stream_id = tn.read_all().decode("utf-8").splitlines()[0]
self.logger.debug("stream_id: %s" % stream_id)
return stream_id
except Exception as e:
self.logger.error(str(e))
self.logger.error(traceback.format_exc())
finally:
self.telnet_lock.release()
@ls_timeout
def disconnect_source(self, sourcename):
self.logger.debug("Disconnecting source: %s", sourcename)
command = ""
if sourcename == "master_dj":
command += "master_harbor.stop\n"
elif sourcename == "live_dj":
command += "live_dj_harbor.stop\n"
try:
self.telnet_lock.acquire()
tn = telnetlib.Telnet(self.ls_host, self.ls_port)
self.logger.info(command)
tn.write(command.encode("utf-8"))
tn.write("exit\n".encode("utf-8"))
tn.read_all().decode("utf-8")
except Exception as e:
self.logger.error(traceback.format_exc())
finally:
self.telnet_lock.release()
@ls_timeout
def telnet_send(self, commands):
try:
self.telnet_lock.acquire()
tn = telnetlib.Telnet(self.ls_host, self.ls_port)
for i in commands:
self.logger.info(i)
if type(i) is str:
i = i.encode("utf-8")
tn.write(i)
tn.write("exit\n".encode("utf-8"))
tn.read_all().decode("utf-8")
except Exception as e:
self.logger.error(str(e))
self.logger.error(traceback.format_exc())
finally:
self.telnet_lock.release()
def switch_source(self, sourcename, status):
self.logger.debug('Switching source: %s to "%s" status', sourcename, status)
command = "streams."
if sourcename == "master_dj":
command += "master_dj_"
elif sourcename == "live_dj":
command += "live_dj_"
elif sourcename == "scheduled_play":
command += "scheduled_play_"
if status == "on":
command += "start\n"
else:
command += "stop\n"
self.telnet_send([command])
class DummyTelnetLiquidsoap:
def __init__(self, telnet_lock, logger):
self.telnet_lock = telnet_lock
self.liquidsoap_mock_queues = {}
self.logger = logger
for i in range(4):
self.liquidsoap_mock_queues["s" + str(i)] = []
@ls_timeout
def queue_push(self, queue_id, media_item):
try:
self.telnet_lock.acquire()
self.logger.info("Pushing %s to queue %s" % (media_item, queue_id))
from datetime import datetime
print("Time now: {:s}".format(datetime.utcnow()))
annotation = create_liquidsoap_annotation(media_item)
self.liquidsoap_mock_queues[queue_id].append(annotation)
except Exception:
raise
finally:
self.telnet_lock.release()
@ls_timeout
def queue_remove(self, queue_id):
try:
self.telnet_lock.acquire()
self.logger.info("Purging queue %s" % queue_id)
from datetime import datetime
print("Time now: {:s}".format(datetime.utcnow()))
except Exception:
raise
finally:
self.telnet_lock.release()
class QueueNotEmptyException(Exception):
pass

View file

@ -0,0 +1,93 @@
import logging
import signal
import sys
from datetime import datetime, timedelta
from queue import Queue
from threading import Lock
from .pypoliqqueue import PypoLiqQueue
from .telnetliquidsoap import DummyTelnetLiquidsoap, TelnetLiquidsoap
def keyboardInterruptHandler(signum, frame):
logger = logging.getLogger()
logger.info("\nKeyboard Interrupt\n")
sys.exit(0)
signal.signal(signal.SIGINT, keyboardInterruptHandler)
# configure logging
format = "%(levelname)s - %(pathname)s - %(lineno)s - %(asctime)s - %(message)s"
logging.basicConfig(level=logging.DEBUG, format=format)
logging.captureWarnings(True)
telnet_lock = Lock()
pypoPush_q = Queue()
pypoLiq_q = Queue()
liq_queue_tracker = {
"s0": None,
"s1": None,
"s2": None,
"s3": None,
}
# dummy_telnet_liquidsoap = DummyTelnetLiquidsoap(telnet_lock, logging)
dummy_telnet_liquidsoap = TelnetLiquidsoap(telnet_lock, logging, "localhost", 1234)
plq = PypoLiqQueue(
pypoLiq_q, telnet_lock, logging, liq_queue_tracker, dummy_telnet_liquidsoap
)
plq.daemon = True
plq.start()
print("Time now: {:s}".format(datetime.utcnow()))
media_schedule = {}
start_dt = datetime.utcnow() + timedelta(seconds=1)
end_dt = datetime.utcnow() + timedelta(seconds=6)
media_schedule[start_dt] = {
"id": 5,
"type": "file",
"row_id": 9,
"uri": "",
"dst": "/home/martin/Music/ipod/Hot Chocolate - You Sexy Thing.mp3",
"fade_in": 0,
"fade_out": 0,
"cue_in": 0,
"cue_out": 300,
"start": start_dt,
"end": end_dt,
"show_name": "Untitled",
"replay_gain": 0,
"independent_event": True,
}
start_dt = datetime.utcnow() + timedelta(seconds=2)
end_dt = datetime.utcnow() + timedelta(seconds=6)
media_schedule[start_dt] = {
"id": 5,
"type": "file",
"row_id": 9,
"uri": "",
"dst": "/home/martin/Music/ipod/Good Charlotte - bloody valentine.mp3",
"fade_in": 0,
"fade_out": 0,
"cue_in": 0,
"cue_out": 300,
"start": start_dt,
"end": end_dt,
"show_name": "Untitled",
"replay_gain": 0,
"independent_event": True,
}
pypoLiq_q.put(media_schedule)
plq.join()

42
playout/pypo/timeout.py Normal file
View file

@ -0,0 +1,42 @@
import threading
from . import pypofetch
def __timeout(func, timeout_duration, default, args, kwargs):
class InterruptableThread(threading.Thread):
def __init__(self):
threading.Thread.__init__(self)
self.result = default
def run(self):
self.result = func(*args, **kwargs)
first_attempt = True
while True:
it = InterruptableThread()
it.start()
if not first_attempt:
timeout_duration = timeout_duration * 2
it.join(timeout_duration)
if it.is_alive():
"""Restart Liquidsoap and try the command one more time. If it
fails again then there is something critically wrong..."""
if first_attempt:
# restart liquidsoap
pypofetch.PypoFetch.ref.restart_liquidsoap()
else:
raise Exception("Thread did not terminate")
else:
return it.result
first_attempt = False
def ls_timeout(f, timeout=15, default=None):
def new_f(*args, **kwargs):
return __timeout(f, timeout, default, args, kwargs)
return new_f

45
playout/setup.py Normal file
View file

@ -0,0 +1,45 @@
from os import chdir
from pathlib import Path
from setuptools import setup
# Change directory since setuptools uses relative paths
here = Path(__file__).parent
chdir(here)
setup(
name="libretime-playout",
version="1.0",
description="LibreTime Playout",
author="LibreTime Contributors",
url="https://github.com/libretime/libretime",
project_urls={
"Bug Tracker": "https://github.com/libretime/libretime/issues",
"Documentation": "https://libretime.org",
"Source Code": "https://github.com/libretime/libretime",
},
license="AGPLv3",
packages=[
"pypo",
"liquidsoap",
],
package_data={"": ["**/*.liq", "*.cfg", "*.types"]},
scripts=[
"bin/libretime-playout",
"bin/libretime-liquidsoap",
"bin/libretime-playout-notify",
],
python_requires=">=3.6",
install_requires=[
f"libretime-api-client @ file://localhost/{here.parent}/api_client#egg=libretime-api-client",
"amqplib",
"configobj",
"defusedxml",
"kombu",
"mutagen",
"packaging",
"pytz",
"requests",
],
zip_safe=False,
)