Compare commits

10 Commits

Author SHA1 Message Date
Sli
fd1cbc0e60 Add music title to html title and as a tooltip for audio player
All checks were successful
ci / deploy (push) Successful in 3m53s
2026-01-28 14:56:56 +01:00
Sli
4fe1e88220 Really fix overflow not being updated properly + scroll title enough so that we can see it
All checks were successful
ci / deploy (push) Successful in 2m12s
2026-01-23 09:41:02 +01:00
Sli
8239a21c43 Hopefully fix overflow not being updated properly once it overflows once
All checks were successful
ci / deploy (push) Successful in 2m8s
2026-01-23 01:57:34 +01:00
Sli
7d938f05e7 Ajoute un style plus classieux grâce aux conseils du criquet. Ajoute un titre qui scroll c'est la classe (le criquet est nul pour détecter un overflow)
All checks were successful
ci / deploy (push) Successful in 2m9s
2026-01-23 01:48:56 +01:00
Sli
b874da3a9c En vrai, envoyer une requête toutes les secondes c'est abusé, toutes les 2 secondes c'est mieux
All checks were successful
ci / deploy (push) Successful in 2m16s
2026-01-23 00:05:46 +01:00
Sli
778437da35 Fix crash when playlist.pls exists but not the json one
All checks were successful
ci / deploy (push) Successful in 29s
2026-01-21 00:46:07 +01:00
Sli
8ca5deebb2 Display current music
All checks were successful
ci / deploy (push) Successful in 2m30s
2026-01-21 00:37:15 +01:00
Sli
f397ec62ed Fix environment variable not being forwarded to liquidsoap
All checks were successful
ci / deploy (push) Successful in 2m30s
2026-01-20 14:19:10 +01:00
Sli
cc5ffc0133 L'oncle claude ce nulos il sait pas déterminenr combien de temps ça dure une musique
All checks were successful
ci / deploy (push) Successful in 5m53s
2026-01-19 23:01:47 +01:00
Sli
1a5453a714 Free range grass fed organic code to have a real http server and real air-support 2026-01-19 21:44:27 +01:00
13 changed files with 347 additions and 85 deletions

View File

@@ -5,7 +5,11 @@ ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update
RUN apt-get install -y ffmpeg liquidsoap
WORKDIR /config
RUN curl -fsSL https://deno.land/install.sh | sh
ENV DENO_INSTALL="/$HOME/.deno"
ENV PATH="$DENO_INSTALL/bin:$PATH"
WORKDIR /
RUN adduser zambla
@@ -13,19 +17,13 @@ COPY radio.liq /opt/radio.liq
COPY yt_sync.py /opt
COPY ultrasync.sh /opt
COPY je_te_met_en_pls.py /opt
COPY met_extractor.py /opt
COPY www /var/www
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/ultrasync.sh /opt/je_te_met_en_pls.py
RUN chmod +x /opt/yt_sync.py /opt/ultrasync.sh /opt/je_te_met_en_pls.py /opt/met_extractor.py
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/liquidsoap
ADD air-support /air-support

View File

@@ -27,4 +27,4 @@ Then visit http://localhost:8000 to listen!
- **Liquidsoap** - Audio stream generation and HTTP server
- **yt-dlp** - YouTube playlist downloading
- **FFmpeg** - Audio format conversion
- **Python 3.9** - Automation scripts
- **Python 3.14** - Automation scripts

1
air-support/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*.pls

View File

@@ -1,17 +1,16 @@
#!/bin/bash -e
HOSTNAME=${HOSTNAME:=localhost}
PORT=${PORT:=8000}
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}
touch /songs/playlist.pls /jingles/playlist.pls
runuser -l zambla 'touch /songs/playlist.pls /jingles/playlist.pls'
# fallback
python /opt/je_te_met_en_pls.py /air-support
python /opt/je_te_met_en_pls.py /songs
python /opt/je_te_met_en_pls.py /jingles
# Start background sync process
/opt/ultrasync.sh &
# 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'
runuser -l zambla -c "HOSTNAME=$HOSTNAME PORT=$PORT liquidsoap /opt/radio.liq"

View File

