#!/bin/bash
#
# radio.sh - convenience features for radio streams
# Copyright (C) 2023  Singustromo
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.

readonly version='2.0.9'
readonly contact='singustromo@disroot.org'

workdir="$(realpath "$0")"
readonly workdir="${workdir%/*}"
readonly scriptname="${0##*/}"

declare -r -a dependencies=(mpv socat jq pgrep)
declare -r -a supported_menus=(dmenu zenity fzf)
declare -r -a config_directories=("$HOME/.config/radio-sh" "/etc/xdg/radio-sh" "$workdir")

# these are the default options
declare -A option=(
    [config_filename]="radio_config.json"
    [internal_delimiter]=";"                # internal usage only
    [max_title_length]=40                   # `get title short`
    [menu_cmd]="dmenu"                      # menu used for output
    [menu_sortby_listencount]=1             # how to sort stations in menu
    [zenity_dimensions]="680x360"
    [volume_default]=100
    [volume_maximum]=150                    # allowed volume (interactive)
    [clientname]="radiostream"              # mpv client name (unique ident.)
    [streamer_checktime]=10                 # wait time:stations's listen_count
    [streamer_cachesize]=100MiB
    [streamer_socket]="/tmp/mpvradiosock" ) # mpv's ipc socket

readonly read_options="${RADIO_LOAD_OPTIONS:-1}" # read from config
verbose="${RADIO_VERBOSE:-0}"

configuration="" # Holds the json configuration file
config_file_location="" # set in config_load()
declare -a config_stations=() # set in config_get_stations()
menu_items="" # current menu

# $1: message
exec 6>&1 # redirect fd globally; enables logging in funcs with printf as return
logging() { 
    (( verbose )) && {
        [ -n "$*" ] || return
        local i=2 out="(${FUNCNAME[1]}) " # called function
        while [ -n "${FUNCNAME[$i]}" ]; do # caller functions
            [ "$i" -eq 2 ] && out+="[caller: "
            # recursive functions fix
            [ "$i" -eq 3 ] \
                && [ "${FUNCNAME[$i]}" = "${FUNCNAME[2]}" ] \
                && break
            out+="${FUNCNAME[$i]}<"
            ((i++))
        done
        [ "$i" -gt 2 ] && out="${out::-1}] "
        printf "%s%b\n" "$out" "$1" >&6 # write to fd; important
    }
}

# Prints normal user messages (cli)
# $1: Type: err,error, i, info (optional)
# $2-X: Text
message() {
    local type="$1" output=""

    [ -z "$1" ] && return
    case $1 in
        (e|err|error) output+="Error: "; exec 1>&2; shift ;;
        (i|info) output+="Info: "; shift ;;
    esac

    if [[ "$1" =~ ([0-9a-zA-Z].,;: )* ]]; then
        output+="$1"
        if [ -z "$2" ]; then
            printf "%b\n" "$output"
            return
        else
            output+=' '
            printf "%b" "$output"
        fi
        shift
        message "$@"
    fi
}

# Prints the help to the screen
# $1: invalid option to display additionally
# $2: 'short': Does not display the long help text
# Depends: message(), $scriptname, $config_directories=(...),
#          $version, $contact
usage() {
    [ -n "$1" ] \
        && message err "$scriptname: invalid option -- ${1//-/}"
    [ "$2" == "short" ] \
        && message err "Try '$scriptname --help' for more information." \
        && exit 1

    local config_directory_list="${config_directories[*]}"
    config_directory_list="${config_directory_list// /
        }" # format for output (with newline)

    cat <<EOF
radio (bash) version $version

NAME
    $scriptname - convenience features for radio streams

DESCRIPTION
    The '$scriptname' bash script provides convenience features for radio streams.
    Usage happens through the command line and various menus like dmenu or zenity.
    Command Line arguments only yield results when mpv runs with an ipc-server,
    which makes the script output-friendly for other external scripts without requiring
    a lot of string manipulation.
    This script is aware of it's dependencies, wheter they are files or executables.
    If a dependency is not met, the script aborts with a corresponding hint.

PARAMETERS
    set     vol|volume integer         Set the stream volume (absolute)

    get     station                    Outputs current station name
                                       This information is provided by the stream.
            title [short]              Outputs current stream title
                                       Shows station name if title is empty.
            timepos                    Outputs the current time position (absolute)
                                       Displayed in this format: hh:mm:ss
            timeleft                   Outputs the remaining stream time

    toggle  pause                      Pauses or resumes the stream
            mute                       Mutes the current stream
    stop                               Stops the stream
    seek    [-]Integer                 Seeks the stream in seconds
            [-][h]h:mm:ss              Seeks the stream in given format
            [-][m]m:ss
                                       Examples:
                                         $scriptname seek -90
                                         $scriptname seek -04:21:30
                                         $scriptname seek 4:21:30
                                         $scriptname seek 9:19

    status                             Outputs information whether a
                                       stream is running or not.
                                       Returns a corresponding exit status.

    -h, --help                         Shows this help
    --version                          Displays the version
    --verbose                          Increase verbosity

