358 lines
12 KiB
Bash
Executable File
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
|