Last active
February 18, 2026 09:50
-
-
Save mpalpha/6845807a40e48b683aea237df23f49ee to your computer and use it in GitHub Desktop.
MiSTer Emby Client (CRT-Optimized, Interactive Search, Auto Cleanup) : A lightweight Emby client for MiSTer FPGA that streams media via HLS and plays it using mplayer.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Emby server | |
| EMBY_URL="http://192.168.1.50:8096" | |
| EMBY_API_KEY="PASTE_KEY" | |
| USER_ID="PASTE_USER_ID" | |
| DEVICE_ID="MiSTer-Emby-SAM-001" | |
| # Transcode caps (stream complexity caps) | |
| MAX_WIDTH=320 | |
| MAX_HEIGHT=240 | |
| VIDEO_BITRATE=800000 | |
| AUDIO_BITRATE=128000 | |
| VIDEO_CODEC="h264" | |
| AUDIO_CODEC="aac" | |
| AUDIO_CHANNELS=2 | |
| # SAM playback mode | |
| SAM_VIDEO_OUTPUT="crt" # "crt" or "hdmi" | |
| SAM_VIDEO_SOURCE_HINT="youtube" # keep "youtube" to make SAM use samvideo_crtmode320 | |
| samvideo_crtmode320="video_mode=320,-16,32,32,240,1,3,13,5670" | |
| # Optional: override framebuffer size used by vmode rgb32 | |
| # SAM_FB_RES="320 240" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # emby.sh — MiSTer Emby Client | |
| # Menu on HDMI (keyboard input) | |
| # Video playback on CRT/BVM via SAM video pipeline (same as MiSTer Plex) | |
| # | |
| # Requires: curl, jq, MiSTer SAM (provides mplayer + mbc) | |
| # Config: /media/fat/Scripts/emby.conf | |
| set -o pipefail | |
| # Debug log (append only, doesn't redirect stdin/stdout) | |
| LOGFILE="/tmp/emby_debug.log" | |
| log() { echo "[$(date +%H:%M:%S)] $*" >> "$LOGFILE"; } | |
| SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" | |
| CONF="${SCRIPT_DIR}/emby.conf" | |
| SAM_ON="/media/fat/Scripts/MiSTer_SAM_on.sh" | |
| # ---- Dependency checks ---- | |
| need() { command -v "$1" >/dev/null 2>&1 || { echo "Missing: $1"; exit 1; }; } | |
| need curl | |
| need jq | |
| [[ -f "$CONF" ]] || { echo "Missing config: $CONF"; exit 1; } | |
| # shellcheck disable=SC1090 | |
| source "$CONF" | |
| : "${EMBY_URL:?Set EMBY_URL in $CONF}" | |
| : "${EMBY_API_KEY:?Set EMBY_API_KEY in $CONF}" | |
| : "${USER_ID:?Set USER_ID in $CONF}" | |
| : "${DEVICE_ID:?Set DEVICE_ID in $CONF}" | |
| # Transcode defaults | |
| MAX_WIDTH="${MAX_WIDTH:-640}" | |
| MAX_HEIGHT="${MAX_HEIGHT:-240}" | |
| VIDEO_BITRATE="${VIDEO_BITRATE:-800000}" | |
| AUDIO_BITRATE="${AUDIO_BITRATE:-128000}" | |
| VIDEO_CODEC="${VIDEO_CODEC:-h264}" | |
| AUDIO_CODEC="${AUDIO_CODEC:-aac}" | |
| AUDIO_CHANNELS="${AUDIO_CHANNELS:-2}" | |
| # CRT video_mode for playback — 640x240p (NTSC 240p timing) | |
| # H=800 total, V=258 total, 12.38MHz pixel clock ≈ 15.47kHz H / 59.98Hz V | |
| CRT_VIDEO_MODE="${CRT_VIDEO_MODE:-640,16,64,80,240,1,3,14,12380}" | |
| INI_FILE="/media/fat/MiSTer.ini" | |
| INI_TEMP="/tmp/emby_mister_ini_temp" | |
| INI_MOUNTED=0 | |
| # ---- SAM mplayer + mbc (binaries only) ---- | |
| [[ -f "$SAM_ON" ]] || { echo "Error: MiSTer SAM not installed ($SAM_ON missing)."; exit 1; } | |
| # shellcheck disable=SC1090 | |
| source "$SAM_ON" --source-only | |
| if [[ ! -f "${mrsampath}/mplayer" || ! -f "${mrsampath}/mbc" ]]; then | |
| echo "Downloading SAM video components..." | |
| get_samvideo || { echo "Error: get_samvideo failed."; exit 1; } | |
| get_mbc || { echo "Error: get_mbc failed."; exit 1; } | |
| fi | |
| # ---- Internal state ---- | |
| PLAY_SESSION_ID="" | |
| MEDIA_SOURCE_ID="" | |
| # ---- URL encode (jq) ---- | |
| urlenc() { jq -rn --arg v "$1" '$v|@uri'; } | |
| # ---- Emby API ---- | |
| emby_get() { | |
| local resp | |
| resp="$(curl -fsS -H "X-Emby-Token: ${EMBY_API_KEY}" "${EMBY_URL%/}/$1" 2>&1)" || { | |
| echo "API error: $1" >&2 | |
| return 1 | |
| } | |
| printf '%s' "$resp" | |
| } | |
| emby_post() { | |
| local resp | |
| resp="$(curl -fsS -X POST \ | |
| -H "Content-Type: application/json" \ | |
| -H "X-Emby-Token: ${EMBY_API_KEY}" \ | |
| -H "X-Emby-Device-Id: ${DEVICE_ID}" \ | |
| -H "X-Emby-Client: MiSTerEmby" \ | |
| "${EMBY_URL%/}/$1" \ | |
| -d "$2" 2>&1)" || { | |
| echo "API error: POST $1" >&2 | |
| return 1 | |
| } | |
| printf '%s' "$resp" | |
| } | |
| emby_delete() { | |
| curl -fsS -X DELETE -H "X-Emby-Token: ${EMBY_API_KEY}" "${EMBY_URL%/}/$1" >/dev/null 2>&1 || true | |
| } | |
| get_playback_info() { | |
| local item_id="$1" | |
| emby_post "Items/${item_id}/PlaybackInfo" '{ | |
| "EnableDirectPlay": false, | |
| "EnableDirectStream": false, | |
| "EnableTranscoding": true, | |
| "AllowVideoStreamCopy": false, | |
| "AllowAudioStreamCopy": false | |
| }' | |
| } | |
| build_stream_url() { | |
| # Use /stream endpoint (direct transcode) instead of HLS master.m3u8 | |
| # because SAM's MPlayer can't resolve relative URLs in HLS playlists | |
| # $4 = optional StartTimeTicks (100ns units, e.g. 6000000000 = 10 min) | |
| local item_id="$1" msid="$2" psid="$3" start_ticks="${4:-0}" | |
| printf '%s' \ | |
| "${EMBY_URL%/}/Videos/${item_id}/stream"\ | |
| "?Static=false"\ | |
| "&MediaSourceId=$(urlenc "$msid")"\ | |
| "&DeviceId=$(urlenc "$DEVICE_ID")"\ | |
| "&PlaySessionId=$(urlenc "$psid")"\ | |
| "&StartTimeTicks=${start_ticks}"\ | |
| "&MaxWidth=${MAX_WIDTH}"\ | |
| "&MaxHeight=${MAX_HEIGHT}"\ | |
| "&VideoBitRate=${VIDEO_BITRATE}"\ | |
| "&AudioBitRate=${AUDIO_BITRATE}"\ | |
| "&TranscodingMaxAudioChannels=${AUDIO_CHANNELS}"\ | |
| "&VideoCodec=$(urlenc "$VIDEO_CODEC")"\ | |
| "&AudioCodec=$(urlenc "$AUDIO_CODEC")"\ | |
| "&api_key=$(urlenc "$EMBY_API_KEY")" | |
| } | |
| stop_transcode() { | |
| [[ -n "$PLAY_SESSION_ID" ]] || return 0 | |
| emby_delete "Videos/ActiveEncodings?DeviceId=$(urlenc "$DEVICE_ID")&PlaySessionId=$(urlenc "$PLAY_SESSION_ID")" | |
| PLAY_SESSION_ID="" | |
| } | |
| # ---- UI helpers ---- | |
| clean_input() { | |
| # Strip escape sequences and control chars, keep printable ASCII | |
| # Defense-in-depth for Xbox 360 virtual keyboard noise | |
| local raw="$1" | |
| raw="${raw//$'\e[A'/}" | |
| raw="${raw//$'\e[B'/}" | |
| raw="${raw//$'\e[C'/}" | |
| raw="${raw//$'\e[D'/}" | |
| raw="${raw//$'\e'/}" | |
| printf '%s' "$raw" | |
| } | |
| print_items() { | |
| # $1 = Emby Items JSON string. Prints numbered list. | |
| printf '%s' "$1" | jq -r '.Items | to_entries[] | "\(.key+1)) \(.value.Name)\(if .value.ProductionYear then " (\(.value.ProductionYear))" else "" end) [\(.value.Type)]"' | |
| } | |
| # Global result from pick_item — avoids running read inside $(...) | |
| # Because no working MiSTer script (SAM, bluetooth_pair, ini_settings) | |
| # captures interactive reads in command substitution subshells. | |
| PICKED_ID="" | |
| pick_item() { | |
| # $1 = Emby Items JSON string. Sets PICKED_ID global. | |
| PICKED_ID="" | |
| local json="$1" | |
| local count | |
| count="$(printf '%s' "$json" | jq '.TotalRecordCount // 0')" | |
| if [[ "$count" == "0" ]]; then | |
| echo "No results found." | |
| return 1 | |
| fi | |
| print_items "$json" | |
| echo "" | |
| read -rp "Enter number (or q to cancel): " pick | |
| pick="${pick//[^0-9qQ]/}" | |
| pick="${pick:0:1}" | |
| [[ "$pick" == "q" || "$pick" == "Q" || -z "$pick" ]] && return 1 | |
| if [[ "$pick" =~ ^[0-9]+$ ]]; then | |
| PICKED_ID="$(printf '%s' "$json" | jq -r --argjson n "$pick" '.Items[($n-1)].Id // empty')" | |
| fi | |
| [[ -n "$PICKED_ID" ]] | |
| } | |
| # ---- CRT video mode (no vga_scaler — BVM needs direct FPGA output) ---- | |
| apply_crt_mode() { | |
| [[ -f "$INI_FILE" ]] || return 1 | |
| mountpoint -q "$INI_FILE" 2>/dev/null && return 0 | |
| # Strip existing [Menu] section, rebuild with CRT video_mode only | |
| awk ' | |
| BEGIN { inside_menu = 0 } | |
| /^\[[Mm][Ee][Nn][Uu]\]/ { inside_menu = 1; next } | |
| /\[.*\]/ && !/^\[[Mm][Ee][Nn][Uu]\]/ { inside_menu = 0 } | |
| !inside_menu { print } | |
| ' "$INI_FILE" > "$INI_TEMP" | |
| { | |
| echo "" | |
| echo "[Menu]" | |
| echo "; Temporary override by emby.sh for CRT playback" | |
| echo "video_mode=${CRT_VIDEO_MODE}" | |
| echo "vga_scaler=1" | |
| echo "fb_terminal=1" | |
| } >> "$INI_TEMP" | |
| mount --bind "$INI_TEMP" "$INI_FILE" || return 1 | |
| INI_MOUNTED=1 | |
| } | |
| restore_ini() { | |
| if [[ "$INI_MOUNTED" -eq 1 ]] && mountpoint -q "$INI_FILE" 2>/dev/null; then | |
| umount "$INI_FILE" 2>/dev/null | |
| INI_MOUNTED=0 | |
| fi | |
| rm -f "$INI_TEMP" | |
| } | |
| # ---- Playback ---- | |
| find_input_devs() { | |
| # Find keyboard and gamepad /dev/input/event* devices for input monitoring | |
| INPUT_DEVS=() | |
| for ev in /dev/input/event*; do | |
| [[ -r "$ev" ]] || continue | |
| local num="${ev##*event}" | |
| local name | |
| name="$(cat "/sys/class/input/event${num}/device/name" 2>/dev/null)" || continue | |
| case "$name" in | |
| *[Kk]eyboard*|*[Kk]bd*) | |
| # Skip sub-devices (mouse, system control, consumer control) | |
| case "$name" in *Mouse*|*System*|*Consumer*) continue ;; esac | |
| INPUT_DEVS+=("$ev") | |
| ;; | |
| *[Xx]-[Bb]ox*|*[Gg]amepad*|*[Jj]oystick*|*pad*) | |
| INPUT_DEVS+=("$ev") | |
| ;; | |
| esac | |
| done | |
| log "find_input_devs: found ${#INPUT_DEVS[@]} devices: ${INPUT_DEVS[*]}" | |
| } | |
| drain_input_devs() { | |
| # Drain any queued events (e.g. the keypress that selected "play") | |
| for dev in "${INPUT_DEVS[@]}"; do | |
| dd if="$dev" of=/dev/null bs=256 count=64 iflag=nonblock 2>/dev/null || true | |
| done | |
| } | |
| play_url() { | |
| local title="$1" | |
| local url="$2" | |
| log "play_url: title=$title url=${url:0:80}" | |
| # Kill agetty on tty1 to prevent "login:" from appearing over video | |
| local agetty_pid | |
| agetty_pid="$(ps -o pid,args | grep 'agetty.*tty1' | grep -v grep | awk '{print $1}')" || true | |
| if [[ -n "$agetty_pid" ]]; then | |
| log "play_url: killing agetty pid=$agetty_pid" | |
| kill "$agetty_pid" 2>/dev/null | |
| sleep 0.3 | |
| fi | |
| # Clear tty1 and hide cursor | |
| echo -e '\033[2J' > /dev/tty1 2>/dev/null || true | |
| echo 0 > /sys/class/graphics/fbcon/cursor_blink 2>/dev/null || true | |
| echo -e '\033[?17;0;0c' > /dev/tty1 2>/dev/null || true | |
| log "play_url: apply_crt_mode" | |
| apply_crt_mode | |
| log "play_url: apply_crt_mode rc=$?" | |
| log "play_url: load menu core" | |
| echo load_core /media/fat/menu.rbf > /dev/MiSTer_cmd | |
| sleep 2 | |
| log "play_url: mbc raw_seq :43" | |
| "${mrsampath}/mbc" raw_seq :43 | |
| log "play_url: mbc rc=$?" | |
| log "play_url: vmode 640 240" | |
| vmode -r 640 240 rgb32 | |
| log "play_url: vmode rc=$?" | |
| # Find and drain input devices before starting monitors | |
| find_input_devs | |
| drain_input_devs | |
| log "play_url: starting mplayer (background)" | |
| # Run mplayer in background so we can monitor input | |
| nice -n -20 env LD_LIBRARY_PATH="${mrsampath}" \ | |
| "${mrsampath}/mplayer" \ | |
| -vo fbdev2 -vf scale=640:240 \ | |
| -framedrop -autosync 30 \ | |
| -cache 8192 -cache-min 20 \ | |
| -msglevel all=0:statusline=5 \ | |
| "$url" 2>/dev/null & | |
| local mpid=$! | |
| log "play_url: mplayer pid=$mpid" | |
| # Brief delay to let mplayer start and avoid stale input events | |
| sleep 2 | |
| drain_input_devs | |
| # Monitor input devices — any keypress/button kills mplayer (SAM pattern) | |
| local monitor_pids=() | |
| for dev in "${INPUT_DEVS[@]}"; do | |
| ( dd if="$dev" bs=16 count=1 2>/dev/null | |
| kill "$mpid" 2>/dev/null ) & | |
| monitor_pids+=($!) | |
| done | |
| log "play_url: input monitors started: ${monitor_pids[*]}" | |
| # Wait for mplayer to exit (naturally or killed by input monitor) | |
| wait "$mpid" 2>/dev/null | |
| local rc=$? | |
| log "play_url: mplayer exited rc=$rc" | |
| # Kill remaining input monitor processes | |
| for pid in "${monitor_pids[@]}"; do | |
| kill "$pid" 2>/dev/null | |
| wait "$pid" 2>/dev/null | |
| done | |
| # Restore original MiSTer.ini, framebuffer, and return to HDMI | |
| restore_ini | |
| /usr/sbin/vmode -r 1920 1080 rgb32 2>/dev/null | |
| echo load_core /media/fat/menu.rbf > /dev/MiSTer_cmd | |
| sleep 1 | |
| echo 1 > /sys/class/graphics/fbcon/cursor_blink 2>/dev/null || true | |
| # Restart agetty on tty1 | |
| /sbin/agetty --nohostname -L tty1 linux & | |
| if [[ $rc -ne 0 && $rc -ne 137 ]]; then | |
| # rc=137 is SIGKILL from our input monitor — not a failure | |
| echo "Playback failed (mplayer exit: $rc)" | |
| fi | |
| } | |
| play_item() { | |
| local item_id="$1" | |
| echo "Loading item ${item_id}..." | |
| local json | |
| json="$(get_playback_info "$item_id")" || { | |
| echo "Error: Failed to get playback info." | |
| return | |
| } | |
| [[ -n "$json" ]] || { echo "Error: Empty playback info."; return; } | |
| PLAY_SESSION_ID="$(printf '%s' "$json" | jq -r '.PlaySessionId // empty')" | |
| MEDIA_SOURCE_ID="$(printf '%s' "$json" | jq -r '.MediaSources[0].Id // empty')" | |
| if [[ -z "$PLAY_SESSION_ID" ]]; then | |
| echo "Error: No PlaySessionId. Check API key." | |
| return | |
| fi | |
| if [[ -z "$MEDIA_SOURCE_ID" ]]; then | |
| echo "Error: No MediaSource. Item may not be playable." | |
| return | |
| fi | |
| # Get resume position from Emby UserData (0 = start from beginning) | |
| local start_ticks="0" | |
| local stream_url | |
| stream_url="$(build_stream_url "$item_id" "$MEDIA_SOURCE_ID" "$PLAY_SESSION_ID" "$start_ticks")" | |
| log "[DEBUG] PlaySession=$PLAY_SESSION_ID MediaSource=$MEDIA_SOURCE_ID" | |
| log "[DEBUG] Stream URL: ${stream_url:0:100}..." | |
| local name="Emby Item" | |
| name="$(emby_get "Users/${USER_ID}/Items/${item_id}" | jq -r '.Name // "Emby Item"' 2>/dev/null)" || name="Emby Item" | |
| log "[DEBUG] Name=$name" | |
| play_url "$name" "$stream_url" | |
| stop_transcode | |
| } | |
| # ---- Menu modes ---- | |
| search_emby() { | |
| # Fuzzy search: try exact match, then progressively shorter prefixes | |
| local term="$1" | |
| local json count | |
| json="$(emby_get "Users/${USER_ID}/Items?SearchTerm=$(urlenc "$term")&Recursive=true&IncludeItemTypes=Movie,Episode,Series&Limit=25")" || return 1 | |
| count="$(printf '%s' "$json" | jq '.TotalRecordCount // 0')" | |
| if [[ "$count" != "0" ]]; then | |
| printf '%s' "$json" | |
| return 0 | |
| fi | |
| # No exact results — try shorter prefixes | |
| local len=${#term} | |
| while (( len > 2 )); do | |
| len=$(( len - 1 )) | |
| local prefix="${term:0:$len}" | |
| json="$(emby_get "Users/${USER_ID}/Items?SearchTerm=$(urlenc "$prefix")&Recursive=true&IncludeItemTypes=Movie,Episode,Series&Limit=25")" || continue | |
| count="$(printf '%s' "$json" | jq '.TotalRecordCount // 0')" | |
| if [[ "$count" != "0" ]]; then | |
| echo "No exact match. Showing results for: ${prefix}..." >&2 | |
| printf '%s' "$json" | |
| return 0 | |
| fi | |
| done | |
| printf '%s' "$json" | |
| } | |
| search_mode() { | |
| log "search_mode: prompting" | |
| read -rp "Search: " q | |
| local read_rc=$? | |
| log "search_mode: read rc=$read_rc raw='$(printf '%s' "$q" | cat -v)'" | |
| q="$(clean_input "$q")" | |
| log "search_mode: cleaned='${q}'" | |
| [[ -n "$q" ]] || return | |
| echo "Searching for: ${q}" | |
| local json | |
| json="$(search_emby "$q")" || { | |
| echo "Search failed." | |
| return | |
| } | |
| pick_item "$json" || return | |
| play_item "$PICKED_ID" | |
| } | |
| continue_mode() { | |
| log "continue_mode: start" | |
| echo "Loading continue watching..." | |
| local json | |
| json="$(emby_get "Users/${USER_ID}/Items/Resume?Limit=25")" || { | |
| log "continue_mode: API failed" | |
| echo "Failed to load." | |
| return | |
| } | |
| log "continue_mode: API returned $(printf '%s' "$json" | jq -r '.TotalRecordCount // "null"') items" | |
| pick_item "$json" || { log "continue_mode: pick_item returned $?"; return; } | |
| log "continue_mode: picked $PICKED_ID" | |
| play_item "$PICKED_ID" | |
| } | |
| libraries_mode() { | |
| echo "Loading libraries..." | |
| local views | |
| views="$(emby_get "Users/${USER_ID}/Views")" || { | |
| echo "Failed to load." | |
| return | |
| } | |
| echo "" | |
| echo "Libraries:" | |
| printf '%s' "$views" | jq -r '.Items | to_entries[] | "\(.key+1)) \(.value.Name)"' | |
| echo "" | |
| read -rp "Pick library (or q): " pick | |
| pick="${pick//[^0-9qQ]/}" | |
| pick="${pick:0:1}" | |
| [[ "$pick" == "q" || "$pick" == "Q" || -z "$pick" ]] && return | |
| local lib_id | |
| if [[ "$pick" =~ ^[0-9]+$ ]]; then | |
| lib_id="$(printf '%s' "$views" | jq -r --argjson n "$pick" '.Items[($n-1)].Id // empty')" | |
| else | |
| lib_id="$pick" | |
| fi | |
| [[ -n "$lib_id" ]] || { echo "Invalid selection."; return; } | |
| echo "Loading..." | |
| local items | |
| items="$(emby_get "Users/${USER_ID}/Items?ParentId=$(urlenc "$lib_id")&Recursive=false&Limit=50")" || { | |
| echo "Failed to load." | |
| return | |
| } | |
| pick_item "$items" || return | |
| play_item "$PICKED_ID" | |
| } | |
| play_by_id_mode() { | |
| read -rp "ItemId: " id | |
| id="$(clean_input "$id")" | |
| [[ -n "$id" ]] || return | |
| play_item "$id" | |
| } | |
| # ---- Cleanup ---- | |
| cleanup() { | |
| stop_transcode | |
| restore_ini | |
| /usr/sbin/vmode -r 1920 1080 rgb32 2>/dev/null | |
| echo load_core /media/fat/menu.rbf > /dev/MiSTer_cmd 2>/dev/null | |
| echo 1 > /sys/class/graphics/fbcon/cursor_blink 2>/dev/null || true | |
| } | |
| trap cleanup EXIT INT TERM | |
| # ---- Main ---- | |
| # Direct play mode: emby.sh <ItemId> | |
| if [[ $# -ge 1 && -n "${1:-}" ]]; then | |
| play_item "$1" | |
| exit 0 | |
| fi | |
| log "=== script started ===" | |
| while true; do | |
| echo "" | |
| echo "=== MiSTer Emby ===" | |
| echo "1) Search" | |
| echo "2) Continue Watching" | |
| echo "3) Browse Libraries" | |
| echo "4) Play by ItemId" | |
| echo "q) Quit" | |
| echo "" | |
| echo "Playback: SPACE=Pause q/ESC=Stop Arrows=Seek" | |
| echo "" | |
| log "waiting for input..." | |
| read -rp "> " c || exit 0 # Exit on EOF (broken terminal/pipe) | |
| log "raw input: '$(printf '%s' "$c" | cat -v)'" | |
| c="${c//[^0-9qQ]/}" | |
| c="${c:0:1}" | |
| log "cleaned input: '$c'" | |
| case "$c" in | |
| 1) log "-> search_mode"; search_mode ;; | |
| 2) log "-> continue_mode"; continue_mode ;; | |
| 3) log "-> libraries_mode"; libraries_mode ;; | |
| 4) log "-> play_by_id_mode"; play_by_id_mode ;; | |
| q|Q) exit 0 ;; | |
| *) log "-> invalid: '$c'"; echo "Invalid option." ;; | |
| esac | |
| log "returned to main loop" | |
| done |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment