From f9c0bd5a05dede8ea8fb779a5f67c604e0e3360d Mon Sep 17 00:00:00 2001 From: Julien Valentin Date: Tue, 25 Feb 2025 11:47:54 +0100 Subject: [PATCH] feat(playout): add Liquidsoap 2.0 support (#2786) ### Description add Liquidsoap2.0 files (port syntax 1.4 to 2.0) ### Testing Notes I ran libretime on ubuntu 22.04 and liquidsoap2.0 this pr is just the beginning, just 2 files added it's a clean one... in order to work under 22.04,it requires changes in 1. this pr 1. (https://github.com/libretime/libretime/pull/2789) 1. libretime/propel (https://github.com/libretime/propel1/pull/1) or change legacy /composer.json ``` "type": "vcs", - "url": "https://github.com/libretime/propel1" + "url": "https://github.com/mp3butcher/propel1" }, { "type": "vcs", @@ -30,7 +30,7 @@ "james-heinrich/getid3": "^1.9", "league/uri": "^6.7", "libretime/celery-php": "dev-main", - "libretime/propel1": "dev-main", + "mp3butcher/propel1": "main", "php-amqplib/php-amqplib": "^3.0", ``` 4. and few mods in install ``` case "$ID-$VERSION_ID" in ubuntu-20.04) is_ubuntu=true && distro="focal" ;; + ubuntu-22.04) is_ubuntu=true && distro="jammy" ;; debian-11) is_debian=true && distro="bullseye" ;; *) error "could not determine supported distribution '$ID-$VERSION_ID' @@ -375,8 +376,12 @@ prepare_packages_install() { if $is_ubuntu; then install_packages software-properties-common - add-apt-repository -y ppa:libretime/libretime + +if echo $distro | grep -q 'focal'; then + add-apt-repository -y ppa:libretime/libretime + fi + if echo $distro | grep -q 'jammy'; then + apt-get install php-cli php-dev php php-fpm php-pear php-yaml php-gd php-bcmath php-curl + fi DEBIAN_FRONTEND=noninteractive apt-get -q update fi } ``` It will require testing changes against ubuntu 20.4 and debian,that's why i think a testing branch can be wise --------- Co-authored-by: mp3butcher --- .../liquidsoap/2.0/ls_lib.liq | 264 ++++++++++++++++++ .../liquidsoap/2.0/ls_script.liq | 218 +++++++++++++++ 2 files changed, 482 insertions(+) create mode 100644 playout/libretime_playout/liquidsoap/2.0/ls_lib.liq create mode 100644 playout/libretime_playout/liquidsoap/2.0/ls_script.liq diff --git a/playout/libretime_playout/liquidsoap/2.0/ls_lib.liq b/playout/libretime_playout/liquidsoap/2.0/ls_lib.liq new file mode 100644 index 000000000..9bc6ef3d9 --- /dev/null +++ b/playout/libretime_playout/liquidsoap/2.0/ls_lib.liq @@ -0,0 +1,264 @@ +def gateway(args) + command = "timeout --signal=KILL 45 libretime-playout-notify #{args} &" + log(command) + process.run(command) +end + +def notify(m) + gateway("media '#{m['schedule_table_id']}'") +end + +def notify_queue(m) + f = !dynamic_metadata_callback + ignore(f(m)) + notify(m) + m +end + +def notify_stream(m) + if !web_stream_id != "-1" then + 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) + + gateway("webstream '#{!web_stream_id}' '#{json_str}'") + end +end + +# A function applied to each metadata chunk +def append_title(m) = + log("Using message format #{message_format()}") + + 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 message_format() == "1" then + [("title", "#{show_name()} - #{m['artist']} - #{m['title']}"), ("mapped", "true")] + elsif message_format() == "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.in(duration=input_fade_transition(), b) + ]), + fade.out(duration=input_fade_transition(), a) + ] + ) +end + +# we need this function for special transition case(from default to queue) we don't want +# the transition 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.in(duration=input_fade_transition(), b) + ]), + fade.out(duration=input_fade_transition(), 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.out(old) + # Compose this in sequence with the new source + sequence([old,new]) +end + + +def make_ouput_on_connect_handler(stream) + def on_connect() + gateway("stream '#{stream}' '#{boot_timestamp}'") + end + on_connect +end + +def make_ouput_on_error_handler(stream) + def on_error(msg) + gateway("stream '#{stream}' '#{boot_timestamp}' --error='#{msg}'") + 5. + end + on_error +end + +def clear_queue(s) + source.skip(s) +end + +# 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().buffer=5.,max=15.,,autostart=false +def input.http_restart(~id,~initial_url="http://dummy/url") + + source = audio_to_stereo( mksafe(input.http(id=id,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) + + 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 + m + 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 = map_metadata(on_m,source) + + cross_d = 3. + + def crosser(ending, starting) + 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(ending.source)} sec before, \ + # #{source.remaining(starting.source)} 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,ending.source),fade.in(starting.source)]) + else + # This is done on tracks inside a single stream. + # Do NOT cross here or you'll gradually empty the buffer! + sequence([ending.source,starting.source]) + 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 := time() + true + else + time() < !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.in(b),fade.out(a)]) + end + def to_static(a,b) = + log("TRANSITION to static") + sequence([fade.out(a),fade.in(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 !web_stream_enabled}, http), + ({true},default)]) + +end diff --git a/playout/libretime_playout/liquidsoap/2.0/ls_script.liq b/playout/libretime_playout/liquidsoap/2.0/ls_script.liq new file mode 100644 index 000000000..cd934fcd5 --- /dev/null +++ b/playout/libretime_playout/liquidsoap/2.0/ls_script.liq @@ -0,0 +1,218 @@ +boot_timestamp = string_of(time()) + +web_stream_enabled = ref(false) +web_stream_id = ref( '-1') + +show_name = interactive.string("show_name", "") + +dynamic_metadata_callback = ref (fun (~new_track=false, s) -> begin () end) + +just_switched = ref (false) + +%include "ls_lib.liq" + +sources = ref([]) +source_id = ref (0) + +def create_source() + this_source_id = !source_id + l = request.queue(id="s#{this_source_id}")#, length=0.5 + + l = audio_to_stereo(id="queue_src", l) + l = cue_cut(l) + l = amplify(1., override="replay_gain", l) + + # Fade the tracks to avoid hard cuts in between two tracks and at the end of the shows. + # Liquidsoap reads the fade in/out durations from the annotation "liq_fade_in/out" which + # value can be set via Libretime settings. + l = fade.in(l) + l = fade.out(l) + + l = map_metadata(notify_queue,l) + l = cross_http(http_input_id="http",l) + l = http_fallback(http_input_id="http", http=l, default=l) + l = map_metadata(id="map_metadata:schedule", update=false, append_title, l) + sources := list.append([l], !sources) + server.register(namespace="queues", + "s#{this_source_id}_skip", + fun (s) -> begin log("queues.s#{this_source_id}_skip") + clear_queue(l) + "Done" + end) + source_id := !source_id + 1 +end + +enable_replaygain_metadata() + +create_source() +create_source() +create_source() +create_source() + +queue = add(!sources)#, normalize=false) +queue = insert_metadata(queue) +dynamic_metadata_callback := queue.insert_metadata + +output.dummy(fallible=true, queue) + +#http = input.http_restart(id="http") +#output.dummy(fallible=true, http) + +ignore(output.dummy(queue, fallible=true)) + +def web_stream_set_id(value) + web_stream_id := value + string_of(!web_stream_id) +end + +def web_stream_get_id() + string_of(!web_stream_id) +end + +server.register(namespace="sources", + description="Start webstream source", + "start_web_stream", + fun (s) -> begin log("sources.start_web_stream") + notify([("schedule_table_id", !web_stream_id)]) + web_stream_enabled := true "enabled" end) +server.register(namespace="sources", + description="Stop webstream source", + "stop_web_stream", + fun (s) -> begin log("sources.stop_web_stream") web_stream_enabled := false "disabled" end) + +server.register(namespace="web_stream", + description="Set the web stream id", + "set_id", + fun (s) -> begin log("web_stream.set_id") web_stream_set_id(s) end) + +server.register(namespace="web_stream", + description="Get the web stream id", + "get_id", + fun (s) -> begin log("web_stream.get_id") web_stream_get_id() end) + +default = amplify(id="silence_src", 0.00001, noise()) + +def map_message_offline(m) = + [("title", message_offline())] +end + +default = map_metadata(id="map_metadata:offline", map_message_offline, default) +ignore(output.dummy(default, fallible=true)) + +input_main_streaming = ref (false) +input_show_streaming = ref (false) +schedule_streaming = ref (false) + +def start_input_main() input_main_streaming := true end +def stop_input_main() input_main_streaming := false end +def start_input_show() input_show_streaming := true end +def stop_input_show() input_show_streaming := false end +def start_schedule() schedule_streaming := true; just_switched := true end +def stop_schedule() schedule_streaming := false end + +def update_source_status(sourcename, status) = + gateway("live '#{sourcename}' '#{status}'") +end + +def input_main_on_connect(header) update_source_status("master_dj", true) end +def input_main_on_disconnect() update_source_status("master_dj", false) end +def input_show_on_connect(header) update_source_status("live_dj", true) end +def input_show_on_disconnect() update_source_status("live_dj", false) end + +def make_input_auth_handler(input_name) + def auth_handler(args) + log("user '#{args.user}' connected", label="#{input_name}_input") + + # Check auth based on return value from auth script + ret = test_process("libretime-playout-notify live-auth '#{input_name}' '#{args.user}' '#{args.password}'") + if ret then + log("user '#{args.user}' authenticated", label="#{input_name}_input") + else + log("user '#{args.user}' auth failed", label="#{input_name}_input",level=2) + end + + ret + end + auth_handler +end + +s = switch(id="switch:blank+schedule", + track_sensitive=false, + transitions=[transition_default, transition], + [({!schedule_streaming}, queue), ({true}, default)] + ) + +s = if input_show_port != 0 and input_show_mount != "" then + input_show_source = + audio_to_stereo( + input.harbor(id="harbor:input_show", + input_show_mount, + port=input_show_port, + auth=make_input_auth_handler("show"), + max=40., + on_connect=input_show_on_connect, + on_disconnect=input_show_on_disconnect)) + + ignore(output.dummy(input_show_source, fallible=true)) + + switch(id="switch:blank+schedule+show", + track_sensitive=false, + transitions=[transition, transition], + [({!input_show_streaming}, input_show_source), ({true}, s)] + ) +else + s +end + +s = if input_main_port != 0 and input_main_mount != "" then + input_main_source = + audio_to_stereo( + input.harbor(id="harbor:input_main", + input_main_mount, + port=input_main_port, + auth=make_input_auth_handler("main"), + max=40., + on_connect=input_main_on_connect, + on_disconnect=input_main_on_disconnect)) + + ignore(output.dummy(input_main_source, fallible=true)) + + switch(id="switch:blank+schedule+show+main", + track_sensitive=false, + transitions=[transition, transition], + [({!input_main_streaming}, input_main_source), ({true}, s)] + ) +else + s +end + +server.register(namespace="sources", + description="Stop main input source.", + usage="stop_input_main", + "stop_input_main", + fun (s) -> begin log("sources.stop_input_main") stop_input_main() "Done." end) +server.register(namespace="sources", + description="Start main input source.", + usage="start_input_main", + "start_input_main", + fun (s) -> begin log("sources.start_input_main") start_input_main() "Done." end) +server.register(namespace="sources", + description="Stop show input source.", + usage="stop_input_show", + "stop_input_show", + fun (s) -> begin log("sources.stop_input_show") stop_input_show() "Done." end) +server.register(namespace="sources", + description="Start show input source.", + usage="start_input_show", + "start_input_show", + fun (s) -> begin log("sources.start_input_show") start_input_show() "Done." end) +server.register(namespace="sources", + description="Stop schedule source.", + usage="stop_schedule", + "stop_schedule", + fun (s) -> begin log("sources.stop_schedule") stop_schedule() "Done." end) +server.register(namespace="sources", + description="Start schedule source.", + usage="start_schedule", + "start_schedule", + fun (s) -> begin log("sources.start_schedule") start_schedule() "Done." end)