master uint/parados / clients / sh / shclient.sh
  1#!/bin/sh
  2#
  3# shclient
  4# A REPL client for parados
  5
  6set -u
  7
  8CACHE_LOGIN=${CACHE_LOGIN:-1}
  9VIDEO_PLAYER=${VIDEO_PLAYER:-mpv}
 10VIDEO_PLAYER_ARGS=${VIDEO_PLAYER_ARGS:-}
 11PARADOS_URL=${PARADOS_URL:-http://127.0.0.1:8088}
 12PARADOS_CURL=${PARADOS_CURL:-curl}
 13
 14TAB=$(printf '\t')
 15CUR_DIR=""
 16AUTH=""
 17C_RESET=""
 18C_DIM=""
 19C_PROMPT=""
 20C_DIR=""
 21C_VID=""
 22
 23CACHE_DIR=${XDG_CACHE_HOME:-"$HOME/.cache"}
 24AUTH_FILE="$CACHE_DIR/shclient.auth"
 25
 26TMP_DIR=${TMPDIR:-/tmp}/shclient.$$
 27LIB_FILE="$TMP_DIR/library.tsv"
 28MAP_FILE="$TMP_DIR/view.tsv"
 29
 30# cleanup on exit
 31cleanup() { rm -rf "$TMP_DIR"; }
 32
 33# print err message, exit on failure
 34die() { printf '%s\n' "$*" >&2; exit 1; }
 35
 36# check if command exists
 37cmd_exists() { command -v "$1" >/dev/null 2>&1 || die "missing command: $1"; }
 38
 39# setup subtle ANSI colors for interactive terminal output
 40setup_color()
 41{
 42	[ -t 1 ] || return 0
 43	[ -z "${NO_COLOR:-}" ] || return 0
 44
 45	C_RESET=$(printf '\033[0m')
 46	C_DIM=$(printf '\033[2m')
 47	C_PROMPT=$(printf '\033[33m')
 48	C_DIR=$(printf '\033[36m')
 49	C_VID=$(printf '\033[32m')
 50}
 51
 52# make sure arg isnt negative
 53validate_idx()
 54{
 55	case "$1" in
 56		''|*[!0-9]*) return 1 ;;
 57		*) return 0 ;;
 58	esac
 59}
 60
 61# make sure arg is 16Byte hex
 62validate_id()
 63{
 64	case "$1" in
 65		????????????????)
 66			case "$1" in *[!0-9a-fA-F]*) return 1 ;; *) return 0 ;; esac
 67			;;
 68		*) return 1 ;;
 69	esac
 70}
 71
 72# check if path is video based on extension
 73is_video_path()
 74{
 75	p=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')
 76	case "$p" in
 77		*.mkv|*.mp4|*.webm|*.mov|*.m4v|*.avi|*.mpg|*.mpeg|*.ts|*.m2ts|*.flv|*.wmv|*.3gp) return 0 ;;
 78		*) return 1 ;;
 79	esac
 80}
 81
 82# percent encode credentials to be safely embedded in the URL
 83urlencode_userinfo()
 84{
 85	LC_ALL=C awk -v s="$1" '
 86	BEGIN {
 87		safe = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~"
 88		for (i = 1; i < 256; i++)
 89			ord[sprintf("%c", i)] = i
 90		for (i = 1; i <= length(s); i++) {
 91			c = substr(s, i, 1)
 92			if (index(safe, c) > 0)
 93				printf "%s", c
 94			else
 95				printf "%%%02X", ord[c]
 96		}
 97	}'
 98}
 99
100# do a HTTP GET request, save body to file, return HTTP code
101http_get()
102{
103	if [ -n "$AUTH" ]; then
104		"$PARADOS_CURL" -q -sS -u "$AUTH" -o "$2" -w '%{http_code}' "${PARADOS_URL}$1"
105	else
106		"$PARADOS_CURL" -q -sS -o "$2" -w '%{http_code}' "${PARADOS_URL}$1"
107	fi
108}
109
110# parse /library JSON response, print "id<TAB>path" lines
111parse_library_json()
112{
113	LC_ALL=C awk '
114	{
115		s = $0
116		while (match(s, /"id":"([^"]+)","path":"([^"]*)"/)) {
117			chunk = substr(s, RSTART, RLENGTH)
118			id = chunk; sub(/^"id":"/, "", id); sub(/","path":"[^"]*"$/, "", id)
119			path = chunk; sub(/^"id":"[^"]+","path":"/, "", path); sub(/"$/, "", path)
120			gsub(/\\"/, "\"", path); gsub(/\\t/, "\t", path); gsub(/\\r/, "", path); gsub(/\\n/, "", path)
121			printf "%s\t%s\n", id, path
122			s = substr(s, RSTART + RLENGTH)
123		}
124	}
125	' "$1"
126}
127
128# fetch /library, parse, save to LIB_FILE
129refresh_library()
130{
131	tmp="$TMP_DIR/library.json"
132	code=$(http_get "/library" "$tmp") || { printf '%s\n' "request failed: /library" >&2; return 1; }
133	case "$code" in
134		200)
135			parse_library_json "$tmp" | while IFS="$TAB" read -r id path; do
136				validate_id "$id" || continue
137				is_video_path "$path" || continue
138				printf '%s\t%s\n' "$id" "$path"
139			done | sort -f -t "$TAB" -k2,2 > "$LIB_FILE" || { printf '%s\n' "failed parsing /library response" >&2; return 1; }
140			;;
141		401) printf '%s\n' "unauthorized: use login" >&2; return 1 ;;
142		*) printf '%s\n' "request failed: /library (HTTP $code)" >&2; return 1 ;;
143	esac
144	return 0
145}
146
147# build view of current dir, save to MAP_FILE with lines:
148build_view()
149{
150	dirs_raw="$TMP_DIR/dirs.raw"; dirs_sorted="$TMP_DIR/dirs.sorted"
151	files_raw="$TMP_DIR/files.raw"; files_sorted="$TMP_DIR/files.sorted"
152	: > "$dirs_raw"; : > "$files_raw"; : > "$MAP_FILE"
153
154	while IFS="$TAB" read -r id path; do
155		[ -n "$id" ] || continue
156		if [ -n "$CUR_DIR" ]; then
157			prefix="$CUR_DIR/"
158			case "$path" in "$prefix"*) rel=${path#"$prefix"} ;; *) continue ;; esac
159		else
160			rel=$path
161		fi
162		case "$rel" in
163			*/*) printf '%s\n' "${rel%%/*}" >> "$dirs_raw" ;;
164			*) printf '%s\t%s\t%s\n' "$id" "$rel" "$path" >> "$files_raw" ;;
165		esac
166	done < "$LIB_FILE"
167
168	sort -fu "$dirs_raw" > "$dirs_sorted"
169	sort -f -t "$TAB" -k2,2 "$files_raw" > "$files_sorted"
170
171	n=1
172	while IFS= read -r dir; do
173		[ -n "$dir" ] || continue
174		if [ -n "$CUR_DIR" ]; then full="$CUR_DIR/$dir"; else full="$dir"; fi
175		printf '%s\tD\t-\t%s\t%s\n' "$n" "$dir" "$full" >> "$MAP_FILE"
176		n=$((n + 1))
177	done < "$dirs_sorted"
178
179	while IFS="$TAB" read -r id name full; do
180		[ -n "$id" ] || continue
181		printf '%s\tF\t%s\t%s\t%s\n' "$n" "$id" "$name" "$full" >> "$MAP_FILE"
182		n=$((n + 1))
183	done < "$files_sorted"
184}
185
186# given ls index, print corresponding line from MAP_FILE
187lookup_entry()
188{
189	awk -F "$TAB" -v i="$1" '$1 == i { print; found=1; exit } END { if (!found) exit 1 }' "$MAP_FILE"
190}
191
192# split MAP_FILE line into vars
193split_entry()
194{
195	IFS="$TAB" read -r ENTRY_N ENTRY_TYP ENTRY_ID ENTRY_NAME ENTRY_FULL <<EOF2
196$1
197EOF2
198}
199
200# print command prompt
201print_prompt()
202{
203	if [ -n "$CUR_DIR" ]; then
204		printf '%sparados>%s %s[/%s]%s ' "$C_PROMPT" "$C_RESET" "$C_DIM" "$CUR_DIR" "$C_RESET"
205	else
206		printf '%sparados>%s ' "$C_PROMPT" "$C_RESET"
207	fi
208}
209
210# output all commands
211cmd_help()
212{
213	cat <<'EOF2'
214Commands:
215  help                 show this help table
216  clear                clear screen
217  ping                 call "/ping"
218  login                prompt for username, password
219  logout               clear login (and cache if its enabled)
220  rescan               do server rescan + refresh local view
221  ls                   list dirs/videos in current folder
222  cd n                 enter directory by ls index
223  cd ..                go up one directory
224  cd /                 go to root
225  watch n              play video at ls index using VIDEO_PLAYER
226  url URL              set server URL
227  pwd                  show current folder
228  quit | exit          leave shclient
229EOF2
230}
231
232# clear screen
233cmd_clear() { command -v clear >/dev/null 2>&1 && clear || printf '\033[2J\033[H'; }
234
235# call "/ping", print response or error
236cmd_ping()
237{
238	tmp="$TMP_DIR/ping.txt"
239	code=$(http_get "/ping" "$tmp") || { printf '%s\n' "request failed: /ping" >&2; return 1; }
240	case "$code" in
241		200) cat "$tmp"; printf '\n' ;;
242		401) printf '%s\n' "unauthorized: use login" >&2; return 1 ;;
243		*) printf '%s\n' "request failed: /ping (HTTP $code)" >&2; return 1 ;;
244	esac
245}
246
247# list current dir based on MAP_FILE
248cmd_ls()
249{
250	build_view
251	[ -n "$CUR_DIR" ] && printf 'cwd: /%s\n' "$CUR_DIR" || printf 'cwd: /\n'
252	[ -s "$MAP_FILE" ] || { printf '%s\n' "(empty)"; return 0; }
253	while IFS="$TAB" read -r n typ id name full; do
254		case "$typ" in
255			D) printf '%3s  %s[dir]%s %s/\n' "$n" "$C_DIR" "$C_RESET" "$name" ;;
256			F) printf '%3s  %s[vid]%s %s\n' "$n" "$C_VID" "$C_RESET" "$name" ;;
257		esac
258	done < "$MAP_FILE"
259}
260
261# print current dir
262cmd_pwd() { [ -n "$CUR_DIR" ] && printf '/%s\n' "$CUR_DIR" || printf '/\n'; }
263
264# set server URL and reload library from new endpoint
265cmd_url()
266{
267	[ $# -eq 1 ] || { printf '%s\n' "usage: url <http://host:port>" >&2; return 1; }
268	PARADOS_URL=$1
269	CUR_DIR=""
270	if refresh_library; then
271		printf 'server: %s\n' "$PARADOS_URL"
272	else
273		printf 'server set to: %s\n' "$PARADOS_URL"
274		return 1
275	fi
276}
277
278# change directory based on ls index, .., or /
279cmd_cd()
280{
281	[ $# -eq 1 ] || { printf '%s\n' "usage: cd <n|..|/>" >&2; return 1; }
282	case "$1" in
283		/) CUR_DIR=""; return 0 ;;
284		..)
285			[ -z "$CUR_DIR" ] && return 0
286			case "$CUR_DIR" in */*) CUR_DIR=${CUR_DIR%/*} ;; *) CUR_DIR="" ;; esac
287			return 0
288			;;
289	esac
290	validate_idx "$1" || { printf '%s\n' "cd expects an ls index, '..', or '/'" >&2; return 1; }
291	build_view
292	line=$(lookup_entry "$1") || { printf '%s\n' "invalid index: $1" >&2; return 1; }
293	split_entry "$line"
294	[ "$ENTRY_TYP" = "D" ] || { printf '%s\n' "index is not a directory: $1" >&2; return 1; }
295	CUR_DIR=$ENTRY_FULL
296}
297
298# build URL for "/stream/id", embedding auth if needed for seeking
299build_stream_url()
300{
301	base=${PARADOS_URL%/}
302	path="/stream/$1"
303
304	[ -n "$AUTH" ] || {
305		printf '%s%s' "$base" "$path"
306		return 0
307	}
308
309	case "$AUTH" in
310		*:)
311			user=${AUTH%:}
312			pass=""
313			;;
314		*:*)
315			user=${AUTH%%:*}
316			pass=${AUTH#*:}
317			;;
318		*)
319			user=$AUTH
320			pass=""
321			;;
322	esac
323
324	case "$base" in
325		*://*)
326			user_enc=$(urlencode_userinfo "$user") || return 1
327			pass_enc=$(urlencode_userinfo "$pass") || return 1
328			scheme=${base%%://*}
329			rest=${base#*://}
330			printf '%s://%s:%s@%s%s' "$scheme" "$user_enc" "$pass_enc" "$rest" "$path"
331			;;
332		*)
333			printf '%s%s' "$base" "$path"
334			;;
335	esac
336}
337
338# given ls index, get video id, launch VIDEO_PLAYER on streams URL
339cmd_watch()
340{
341	[ $# -eq 1 ] || { printf '%s\n' "usage: watch <n>" >&2; return 1; }
342	validate_idx "$1" || { printf '%s\n' "watch expects an ls index" >&2; return 1; }
343	build_view
344	line=$(lookup_entry "$1") || { printf '%s\n' "invalid index: $1" >&2; return 1; }
345	split_entry "$line"
346	[ "$ENTRY_TYP" = "F" ] || { printf '%s\n' "index is not a video: $1" >&2; return 1; }
347	validate_id "$ENTRY_ID" || { printf '%s\n' "invalid id in index: $1" >&2; return 1; }
348	cmd_exists "$VIDEO_PLAYER"
349	stream_url=$(build_stream_url "$ENTRY_ID") || { printf '%s\n' "failed building stream URL" >&2; return 1; }
350	printf 'watching: %s\n' "$ENTRY_NAME"
351	# shellcheck disable=SC2086
352	set -- "$VIDEO_PLAYER" $VIDEO_PLAYER_ARGS "$stream_url"
353	"$@"
354}
355
356# call "/rescan", print response or error
357cmd_rescan()
358{
359	tmp="$TMP_DIR/rescan.txt"
360	code=$(http_get "/rescan" "$tmp") || { printf '%s\n' "request failed: /rescan" >&2; return 1; }
361	case "$code" in
362		200) printf '%s\n' "rescan: ok"; refresh_library ;;
363		401) printf '%s\n' "unauthorized: use login" >&2; return 1 ;;
364		403) printf '%s\n' "rescan forbidden: auth may be disabled server-side" >&2; return 1 ;;
365		*) printf '%s\n' "request failed: /rescan (HTTP $code)" >&2; return 1 ;;
366	esac
367}
368
369# prompt for password without echo, return input
370prompt_password()
371{
372	printf 'pass: ' >&2
373	if command -v stty >/dev/null 2>&1; then
374		stty_state=$(stty -g 2>/dev/null || true)
375		stty -echo 2>/dev/null || true
376		IFS= read -r pass || return 1
377		[ -n "$stty_state" ] && stty "$stty_state" 2>/dev/null || stty echo 2>/dev/null || true
378		printf '\n' >&2
379	else
380		IFS= read -r pass || return 1
381	fi
382	printf '%s' "$pass"
383}
384
385# save authentication cache if enabled and AUTH set, with permissions 600
386save_auth_cache()
387{
388	[ "$CACHE_LOGIN" = "1" ] || return 0
389	[ -n "$AUTH" ] || return 0
390	mkdir -p "$CACHE_DIR" || return 1
391	(umask 077 && printf '%s' "$AUTH" > "$AUTH_FILE") || return 1
392}
393
394# load authentication cache if enabled and cache file exists, set AUTH
395load_auth_cache()
396{
397	[ "$CACHE_LOGIN" = "1" ] || return 0
398	[ -f "$AUTH_FILE" ] || return 0
399	AUTH=$(tr -d '\r\n' < "$AUTH_FILE")
400}
401
402# delete authentication cache file if enabled
403clear_auth_cache()
404{
405	[ "$CACHE_LOGIN" = "1" ] || return 0
406	rm -f "$AUTH_FILE"
407}
408
409# prompt for username and password, set AUTH
410cmd_login()
411{
412	printf 'user: '
413	IFS= read -r user || return 1
414	[ -n "$user" ] || { printf '%s\n' "empty username" >&2; return 1; }
415	pass=$(prompt_password) || return 1
416	AUTH="$user:$pass"
417	if ! refresh_library; then AUTH=""; return 1; fi
418	save_auth_cache || printf '%s\n' "warning: failed to write auth cache" >&2
419	printf '%s\n' "login: ok"
420}
421
422# clear AUTH, delete auth cache, print result
423cmd_logout() { AUTH=""; clear_auth_cache; printf '%s\n' "logout: ok"; }
424
425# dispatch command based on first arg, pass rest as args.
426# return 99 to signal quit, 0 for success, 1 for error
427dispatch()
428{
429	cmd=$1
430	shift || true
431	case "$cmd" in
432		help) cmd_help ;;
433		clear) cmd_clear ;;
434		ping) cmd_ping ;;
435		login) cmd_login ;;
436		logout) cmd_logout ;;
437		rescan) cmd_rescan ;;
438		ls) cmd_ls ;;
439		cd) cmd_cd "$@" ;;
440		watch) cmd_watch "$@" ;;
441		url) cmd_url "$@" ;;
442		pwd) cmd_pwd ;;
443		quit|exit) return 99 ;;
444		'') return 0 ;;
445		*) printf '%s\n' "unknown command: $cmd (use: help)" >&2; return 1 ;;
446	esac
447}
448
449main()
450{
451	trap cleanup EXIT INT TERM
452	cmd_exists "$PARADOS_CURL"
453	mkdir -p "$TMP_DIR" || die "failed creating temp dir: $TMP_DIR"
454	: > "$LIB_FILE"; : > "$MAP_FILE"
455
456	if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then cmd_help; exit 0; fi
457	[ $# -eq 0 ] || die "usage: shclient"
458
459	load_auth_cache
460	if ! refresh_library; then printf '%s\n' "not logged in. use: login" >&2; fi
461
462	printf '%s\n' "shclient: minimal video repl"
463	printf 'server: %s\n' "$PARADOS_URL"
464	printf 'player: %s %s\n' "$VIDEO_PLAYER" "$VIDEO_PLAYER_ARGS"
465	cmd_help
466
467	setup_color
468
469	while :; do
470		print_prompt
471		if ! IFS= read -r line; then printf '\n'; break; fi
472		[ -n "$line" ] || continue
473		set -- $line
474		cmd=${1:-}
475		shift || true
476		dispatch "$cmd" "$@"
477		rc=$?
478		[ "$rc" -eq 99 ] && break
479	done
480}
481
482main "$@"
483