# denpa-radio liquidsoap script # adding a station: copy a station block, rename, restart liquidsoap. settings.log.stdout.set(true) settings.log.level.set(3) source_pw = environment.get(default="", "SOURCE_PW") # deck-shuffle: full shuffle once, hand out in order, reshuffle on exhaustion. # returns a record with .source (request.dynamic) and .peek (returns next N filenames). def make_deck(dir) = files = ref([]) cursor = ref(0) last_played = ref("") def reload_and_shuffle() = all = file.ls(dir, recursive=true, absolute=true) audio_exts = ["flac", "mp3", "opus", "m4a", "wav", "ogg"] audio = list.filter( fun(f) -> list.mem(string.case(lower=true, file.extension(leading_dot=false, f)), audio_exts), all ) files := list.shuffle(audio) if !last_played != "" and list.length(!files) > 1 and list.nth(default="", !files, 0) == !last_played then files := list.append( [list.nth(default="", !files, 1), list.nth(default="", !files, 0)], list.tl(list.tl(!files)) ) end cursor := 0 end def next_file() = if !cursor >= list.length(!files) then reload_and_shuffle() end if list.length(!files) == 0 then "" else f = list.nth(default="", !files, !cursor) cursor := !cursor + 1 last_played := f f end end def next_request() = f = next_file() if f == "" then null() else request.create(f) end end def peek(n) = list.init( min(n, list.length(!files) - !cursor), fun(i) -> list.nth(default="", !files, !cursor + i) ) end reload_and_shuffle() { source = request.dynamic(next_request), peek = peek } end # walk up from the audio file to find cover.{jpg,png,webp}. # falls back to /cover.* (one level above tracks/). def cover_for(filename) = def find_in(dir) = candidates = ["#{dir}/cover.jpg", "#{dir}/cover.png", "#{dir}/cover.webp"] list.fold( fun(found, c) -> if found == "" and file.exists(c) then c else found end, "", candidates ) end album_dir = path.dirname(filename) c1 = find_in(album_dir) if c1 != "" then c1 else tracks_dir = path.dirname(album_dir) station_root = path.dirname(tracks_dir) find_in(station_root) end end # build a json object for one upcoming track from its filename. # reads tags via request.metadata; uses request.duration for length. def track_meta_json(filename) = r = request.create(filename) ignore(request.resolve(r)) m = request.metadata(r) request.destroy(r) d = null.get(default=-1., request.duration(filename)) obj = json() obj.add("title", m["title"]) obj.add("artist", m["artist"]) obj.add("album", m["album"]) obj.add("duration", if d > 0. then string(int_of_float(d)) else "" end) obj.add("cover_path", cover_for(filename)) obj end def emit_now_playing(station, m, upcoming) = filename = m["filename"] if filename != "" then meta_dur = m["duration"] dur_str = if meta_dur != "" then meta_dur else d = null.get(default=-1., request.duration(filename)) if d > 0. then string(int_of_float(d)) else "" end end payload = json() payload.add("station", station) payload.add("artist", m["artist"]) payload.add("title", m["title"]) payload.add("album", m["album"]) payload.add("filename", filename) payload.add("duration", dur_str) payload.add("started_at", string(int_of_float(time()))) payload.add("cover_path", cover_for(filename)) up_arr = list.map(track_meta_json, upcoming) payload.add("up_next", up_arr) file.write( data=payload.stringify(), atomic=true, temp_dir="/now-playing", "/now-playing/#{station}.json" ) end end def append_history(station, m) = filename = m["filename"] if filename != "" then history_path = "/now-playing/#{station}.history.json" entry = { title = m["title"], artist = m["artist"], album = m["album"], filename = filename, started_at = string(int_of_float(time())) } arr = if file.exists(history_path) then existing = file.contents(history_path) try let json.parse (parsed : [{ title: string, artist: string, album: string, filename: string, started_at: string }]) = existing parsed catch _ do [] end else [] end new_arr = list.add(entry, list.prefix(49, arr)) out_str = json.stringify(new_arr) file.write(data=out_str, atomic=true, temp_dir="/now-playing", history_path) end end # === station: minecraft === minecraft_deck = make_deck("/library/minecraft/tracks") minecraft = minecraft_deck.source minecraft.on_track(fun (m) -> begin emit_now_playing("minecraft", m, minecraft_deck.peek(4)) append_history("minecraft", m) end) minecraft = crossfade(minecraft) minecraft = mksafe(minecraft) output.icecast( %mp3(bitrate=192, stereo=true), host="icecast", port=8000, password=source_pw, mount="/minecraft.mp3", name="Denpa — Minecraft", description="Minecraft soundtracks", genre="game-ost", url="https://denpa.femboy.page", minecraft ) output.icecast( %opus(bitrate=96, vbr="constrained", samplerate=48000, channels=2), host="icecast", port=8000, password=source_pw, mount="/minecraft.opus", name="Denpa — Minecraft", description="Minecraft soundtracks", genre="game-ost", url="https://denpa.femboy.page", minecraft ) output.url( %ffmpeg(format="s16le", %audio(codec="pcm_s16le", ac=2, ar=48000)), url="tcp://0.0.0.0:9100?listen=1", minecraft )