#!/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 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:-}" >&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