CONFIGURATION
    The configuration (${option[config_filename]}) is read
    from one of these locations (descending priority):
        $config_directory_list

    config  list                       Prints all options from the
                                       configuration with their current values.
            set <option> <value>       Change the value of an option.
            stations                   Add, modify or remove radio stations

ENVIRONMENT
    Valid values for environment variables are 1 and 0.
    Following environment variables are read by the script:

    RADIO_VERBOSE                      Increase verbosity
    RADIO_LOAD_OPTIONS                 Read options from configuration file
                                       If 'false', default values are used.
                                       (Default: on)

Tested with: bash 5.1.16, jq 1.6
Report bugs or send patches to: '$contact'
EOF
    exit 0
}

# returns the most recent PID of the running mpv instance
# Ideally only one instance runs
# Depends: ${option[clientname]}
get_pid() {
    local pid
    pid="$(pgrep -f "mpv.*${option[clientname]}")"
    logging "pid='$pid'"
    pid="${pid##*$'\n'}" # get most recent one (last line)
    printf "%s" "${pid//'\n'/}" # remove new line
}

# checks for dependencies in global dependencies array
# alternatively, dependencies can be given as arguments
# suffix 'f:' >> check if it is readable file
# suffix 'opt:' >> optional dependency
#   feature_x_enabled will be set to 0 if not available
# Depends: $dependencies=(...), 
depcheck() {
    local dependency_list unmet_dep unmet_file
    local file file_checktype msg_suffix
    local optional_dep

    if [ $# -gt 0 ]; then # dependencies via arguments
        dependency_list="$*"
    elif [ -n "${dependencies[*]}" ]; then
        dependency_list="${dependencies[*]}"
    else
        return 1
    fi

    for dependency in $dependency_list; do
        if [[ "$dependency" =~ ^f[wr]{1}\: ]]; then
            file="${dependency#*:}"
            file_checktype="${dependency:1:1}" # 2nd character

            if eval "[ ! -$file_checktype $file ]"; then
                unmet_file+="$file\n"
            else
                if [ "$file_checktype" == "w" ]; then
                    msg_suffix="writable"
                else
                    msg_suffix="readable"
                fi  
                logging "$file is $msg_suffix."
            fi
        elif [[ "$dependency" =~ ^opt\: ]]; then
            optional_dep="${dependency#opt:}"
            if [ -x "$(command -v "$optional_dep")" ]; then
                eval "feat_${optional_dep}_enabled=1"
                logging "$optional_dep binary found. Feature enabled."
            else
                eval "feat_${optional_dep}_enabled=0" # Disable feature
                logging "$optional_dep binary not found. Feature disabled."
            fi
        elif [ -x "$(command -v "$dependency")" ]; then
            logging "$dependency binary found."
        else
            logging "$dependency binary not found."
            unmet_dep+="$dependency "
        fi
    done

    dep_error_packages() { [ "$unmet_dep" != "" ]; }
    dep_error_files() { [ "$unmet_file" != "" ]; }

    dep_error_files \
        && message err "Following files are not readable (or writable):\n$unmet_file"

    if dep_error_packages; then
        unmet_dep="missing dependencies:\n${unmet_dep}"
        dep_error_files \
            && unmet_dep="\n${unmet_dep}" # Add padding (newline)
        message err "$unmet_dep"
    fi

    { dep_error_packages || dep_error_files; } && exit 1
}

# creates an empty configuration file
# Depends: logging(), $configuration, $config_directories=(...),
#          $config_file_location, ${option[config_filename]}
config_create() {
    configuration="$(printf '{"option": { },"stations": []}' | jq)"
    for dir in "${config_directories[@]}"; do
        [ ! -d "$dir" ] && { mkdir -p "$dir" || continue; }
        [ ! -w "$dir" ] && continue
        config_file_location="$dir/${option[config_filename]}"
        logging "$dir is writable. Creating empty configuration."
        logging "Set config_file_location = $config_file_location"
        printf "%s" "$configuration" > "$dir/${option[config_filename]}" \
          && logging "Wrote to $dir/${option[config_filename]}"
        return "$?"
    done
    return 1
}

# loads the configuration contents into the public $configuration variable
# Depends: logging(), ${option[config_filename]}, $configuration
config_load() {
    local config_file=""
    local filename="${option[config_filename]}"

    for directory in "${config_directories[@]}"; do
        if [ -r "$directory/$filename" ]; then
            config_file="$directory/$filename"
            logging "Selected '$config_file'"
            break
        fi
    done
    
    if [ -n "$config_file" ]; then
        if configuration="$(cat "$config_file")"; then
          logging "Configuration file loaded successfully"
          config_file_location="$config_file"
          return 0
        else
          logging "Unable to load the configuration file"
        fi
    else
      logging "No configuration file found"
    fi
    return 1
}

# saves the $configuration contents to file
# checks if it is a valid JSON
# Depends: logging(), config_load(), $configuration, $config_file_location
config_save() {
    logging "Saving to $config_file_location"
    if printf '%b' "$configuration" | jq >/dev/null 2>&1 \
        && [ -n "$configuration" ]
    then
        cp "$config_file_location" "$config_file_location.bak" \
            && printf "%s\n" "$configuration" > "$config_file_location"
    else
        logging "There was an error. Restoring old configuration."
        config_load || exit 1
        return 1
    fi
}

# removes an option from the configuration file
# $1: option_name
# Depends: logging(), config_save(), $configuration
config_remove_option() {
    local option_name="$1" configuration_new
    logging "Option removal: '$option_name'"
    configuration_new="$(printf "%s" "$configuration" \
                         | jq "del(.option.$option_name)")"
    if [ "$configuration_new" != "$configuration" ]; then
        logging "Option successfully removed"
        configuration="$configuration_new"
        config_save
    else logging "Configuration is the same."
    fi
}

# sets the corresponding option
# TODO: validation in separate function
# $1: option_name
# $2: value
# returns 1 if option_name or value is invalid
# returns 2 if value is current
# returns 3 if integer value is malformed
# Depends: -A $option=([..]='..',...), $workdir, $scriptname,
#          supported_menus=(...), config_remove_option(), config_save()
config_set_option() {
    local option_name="$1"
    local value="$2"
    local valid=0
    local config_new

    [ -z "$value" ] || [ "$value" == " " ] && return 1

    for opt in "${!option[@]}"; do # valid option name?
        if [ "$option_name" == "$opt" ]; then
            (( valid++ ))
            break
        fi
    done
    [ "$valid" -eq 0 ] && return 1

    # Check value validity
    # We don't want to add an option if value is the same as default.
    if [ "$value" == "$(eval "$workdir/$scriptname _option-default $option_name")" ]; then
        config_remove_option "$option_name" # remove old entry from configuration
        return 2
    fi

    if [ "$option_name" == "menu_cmd" ]; then
        for menu in "${supported_menus[@]}"; do
            if [ "$value" == "$menu" ]; then
                (( valid++ ))
                break
            fi
        done
    elif [ "$option_name" == "zenity_dimensions" ]; then
        [[ "$value" =~ ^[0-9]+x[0-9]+$ ]] && (( valid++ ))
    elif [[ "$option_name" =~ volume_maximum|max_title_length|volume_default ]]; then
        if [[ "$value" =~ [0-9]+ ]]; then # Should be a number
            (( valid++ ))
        else
            return 3
        fi
    else
        (( valid++ ))
    fi

    if [ "$valid" -gt 1 ]; then
        logging "Set configuration option: '$option_name' = '$value'"
        if [[ "$value" =~ ^[0-9]+$ ]]; then
            config_new="$(printf "%s" "$configuration" \
                          | jq '.option += { "'"$option_name"'": '"$value"' }' 2>&-)"
        else # it is a string, assign as such
            config_new="$(printf "%s" "$configuration" \
                          | jq '.option += { "'"$option_name"'": "'"$value"'" }' 2>&-)"
        fi
    else
        return 1
    fi

    configuration="$config_new"
    config_save
    return 0
}

# prints the corresponding option values from the json configuration
# If multiple (separated by space) are given as parameters,
# they are separated by a new line.
# Valid options are the ones in the associative array (beginning of script)
# If requested option is invalid, it returns 'nil' in respective line.
# Depends: $configuration 
config_get_option() {
    local options="$*"
    local return_values jq_query jq_result
    local option_values=()
    
    logging "params: $options" 
    (( $# )) || return
    jq_query=".${options// /,.}"
    jq_result="$(printf "%s" "$configuration" \
                 | jq -r '.option | '"$jq_query")"
    # jq returns null for unset options

    local i=0
    for result in $jq_result; do
        # assign result to an empty field
        option_values[$i]="$result"
        (( i++ ))
    done

    return_values="${option_values[*]}" # has to be separate
    printf "%b\n" "${return_values// /\\n}"
}

# Returns all options with respective value
# Configuration overwrites the default values
# One option per line with the corresponding value and delimiter in between
# Depends: -A option=([..]='..',...), config_get_option(),
#          ${option[internal_delimiter]}
config_get_options_all() {
    local optionlist name value option_names option_values
    
    option_names="${!option[*]}"
    option_values="$(config_get_option "$option_names")"
    option_names="${option_names// /\\n}" # replace spaces

    # combine both into 'name;value' per line
    # Don't return options with the value 'null'
    local line=1
    while IFS="${option[internal_delimiter]}" read -r value; do
        name="$(printf "%b" "$option_names" | sed "${line}q;d")"
        (( line++ ))
        # non-exposed options
        [[ "$name" =~ internal_delimiter|streamer_args|config_filename ]] \
            && { logging "$name not exposed"; continue; }
        optionlist+="${name}${option[internal_delimiter]}"
        if [ "$value" != "null" ]; then # ignore nonexistant options
            optionlist+="$value\n"
        else # get value from array
            logging "$name not set. Using default: ${option[$name]}"
            optionlist+="${option[$name]}\n"
        fi
    done <<< "$option_values"
    
    printf "%b" "$optionlist"
    return 0
}

# loads the options from the configuration file
# into the $option associative array
# Depends: config_get_options_all(), ${option[internal_delimiter]},
#          logging(), -A option=(), 
# TODO reduce execution time (currently 35% of total exec time)
config_load_options() { 
    local optionlist
    logging "Loading options from configuration"
    optionlist="$(config_get_options_all)"
    [ -z "$optionlist" ] && return 1

    while IFS="${option[internal_delimiter]}" read -r option_name value; do
        # ignore invalid options or default values
        if [ "$value" != "${option[$option_name]}" ]; then
            [ "$value" == null ] || [ "$value" == " " ] \
                && continue
            logging "Assigning $option_name = \"$value\""
            option["$option_name"]="$value"
        fi
    done <<< "$optionlist"
}

# prints the options with respective values
# Depends: config_get_options_all(), message(), ${option[internal_delimiter]}
config_display_optionlist() {
    local optionlist
    optionlist="$(config_get_options_all | sort)"
    message "Current configuration:\n${optionlist//${option[internal_delimiter]}/ = }"
    exit 0
}

# Prints station tuples to stdout, one pair per line
# returns true, if jq had any output
# Depends: ${option[menu_sortby_listencount]}, ${option[internal_delimiter]},
#          $configuration, -a config_stations()
config_get_stations() {
    local stations jq_cmd delimiter="${option[internal_delimiter]}"
    jq_cmd="jq -j '.stations | "

    if [ "${option[menu_sortby_listencount]}" -eq 1 ]; then
        logging "sorting by listen count"
        jq_cmd+="sort_by(.listen_count) | reverse | .[] | "
    else
        jq_cmd+="sort_by(.name) | .[] | "
    fi

    jq_cmd+="(.name + \"$delimiter\"), (.url + \"\n\")'"
    stations="$(printf "%s" "$configuration" \
                | eval "$jq_cmd 2>&-")"

    while IFS=$'\n' read -r line; do
        config_stations+=("${line%$delimiter*}" "${line#*$delimiter}")
    done <<< "$stations"

    [ -n "$stations" ] && return 0 || return 1
}

# Adds a station to the configuration variable content
# When $1 ist 'raw' it accepts raw json to add to .stations[]
# Also saves the configuration
# returns 1 if station already exists
# $1: name
# $2: url
# $3: listen_count (optional)
# Depends: logging(), $configuration
config_add_station() {
    local name="$1"
    local url="$2"
    local listen_count="${3:-0}"
    local config_new

    # Check if a station with that name already exists
    if printf "%s" "$configuration" \
        | jq -e --arg name "$name" '.stations[] | select(.name == $name)' >/dev/null 2>&1; then
        return 1
    fi

    if [ "$1" == "raw" ]; then
        shift
        logging "Add Station (raw):\n$1"
        config_new="$(printf "%s" "$configuration" \
                      | jq '.stations += ['"$1"']')"
    else
        logging "Add Station:\nName: '$name'\nUrl: '$url'"
        config_new="$(printf "%s" "$configuration" \
                      | jq '.stations += [{ "name": "'"$name"'", "url": "'"$url"'", "listen_count": '"$listen_count"' }]')"
    fi

    configuration="$config_new"
    return 0
}

# Removes a station by it's name
# returns COMPLETE json without station
# $1: name
# Depends: $configuration
config_remove_station() {
    local name="$1"
    logging "removing '$1'"
    printf "%s" "$configuration" \
        | jq 'del(.stations[] | select(.name == "'"$name"'"))'
}

# Update a station by it's name
# and saves the config file
# $1: name
# $2: what to update
# $3: value
# Depends: $configuration, logging(), config_remove_station(),
#          config_add_station(), config_save()
config_update_station() {
    local name="$1"
    local type="$2"
    local value="$3"
    local station_modified command config_backup jq_cmd

    jq_cmd="jq '.stations[] | select(.name == \"$name\") | "
    case "$type" in
        listen_count)
            jq_cmd+=".listen_count += 1'" ;;
        name|url)
            jq_cmd+=".$type = \"$value\"'" ;;
        *) return 1 ;; # indicate error
    esac
    station_modified="$(printf "%s" "$configuration" | eval "$jq_cmd" )"

    config_backup="$configuration"
    logging "Update Station: '$name'"
    configuration="$(config_remove_station "$name")" # remove old station

    # now write modified station to file by 'adding the station'
    if [ -n "$station_modified" ] && config_add_station raw "$station_modified"; then
        config_save
    else
        configuration="$config_backup"
    fi
}

