Files
thamanyah/upload-video-demo.sh
FahdShalhoub db50e015bf
Build, Push and Deploy Discovery / build-push-deploy (push) Successful in 5m2s
FEAT: Swicthed QUERY Method To GET
2026-08-30 10:56:51 +03:00

358 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# upload-video.sh — drive the cms upload flow end to end from a local file.
#
# 1. GET /api/categories pick what the video is filed under
# 2. POST /api/videos/presign ask for an upload slot
# 3. PUT <presigned url> send the file straight to S3, not through cms
# 4. POST /api/videos register it, which queues the transcode
# 5. GET /api/videos/{id} poll until playbackUrl appears
#
# The playback URL is empty until the MediaConvert job reports success and
# cms's consumer records it, so step 5 is a wait, not a formality. A job that
# comes back "failed" never gets a URL — the script stops rather than polls on.
#
# Usage: scripts/upload-video.sh [options]
# Run with --help for the full list. With no options it asks for everything.
set -euo pipefail
readonly DEFAULT_BASE_URL="http://cms-alb-d478b02-648162889.us-east-1.elb.amazonaws.com"
readonly DEFAULT_POLL_INTERVAL=5
readonly DEFAULT_POLL_TIMEOUT=900
base_url="${CMS_BASE_URL:-$DEFAULT_BASE_URL}"
file_path=""
title=""
description=""
tags=""
categories_arg=""
poll_interval="$DEFAULT_POLL_INTERVAL"
poll_timeout="$DEFAULT_POLL_TIMEOUT"
skip_poll=false
usage() {
cat <<'USAGE'
Upload a video to the Thamanyah cms and wait for its playback URL.
Usage: upload-video.sh [options]
Options:
-f, --file PATH Video file to upload (.mp4 or .mov). Prompted for if omitted.
-t, --title TITLE Video title. Prompted for if omitted.
-d, --description TEXT Video description. Optional.
--tags TAGS Free-text comma-separated tags. Optional.
-c, --categories LIST Comma-separated category names or ids (e.g. "news,podcast"
or "2,4"). Prompted for if omitted.
-u, --url URL cms base URL. Default: $CMS_BASE_URL or http://localhost:8081
-i, --interval SECONDS Seconds between status polls. Default: 5
--timeout SECONDS Give up waiting after this long. Default: 900
--no-poll Register the video and exit without waiting.
-h, --help Show this help.
Requires: curl, jq.
Exit codes: 0 ready, 1 usage/upload error, 2 transcode failed, 3 poll timed out.
USAGE
}
die() {
printf '\nerror: %s\n' "$*" >&2
exit 1
}
info() { printf '%s\n' "$*" >&2; }
# The poll writes over one line on a terminal and one line per check when
# redirected — an escape-littered log file helps nobody.
progress() {
if [[ -t 2 ]]; then
printf '\r\033[K %s' "$*" >&2
else
printf ' %s\n' "$*" >&2
fi
}
clear_progress() { [[ -t 2 ]] && printf '\r\033[K' >&2 || true; }
require_tools() {
local missing=()
for tool in curl jq; do
command -v "$tool" >/dev/null 2>&1 || missing+=("$tool")
done
[[ ${#missing[@]} -eq 0 ]] || die "missing required command(s): ${missing[*]}"
}
# The API reports failures as RFC 9457 problem details; surface the human parts
# of that rather than dumping the raw body.
report_api_error() {
local context="$1" status="$2" body="$3" problem_title problem_detail
problem_title=$(jq -r '.title // empty' <<<"$body" 2>/dev/null || true)
problem_detail=$(jq -r '.detail // empty' <<<"$body" 2>/dev/null || true)
if [[ -n "$problem_title" ]]; then
printf '\nerror: %s (HTTP %s)\n %s\n' "$context" "$status" "$problem_title" >&2
[[ -n "$problem_detail" ]] && printf ' %s\n' "$problem_detail" >&2
else
printf '\nerror: %s (HTTP %s)\n %s\n' "$context" "$status" "${body:-<empty response>}" >&2
fi
exit 1
}
# Splits curl's "body + trailing status line" into the two globals the callers
# read, so a request needs one subshell rather than two round trips.
http_status=""
http_body=""
request() {
local response
response=$(curl --silent --show-error --location --write-out $'\n%{http_code}' "$@") ||
die "request to cms failed — is it running at $base_url ?"
http_status="${response##*$'\n'}"
http_body="${response%$'\n'*}"
}
content_type_for() {
case "${1,,}" in
*.mp4) printf 'video/mp4' ;;
*.mov) printf 'video/quicktime' ;;
*) die "unsupported file type: $1 — cms accepts only .mp4 and .mov" ;;
esac
}
prompt_required() {
local varname="$1" prompt="$2" value=""
[[ -t 0 ]] || die "$varname not given and stdin is not a terminal — pass it as an option"
while [[ -z "$value" ]]; do
read -r -e -p "$prompt" value
value="${value#"${value%%[![:space:]]*}"}"
done
printf '%s' "$value"
}
while [[ $# -gt 0 ]]; do
case "$1" in
-f | --file)
file_path="${2:-}"
shift 2
;;
-t | --title)
title="${2:-}"
shift 2
;;
-d | --description)
description="${2:-}"
shift 2
;;
--tags)
tags="${2:-}"
shift 2
;;
-c | --categories)
categories_arg="${2:-}"
shift 2
;;
-u | --url)
base_url="${2:-}"
shift 2
;;
-i | --interval)
poll_interval="${2:-}"
shift 2
;;
--timeout)
poll_timeout="${2:-}"
shift 2
;;
--no-poll)
skip_poll=true
shift
;;
-h | --help)
usage
exit 0
;;
*)
usage >&2
die "unknown option: $1"
;;
esac
done
require_tools
base_url="${base_url%/}"
[[ "$poll_interval" =~ ^[0-9]+$ && "$poll_interval" -gt 0 ]] || die "--interval must be a positive integer"
[[ "$poll_timeout" =~ ^[0-9]+$ && "$poll_timeout" -gt 0 ]] || die "--timeout must be a positive integer"
# ---------------------------------------------------------------- the file ---
if [[ -z "$file_path" ]]; then
# -e gives readline's filename completion, which is the whole point of
# asking here rather than making the flag mandatory.
file_path=$(prompt_required "--file" "Video file to upload: ")
fi
file_path="${file_path/#\~/$HOME}"
[[ -f "$file_path" ]] || die "no such file: $file_path"
[[ -r "$file_path" ]] || die "file is not readable: $file_path"
[[ -s "$file_path" ]] || die "file is empty: $file_path"
file_name=$(basename -- "$file_path")
content_type=$(content_type_for "$file_name")
info "Checking cms at $base_url ..."
request "$base_url/health"
[[ "$http_status" == "200" ]] || report_api_error "cms is not healthy" "$http_status" "$http_body"
# ---------------------------------------------------------- the categories ---
request "$base_url/api/categories"
[[ "$http_status" == "200" ]] || report_api_error "could not list categories" "$http_status" "$http_body"
categories_json="$http_body"
if [[ -z "$categories_arg" ]]; then
info ""
info "Available categories:"
jq -r '.categories[] | " \(.id)) \(.name)"' <<<"$categories_json" >&2
info ""
categories_arg=$(prompt_required "--categories" "Categories (comma-separated ids or names): ")
fi
# Accept either spelling — an id straight from the list, or the name it goes by
# — and resolve both to the ids POST /api/videos wants. Unmatched entries come
# back in their own member rather than as a jq error, so they can be named.
resolved=$(jq -c --arg raw "$categories_arg" '
[$raw | split(",") | .[] | gsub("^\\s+|\\s+$"; "") | select(length > 0)] as $wanted
| {
ids: [ $wanted[] as $w
| $categories.categories[]
| select((.id | tostring) == $w or (.name | ascii_downcase) == ($w | ascii_downcase))
| .id ] | unique,
unknown: [ $wanted[] as $w
| select([ $categories.categories[]
| select((.id | tostring) == $w or (.name | ascii_downcase) == ($w | ascii_downcase)) ] | length == 0)
| $w ]
}
' --argjson categories "$categories_json" -n)
unknown_categories=$(jq -r '.unknown | join(", ")' <<<"$resolved")
[[ -z "$unknown_categories" ]] || die "no category is called \"$unknown_categories\" — pick from the list with GET $base_url/api/categories"
category_ids_json=$(jq -c '.ids' <<<"$resolved")
[[ "$category_ids_json" != "[]" ]] || die "at least one category is required"
category_names=$(jq -r --argjson ids "$category_ids_json" \
'[.categories[] | select(.id as $i | $ids | index($i)) | .name] | join(", ")' <<<"$categories_json")
# -------------------------------------------------------------- the metadata ---
[[ -n "$title" ]] || title=$(prompt_required "--title" "Title: ")
info ""
info "Uploading : $file_path ($content_type)"
info "Title : $title"
info "Categories : $category_names"
info ""
# ------------------------------------------------------------- 1. presign ---
info "Requesting an upload URL ..."
presign_body=$(jq -n --arg fileName "$file_name" --arg contentType "$content_type" \
'{fileName: $fileName, contentType: $contentType}')
request -X POST "$base_url/api/videos/presign" \
-H 'Content-Type: application/json' \
--data-binary "$presign_body"
[[ "$http_status" == "200" ]] || report_api_error "could not get an upload URL" "$http_status" "$http_body"
upload_url=$(jq -r '.uploadUrl' <<<"$http_body")
storage_key=$(jq -r '.key' <<<"$http_body")
[[ -n "$upload_url" && "$upload_url" != "null" ]] || die "cms returned no upload URL"
# ----------------------------------------------------- 2. PUT the file to S3 ---
# The content type is signed into the URL, so this header has to match the one
# sent to /presign exactly or S3 rejects the PUT as a signature mismatch.
info "Uploading $(du -h -- "$file_path" | cut -f1) to storage ..."
upload_status=$(curl --silent --show-error --progress-bar \
--request PUT \
--header "Content-Type: $content_type" \
--upload-file "$file_path" \
--write-out '%{http_code}' \
--output /dev/null \
"$upload_url") || die "upload to storage failed"
[[ "$upload_status" =~ ^2 ]] || die "storage rejected the upload (HTTP $upload_status)"
info "Upload complete: $storage_key"
# ------------------------------------------- 3. register + queue transcoding ---
info "Registering the video ..."
complete_body=$(jq -n \
--arg title "$title" \
--arg description "$description" \
--arg tags "$tags" \
--arg fileName "$file_name" \
--arg key "$storage_key" \
--argjson categoryIds "$category_ids_json" \
'{title: $title, description: $description, categoryIds: $categoryIds, tags: $tags, fileName: $fileName, key: $key}')
request -X POST "$base_url/api/videos" \
-H 'Content-Type: application/json' \
--data-binary "$complete_body"
[[ "$http_status" == "201" ]] || report_api_error "could not register the video" "$http_status" "$http_body"
video_id=$(jq -r '.id' <<<"$http_body")
info "Registered as $video_id (status: $(jq -r '.status' <<<"$http_body"))"
if [[ "$skip_poll" == true ]]; then
info ""
info "Not waiting for transcoding (--no-poll). Follow it with:"
info " curl -s $base_url/api/videos/$video_id | jq"
printf '%s\n' "$video_id"
exit 0
fi
# ------------------------------------------ 4. poll until the URL is there ---
info ""
info "Waiting for transcoding to finish (timeout ${poll_timeout}s, checking every ${poll_interval}s) ..."
started_at=$SECONDS
while :; do
request "$base_url/api/videos/$video_id"
[[ "$http_status" == "200" ]] || report_api_error "could not read the video" "$http_status" "$http_body"
status=$(jq -r '.status' <<<"$http_body")
playback_url=$(jq -r '.playbackUrl // empty' <<<"$http_body")
elapsed=$((SECONDS - started_at))
# The URL is what the caller is actually waiting for, and it is written
# alongside the status — so wait for the URL, not merely for "ready".
if [[ -n "$playback_url" ]]; then
clear_progress
info "Ready after ${elapsed}s."
info ""
info "Playback URL:"
printf '%s\n' "$playback_url"
exit 0
fi
if [[ "$status" == "failed" ]]; then
clear_progress
printf '\nerror: transcoding failed for video %s after %ss — no playback URL will be produced.\n' "$video_id" "$elapsed" >&2
exit 2
fi
if ((elapsed >= poll_timeout)); then
clear_progress
printf '\nerror: timed out after %ss with status %q and no playback URL.\n Check again with: curl -s %s/api/videos/%s | jq\n' \
"$elapsed" "$status" "$base_url" "$video_id" >&2
exit 3
fi
progress "status: $status${elapsed}s elapsed"
sleep "$poll_interval"
done