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:
aethor
2026-01-18 22:30:35 +08:00
parent 9374af6ed5
commit 233389df8f
11 changed files with 257 additions and 200 deletions

3
.gitignore vendored
View File

@@ -1,3 +1,2 @@
*~
state.json
*.xml
*.log

View File

@@ -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

View File

@@ -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

View File

@@ -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'

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
View 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)

View File

@@ -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
View 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&eacute; 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")

View File

@@ -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