# Clears the menu items
# Depends: logging(), $menu_items
menu_clear() { logging ""; [ -n "$menu_items" ] && menu_items=""; }

# Inserts commands with description (recursive)
# arguments: <text command> [text command] ..
# Depends: logging(), $menu_items
menu_insert() {
    local text="$1" command="${2:-}" # command defaults to no action ''
    logging "\n\t'$text' cmd:'$command'"
    menu_items+="${text}${option[internal_delimiter]}$command\n"

    if [ -n "$3" ]; then
        shift; shift
        menu_insert "$@"
    fi
}

# opens the chosen menu
# $1: text (selection); - for none
# $2: type: list, entry; for none
# $3: title
# Depends: $scriptname, $version, $menu_items,
#          ${option[menu_cmd,zenity_dimensions]}
menu_open() {
    # specifying only one parameter (or via stdin) sets 'text',
    # therefore 'type' and 'text' have default values
    local text="${1:-}"
    local type="${2:-list}"
    local title="${3:-$scriptname ${version%.*} }"
    local item returnval
    local menu_cmd menu_printed

    logging "params: $*"
    [ "$type" == '-' ] && type="list"
    [ "$text" == '-' ] && text=""

    # prepare for display
    if [ -z "$text" ]; then
        while IFS=$'\n' read -r station; do
            text+="${station%"${option[internal_delimiter]}"*}\n"
        done <<< "$(printf '%b' "$menu_items")"
    fi

    case "${option[menu_cmd]}" in
        "dmenu")
            case "$type" in
                "list")
                    menu_cmd="printf \"%b\" \"$text\" | dmenu -l 10"
                    item="$(eval "$menu_cmd -p \"$title\"")" ;;
                "entry")
                    menu_cmd="printf \"\" | dmenu -l 10"
                    item="$(eval "$menu_cmd -p \"${text//'\n'/ | }\"")" ;;
            esac ;;
        "zenity")
            menu_cmd="zenity \
                --title=\"$title\" \
                --width=${option[zenity_dimensions]//x*/} \
                --height=${option[zenity_dimensions]//*x/}"

            case "$type" in
                "list")
                    text="${text//'\n'/\" \"}" # needs to be replaced
                    text="${text%\" \"}" # remove trailing empty list line
                    item="$(eval "$menu_cmd --list \
                           --column \"$title\" \"$text\"")" ;;
                "entry")
                    item="$(eval "$menu_cmd --entry \
                           --text=\"$text\"")" ;;
            esac ;;
        "fzf")
            item="$(printf "%b" "$text" \
                    | fzf --layout=reverse --no-sort --header="$title" )"
            ;;
    esac

    menu_printed="$(printf '%b' "$menu_items")"
    [ "${#item}" -eq "${#menu_printed}" ] || [ -z "$item" ] \
        && return 1 # Selected nothing

    # Menu was filled via menu_insert
    if [ -n "$menu_items" ]; then
        returnval="$(printf "%b" "$menu_items" | grep "$item")"
        menu_clear
    fi
    logging "return: '${returnval:-"$item"}'"
    printf "%s" "${returnval:-"$item"}" # return selection to caller
}

