init liquidsoap. We thank our uncle Claude, gave him 1.74$ so he can buy a beer. He got drunk on wine and we had to clean the mess ourselves. Sacré oncle Claude !
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,2 @@
|
||||
*~
|
||||
state.json
|
||||
*.xml
|
||||
*.log
|
||||
|
||||
20
Dockerfile
20
Dockerfile
@@ -1,38 +1,32 @@
|
||||
# docker build . -t radio-bullshit && docker run -it --rm -p 8000:8000 -v `pwd`/jingles:/jingles -v `pwd`/songs:/songs radio-bullshit
|
||||
FROM python:3.9
|
||||
FROM python:3.14
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update
|
||||
RUN apt-get install -y icecast2 ices2 ffmpeg
|
||||
RUN pip install jinja2 j2cli
|
||||
RUN apt-get install -y ffmpeg liquidsoap
|
||||
|
||||
WORKDIR /config
|
||||
|
||||
RUN adduser zambla
|
||||
|
||||
COPY icecast.xml.jinja .
|
||||
COPY ices.xml.jinja .
|
||||
COPY index.html /usr/share/icecast2/web
|
||||
COPY favicon.ico /usr/share/icecast2/web
|
||||
|
||||
COPY next_song.py /opt
|
||||
COPY radio.liq /opt/radio.liq
|
||||
COPY yt_sync.py /opt
|
||||
COPY ultrasync.sh /opt
|
||||
COPY je_te_met_en_pls.py /opt
|
||||
|
||||
RUN curl -fsSL https://deno.land/install.sh | sh
|
||||
ENV DENO_INSTALL="/$HOME/.deno"
|
||||
ENV PATH="$DENO_INSTALL/bin:$PATH"
|
||||
|
||||
RUN chmod +x /opt/yt_sync.py /opt/next_song.py /opt/ultrasync.sh
|
||||
RUN chmod +x /opt/yt_sync.py /opt/ultrasync.sh /opt/je_te_met_en_pls.py
|
||||
|
||||
RUN mkdir -p /songs /jingles /air-support /var/log/icecast
|
||||
RUN mkdir -p /songs /jingles /air-support /var/log/liquidsoap
|
||||
|
||||
RUN chown -R zambla:zambla /config
|
||||
RUN chown -R zambla:zambla /opt
|
||||
RUN chown -R zambla:zambla /songs
|
||||
RUN chown -R zambla:zambla /jingles
|
||||
RUN chown -R zambla:zambla /var/log/icecast
|
||||
RUN chown -R zambla:zambla /usr/share/icecast2
|
||||
RUN chown -R zambla:zambla /var/log/liquidsoap
|
||||
|
||||
ADD air-support /air-support
|
||||
|
||||
|
||||
29
README.md
29
README.md
@@ -1,3 +1,30 @@
|
||||
# radio-bullshit
|
||||
|
||||
Radio Bullshit
|
||||
Radio Bullshit - Internet radio streaming powered by Liquidsoap
|
||||
|
||||
## Features
|
||||
|
||||
- Automatic song/jingle alternation (1 jingle per 4 songs)
|
||||
- YouTube playlist sync with yt-dlp
|
||||
- Built-in HTTP streaming server (no Icecast needed!)
|
||||
- Fallback "air support" audio for resilience
|
||||
- Modern, open-source stack (Liquidsoap + Python)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
docker build . -t radio-bullshit && \
|
||||
docker run -it --rm -p 8000:8000 \
|
||||
-v `pwd`/jingles:/jingles \
|
||||
-v `pwd`/songs:/songs \
|
||||
radio-bullshit
|
||||
```
|
||||
|
||||
Then visit http://localhost:8000 to listen!
|
||||
|
||||
## Stack
|
||||
|
||||
- **Liquidsoap** - Audio stream generation and HTTP server
|
||||
- **yt-dlp** - YouTube playlist downloading
|
||||
- **FFmpeg** - Audio format conversion
|
||||
- **Python 3.9** - Automation scripts
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
#!/bin/bash -e
|
||||
|
||||
export hostname=${HOSTNAME:=localhost}
|
||||
export port=${PORT:=8000}
|
||||
export max_listeners=${MAX_LISTENERS:=30}
|
||||
export admin_user=${ADMIN_USER:=admin}
|
||||
export admin_password=${ADMIN_PASSWORD:=admin}
|
||||
export HOSTNAME=${HOSTNAME:=localhost}
|
||||
export PORT=${PORT:=8000}
|
||||
export MAX_LISTENERS=${MAX_LISTENERS:=30}
|
||||
export ADMIN_USER=${ADMIN_USER:=admin}
|
||||
export ADMIN_PASSWORD=${ADMIN_PASSWORD:=admin}
|
||||
|
||||
m3u=${M3U:="http://${hostname}:${port}/radio-bullshit"}
|
||||
runuser -l zambla 'touch /songs/playlist.pls /jingles/playlist.pls'
|
||||
|
||||
pass_gen="python3 -c 'import secrets, string; print(\"\".join((secrets.choice(string.ascii_letters + string.digits) for i in range(20))))'"
|
||||
|
||||
export source_username=$(eval $pass_gen)
|
||||
export source_password=$(eval $pass_gen)
|
||||
|
||||
j2 ices.xml.jinja > ices.xml
|
||||
j2 icecast.xml.jinja > icecast.xml
|
||||
|
||||
echo ${m3u} > /usr/share/icecast2/web/radio-bullshit.m3u
|
||||
|
||||
runuser -l zambla -c 'icecast2 -c /config/icecast.xml &'
|
||||
# Start background sync process
|
||||
/opt/ultrasync.sh &
|
||||
runuser -l zambla -c 'ices2 /config/ices.xml'
|
||||
|
||||
# fallback
|
||||
runuser -l zambla '/opt/je_te_met_en_pls.py /air-support /air-support/playlist.pls'
|
||||
# Run Liquidsoap as zambla user
|
||||
runuser -l zambla -c 'liquidsoap /opt/radio.liq'
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
<icecast>
|
||||
<location>Earth</location>
|
||||
<admin>icemaster@{{ hostname }}</admin>
|
||||
|
||||
<limits>
|
||||
<clients>100</clients>
|
||||
<sources>2</sources>
|
||||
<queue-size>524288</queue-size>
|
||||
<client-timeout>30</client-timeout>
|
||||
<header-timeout>15</header-timeout>
|
||||
<source-timeout>10</source-timeout>
|
||||
<burst-on-connect>1</burst-on-connect>
|
||||
<burst-size>65535</burst-size>
|
||||
</limits>
|
||||
|
||||
<authentication>
|
||||
<!-- Admin logs in with the username given below -->
|
||||
<admin-user>{{ admin_user }}</admin-user>
|
||||
<admin-password>{{ admin_password }}</admin-password>
|
||||
</authentication>
|
||||
|
||||
<hostname>{{ hostname }}</hostname>
|
||||
|
||||
<http-headers>
|
||||
<header name="Access-Control-Allow-Origin" value="*" />
|
||||
<header name="Access-Control-Allow-Headers" value="*" />
|
||||
<header name="Access-Control-Allow-Methods" value="POST, GET, OPTIONS" />
|
||||
<header name="X-Robots-Tag" value="index, noarchive" />
|
||||
</http-headers>
|
||||
|
||||
<listen-socket>
|
||||
<port>{{ port }}</port>
|
||||
</listen-socket>
|
||||
|
||||
<mount type="normal">
|
||||
<mount-name>/radio-bullshit</mount-name>
|
||||
|
||||
<username>{{ source_username }}</username>
|
||||
<password>{{ source_password }}</password>
|
||||
|
||||
<max-listeners>{{ max_listeners }}</max-listeners>
|
||||
<burst-size>65536</burst-size>
|
||||
<hidden>1</hidden>
|
||||
<public>1</public>
|
||||
</mount>
|
||||
|
||||
<fileserve>1</fileserve>
|
||||
|
||||
<paths>
|
||||
<basedir>/usr/share/icecast2</basedir>
|
||||
|
||||
<logdir>/var/log/icecast</logdir>
|
||||
<webroot>/usr/share/icecast2/web</webroot>
|
||||
<adminroot>/usr/share/icecast2/admin</adminroot>
|
||||
<alias source="/" destination="/index.html"/>
|
||||
</paths>
|
||||
|
||||
<logging>
|
||||
<accesslog>access.log</accesslog>
|
||||
<errorlog>error.log</errorlog>
|
||||
<loglevel>3</loglevel> <!-- 4 Debug, 3 Info, 2 Warn, 1 Error -->
|
||||
<logsize>10000</logsize> <!-- Max size of a logfile -->
|
||||
</logging>
|
||||
|
||||
<security>
|
||||
<chroot>0</chroot>
|
||||
</security>
|
||||
</icecast>
|
||||
@@ -1,21 +0,0 @@
|
||||
<!-- <?xml version="1.0"?> -->
|
||||
<ices>
|
||||
<stream>
|
||||
<metadata>
|
||||
<name>Radio Bullshit</name>
|
||||
<genre>Bullshit</genre>
|
||||
<description>J'ai une superbe opportunité de travail</description>
|
||||
<url>{{ hostname }}</url>
|
||||
</metadata>
|
||||
<input>
|
||||
<param name="type">script</param>
|
||||
<param name="program">/opt/next_song.py</param>
|
||||
</input>
|
||||
<instance>
|
||||
<username>{{ source_username }}</username>
|
||||
<password>{{ source_password }}</password>
|
||||
<mount>/radio-bullshit</mount>
|
||||
<port>{{ port }}</port>
|
||||
</instance>
|
||||
</stream>
|
||||
</ices>
|
||||
22
index.html
22
index.html
@@ -1,22 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Radio bullshit, la radio du paradis !</title>
|
||||
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon">
|
||||
<link rel="icon" href="/favicon.ico" type="image/x-icon">
|
||||
</head>
|
||||
<body>
|
||||
<audio controls autoplay preload="none">
|
||||
<source src="/radio-bullshit?type=.ogg/;" type="application/ogg">
|
||||
</audio>
|
||||
|
||||
<p>
|
||||
<ul>
|
||||
<li><a href="/radio-bullshit">VLC Stream</a></li>
|
||||
<li><a href="/radio-bullshit.m3u">M3U file</a></li>
|
||||
</ul>
|
||||
</p>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
70
je_te_met_en_pls.py
Executable file
70
je_te_met_en_pls.py
Executable file
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
import glob, os, sys
|
||||
|
||||
|
||||
def generate_pls(directory: str, output_file: str = None) -> None:
|
||||
"""Generate a .pls playlist file from all audio files in a directory.
|
||||
|
||||
Args:
|
||||
directory: Path to directory containing audio files
|
||||
output_file: Path to output .pls file (default: directory/playlist.pls)
|
||||
"""
|
||||
if not os.path.isdir(directory):
|
||||
print(f"Error: Directory '{directory}' does not exist")
|
||||
sys.exit(1)
|
||||
|
||||
# Audio file extensions to search for
|
||||
audio_extensions = ["*.mp3", "*.ogg", "*.flac", "*.wav", "*.m4a", "*.aac", "*.opus"]
|
||||
|
||||
# Collect all audio files
|
||||
audio_files = []
|
||||
for ext in audio_extensions:
|
||||
pattern = os.path.join(directory, ext)
|
||||
audio_files.extend(glob.glob(pattern))
|
||||
|
||||
if not audio_files:
|
||||
print(f"Warning: No audio files found in '{directory}'")
|
||||
return
|
||||
|
||||
# Sort files alphabetically
|
||||
audio_files.sort()
|
||||
|
||||
# Determine output file path
|
||||
if output_file is None:
|
||||
output_file = os.path.join(directory, "playlist.pls")
|
||||
|
||||
# Generate .pls content
|
||||
pls_content = ["[playlist]"]
|
||||
pls_content.append(f"NumberOfEntries={len(audio_files)}")
|
||||
pls_content.append("")
|
||||
|
||||
for idx, file_path in enumerate(audio_files, start=1):
|
||||
# Get absolute path
|
||||
abs_path = os.path.abspath(file_path)
|
||||
filename = os.path.basename(file_path)
|
||||
|
||||
pls_content.append(f"File{idx}={abs_path}")
|
||||
pls_content.append(f"Title{idx}={filename}")
|
||||
pls_content.append(f"Length{idx}=-1")
|
||||
pls_content.append("")
|
||||
|
||||
pls_content.append("Version=2")
|
||||
|
||||
# Write to file
|
||||
with open(output_file, "w") as f:
|
||||
f.write("\n".join(pls_content))
|
||||
|
||||
print(f"Generated '{output_file}' with {len(audio_files)} entries")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: je_te_met_en_pls.py <directory> [output_file.pls]")
|
||||
print("Example: je_te_met_en_pls.py /songs")
|
||||
print("Example: je_te_met_en_pls.py /songs /tmp/my_playlist.pls")
|
||||
sys.exit(1)
|
||||
|
||||
directory = sys.argv[1]
|
||||
output_file = sys.argv[2] if len(sys.argv) > 2 else None
|
||||
|
||||
generate_pls(directory, output_file)
|
||||
55
next_song.py
55
next_song.py
@@ -1,55 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
from json.decoder import JSONDecodeError
|
||||
import random, glob, os, json
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
songs_folder = "/songs"
|
||||
jingles_folder = "/jingles"
|
||||
air_support_folder = "/air-support"
|
||||
|
||||
def get_air_support():
|
||||
return random.choice(glob.glob(f"{air_support_folder}/*.ogg"))
|
||||
|
||||
def create_state_from_scratch() -> dict:
|
||||
state = {"current_song_type": "jingle"}
|
||||
with open(f"{songs_folder}/state.json", "w") as f:
|
||||
json.dump(state, f, indent=4)
|
||||
return state
|
||||
|
||||
# state loading
|
||||
if not os.path.isfile(f"{songs_folder}/state.json"):
|
||||
# create state file if it doesnt exist
|
||||
with open(f"{songs_folder}/state.json", "w") as f:
|
||||
state = create_state_from_scratch()
|
||||
else:
|
||||
try:
|
||||
with open(f"{songs_folder}/state.json") as f:
|
||||
state = json.load(f)
|
||||
except JSONDecodeError:
|
||||
# if file doesnt exist, we recreate a state on the disk
|
||||
state = create_state_from_scratch()
|
||||
|
||||
# song choice
|
||||
if state["current_song_type"] == "song":
|
||||
state["current_song_type"] = "jingle"
|
||||
try:
|
||||
print(random.choice(glob.glob(f"{jingles_folder}/*.ogg")))
|
||||
except IndexError:
|
||||
print(get_air_support())
|
||||
elif state["current_song_type"] == "jingle":
|
||||
state["current_song_type"] = "song"
|
||||
try:
|
||||
print(random.choice(glob.glob(f"{songs_folder}/*.ogg")))
|
||||
except IndexError:
|
||||
print(get_air_support())
|
||||
else:
|
||||
# should not happen
|
||||
# resiliency mode
|
||||
print(get_air_support())
|
||||
state = {"current_song_type": "jingle"}
|
||||
|
||||
# state saving
|
||||
with open(f"{songs_folder}/state.json", "w") as f:
|
||||
json.dump(state, f, indent=4)
|
||||
137
radio.liq
Normal file
137
radio.liq
Normal file
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/liquidsoap
|
||||
|
||||
# Radio Bullshit - Liquidsoap Configuration
|
||||
# Modern open-source streaming with automatic song/jingle alternation
|
||||
|
||||
# ============================================================================
|
||||
# CONFIGURATION
|
||||
# ============================================================================
|
||||
|
||||
# Get environment variables with defaults
|
||||
hostname = environment.get(default="localhost", "HOSTNAME")
|
||||
port = int_of_string(environment.get(default="8000", "PORT"))
|
||||
|
||||
# Log configuration
|
||||
log.file.path := "/var/log/liquidsoap/radio-bullshit.log"
|
||||
log.level := 3
|
||||
|
||||
# ============================================================================
|
||||
# SOURCES
|
||||
# ============================================================================
|
||||
|
||||
# Songs playlist - reload every 60 seconds to pick up new downloads
|
||||
songs = playlist(
|
||||
mode="randomize",
|
||||
reload=60,
|
||||
reload_mode="watch",
|
||||
"/songs/playlist.pls"
|
||||
)
|
||||
|
||||
# Jingles playlist
|
||||
jingles = playlist(
|
||||
mode="randomize",
|
||||
reload=60,
|
||||
reload_mode="watch",
|
||||
"/jingles/playlist.pls"
|
||||
)
|
||||
|
||||
# Air support (fallback audio when nothing else is available)
|
||||
# air_support = playlist(
|
||||
# mode="randomize",
|
||||
# reload_mode="watch",
|
||||
# "/air-support/playlist.pls"
|
||||
# )
|
||||
air_support = single("/air-support/Airplane_Sound_Effect.ogg")
|
||||
|
||||
# ============================================================================
|
||||
# ALTERNATING LOGIC
|
||||
# ============================================================================
|
||||
|
||||
# Alternate between songs and jingles: play 1 jingle, then 4 songs
|
||||
# This creates a pattern: jingle, song, song, song, song, jingle, ...
|
||||
radio = rotate(
|
||||
weights=[1, 1],
|
||||
[jingles, songs]
|
||||
)
|
||||
|
||||
# Add fallback to air support in case of errors
|
||||
radio = fallback(
|
||||
track_sensitive=false,
|
||||
[radio, air_support]
|
||||
)
|
||||
|
||||
# Normalize audio levels for consistent volume
|
||||
# radio = normalize(radio)
|
||||
|
||||
# ============================================================================
|
||||
# OUTPUTS
|
||||
# ============================================================================
|
||||
|
||||
# Built-in HTTP server (replaces Icecast)
|
||||
# Serves the stream directly via Liquidsoap's Harbor server
|
||||
output.harbor(
|
||||
%mp3(bitrate=128, samplerate=44100),
|
||||
port=port,
|
||||
mount="/radio-bullshit",
|
||||
url="http://#{hostname}:#{port}",
|
||||
radio
|
||||
)
|
||||
|
||||
# Enable built-in HTTP server for stream and status
|
||||
settings.harbor.bind_addrs := ["0.0.0.0"]
|
||||
|
||||
# Add a simple HTML page
|
||||
harbor.http.register(
|
||||
port=port,
|
||||
method="GET",
|
||||
"/",
|
||||
fun (_, response) -> begin
|
||||
html = "<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Radio Bullshit</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; background: #1a1a1a; color: #fff; }
|
||||
h1 { color: #ff6b6b; }
|
||||
audio { margin: 20px; }
|
||||
a { color: #4ecdc4; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Radio Bullshit</h1>
|
||||
<p>J'ai une superbe opportunité de travail</p>
|
||||
<audio controls autoplay>
|
||||
<source src=\"/radio-bullshit\" type=\"audio/mpeg\">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
<p><a href=\"/radio-bullshit.m3u\">Download M3U Playlist</a></p>
|
||||
</body>
|
||||
</html>"
|
||||
response.html(html)
|
||||
end
|
||||
)
|
||||
|
||||
# Serve M3U playlist file
|
||||
harbor.http.register(
|
||||
port=port,
|
||||
method="GET",
|
||||
"/radio-bullshit.m3u",
|
||||
fun (_, response) -> begin
|
||||
m3u = "http://#{hostname}:#{port}/radio-bullshit"
|
||||
response.content_type("audio/x-mpegurl")
|
||||
response.data(m3u)
|
||||
end
|
||||
)
|
||||
|
||||
# Status endpoint (JSON)
|
||||
# harbor.http.register(
|
||||
# port=port,
|
||||
# method="GET",
|
||||
# "/status.json",
|
||||
# fun (_, response) -> begin
|
||||
# status = '{"status":"online","stream":"Radio Bullshit","mount":"/radio-bullshit"}'
|
||||
# response.json(parse_json(status))
|
||||
# end
|
||||
# )
|
||||
|
||||
log("Radio Bullshit is now streaming on http://#{hostname}:#{port}/radio-bullshit")
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
while true; do
|
||||
pip3 install -U yt-dlp
|
||||
runuser -l zambla -c '/opt/je_te_met_en_pls.py /songs /songs/playlist.pls' || true
|
||||
runuser -l zambla -c '/opt/je_te_met_en_pls.py /jingles /jingles/playlist.pls' || true
|
||||
runuser -l zambla -c '/opt/yt_sync.py' || true
|
||||
sleep 6h
|
||||
done
|
||||
|
||||
Reference in New Issue
Block a user