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