Last active
February 16, 2023 01:52
-
-
Save LunaSquee/274bc22c77c68232c6aa90635bdcd0fc to your computer and use it in GitHub Desktop.
Liquidsoap radio + youtube-dl queueing (Node.js as helper)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// $ node queue <file name or youtube URL> | |
const net = require('net') | |
let client = net.connect(1234, 'localhost') | |
client.on('connect', function () { | |
if (process.argv[2]) { | |
console.log('Queueing ' + process.argv[2]) | |
client.write('queue.push smart:' + process.argv[2] + '\r\n') | |
} | |
client.write('queue.queue\r\n') | |
client.end('quit\r\n') | |
}) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/liquidsoap | |
# Some parts of this code were taken from djazz's Parasprite Radio project. | |
# Check it out here: https://github.com/daniel-j/parasprite-radio | |
set("log.file.path", "radio.log") | |
# Start the stream by giving it a playlist | |
radio = mksafe(playlist("~/radio/playlist.m3u")) | |
# Use the telnet server for requests | |
set("server.telnet", true) | |
set("server.telnet.bind_addr", "0.0.0.0") | |
set("server.telnet.port", 1234) | |
set("harbor.bind_addr", "0.0.0.0") | |
set("harbor.verbose", false) | |
set("harbor.reverse_dns", true) | |
live_meta = ref [] | |
live_token = ref "" | |
skip_source = ref blank() | |
# Main queue for the radio | |
queue = audio_to_stereo(request.equeue(id="queue", conservative=true, length=60., timeout=1000.)) | |
# Fallback to the radio when the queue is empty | |
radio = fallback([queue, radio]) | |
# Remove songs marked as temporary | |
def track_end_cleanup(time, m) | |
if m["temporary"] == "true" and m["filename"] != "" then | |
print("rm "^quote(m["filename"])) | |
system("rm "^quote(m["filename"])) | |
end | |
end | |
# since liquidsoap doesn't have list[key] = value | |
# this keeps the assoc list clean of duplicate keys | |
# usage: list.set_pair((key, value), list) | |
def list.set_pair(p, l) | |
l = list.remove_assoc(fst(p), l) | |
list.add(p, l) | |
end | |
# same as above but takes a list of pairs instead | |
def list.set_list(new, l) | |
outl = ref l | |
list.iter(fun(p) -> begin | |
outl := list.remove_assoc(fst(p), !outl) | |
end, new) | |
list.append(new, !outl) | |
end | |
def add_skip_command(~command,s) | |
# Register the command: | |
server.register( | |
usage=command, | |
description="Skip the current song in source.", | |
command, | |
fun(_) -> begin | |
print("Skipping...") | |
source.skip(s) | |
"OK!" | |
end | |
) | |
end | |
# Register a custom protocol to better queue songs | |
def smart_protocol(arg,delay) | |
res = get_process_lines("node smart "^quote(arg)) | |
print(res) | |
res | |
end | |
add_protocol("smart", smart_protocol) | |
# Add skip commands | |
add_skip_command(command="queue.skip", queue) | |
add_skip_command(command="skip", radio) | |
skip_source := radio | |
# Remove songs marked as temporary | |
radio = on_end(delay=0., track_end_cleanup, radio) | |
# Crossfade music | |
radio = smart_crossfade(conservative=true, start_next=3., fade_in=2., fade_out=3., width=5., radio) | |
# Temporary authentication means | |
live_pass_word = "hackme" | |
# Fade to livestream | |
def to_live(old,new) | |
old = fade.final(duration=2., old) | |
new = fade.initial(duration=2., new) | |
sequence(merge=true, [old,new]) | |
end | |
# Fade to songs | |
def to_songs(a,b) | |
add(normalize=false, [ | |
sequence([ | |
amplify(0.0, fade.final(duration=3.0, b)), | |
fade.initial(duration=3.0, b) | |
]), | |
fade.final(duration=8.0, a) | |
]) | |
end | |
def auth_live(user,password) | |
print("LIVE: A user is connecting...") | |
current_token = !live_token | |
if current_token == "" or current_token == user then | |
if password == live_pass_word then | |
print("LIVE: User authenticated successfully") | |
live_token := user | |
true | |
else | |
print("LIVE: Authentication error: Invalid username/password.") | |
false | |
end | |
else | |
print("LIVE: Another user is already connected!") | |
false | |
end | |
end | |
def user_connected(headers) | |
print("LIVE: Headers") | |
print(headers) | |
m = list.set_list([ | |
("live_name", headers["ice-name"]), | |
("artist", !live_token), | |
("live_description", headers["ice-description"]), | |
("live_ice_genre", headers["ice-genre"]), | |
("live_ice_url", headers["ice-url"]) | |
], !live_meta) | |
# cleanup | |
def filter(x) | |
value = snd(x) | |
if value == "(none)" or value == " " then | |
false | |
else | |
true | |
end | |
end | |
m = list.filter(filter, m) | |
live_meta := m | |
end | |
def user_disconnected() | |
print("LIVE: User disconnected") | |
live_token := "" | |
end | |
def live_begin(m) | |
person = !live_token | |
if person != "" then | |
source.skip(!skip_source) | |
print("LIVE: Got track! Streamer: "^person^"; Stream name: "^m["live_name"]) | |
end | |
end | |
def live_end(t,m) | |
print("LIVE: Stopped streaming") | |
end | |
# Live input | |
live = audio_to_stereo(input.harbor( | |
"/", # supporting shoutcast and icecast (empty mount) sources | |
id = "live", | |
buffer = 10., | |
max = 15., | |
port = 8009, | |
auth = auth_live, | |
icy = true, # enables Shoutcast support (untested, uses port_input+1) | |
icy_metadata_charset = 'UTF-8', | |
metadata_charset = 'UTF-8', | |
on_connect = user_connected, | |
on_disconnect = user_disconnected | |
)) | |
live = map_metadata(fun(m) -> !live_meta, live) | |
live = map_metadata(fun(m) -> begin | |
l = ref [] | |
if m["song"] != "" then | |
info = string.extract(pattern="(.*?) - (.*)$", m["song"]) | |
artist = info["1"] | |
title = info["2"] | |
l := list.append([ | |
("title", title), | |
("artist", artist) | |
], !l) | |
else | |
if m["live_name"] != "" then | |
l := list.add(("title", "LIVE: "^m['live_name']), !l) | |
else | |
l := list.add(("title", "LIVE: "^!live_token^"'s stream"), !l) | |
end | |
end | |
!l | |
end, live) | |
live = on_track(live_begin, live) | |
live = on_end(delay=0., live_end, live) | |
radio = fallback(track_sensitive=false, transitions=[to_live, to_songs], [live, radio]) | |
# Send it off to icecast! | |
output.icecast(%vorbis.cbr(samplerate=44100, channels=2, bitrate=320), | |
port = 8008, | |
password="hackme", | |
mount = "mystream.ogg", | |
name = "Iced Potato Radio", | |
description = "Diamond's personal listening hub.", | |
url="http://radio.lunasqu.ee/", | |
radio) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
'use strict' | |
// Some parts of this code were taken from djazz's Parasprite Radio project. | |
// Check it out here: https://github.com/daniel-j/parasprite-radio | |
const spawn = require('child_process').spawn | |
const os = require('os') | |
// URLs that youtube-dl should hande for you | |
const ytdlsupports = ["youtube.com/", "youtu.be/", "soundcloud.com/"] | |
let arg = (process.argv[2] || '').trim() | |
function protocol (handleCb) { | |
let yt = spawn('youtube-dl', ['--no-playlist', '--playlist-end', 1, '-j', '-f', 'bestaudio/best', arg]) | |
let output = '' | |
yt.stdout.on('data', function (chunk) { | |
output += chunk.toString('utf8') | |
}) | |
yt.on('close', function () { | |
let data = JSON.parse(output) | |
delete data.formats | |
fetchVideo(data, handleCb) | |
}) | |
} | |
function fetchVideo (data, cb) { | |
if (data.acodec !== 'mp3' || data.vcodec !== 'none') { | |
let tempName = os.tmpdir() + '/tmp.yt.' + data.id + '.mp3' | |
// joint stereo VBR2 mp3 | |
spawn('ffmpeg', ['-i', data.url, '-codec:a', 'libmp3lame', '-q:a', 2, '-joint_stereo', 1, '-y', tempName]) | |
data.filename = tempName | |
console.error('Downloading ' + data.title + '...') | |
} else { | |
data.filename = data.url + '&liquidtype=.mp3' | |
} | |
outputVideo(data, cb) | |
} | |
function outputVideo (video, cb) { | |
cb({ | |
title: video.title, | |
artist: video.uploader, | |
url: video.webpage_url, | |
art: video.thumbnail, | |
source: video.filename, | |
temporary: true | |
}) | |
} | |
function formatter (o) { | |
if (Array.isArray(o)) { | |
o.forEach(utils.formatter) | |
return | |
} | |
if (o.error) { | |
o = utils.sayErr(o.what, o.error) | |
} | |
let list = [] | |
for (let key in o) { | |
if (o.hasOwnProperty(key) && key !== 'source' && o[key] !== null && o[key] !== undefined) { | |
list.push(key + '=' + JSON.stringify(o[key])) | |
} | |
} | |
let out = '' | |
if (list.length > 0) { | |
out += 'annotate:' + list.join(',') + ':' | |
} | |
out += o.source | |
console.log(out) | |
} | |
function isUrl(s) { | |
var regexp = /(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?/ | |
return regexp.test(s); | |
} | |
function inArray (a, b) { | |
for (let i in b) { | |
if (a.indexOf(b[i]) !== -1) { | |
return true | |
} | |
} | |
return false | |
} | |
function retrieve() { | |
if (!arg && arg == '') return console.log('') | |
if (isUrl(arg)) { | |
if (inArray(arg, ytdlsupports)) { | |
protocol((dat) => { | |
if (!dat) return | |
formatter(dat) | |
}) | |
} else { | |
console.log(arg) | |
} | |
} else { | |
if (arg.indexOf('/my/music/dir/') === -1) | |
arg = '/my/music/dir/' + arg | |
console.log(arg) | |
} | |
} | |
retrieve() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment