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