# Checks if socket is available
# $1: 'quiet' : does not complain or exit.
# Depends: ${option[streamer_socket]}, message()
socket_available() {
    if ! socat -lf /dev/null -u OPEN:/dev/null UNIX-CONNECT:"${option[streamer_socket]}"; then
        logging "not available"
        [ "$1"  != "quiet" ] \
            && message err "'${option[streamer_socket]}' is not writable nor readable." \
                           "Is a radio stream running?" \
                && exit 1
        return 1
    fi
}

# $1: action (start, stop, restart)
# $2: resource (the url to open)
# $3: volume (initial volume; optional)
# returns 1 if the called mpv instance is not running
# Depends: message(), logging(), get_pid(), socket_available(), $streamer_cmd,
#          ${option[streamer_socket]}, ${option[streamer_checktime]}
#          ${option[volume_default]}}, ${option[streamer_cachesize]},
streamer() {
    local action="$1"
    local resource="$2"
    local volume="${3:-${option[volume_default]}}"
    local streamer_pid_initial
    local socket_file="${option[streamer_socket]}"
    
    logging "\n\taction: $action\n\turl: '$resource'\n\tvolume: $volume"
    case "$action" in
        "start")
            [ -z "$resource" ] && return 1 # a url is needed
            if [ ! -w "${socket_file%/*}" ]; then
                message err "The directory for the socket is not writable!"
                return 1
            fi
            eval "$streamer_cmd \
                  --volume=$volume \
                  --demuxer-max-bytes=${option[streamer_cachesize]} \
                  $resource >&- & disown"

            sleep 2 && streamer_pid="$(get_pid)"

            logging "Streamer: Waiting ${option[streamer_checktime]}s for check"
            sleep "${option[streamer_checktime]}" # wait some seconds for check
            streamer_pid_initial="$streamer_pid" # save initial pid
            streamer_pid="$(get_pid)"

            if [ -z "$streamer_pid" ]; then # User may have stopped the stream
                logging "The stream '$resource' is not available."
                return 1
            elif [ "$streamer_pid" != "$streamer_pid_initial" ]; then
                logging "Streamer: User changed the station."
                return 1
            fi ;;
        "stop") # close gracefully, if socket available
            if socket_available quiet; then
                printf "%s\n" "quit" \
                | eval "$socketcmd"
            elif [ -n "$streamer_pid" ]; then
                kill -SIGTERM "$streamer_pid"
            else
                return 1
            fi
            return 0
            ;;
        "restart") # triggers a recursive call to 'start' after previous instance has stopped
            streamer stop \
                && streamer start "$resource" "$volume" ;;
    esac
}

