#!/usr/bin/env bash # Internet radio (radiorecord.ru) and MPRIS "now playing" for the media panel. # # radio status loop (deflisten) # radio start ID tune to station ID # radio toggle play / stop the last station # radio stop # radio vol PERCENT volume of the radio stream, else of the external player # radio mute mute toggle, same target as vol # # mpv runs as the transient user unit eww-radio: independent of the eww # daemon, and `systemctl --user stop eww-radio` always works. api="https://www.radiorecord.ru/api" unit=eww-radio state_dir=${XDG_STATE_HOME:-$HOME/.local/state}/eww cache_dir=${XDG_CACHE_HOME:-$HOME/.cache}/eww id_file=$state_dir/radio-station mkdir -p "$state_dir" "$cache_dir" station_ids='[507,522,523,536,537,42532,42602]' # Extra stations: {id (unique integer >= 1000000), title, stream_hls, icon_fill_white ("" for none)} custom_stations='[]' default_song='{"artist":"","song":"","image600":""}' default_media='{"player":"","status":"Stopped","artist":"","title":"","art":""}' stations_cache="$cache_dir/radio-stations-$(cksum <<<"$station_ids$custom_stations" | cut -d' ' -f1).json" # --- stations -------------------------------------------------------------- # Station list, cached for a day (it only changes with station_ids). stations() { if [[ -s $stations_cache && -n $(find "$stations_cache" -mmin -1440 2>/dev/null) ]]; then cat "$stations_cache" return fi local rr out rr=$(curl -sf --compressed --max-time 8 "$api/stations/" 2>/dev/null \ | jq -c --argjson ids "$station_ids" \ '.result.stations | map(select(.id | IN($ids[]))) | map({id, title, stream_hls, icon_fill_white, radiorecord: true})' 2>/dev/null) if [[ -z $rr || $rr == null ]]; then [[ -s $stations_cache ]] && { cat "$stations_cache"; return; } rr='[]' fi out=$(jq -cn --argjson rr "$rr" --argjson custom "$custom_stations" '$rr + ($custom | map(. + {radiorecord: false}))') [[ $rr != '[]' ]] && printf '%s\n' "$out" >"$stations_cache" printf '%s\n' "$out" } station() { # ID FIELD jq -r --argjson id "${1:-0}" --arg f "$2" 'first(.[] | select(.id == $id) | .[$f]) // empty' <<<"$STATIONS" } current_id() { local id id=$(cat "$id_file" 2>/dev/null) [[ $id =~ ^[0-9]+$ ]] && echo "$id" || echo 0 } playing() { systemctl --user is-active --quiet "$unit"; } # --- playback -------------------------------------------------------------- start() { local id=${1:-0} url ((id > 0)) || return 1 STATIONS=$(stations) url=$(station "$id" stream_hls) [[ -n $url && $url != null ]] || return 1 systemctl --user stop "$unit" 2>/dev/null detach -u "$unit" mpv --no-video --really-quiet --audio-client-name="$unit" "$url" || return 1 printf '%s\n' "$id" >"$id_file" } stop() { systemctl --user stop "$unit" 2>/dev/null; } # --- volume ---------------------------------------------------------------- # PipeWire node id of the first stream whose line matches $1 (case-insensitive). stream_node() { wpctl status 2>/dev/null | awk -v pat="$1" ' /Streams:/ { s = 1; next } /Sinks:|Sources:|Filters:|Clients:/ { s = 0 } s && tolower($0) ~ tolower(pat) && match($0, /[0-9]+\./) { print substr($0, RSTART, RLENGTH - 1); exit }' } external_player() { playerctl -l 2>/dev/null | grep -v '^mpv' | head -n1; } # "pw NODE" while the radio plays, else "mpris PLAYER" for an external player. volume_target() { local n p if playing; then n=$(stream_node "$unit") [[ -n $n ]] && { echo "pw $n"; return; } fi p=$(external_player) [[ -n $p ]] && echo "mpris $p" } get_volume() { local kind target read -r kind target < <(volume_target) case $kind in pw) wpctl get-volume "$target" 2>/dev/null | awk '{ printf "%d", $2 * 100 + 0.5 }' ;; mpris) playerctl -p "$target" volume 2>/dev/null | awk '{ printf "%d", $1 * 100 + 0.5 }' ;; esac } set_volume() { local vol=${1%.*} kind target read -r kind target < <(volume_target) case $kind in pw) wpctl set-volume -l 1.0 "$target" "${vol}%" ;; mpris) playerctl -p "$target" volume "$(awk -v v="$vol" 'BEGIN { printf "%.2f", v / 100 }')" 2>/dev/null ;; esac eww update radio-vol="$vol" 2>/dev/null } toggle_mute() { local kind target n="" read -r kind target < <(volume_target) case $kind in pw) n=$target ;; mpris) n=$(stream_node "${target%%.*}") ;; esac [[ -n $n ]] || return wpctl set-mute "$n" toggle if wpctl get-volume "$n" 2>/dev/null | grep -q MUTED; then muted=true; else muted=false; fi eww update radio-muted="$muted" 2>/dev/null } # --- status loop ----------------------------------------------------------- media_json() { # PLAYER STATUS ARTIST TITLE ART jq -cn --arg player "$1" --arg status "${2:-Stopped}" --arg artist "$3" --arg title "$4" --arg art "$5" \ '{player: $player, status: $status, artist: $artist, title: $title, art: $art}' } song_playing() { # ID → SONG from the station history (radiorecord) or the title local id=$1 fetched if [[ $(station "$id" radiorecord) == true ]]; then fetched=$(curl -sf --compressed --max-time 8 "$api/station/history/?id=$id" 2>/dev/null \ | jq -c '.result.history[0] | {artist: (.artist // ""), song: (.song // ""), image600: (.image600 // "")}' 2>/dev/null) [[ -n $fetched && $fetched != null ]] && SONG=$fetched else SONG=$(jq -cn --arg t "$(station "$id" title)" '{artist: "", song: $t, image600: ""}') fi } song_idle() { # ID → placeholder with the station's name and logo SONG=$(jq -cn --arg t "$(station "$1" title)" --arg i "$(station "$1" icon_fill_white)" \ '{artist: "", song: $t, image600: $i}') } # playerctl --follow line: media\tPLAYER\tSTATUS\tARTIST\tTITLE\tART media_event() { local player status artist title art IFS=$'\t' read -r player status artist title art <<<"$1" if [[ -z $player ]]; then MEDIA=$default_media MEDIA_PLAYER="" elif [[ $player != mpv* ]]; then # the radio and jellyfin are mpv, not "external" MEDIA=$(media_json "$player" "$status" "$artist" "$title" "$art") MEDIA_PLAYER=$player fi } _media_events() { exec playerctl -a --follow metadata \ --format $'media\t{{playerName}}\t{{status}}\t{{artist}}\t{{title}}\t{{mpris:artUrl}}' } refresh() { local line id now for line in "$@"; do [[ $line == media$'\t'* ]] && media_event "${line#media$'\t'}" done # a player that quit without a final metadata line if [[ -n $MEDIA_PLAYER ]] && ! playerctl -l 2>/dev/null | grep -qx "$MEDIA_PLAYER"; then MEDIA=$default_media MEDIA_PLAYER="" fi id=$(current_id) now=$SECONDS if playing; then if [[ $PLAYING != true ]] || ((now - LAST_SONG >= 15)); then PLAYING=true song_playing "$id" LAST_SONG=$now fi elif [[ $PLAYING != false ]]; then PLAYING=false song_idle "$id" fi emit "$(jq -cn --argjson stations "$STATIONS" --argjson radio "$id" \ --argjson paused "$([[ $PLAYING == true ]] && echo 0 || echo 1)" \ --argjson song "$SONG" --argjson media "$MEDIA" \ '{is_paused: $paused, song: $song, radio: $radio, stations: $stations, media: $media}')" local v v=$(get_volume) if [[ -n $v && $v != "$LAST_VOL" ]]; then LAST_VOL=$v eww update radio-vol="$v" 2>/dev/null fi } status_loop() { STATIONS=$(stations) SONG=$default_song MEDIA=$default_media MEDIA_PLAYER="" PLAYING="" LAST_SONG=0 LAST_VOL="" refresh mux_init mux_add _media_events mux_tick 2 mux_loop refresh } case ${1:-} in start) start "$2" ;; stop) stop ;; toggle) if playing; then stop; else start "$(current_id)"; fi ;; vol) set_volume "$2" ;; mute) toggle_mute ;; *) status_loop ;; esac