diff --git a/.gitignore b/.gitignore index c86b351..518a1c6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ *~ -state.json -*.xml \ No newline at end of file +*.log diff --git a/Dockerfile b/Dockerfile index 477ddad..0a8073a 100644 --- a/Dockerfile +++ b/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 diff --git a/README.md b/README.md index 0411e86..45b6ea3 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,30 @@ # radio-bullshit -Radio Bullshit \ No newline at end of file +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 diff --git a/entrypoint.sh b/entrypoint.sh index 93ca9c7..a0cd1f8 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -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' diff --git a/icecast.xml.jinja b/icecast.xml.jinja deleted file mode 100644 index 62b7332..0000000 --- a/icecast.xml.jinja +++ /dev/null @@ -1,68 +0,0 @@ - - Earth - icemaster@{{ hostname }} - - - 100 - 2 - 524288 - 30 - 15 - 10 - 1 - 65535 - - - - - {{ admin_user }} - {{ admin_password }} - - - {{ hostname }} - - -
-
-
-
- - - - {{ port }} - - - - /radio-bullshit - - {{ source_username }} - {{ source_password }} - - {{ max_listeners }} - 65536 - 1 - 1 - - - 1 - - - /usr/share/icecast2 - - /var/log/icecast - /usr/share/icecast2/web - /usr/share/icecast2/admin - - - - - access.log - error.log - 3 - 10000 - - - - 0 - - diff --git a/ices.xml.jinja b/ices.xml.jinja deleted file mode 100644 index 92332cd..0000000 --- a/ices.xml.jinja +++ /dev/null @@ -1,21 +0,0 @@ - - - - - Radio Bullshit - Bullshit - J'ai une superbe opportunité de travail - {{ hostname }} - - - script - /opt/next_song.py - - - {{ source_username }} - {{ source_password }} - /radio-bullshit - {{ port }} - - - diff --git a/index.html b/index.html deleted file mode 100644 index 4723d54..0000000 --- a/index.html +++ /dev/null @@ -1,22 +0,0 @@ - - - - - Radio bullshit, la radio du paradis ! - - - - - - -

-

-

- - - \ No newline at end of file diff --git a/je_te_met_en_pls.py b/je_te_met_en_pls.py new file mode 100755 index 0000000..0ba0c25 --- /dev/null +++ b/je_te_met_en_pls.py @@ -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 [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) diff --git a/next_song.py b/next_song.py deleted file mode 100755 index 18752b0..0000000 --- a/next_song.py +++ /dev/null @@ -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) diff --git a/radio.liq b/radio.liq new file mode 100644 index 0000000..8b89c9e --- /dev/null +++ b/radio.liq @@ -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 = " + + + Radio Bullshit + + + +

Radio Bullshit

+

J'ai une superbe opportunité de travail

+ +

Download M3U Playlist

+ +" + 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") diff --git a/ultrasync.sh b/ultrasync.sh index c76557a..605208f 100644 --- a/ultrasync.sh +++ b/ultrasync.sh @@ -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