# sets a property
# $1: property; $2: value
# Depends: socket_available(), $socketcmd
set_property() {
    [ -z "$1" ] || [ -z "$2" ] && return 1
    socket_available && {
        logging "'$1' = '$2'"
        printf "{ \"command\": [\"set_property\", \"%s\", \"%s\"] }\n" "$1" "$2" \
        | eval "$socketcmd" \
        | jq ".error" 2>&-
    }
}

# retrieve a property and return JSON data
# accepted properties: mute, pause, loop-file, estimated-frame-number, width, height
#                      filename, idle-active, playlist-count, playlist-pos, playback-time,
#                      playtime-remaining, time-remaining, percent-pos, duration, volume,
#                      file-format, metadata, filtered-metadata, audio-codec, audio-codec-name,
#                      audio-params, audio-out-params, audio-bitrate, current-ao
# Depends: socket_available(), $socketcmd
get_property() {
    [ -z "$1" ] && return 1 
    socket_available quiet && {
        printf '%s\n' "{ \"command\": [\"get_property\", \"${1}\"] }" \
        | eval "$socketcmd"\
        | jq '.data' 2>&-
    }
}

# prints station name
# Depends: get_property()
get_station_name() {
    local station
    station="$(get_property "metadata" \
               | jq '.["icy-name"]' 2>&-)"
    logging "unformatted: '$station'"
    if [ "$station" == "null" ]; then
        station="N/A"
    else
        station="${station//\"/}" # remove "
    fi
    printf "%s" "$station"
}