@@ -1,13 +1,47 @@
#!/usr/bin/env python3
import glob, os, sys
import glob
import os
import sys
import subprocess
import json
from pathlib import Path
def generate_pls(directory: str, output_file: str = None) -> None:
def get_song_length(file: Path) -> float:
ret = subprocess.run(
[
"ffprobe",
"-i",
str(file.absolute()),
"-show_entries",
"format=duration",
"-v",
"quiet",
"-of",
"csv=p=0",
],
stdout=subprocess.PIPE,
)
if ret.returncode != 0:
return -1
try:
return float(ret.stdout)
except ValueError:
return -1
def generate_playlist(
directory: Path,
pls_file: Path | None = None,
json_file: Path | None = 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)
pls_file: Path to output .pls file (default: directory/playlist.pls)
json_file: Path to output .json file (default: directory/playlist.json)
"""
if not os.path.isdir(directory):
print(f"Error: Directory '{directory}' does not exist")
@@ -30,41 +64,64 @@ def generate_pls(directory: str, output_file: str = None) -> None:
audio_files.sort()
# Determine output file path
if output_file is None:
output_file = os.path.join(directory, "playlist.pls")
if pls_file is None:
pls_file = directory / "playlist.pls"
# Generate .pls content
if json_file is None:
json_file = directory / "playlist.json"
json_content = {}
# Generate output 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)
for idx, path in enumerate(audio_files, start=1):
file = Path(path)
pls_content.append(f"File{idx}={abs_path}")
pls_content.append(f"Title{idx}={filename}")
pls_content.append(f"Length{idx}=-1")
title = file.stem.replace('_', ' ')
duration = get_song_length(file)
pls_content.append(f"File{idx}={file.absolute()}")
pls_content.append(f"Title{idx}={title}")
pls_content.append(f"Length{idx}={duration}")
pls_content.append("")
json_content[str(file.absolute())] = {
"index": idx,
"file": str(file.absolute()),
"title": title,
"duration": duration,
}
pls_content.append("Version=2")
# Write to file
with open(output_file, "w") as f:
with open(pls_file, "w") as f:
f.write("\n".join(pls_content))
print(f"Generated '{output_file}' with {len(audio_files)} entries")
with open(json_file, "w") as f:
json.dump(json_content, f)
print(f"Generated '{pls_file}' with {len(audio_files)} entries")
print(f"Generated '{json_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(
"Usage: je_te_met_en_pls.py <directory> [output_file.pls] [output_file.json]"
)
print("Example: je_te_met_en_pls.py /songs")
print("Example: je_te_met_en_pls.py /songs /tmp/my_playlist.pls")
print(
"Example: je_te_met_en_pls.py /songs /tmp/my_playlist.pls /tmp/my_playlist.json"
)
sys.exit(1)
directory = sys.argv[1]
output_file = sys.argv[2] if len(sys.argv) > 2 else None
pls_file = Path(sys.argv[2]) if len(sys.argv) > 2 else None
json_file = Path(sys.argv[3]) if len(sys.argv) > 3 else None
generate_pls(directory, output_file)
generate_playlist(Path(directory), pls_file, json_file)

14
met_extractor.py Executable file
View File

@@ -0,0 +1,14 @@
#!/usr/bin/env python3
import sys
import json
if len(sys.argv) < 2:
print(
"Usage: met_etractor.py <playlist.json> <key>"
)
print("Example: met_extractor.py /tmp/my_playlist.json key")
sys.exit(1)
with open(sys.argv[1], "r", encoding="utf-8") as f:
met = json.load(f)
print(json.dumps(met.get(sys.argv[2], {})))

117
radio.liq
View File

@@ -1,7 +1,6 @@
#!/usr/bin/liquidsoap
# Radio Bullshit - Liquidsoap Configuration
# Modern open-source streaming with automatic song/jingle alternation
# ============================================================================
# CONFIGURATION
@@ -11,6 +10,10 @@
hostname = environment.get(default="localhost", "HOSTNAME")
port = int_of_string(environment.get(default="8000", "PORT"))
folder_songs = "/songs"
folder_jingles = "/jingles"
folder_air_support = "/air-support"
# Log configuration
log.file.path := "/var/log/liquidsoap/radio-bullshit.log"
log.level := 3
@@ -24,7 +27,7 @@ songs = playlist(
mode="randomize",
reload=60,
reload_mode="watch",
"/songs/playlist.pls"
"#{folder_songs}/playlist.pls"
)
# Jingles playlist
@@ -32,23 +35,62 @@ jingles = playlist(
mode="randomize",
reload=60,
reload_mode="watch",
"/jingles/playlist.pls"
"#{folder_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")
air_support = mksafe(
playlist(
mode="randomize",
reload_mode="watch",
"#{folder_air_support}/playlist.pls"
)
)
# ============================================================================
# METADATA UPDATE
# ============================================================================
def enrich_metadata(folder, m)
entry = process.read("/opt/met_extractor.py #{folder}/playlist.json '#{metadata.filename(m)}'")
let json.parse (entry :
{
index: int?,
file: string?,
title: string?,
duration: float?
}
) = entry
[("title", "#{entry.title}")]
end
songs = map_metadata(
fun (m) -> begin
enrich_metadata(folder_songs, m)
end,
songs
)
jingles = map_metadata(
fun (m) -> begin
enrich_metadata(folder_jingles, m)
end,
jingles
)
air_support = map_metadata(
fun (m) -> begin
enrich_metadata(folder_air_support, m)
end,
air_support
)
# ============================================================================
# ALTERNATING LOGIC
# ============================================================================
# Alternate between songs and jingles: play 1 jingle, then 4 songs
# This creates a pattern: jingle, song, song, song, song, jingle, ...
# This creates a pattern: jingle, song, jingle, ...
radio = rotate(
weights=[1, 1],
[jingles, songs]
@@ -80,34 +122,26 @@ output.harbor(
# Enable built-in HTTP server for stream and status
settings.harbor.bind_addrs := ["0.0.0.0"]
# Add a simple HTML page
# Add static folder
harbor.http.static(
port=port,
path="/static/",
content_type=
fun (path) -> begin
file.mime.magic(path)
end,
"/var/www/static"
)
# Add index file
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)
response.html(
file.contents("/var/www/index.html")
)
end
)
@@ -124,14 +158,13 @@ harbor.http.register(
)
# 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
# )
harbor.http.register(
port=port,
method="GET",
"/status.json",
fun (_, response) -> begin
response.json(radio.last_metadata() ?? [("status", "down")])
end
)
log("Radio Bullshit is now streaming on http://#{hostname}:#{port}/radio-bullshit")

View File

@@ -2,8 +2,9 @@
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
/opt/yt_sync.py || true
/opt/je_te_met_en_pls.py /songs || true
/opt/je_te_met_en_pls.py /jingles || true
sleep 6h
done

76
www/index.html Normal file
View File

@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html
x-data="{
metadata: { status: 'down' },
isTitleOverflow: false
}"
x-init="setInterval(
async () => {
metadata = await getMetadata()
},
2000,
)
$watch('metadata.title', async (value, oldValue) => {
if (value == oldValue){
return
}
// First, reset overflow for proper recalculation
isTitleOverflow = false
await $nextTick()
// Now check for overflow
isTitleOverflow = $refs.scrollTitle.offsetWidth < $refs.scrollTitle.scrollWidth
})
"
>
<head>
<script defer src="/static/alpine.min.js"></script>
<meta charset="utf-8">
<title x-text="'Radio bullshit, la radio du paradis ! Now playing: ' + metadata.title"></title>
<link rel="stylesheet" type="text/css" href="/static/style.css">
<link rel="shortcut icon" href="/static/favicon.ico" type="image/x-icon">
<link rel="icon" href="/static/favicon.ico" type="image/x-icon">
</head>
<body>
<div
class="radio-container"
>
<div
class="track-title"
>
<p
x-text="metadata.title ?? '𝐍𝐨𝐰 𝐥𝐨𝐚𝐝𝐢𝐧𝐠. . .'"
x-ref="scrollTitle"
:class="{
overflow: isTitleOverflow
}"
></p>
</div>
<audio :title="metadata.title" controls autoplay>
<source src="/radio-bullshit" type="audio/mpeg" preload="none" >
Ton navigateur ne supporte pas l'audio HTML5. Pas de Zambla pour toi !
</audio>
<p>
<ul class="radio-links">
<li>
<a href="/radio-bullshit" target="_blank">
🎵 VLC Stream
</a>
</li>
<li>
<a href="/radio-bullshit.m3u">
📄 M3U file
</a>
</li>
</ul>
</p>
</div>
</body>
<script>
const getMetadata = async () => {
return Object.fromEntries(
await (await fetch("/status.json")).json()
)
}
</script>
</html>

5
www/static/alpine.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

78
www/static/style.css Normal file
View File

@@ -0,0 +1,78 @@
/* Reset minimal */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
height: 100vh;
background: #0e0e0e;
display: flex;
justify-content: center;
align-items: center;
font-family: Arial, Helvetica, sans-serif;
color: #ffffff;
}
.radio-container {
text-align: center;
}
.track-title {
width: 300px;
overflow: hidden;
white-space: nowrap;
margin-bottom: 12px;
font-size: 0.95rem;
opacity: 0.85;
position: relative;
}
.track-title p.overflow {
padding-left: 100%;
animation: scroll-title 10s linear infinite;
}
.track-title:hover p.overflow {
animation-play-state: paused;
}
@keyframes scroll-title {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-150%);
}
}
audio {
width: 300px;
}
.radio-links {
all: unset;
display: flex;
gap: 12px;
margin-top: 14px;
font-size: 0.8rem;
opacity: 0.7;
}
.radio-links li {
all: unset;
}
.radio-links a {
text-decoration: none;
color: #ffffff;
padding: 4px 8px;
border: 1px solid rgba(255,255,255,0.15);
border-radius: 3px;
}
.radio-links a:hover {
opacity: 1;
background: rgba(255,255,255,0.08);
}

View File

@@ -11,7 +11,7 @@ if __name__ == "__main__":
ydl_parameters = {
"format": "bestaudio/best",
"postprocessors": [{"key": "FFmpegExtractAudio", "preferredcodec": "vorbis"}],
"outtmpl": f"{songs_folder}/%(title)s.godwin",
"outtmpl": f"{songs_folder}/%(title)s",
"ignoreerrors": True,
"restrictfilenames": True,
}
@@ -23,7 +23,7 @@ if __name__ == "__main__":
# Radio Bullshit Jingles
## change output path for Radio Bullshit jingles
ydl_parameters["outtmpl"] = f"{jingles_folder}/%(title)s.godwin"
ydl_parameters["outtmpl"] = f"{jingles_folder}/%(title)s"
## download
ydl = yt_dlp.YoutubeDL(ydl_parameters)
ydl.extract_info(