Skip to content

Instantly share code, notes, and snippets.

@mpalpha
Last active February 18, 2026 09:50
Show Gist options
  • Select an option

  • Save mpalpha/6845807a40e48b683aea237df23f49ee to your computer and use it in GitHub Desktop.

Select an option

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.
# 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"
#!/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