# prints current title
# $1: 'short' = shorten, if needed
# Depends: get_property(), ${option[max_title_length]}
get_stream_title() {
    local shorten=${1:-}
    local title title_len="${option[max_title_length]}"
    title="$(get_property "metadata" \
             | jq '.["icy-title"]' 2>&-)"
    logging "unformatted: '$title'"

    title="${title//'\n'/}" # remove new lines
    title="${title//\"/}"   # remove "
    title="${title##' - '}" # remove leading string
    title="${title//\\/}"   # remove \
    [ "$title" == "null" ] \
        && title="N/A"

    if [ "${#title}" -gt "$title_len" ] \
        && [ "$shorten" == "short" ]; then
        title="${title:0:$title_len}"
        printf "%s" "${title%% }.." # remove trailing space
    else
        printf "%s" "$title"
    fi
}

# prints the time as formatted string
# $1: property
# Depends: get_property()
get_stream_time() {
    local time
    time="$(get_property "$1" \
            | tr '"' ' ' \
            | cut -d'.' -f 1)"
    logging "unformatted: '$time'"
    time="${time// /}" # remove spaces (tr)
    [[ "$time" =~ ^[0-9]+$ ]] && # suppress socket_available errors
        printf '%02d:%02d:%02d' $((time/3600)) $((time%3600/60)) $((time%60))
}

# changes the current stream volume
# $1: volume (integer) or 'interactive'
# Depends: socket_available(), logging(), get_property(), set_property(), message(),
#          menu_open(), ${option[volume_maximum]}
command_ipc_volume() {
    local input="$1"
    local type="$input"
    local current_vol prefix
    socket_available && {
        logging "Change volume ~ Get user input (Type: $type)"
        while :; do
            if [ "$type" == "interactive" ]; then
                current_vol="$(get_property volume)"
                logging "Opening menu.."
                input="$(menu_open "Volume: $current_vol% (Maximum: ${option[volume_maximum]})" "entry")"
                logging "Change volume ~ User input: '$input'"
            fi

            [ "$input" == "" ] && break
            input="${input//\%/}" # remove a possible percent sign

            if [[ "$input" =~ ^[0-9]+$ && # Is a number
                      "$input" -le ${option[volume_maximum]} &&
                      "$input" != "" ]]
            then
                if [ "$input" != "$current_vol" ]; then
                    set_property "volume" "$input" \
                        && logging "Volume set to '$input'"
                    return
                else # same volume level is also valid input
                    break
                fi
            else
                message err "Value should be between 0 and ${option[volume_maximum]}"
                [ "$type" != "interactive" ] && break # command line: stop here
            fi
        done
    }
}

# seeks the current stream through ipc
# $1: time to seek; either as integer or hh:mm:ss
#     or 'interactive' for interactive use
# returns 1 if time is malformed
# Depends: socket_available(), logging(), menu_open(), get_stream_time(),
#          $socketcmd
command_ipc_seek() {
    local input="$1"
    local prefix
    local rerun=1

    [ -z "$input" ] && return 1
    logging "params: $input"

    socket_available && {
        while [ $rerun -eq 1 ]; do
            if [ "$input" == "interactive" ]; then
                logging "Opening menu.."
                time="$(menu_open "Seek Time\nBwd: $(get_stream_time time-pos)\nFwd: $(get_stream_time time-remaining)" "entry")"
                logging "Seek ~ User input: '$time'"
            else # was called through CLI
                time="$input"
                rerun=0
            fi

            [ "$time" == "" ] && return
            prefix="${time:0:1}" # time has a sign?
            [ "$prefix" == '-' ] && time="${time:1}" # strip sign

            if [[ "$time" =~ ^([0-9]{1,2}:){1}[0-9]{2}$ ]]; then # [m]m:ss
                time="$(printf "%s" "$time" \
                        | awk -F ":" '{ printf "%d", $1*60 + $2 }')"
                rerun=0
            elif [[ "$time" =~ ^[0-9]{1,2}:[0-9]{2}:[0-9]{2}$ ]]; then # [h]h:mm:ss
                time="$(printf "%s" "${time}" \
                        | awk -F ":" '{ printf "%d", $1*3600 + $2*60 + $3 }')"
                rerun=0
            elif [[ "$time" =~ ^[0-9]+$ ]]; then
                rerun=0
            else
                return 1
            fi
        done

        [ "$time" -eq 0 ] && return 1
        [ "$prefix" == '-' ] && time="-$time"
        printf "seek %d\n" "$time" \
            | eval "$socketcmd"
    }
}

