This commit is contained in:
11
Dockerfile
11
Dockerfile
@@ -5,6 +5,10 @@ ENV DEBIAN_FRONTEND=noninteractive
|
|||||||
RUN apt-get update
|
RUN apt-get update
|
||||||
RUN apt-get install -y ffmpeg liquidsoap
|
RUN apt-get install -y ffmpeg liquidsoap
|
||||||
|
|
||||||
|
RUN curl -fsSL https://deno.land/install.sh | sh
|
||||||
|
ENV DENO_INSTALL="/$HOME/.deno"
|
||||||
|
ENV PATH="$DENO_INSTALL/bin:$PATH"
|
||||||
|
|
||||||
WORKDIR /
|
WORKDIR /
|
||||||
|
|
||||||
RUN adduser zambla
|
RUN adduser zambla
|
||||||
@@ -13,13 +17,10 @@ COPY radio.liq /opt/radio.liq
|
|||||||
COPY yt_sync.py /opt
|
COPY yt_sync.py /opt
|
||||||
COPY ultrasync.sh /opt
|
COPY ultrasync.sh /opt
|
||||||
COPY je_te_met_en_pls.py /opt
|
COPY je_te_met_en_pls.py /opt
|
||||||
|
COPY met_extractor.py /opt
|
||||||
COPY www /var/www
|
COPY www /var/www
|
||||||
|
|
||||||
RUN curl -fsSL https://deno.land/install.sh | sh
|
RUN chmod +x /opt/yt_sync.py /opt/ultrasync.sh /opt/je_te_met_en_pls.py /opt/met_extractor.py
|
||||||
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 mkdir -p /songs /jingles /air-support /var/log/liquidsoap
|
RUN mkdir -p /songs /jingles /air-support /var/log/liquidsoap
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import glob
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
@@ -30,12 +31,17 @@ def get_song_length(file: Path) -> float:
|
|||||||
return -1
|
return -1
|
||||||
|
|
||||||
|
|
||||||
def generate_pls(directory: str, output_file: str | None = None) -> None:
|
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.
|
"""Generate a .pls playlist file from all audio files in a directory.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
directory: Path to directory containing audio files
|
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):
|
if not os.path.isdir(directory):
|
||||||
print(f"Error: Directory '{directory}' does not exist")
|
print(f"Error: Directory '{directory}' does not exist")
|
||||||
@@ -58,10 +64,15 @@ def generate_pls(directory: str, output_file: str | None = None) -> None:
|
|||||||
audio_files.sort()
|
audio_files.sort()
|
||||||
|
|
||||||
# Determine output file path
|
# Determine output file path
|
||||||
if output_file is None:
|
if pls_file is None:
|
||||||
output_file = os.path.join(directory, "playlist.pls")
|
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 = ["[playlist]"]
|
||||||
pls_content.append(f"NumberOfEntries={len(audio_files)}")
|
pls_content.append(f"NumberOfEntries={len(audio_files)}")
|
||||||
pls_content.append("")
|
pls_content.append("")
|
||||||
@@ -69,28 +80,48 @@ def generate_pls(directory: str, output_file: str | None = None) -> None:
|
|||||||
for idx, path in enumerate(audio_files, start=1):
|
for idx, path in enumerate(audio_files, start=1):
|
||||||
file = Path(path)
|
file = Path(path)
|
||||||
|
|
||||||
|
title = file.stem.replace('_', ' ')
|
||||||
|
duration = get_song_length(file)
|
||||||
|
|
||||||
pls_content.append(f"File{idx}={file.absolute()}")
|
pls_content.append(f"File{idx}={file.absolute()}")
|
||||||
pls_content.append(f"Title{idx}={file.stem.replace('_', ' ')}")
|
pls_content.append(f"Title{idx}={title}")
|
||||||
pls_content.append(f"Length{idx}={get_song_length(file)}")
|
pls_content.append(f"Length{idx}={duration}")
|
||||||
pls_content.append("")
|
pls_content.append("")
|
||||||
|
|
||||||
|
json_content[str(file.absolute())] = {
|
||||||
|
"index": idx,
|
||||||
|
"file": str(file.absolute()),
|
||||||
|
"title": title,
|
||||||
|
"duration": duration,
|
||||||
|
}
|
||||||
|
|
||||||
pls_content.append("Version=2")
|
pls_content.append("Version=2")
|
||||||
|
|
||||||
# Write to file
|
# Write to file
|
||||||
with open(output_file, "w") as f:
|
with open(pls_file, "w") as f:
|
||||||
f.write("\n".join(pls_content))
|
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 __name__ == "__main__":
|
||||||
if len(sys.argv) < 2:
|
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")
|
||||||
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")
|
||||||
|
print(
|
||||||
|
"Example: je_te_met_en_pls.py /songs /tmp/my_playlist.pls /tmp/my_playlist.json"
|
||||||
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
directory = sys.argv[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
14
met_extractor.py
Executable 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], {})))
|
||||||
67
radio.liq
67
radio.liq
@@ -10,6 +10,10 @@
|
|||||||
hostname = environment.get(default="localhost", "HOSTNAME")
|
hostname = environment.get(default="localhost", "HOSTNAME")
|
||||||
port = int_of_string(environment.get(default="8000", "PORT"))
|
port = int_of_string(environment.get(default="8000", "PORT"))
|
||||||
|
|
||||||
|
folder_songs = "/songs"
|
||||||
|
folder_jingles = "/jingles"
|
||||||
|
folder_air_support = "/air-support"
|
||||||
|
|
||||||
# Log configuration
|
# Log configuration
|
||||||
log.file.path := "/var/log/liquidsoap/radio-bullshit.log"
|
log.file.path := "/var/log/liquidsoap/radio-bullshit.log"
|
||||||
log.level := 3
|
log.level := 3
|
||||||
@@ -23,7 +27,7 @@ songs = playlist(
|
|||||||
mode="randomize",
|
mode="randomize",
|
||||||
reload=60,
|
reload=60,
|
||||||
reload_mode="watch",
|
reload_mode="watch",
|
||||||
"/songs/playlist.pls"
|
"#{folder_songs}/playlist.pls"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Jingles playlist
|
# Jingles playlist
|
||||||
@@ -31,7 +35,7 @@ jingles = playlist(
|
|||||||
mode="randomize",
|
mode="randomize",
|
||||||
reload=60,
|
reload=60,
|
||||||
reload_mode="watch",
|
reload_mode="watch",
|
||||||
"/jingles/playlist.pls"
|
"#{folder_jingles}/playlist.pls"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Air support (fallback audio when nothing else is available)
|
# Air support (fallback audio when nothing else is available)
|
||||||
@@ -39,16 +43,54 @@ air_support = mksafe(
|
|||||||
playlist(
|
playlist(
|
||||||
mode="randomize",
|
mode="randomize",
|
||||||
reload_mode="watch",
|
reload_mode="watch",
|
||||||
"/air-support/playlist.pls"
|
"#{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
|
# ALTERNATING LOGIC
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
# Alternate between songs and jingles: play 1 jingle, then 4 songs
|
# 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(
|
radio = rotate(
|
||||||
weights=[1, 1],
|
weights=[1, 1],
|
||||||
[jingles, songs]
|
[jingles, songs]
|
||||||
@@ -112,14 +154,13 @@ harbor.http.register(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Status endpoint (JSON)
|
# Status endpoint (JSON)
|
||||||
# harbor.http.register(
|
harbor.http.register(
|
||||||
# port=port,
|
port=port,
|
||||||
# method="GET",
|
method="GET",
|
||||||
# "/status.json",
|
"/status.json",
|
||||||
# fun (_, response) -> begin
|
fun (_, response) -> begin
|
||||||
# status = '{"status":"online","stream":"Radio Bullshit","mount":"/radio-bullshit"}'
|
response.json(radio.last_metadata() ?? [("status", "down")])
|
||||||
# response.json(parse_json(status))
|
end
|
||||||
# end
|
)
|
||||||
# )
|
|
||||||
|
|
||||||
log("Radio Bullshit is now streaming on http://#{hostname}:#{port}/radio-bullshit")
|
log("Radio Bullshit is now streaming on http://#{hostname}:#{port}/radio-bullshit")
|
||||||
|
|||||||
@@ -1,16 +1,28 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
|
<script defer src="/static/alpine.min.js"></script>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>Radio bullshit, la radio du paradis !</title>
|
<title>Radio bullshit, la radio du paradis !</title>
|
||||||
<link rel="shortcut icon" href="/static/favicon.ico" type="image/x-icon">
|
<link rel="shortcut icon" href="/static/favicon.ico" type="image/x-icon">
|
||||||
<link rel="icon" href="/static/favicon.ico" type="image/x-icon">
|
<link rel="icon" href="/static/favicon.ico" type="image/x-icon">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div
|
||||||
|
x-data="{ metadata: { status: 'down' } }"
|
||||||
|
x-init="setInterval(
|
||||||
|
async () => {
|
||||||
|
metadata = await get_metadata()
|
||||||
|
},
|
||||||
|
1000,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<p x-show="metadata.status == 'ready'" x-text="metadata.title"></p>
|
||||||
<audio controls autoplay>
|
<audio controls autoplay>
|
||||||
<source src="/radio-bullshit" type="audio/mpeg" preload="none" >
|
<source src="/radio-bullshit" type="audio/mpeg" preload="none" >
|
||||||
</audio>
|
</audio>
|
||||||
|
</div>
|
||||||
<p>
|
<p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="/radio-bullshit">VLC Stream</a></li>
|
<li><a href="/radio-bullshit">VLC Stream</a></li>
|
||||||
@@ -19,4 +31,11 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
<script>
|
||||||
|
const get_metadata = async () => {
|
||||||
|
return Object.fromEntries(
|
||||||
|
await (await fetch("/status.json")).json()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</html>
|
</html>
|
||||||
5
www/static/alpine.min.js
vendored
Normal file
5
www/static/alpine.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user