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