# toggles respective option
# Depends: socket_available(), $socketcmd
command_ipc_toggle() {
    [ -z "$1" ] && return 1
    socket_available \
        && printf "cycle %s\n" "$1" \
           | eval "$socketcmd"
}

# gets input from user
# $1: prompt
# $2: type (name, url, choice); default: name
# Depends: message()
ia_user_input() {
    local answer
    while :; do
        if [ "$2" == "choice" ]; then
            read -r -p "$1 [Y/n] : " answer
            case $answer in
                [yY]|[yY]es|"") return 0
                                break;;
                [nN]|[nN]o) return 1
                            break;;
                * ) message err "Invalid input." ;;
            esac
        else
            read -r -p "$1: " answer
            case "${2:-name}" in
                "name")
                    if [[ "$answer" =~ ^[a-zA-Z0-9\)\(\]\[' ']* ]]; then
                        printf "%s" "$answer"
                        break
                    else
                        message err "Invalid input."
                    fi ;;
                "url") printf "%s" "$answer"
                       break ;;
            esac
        fi
    done
}

# Lets the user interactively modify the station list
# Depends: config_get_stations(), message(), config_add_station(),
#          config_remove_station(), config_update_station(),
#          ia_user_input(), config_save(), ${option[internal_delimiter]}
ia_mod_stations() {
    # Exit immediately on several Signals
    trap exit SIGINT SIGHUP SIGQUIT SIGTERM

    local prompt='> '
    local message="\nSelect an item by the index number."
    local mode station_name station_url stations_raw
    local selection=() stations=()
    PS3="$prompt"

    # Only show other options if we have at least one station
    if config_get_stations; then
        selection=("> Add" "> Remove" "> Rename" "> Change URL" "> Quit")
        message+="\nAn action must be selected before you select a station."
    else
        selection=("> Add" "> Quit")
    fi
    local counter=0
    for station in "${config_stations[@]}"; do
        if [ $(( counter % 2)) -eq 0 ]; then
            selection+=("$station")
        fi
        (( counter++ ))
    done

    message "$message"
    select fav in "${selection[@]}"; do
        case $fav in
            "> Add")
                station_name="$(ia_user_input "Please enter the station name")"
                station_url="$(ia_user_input "Please enter the station url" url)"
                if config_add_station "$station_name" "$station_url"; then
                    config_save
                    message info "Added Station '$station_name'"
                    ia_mod_stations
                else
                    message err "A station with that name already exists."
                fi ;;
            "> Remove")
                PS3="(Removal) $prompt"
                mode="remove" ;;
            "> Rename")
                PS3="(Rename) $prompt"
                mode="rename" ;;
            "> Change URL")
                PS3="(Change URL) $prompt"
                mode="mod_url" ;;
            "> Quit") exit 0;;
            *) [[ ! "$REPLY" =~ ^[0-9]+$ ]] && continue # only accept numbers
               local station_name="${selection[$(( REPLY-1 ))]}"
            case "$mode" in
                "remove")
                    if ia_user_input "Really remove '$station_name'?" choice; then
                        configuration="$(config_remove_station "$station_name")"
                        config_save
                        message info "Removed Station '$station_name'"
                        ia_mod_stations
                    fi ;;
                "rename")
                    message "Current Station name:\n'$station_name'\n"
                    station_name_new="$(ia_user_input "Please enter the new station name")"
                    config_update_station "$station_name" name "$station_name_new"
                    message info "Changed Station Name to '$station_name_new'"
                    ia_mod_stations
                    ;;
                "mod_url")
                    message "Changing the URL of the Station '$station_name'\n"
                    station_url="$(ia_user_input "Please enter the new station url")"
                    config_update_station "$station_name" url "$station_url"
                    message info "Changed URL to '$station_url'"
                    ;;
                *) message "Please select an action first."
            esac
            PS3="$prompt"
            mode=""
            ;;
        esac
    done
}

#######################################
###              MAIN               ###
#######################################

[ "$1" == '--verbose' ] \
    && readonly verbose=1 && shift
case "$1" in
    ('-h'|'--help') usage ;;
    ("--version") printf "%s %s\n" "$scriptname" "$version"; exit 0 ;;
    ("_option-default") # INTERNAL: print default value of an option
        [ -z "$2" ] && exit 1
        printf "%s" "${option[$2]}"; exit 0 ;;
