commit
c0ebbfb925
5 changed files with 493 additions and 0 deletions
@ -0,0 +1,33 @@ |
|||||||
|
FROM alpine:latest |
||||||
|
|
||||||
|
LABEL maintainer "jerryaldrichiii@gmail.com" |
||||||
|
|
||||||
|
RUN apk add --update \ |
||||||
|
icecast \ |
||||||
|
mailcap \ |
||||||
|
jq \ |
||||||
|
&& rm -rf /var/cache/apk/* |
||||||
|
|
||||||
|
RUN mkdir -p /etc/icecast |
||||||
|
|
||||||
|
RUN mv /etc/icecast.xml /etc/icecast/icecast-original.xml |
||||||
|
COPY ./icecast.xml /etc/icecast/icecast.xml |
||||||
|
|
||||||
|
RUN chown -R icecast:icecast /etc/icecast |
||||||
|
RUN chown -R icecast:icecast /var/log/icecast |
||||||
|
RUN chown -R icecast:icecast /usr/share/icecast |
||||||
|
|
||||||
|
EXPOSE 8000 |
||||||
|
|
||||||
|
RUN mkdir /bootstrap |
||||||
|
COPY ./config.json /bootstrap |
||||||
|
RUN chown -R icecast:icecast /bootstrap |
||||||
|
|
||||||
|
COPY ./entrypoint.sh /etc/icecast/entrypoint.sh |
||||||
|
RUN chmod +x /etc/icecast/entrypoint.sh |
||||||
|
|
||||||
|
COPY player.html /usr/share/icecast/web/player.html |
||||||
|
|
||||||
|
USER icecast:icecast |
||||||
|
|
||||||
|
ENTRYPOINT ["/etc/icecast/entrypoint.sh"] |
@ -0,0 +1,43 @@ |
|||||||
|
{ |
||||||
|
"title": "radio.jerryaldrichiii.com", |
||||||
|
"hostname": "localhost", |
||||||
|
"port": "8000", |
||||||
|
"https_streams": false, |
||||||
|
"streams": [ |
||||||
|
{ |
||||||
|
"name": "DEF CON Radio", |
||||||
|
"server":"ice2.somafm.com", |
||||||
|
"port": 80, |
||||||
|
"mount": "/defcon-256-mp3", |
||||||
|
"local_mount":"/defcon" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"name": "SF 10-33", |
||||||
|
"server":"ice2.somafm.com", |
||||||
|
"port": 80, |
||||||
|
"mount": "/sf1033-128-mp3", |
||||||
|
"local_mount":"/sf1033" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"name": "Mission Control", |
||||||
|
"server":"ice2.somafm.com", |
||||||
|
"port": 80, |
||||||
|
"mount": "/missioncontrol-128-mp3", |
||||||
|
"local_mount":"/missioncontrol" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"name": "Cyberia", |
||||||
|
"server":"lainon.life", |
||||||
|
"port": 8000, |
||||||
|
"mount": "/cyberia.mp3", |
||||||
|
"local_mount": "/cyberia" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"name": "Synthwave Radio", |
||||||
|
"server":"stream.syntheticfm.com", |
||||||
|
"port": 8040, |
||||||
|
"mount": "/live", |
||||||
|
"local_mount": "/synthwave" |
||||||
|
} |
||||||
|
] |
||||||
|
} |
@ -0,0 +1,70 @@ |
|||||||
|
#!/bin/sh |
||||||
|
|
||||||
|
set -e |
||||||
|
|
||||||
|
set_val() { |
||||||
|
# Combine args in case there are spaces |
||||||
|
# https://linux.die.net/man/1/ash |
||||||
|
val="${@#$1 }" # Remove smallest prefix pattern (space after $1 intentional) |
||||||
|
sed -i "s/<$1>[^<]*<\/$1>/<$1>$val<\/$1>/g" /etc/icecast/icecast.xml |
||||||
|
} |
||||||
|
|
||||||
|
# The below will replace values in the config with the ENV variable specified. |
||||||
|
# If one is not specified, what follows ':-' will be used. |
||||||
|
# NOTE: The order below matches the order seen in the config. |
||||||
|
set_val location ${ICECAST_LOCATION:-San Francisco} |
||||||
|
set_val admin ${ICECAST_ADMIN:-Someone Failed to Change Me} |
||||||
|
|
||||||
|
set_val clients ${ICECAST_LIMITS_CLIENTS:-100} |
||||||
|
set_val sources ${ICECAST_LIMITS_SOURCES:-2} |
||||||
|
set_val queue-size ${ICECAST_LIMITS_QUEUE_SIZE:-524288} |
||||||
|
set_val client-timeout ${ICECAST_LIMITS_CLIENT_TIMEOUT:-30} |
||||||
|
set_val header-timeout ${ICECAST_LIMITS_HEADER_TIMEOUT:-14} |
||||||
|
set_val source-timeout ${ICECAST_LIMITS_SOURCE_TIMEOUT:-10} |
||||||
|
set_val burst-on-connect ${ICECAST_LIMITS_BURST_ON_CONNECT:-1} |
||||||
|
set_val burst-size ${ICECAST_LIMITS_BURST_SIZE:-65535} |
||||||
|
|
||||||
|
set_val fileserve ${ICECAST_FILESERVE:-1} |
||||||
|
|
||||||
|
set_val source-password ${AUTHENTICATION_SOURCE_PASSWORD:-changeorbehacked} |
||||||
|
set_val relay-password ${AUTHENTICATION_RELAY_PASSWORD:-changeorbehacked} |
||||||
|
set_val admin-user ${AUTHENTICATION_ADMIN_USER:-changeorbehacked} |
||||||
|
set_val admin-password ${AUTHENTICATION_ADMIN_PASSWORD:-changeorbehacked} |
||||||
|
|
||||||
|
set_val relays-on-demand ${RELAYS_ON_DEMAND:-1} |
||||||
|
|
||||||
|
set_val loglevel ${LOG_LEVEL:-3} |
||||||
|
set_val logsize ${LOG_SIZE:-10000} |
||||||
|
set_val logarchive ${LOG_ARCHIVE:-0} |
||||||
|
|
||||||
|
# Build relay XML sections |
||||||
|
CONFIG="$(cat /bootstrap/config.json)" |
||||||
|
for stream in $(echo "$CONFIG" | jq -r '.streams[] | @base64'); do |
||||||
|
_parse() { |
||||||
|
echo ${stream} | base64 -d | jq -r ${1} |
||||||
|
} |
||||||
|
RELAY_XML=$(cat <<-EOX |
||||||
|
$RELAY_XML |
||||||
|
|
||||||
|
<relay> |
||||||
|
<server>$(_parse .server)</server> |
||||||
|
<port>$(_parse .port)</port> |
||||||
|
<mount>$(_parse .mount)</mount> |
||||||
|
<local-mount>$(_parse .local_mount)</local-mount> |
||||||
|
<relay-shoutcast-metadata>1</relay-shoutcast-metadata> |
||||||
|
</relay> |
||||||
|
EOX |
||||||
|
) |
||||||
|
done |
||||||
|
|
||||||
|
# Set hostname (needs to match for listen urls and such) |
||||||
|
host=$(echo "$CONFIG" | jq -r '.hostname') |
||||||
|
set_val hostname ${ICECAST_HOSTNAME:-$host} |
||||||
|
|
||||||
|
# Using | as the delimiter because of URLs containing / |
||||||
|
sed -i "s|ICECAST_RELAYS|$(echo "$RELAY_XML" | tr "\n" "#")|g;s/#/\n/g" /etc/icecast/icecast.xml |
||||||
|
|
||||||
|
# Publish config.json for consumers (e.g. web player) |
||||||
|
cp /bootstrap/config.json /usr/share/icecast/web/ |
||||||
|
|
||||||
|
icecast -c "/etc/icecast/icecast.xml" |
@ -0,0 +1,65 @@ |
|||||||
|
<icecast> |
||||||
|
<hostname>SEE_ENTRYPOINT</hostname> |
||||||
|
<location>SEE_ENTRYPOINT</location> |
||||||
|
<admin>SEE_ENTRYPOINT</admin> |
||||||
|
|
||||||
|
<!-- |
||||||
|
The mime-types config is moved to <paths> in 2.5.0. As of the time of this |
||||||
|
writing 2.5.0 is only in beta. You'll likely need to move once it is |
||||||
|
released for production use. |
||||||
|
--> |
||||||
|
<mime-types>/etc/mime.types</mime-types> |
||||||
|
|
||||||
|
<limits> |
||||||
|
<clients>SEE_ENTRYPOINT</clients> |
||||||
|
<sources>SEE_ENTRYPOINT</sources> |
||||||
|
<queue-size>SEE_ENTRYPOINT</queue-size> |
||||||
|
<client-timeout>SEE_ENTRYPOINT</client-timeout> |
||||||
|
<header-timeout>SEE_ENTRYPOINT</header-timeout> |
||||||
|
<source-timeout>SEE_ENTRYPOINT</source-timeout> |
||||||
|
<burst-on-connect>SEE_ENTRYPOINT</burst-on-connect> |
||||||
|
<burst-size>SEE_ENTRYPOINT</burst-size> |
||||||
|
</limits> |
||||||
|
|
||||||
|
<authentication> |
||||||
|
<source-password>SEE_ENTRYPOINT</source-password> |
||||||
|
<relay-password>SEE_ENTRYPOINT</relay-password> |
||||||
|
<admin-user>SEE_ENTRYPOINT</admin-user> |
||||||
|
<admin-password>SEE_ENTRYPOINT</admin-password> |
||||||
|
</authentication> |
||||||
|
|
||||||
|
<listen-socket> |
||||||
|
<port>8000</port> |
||||||
|
</listen-socket> |
||||||
|
|
||||||
|
<http-headers> |
||||||
|
<header name="Access-Control-Allow-Origin" value="*" /> |
||||||
|
</http-headers> |
||||||
|
|
||||||
|
<relays-on-demand>SEE_ENTRYPOINT</relays-on-demand> |
||||||
|
|
||||||
|
<fileserve>SEE_ENTRYPOINT</fileserve> |
||||||
|
|
||||||
|
<paths> |
||||||
|
<logdir>/var/log/icecast</logdir> |
||||||
|
<webroot>/usr/share/icecast/web</webroot> |
||||||
|
<adminroot>/usr/share/icecast/admin</adminroot> |
||||||
|
<alias source="/" destination="/player.html"/> |
||||||
|
</paths> |
||||||
|
|
||||||
|
<logging> |
||||||
|
<!-- Changed to '-' so logging is sent to STDERR, before was access.log and error.log--> |
||||||
|
<accesslog>-</accesslog> |
||||||
|
<errorlog>-</errorlog> |
||||||
|
<loglevel>SEE_ENTRYPOINT</loglevel> |
||||||
|
<logsize>SEE_ENTRYPOINT</logsize> |
||||||
|
<logarchive>SEE_ENTRYPOINT</logarchive> |
||||||
|
</logging> |
||||||
|
|
||||||
|
<security> |
||||||
|
<chroot>0</chroot> |
||||||
|
</security> |
||||||
|
|
||||||
|
<!-- TODO: Document mounting stream_data.json --> |
||||||
|
ICECAST_RELAYS |
||||||
|
</icecast> |
@ -0,0 +1,282 @@ |
|||||||
|
<!-- If you're reading this...this was a weekend project...please don't judge --> |
||||||
|
<!DOCTYPE HTML> |
||||||
|
<html> |
||||||
|
<head> |
||||||
|
<title>radio.jerryaldrichiii.com</title> |
||||||
|
<meta charset="utf-8"> |
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0"> |
||||||
|
<meta name="Description" content="radio.jerryaldrichiii.com"><Paste> |
||||||
|
<style> |
||||||
|
* { |
||||||
|
background: black; |
||||||
|
background-color: black; |
||||||
|
} |
||||||
|
a, a:hover, a:active, a:visited { |
||||||
|
color: #00FF00; |
||||||
|
} |
||||||
|
body { |
||||||
|
background: black; |
||||||
|
background-color: black; |
||||||
|
color: #00FF00; |
||||||
|
text-align: center; |
||||||
|
overflow-y: scroll; |
||||||
|
display: inline; |
||||||
|
font: calc(0.40em + 1vmin) monospace; |
||||||
|
} |
||||||
|
#stream-info { |
||||||
|
width: 60ch; |
||||||
|
margin: auto; |
||||||
|
margin-bottom: 1ch; |
||||||
|
} |
||||||
|
#controls { |
||||||
|
display: inline-flex; |
||||||
|
justify-content: space-between; |
||||||
|
width: 60ch; |
||||||
|
} |
||||||
|
button { |
||||||
|
background-color: black; |
||||||
|
color: #00FF00; |
||||||
|
border: none; |
||||||
|
cursor: pointer; |
||||||
|
outline: none; |
||||||
|
padding: 0px 0px 0px 0px; |
||||||
|
font-size: inherit; |
||||||
|
} |
||||||
|
#volume-container > * { |
||||||
|
vertical-align: middle; |
||||||
|
} |
||||||
|
#volume { |
||||||
|
-webkit-appearance: none; |
||||||
|
-webkit-transition: .2s; |
||||||
|
background: black; |
||||||
|
outline: auto; |
||||||
|
outline-color: #00FF00; |
||||||
|
opacity: 0.7; |
||||||
|
transition: opacity .2s; |
||||||
|
vertical-align: middle; |
||||||
|
} |
||||||
|
#volume:hover { |
||||||
|
opacity: 1; |
||||||
|
} |
||||||
|
#volume::-webkit-slider-thumb { |
||||||
|
-webkit-appearance: none; |
||||||
|
appearance: none; |
||||||
|
height: .5em; |
||||||
|
width: .5em; |
||||||
|
background: #00FF00; |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
#volume::-moz-range-thumb, .volume::-webkit-slider-thumb { |
||||||
|
background: black; |
||||||
|
width: .5em; |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
select { |
||||||
|
background: black; |
||||||
|
border-color: #00FF00; |
||||||
|
color: #00FF00; |
||||||
|
cursor: pointer; |
||||||
|
font-size: inherit; |
||||||
|
} |
||||||
|
select:focus{ |
||||||
|
outline:none; |
||||||
|
} |
||||||
|
</style> |
||||||
|
</head> |
||||||
|
<audio id="player" src=""></audio> |
||||||
|
<body> |
||||||
|
<div> |
||||||
|
<pre id="stream-info"></pre> |
||||||
|
<div id="controls"> |
||||||
|
<select id="streamSelection"> |
||||||
|
<option value="" disabled selected style="display:none;">Select a Stream</option> |
||||||
|
</select> |
||||||
|
<button id="playButton">Play</button> |
||||||
|
<div id="volume-container"> |
||||||
|
<label for="volume" style="vertical-align: middle">Volume: </label> |
||||||
|
<input id="volume" type="range" min="0" max="100" value="100"> |
||||||
|
</div> |
||||||
|
<button id="muteButton">Mute</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<body> |
||||||
|
<script> |
||||||
|
function loadStreamChoices(data) { |
||||||
|
var selectItem = document.getElementById('streamSelection') |
||||||
|
streams = data["streams"] |
||||||
|
for(let i in streams) { |
||||||
|
var opt = document.createElement('option') |
||||||
|
if(data.https_streams == true) { |
||||||
|
opt.value = "https://" |
||||||
|
} else { |
||||||
|
opt.value = "http://" |
||||||
|
} |
||||||
|
opt.value = opt.value + data.hostname + ":" + data.port + streams[i]["local_mount"] |
||||||
|
opt.innerHTML = streams[i]["name"] |
||||||
|
selectItem.appendChild(opt) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
var configXMLHTTP = new XMLHttpRequest(); |
||||||
|
configXMLHTTP.onreadystatechange = function() { |
||||||
|
if (this.readyState == 4 && this.status == 200) { |
||||||
|
var data = JSON.parse(this.responseText); |
||||||
|
loadStreamChoices(data) |
||||||
|
} |
||||||
|
}; |
||||||
|
configXMLHTTP.open("GET", "/config.json", true); |
||||||
|
configXMLHTTP.send(); |
||||||
|
</script> |
||||||
|
<script> |
||||||
|
var UPDATE_INTERVAL = window.setInterval(renderStreamInfo, 10000) |
||||||
|
|
||||||
|
function titleLine(data, width) { |
||||||
|
title = data["icestats"]["host"] |
||||||
|
width = width - 2 // Subtracting 2 here to account for "|" |
||||||
|
spacing = Math.floor((width - title.length)/2) |
||||||
|
line = "|" + " ".repeat(spacing) + title + " ".repeat(spacing) |
||||||
|
if(spacing + title.length + spacing != width) { |
||||||
|
line = line + " |" // Handle title not being even |
||||||
|
} else { |
||||||
|
line = line + "|" |
||||||
|
} |
||||||
|
return line |
||||||
|
} |
||||||
|
|
||||||
|
function getSourceInfo(data) { |
||||||
|
sources = data["icestats"]["source"] |
||||||
|
for(let i in sources) { |
||||||
|
selectedStream = document.getElementById('streamSelection').value.replace(/^.*[\\\/]/, '') |
||||||
|
icecastStream = sources[i]["listenurl"].replace(/^.*[\\\/]/, '') |
||||||
|
if(selectedStream == icecastStream) { |
||||||
|
sources[i]["streamurl"] = document.getElementById('streamSelection').value |
||||||
|
return sources[i] |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function fetchText(statusData, key) { |
||||||
|
text = "" |
||||||
|
streamInfo = getSourceInfo(statusData) |
||||||
|
if(streamInfo && streamInfo[key]) { |
||||||
|
text = String(streamInfo[key]) |
||||||
|
} |
||||||
|
return text |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
// This is so bad...but special chars mess up '.length' |
||||||
|
function badThing(text) { |
||||||
|
text = text.replace(/[^\x00-\x7F]/g, '') |
||||||
|
return text |
||||||
|
} |
||||||
|
|
||||||
|
function buildLine(width, text, prefix) { |
||||||
|
text = badThing(text) |
||||||
|
prefix = "|" + prefix |
||||||
|
spacing = (width - prefix.length - text.length - 2) // Subtract 2 for " |" |
||||||
|
if(spacing < 0) { |
||||||
|
spacing = 0 |
||||||
|
} |
||||||
|
line = prefix + text + " ".repeat(spacing) + " |" |
||||||
|
// Trim line if too long |
||||||
|
if(line.length > width) { |
||||||
|
end = (width - line.length) |
||||||
|
start = width - 2 // Subtract 2 for " |" |
||||||
|
line = line.substring(end, start) + " |" |
||||||
|
} |
||||||
|
return line |
||||||
|
} |
||||||
|
|
||||||
|
function buildDownloadLine(width, url) { |
||||||
|
line = "| Download: " |
||||||
|
line = line + "<a href='" + url + ".m3u" + "'>M3U</a>" |
||||||
|
line = line + "/" |
||||||
|
line = line + "<a href='" + url + ".xspf" + "'>XSPF</a>" |
||||||
|
line = line + " ".repeat(width - 24) + " |" |
||||||
|
return line |
||||||
|
} |
||||||
|
|
||||||
|
function renderStreamInfo() { |
||||||
|
var statusXMLHTTP = new XMLHttpRequest(); |
||||||
|
statusXMLHTTP.onreadystatechange = function() { |
||||||
|
if (this.readyState == 4 && this.status == 200) { |
||||||
|
var streamData = JSON.parse(this.responseText); |
||||||
|
width = 60 |
||||||
|
songNameLine = buildLine(width, fetchText(streamData, "title"), " Song Title: ") |
||||||
|
listenersLine = buildLine(width, fetchText(streamData, "listeners"), " Listeners: ") |
||||||
|
downloadLine = buildDownloadLine(width, fetchText(streamData, "streamurl")) |
||||||
|
var streamInfo = [] |
||||||
|
streamInfo.push("/" + "-".repeat(width - 2) + "\\") |
||||||
|
streamInfo.push(titleLine(streamData, width)) |
||||||
|
if(songNameLine.match(/Song Title: \S/)) { |
||||||
|
streamInfo.push("|" + " ".repeat(width - 2) + "|") |
||||||
|
streamInfo.push(songNameLine) |
||||||
|
streamInfo.push(listenersLine) |
||||||
|
streamInfo.push(downloadLine) |
||||||
|
streamInfo.push("|" + " ".repeat(width - 2) + "|") |
||||||
|
} |
||||||
|
streamInfo.push("\\" + "-".repeat(width - 2) + "/") |
||||||
|
document.getElementById("stream-info").innerHTML = streamInfo.join("\n") |
||||||
|
} |
||||||
|
}; |
||||||
|
statusXMLHTTP.open("GET", "/status-json.xsl", true); |
||||||
|
statusXMLHTTP.send(); |
||||||
|
} |
||||||
|
|
||||||
|
function setUpdateInterval(interval) { |
||||||
|
window.clearInterval(UPDATE_INTERVAL) |
||||||
|
UPDATE_INTERVAL = window.setInterval(renderStreamInfo, interval) |
||||||
|
renderStreamInfo() |
||||||
|
} |
||||||
|
|
||||||
|
window.onload = function(){ |
||||||
|
renderStreamInfo() |
||||||
|
} |
||||||
|
|
||||||
|
streamSelection.onchange = function() { |
||||||
|
player.src = this.value |
||||||
|
document.title = this.options[this.selectedIndex].text |
||||||
|
player.load() |
||||||
|
player.play() |
||||||
|
} |
||||||
|
|
||||||
|
player.onwaiting = function() { |
||||||
|
setUpdateInterval(100) |
||||||
|
playButton.innerHTML = "Loading..." |
||||||
|
return; |
||||||
|
} |
||||||
|
player.onplaying = function() { |
||||||
|
setUpdateInterval(10000) |
||||||
|
playButton.innerHTML = "Pause" |
||||||
|
return; |
||||||
|
} |
||||||
|
player.onpause = function() { |
||||||
|
setUpdateInterval(60000) |
||||||
|
playButton.innerHTML = "Play" |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
playButton.onclick = function() { |
||||||
|
if(player.paused) { |
||||||
|
player.play(); |
||||||
|
} else { |
||||||
|
player.pause(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
muteButton.onclick = function() { |
||||||
|
if(player.muted) { |
||||||
|
player.muted = false; |
||||||
|
this.innerHTML = "Mute" |
||||||
|
} else { |
||||||
|
player.muted = true; |
||||||
|
this.innerHTML = "Muted" |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
volume.oninput = function() { |
||||||
|
player.volume = this.value/100; |
||||||
|
} |
||||||
|
</script> |
||||||
|
</html> |
Loading…
Reference in new issue