esac

depcheck

if config_load && (( read_options )); then
    config_load_options
else
    message err "A configuration file named" \
                "'${option[config_filename]}' does not exist."
    if ia_user_input "Do you want to create one?" choice; then
        config_create
        ia_user_input "Open the manager to add a station?" choice \
            && ia_mod_stations
        exit 0
    else
        exit 1
    fi
fi

depcheck fw:"$config_file_location" # writable?

# These can be set after config was loaded
option[streamer_args]="--input-ipc-server='${option[streamer_socket]}'\
                       --audio-client-name=${option[clientname]}"
streamer_cmd="mpv ${option[streamer_args]} 2>&-"
socketcmd="socat - ${option[streamer_socket]} 2>&-"
streamer_pid="$(get_pid)" # Needed for check

declare -A display_valid_args=( \
    [get]="station title timepos timeleft volume"
    [set]="vol(ume)"
    [toggle]="pause"
    [config]="set list stations")
case $1:$2:$3:$4 in
    (get:station::) get_station_name ;;
    (get:title:*:) get_stream_title "$3" ;;
    (get:volume::) get_property volume 2>&- ;;
    (get:timepos::) get_stream_time "time-pos" ;;
    (get:timeleft::) get_stream_time "time-remaining" ;;
    ('set':vol:*: | 'set':volume:*:) command_ipc_volume "$3" ;;
    (toggle:pause::) command_ipc_toggle pause;;
    (toggle:mute::) command_ipc_toggle mute ;;
    (stop:::) streamer stop ;;
    (seek:*::)
        if command_ipc_seek "$2"; then
            message "$(get_stream_time "time-pos") / $(get_stream_time "time-remaining")"
        else
            message err "Please enter a positive or negative number or [H]H:MM:SS, [M]M:SS"
            exit 1
        fi
        ;;
    (conf:list:: | config:list::) config_display_optionlist ;;
    (conf:stations:: | config:stations::) ia_mod_stations ;;
    (conf:set:*:* | config:set:*:*)
        config_set_option "$3" "$4"
        case "$?" in
            0 ) message info "Configuration changed successfully." ;;
            1 ) if [[ "$3" =~ menu_cmd ]]; then
                    message err "Valid values for menu_cmd are: ${supported_menus[*]}"
                else
                    message err "Invalid option name or value."
                fi ;;
            2 ) message info "Changed to the default value of '$3'." ;;
            3 ) message err "The value for '$3' should be an integer." ;;
        esac
        exit 1
        ;;
    (status:::)
        if socket_available quiet || [ -n "$streamer_pid" ]; then
            message info "Stream active. (PID: $streamer_pid)"
            exit 0
        else
            message info "Stream inactive."
            exit 1
        fi
        ;;
    (:*) ;; # no parameters (first param is null)
    (*)
        # parameter alias: config
        # only for following usage of $1 ..
        if [ "$1" == 'conf' ]; then 
            global_params="${*/conf/config}" # mod. global param array
            eval "set -- $global_params"
            unset global_params
        fi

        if [ -n "${display_valid_args[$1]}" ]; then
            message err "Accepted arguments for '$1': ${display_valid_args[$1]// /, }"
        else
            usage "$1" short
        fi
        exit 1
        ;;
esac
[ "$#" -gt 0 ] && exit 0 # nothing additional to do

depcheck "${option[menu_cmd]}"

if ! config_get_stations; then
    message err "No stations configured. Please add at least one." \
                "\nType $scriptname --help to get more information."
    exit 1
fi

status_pause=""
display_title=""

# Add controls and playback information to menu
# TODO: This block (get commands) takes up considerable exec time
if socket_available quiet && [ -n "$streamer_pid" ]; then
    status_pause="$(get_property "pause")"
    display_title="$(get_station_name) :: $(get_stream_title short)"

    if [ "$status_pause" == "false" ]; then
        menu_insert "Now playing: $display_title" "" \
                    "> Pause Playback" "pause"
    else
        menu_insert "Paused: $display_title" "" \
                    "> Unpause Playback" "pause"
    fi
    menu_insert "> Stop Playback" "stop" \
                "> Seek Stream" "seek" \
                "> Set volume" "volume" \
                " " # spacer
fi

menu_insert "${config_stations[@]}"
choice="$(menu_open)" || exit 0

station="${choice%"${option[internal_delimiter]}"*}"
command="${choice#*"${option[internal_delimiter]}"}"
logging "Choice: '$station' cmd: '$command'"

case "$command" in
    (pause|unpause) command_ipc_toggle pause ;;
    (stop) streamer stop ;;
    (volume) command_ipc_volume interactive ;;
    (seek) command_ipc_seek interactive ;;
    (""|" ") exit 0 ;; # do nothing.
    (*) if [ -n "$streamer_pid" ]; then
            currentVolume="$(get_property "volume")"
            streamer_action="restart"
        fi

        { streamer "${streamer_action:-start}" "$command" "${currentVolume:-}" \
            && config_update_station "$station" listen_count; } &
        ;;
esac
