#!/usr/bin/env bash # test-syno-slice.sh — verify Synology Recording.Download offsetTimeMs/playTimeMs slicing # # Usage: # ./test-syno-slice.sh "2026-05-02 22:20:30" # 2 min before, 1 min after (defaults) # ./test-syno-slice.sh "2026-05-02 22:20:30" 120 60 # explicit pre/post seconds # COMMISSION_TS=1746224430 ./test-syno-slice.sh # raw UTC unix timestamp # # Requires: curl, jq, date (GNU or BSD). set -euo pipefail # ── CONFIG ───────────────────────────────────────────────────────────────── NAS_URL="${SYNOLOGY_URL:-http://192.168.1.56:5000}" NAS_USER="${SYNOLOGY_USER:-adminyk}" NAS_PASS="${SYNOLOGY_PASSWORD:-X63Yipmz??B^w}" CAMERA_IDS="${SYNOLOGY_CAMERA_IDS:-2}" # comma-sep, e.g. "2,7" PRE_SECONDS="${2:-120}" # seconds before the timestamp POST_SECONDS="${3:-60}" # seconds after OUT_DIR="${OUT_DIR:-./syno-test}" API_VERSION=6 # ─────────────────────────────────────────────────────────────────────────── CYAN='\033[96m'; GRN='\033[92m'; RED='\033[91m'; DIM='\033[90m'; END='\033[0m' # Resolve the commission unix timestamp if [[ -n "${COMMISSION_TS:-}" ]]; then TS_UNIX="$COMMISSION_TS" elif [[ -n "${1:-}" ]]; then # Try GNU date first, fall back to BSD date TS_UNIX=$(date -u -d "$1" +%s 2>/dev/null || date -u -j -f "%Y-%m-%d %H:%M:%S" "$1" +%s 2>/dev/null || true) if [[ -z "$TS_UNIX" ]]; then echo "Could not parse timestamp: $1" >&2 echo "Use ISO-ish format like '2026-05-02 22:20:30' or pass COMMISSION_TS=… as a unix epoch." >&2 exit 1 fi else echo "Pass a timestamp: ./test-syno-slice.sh \"2026-05-02 22:20:30\"" >&2 exit 1 fi FROM_TS=$(( TS_UNIX - PRE_SECONDS )) TO_TS=$(( TS_UNIX + POST_SECONDS )) WINDOW_MS=$(( (PRE_SECONDS + POST_SECONDS) * 1000 )) mkdir -p "$OUT_DIR" echo -e "${CYAN}Window:${END} $(date -u -r "$FROM_TS" 2>/dev/null || date -u -d "@$FROM_TS") → $(date -u -r "$TO_TS" 2>/dev/null || date -u -d "@$TO_TS") (UTC)" echo -e "${CYAN}Cameras:${END} $CAMERA_IDS" echo -e "${CYAN}Commission ts:${END} $TS_UNIX (pre=${PRE_SECONDS}s post=${POST_SECONDS}s → ${WINDOW_MS}ms slice)" echo # ── 1) LOGIN ─────────────────────────────────────────────────────────────── echo -e "${DIM}→ Logging in…${END}" LOGIN_JSON=$(curl -sS -G "${NAS_URL}/webapi/entry.cgi" \ --data-urlencode "api=SYNO.API.Auth" \ --data-urlencode "method=login" \ --data-urlencode "version=7" \ --data-urlencode "account=${NAS_USER}" \ --data-urlencode "passwd=${NAS_PASS}" \ --data-urlencode "session=SurveillanceStation" \ --data-urlencode "format=sid") SID=$(echo "$LOGIN_JSON" | jq -r '.data.sid // empty') if [[ -z "$SID" ]]; then echo -e "${RED}Login failed:${END} $LOGIN_JSON" >&2 exit 1 fi echo -e " ${GRN}✓${END} sid=${SID:0:12}…" cleanup() { curl -sS -G "${NAS_URL}/webapi/entry.cgi" \ --data-urlencode "api=SYNO.API.Auth" \ --data-urlencode "method=logout" \ --data-urlencode "version=7" \ --data-urlencode "session=SurveillanceStation" \ --data-urlencode "_sid=${SID}" >/dev/null 2>&1 || true } trap cleanup EXIT # ── 2) LIST RECORDINGS ───────────────────────────────────────────────────── echo -e "${DIM}→ Listing recordings overlapping window…${END}" LIST_JSON=$(curl -sS -G "${NAS_URL}/webapi/entry.cgi" \ --data-urlencode "api=SYNO.SurveillanceStation.Recording" \ --data-urlencode "method=List" \ --data-urlencode "version=${API_VERSION}" \ --data-urlencode "cameraIds=${CAMERA_IDS}" \ --data-urlencode "fromTime=${FROM_TS}" \ --data-urlencode "toTime=${TO_TS}" \ --data-urlencode "_sid=${SID}") if [[ "$(echo "$LIST_JSON" | jq -r '.success')" != "true" ]]; then echo -e "${RED}List failed:${END} $LIST_JSON" >&2 exit 1 fi # Try both shapes: data.recordings, data.events COUNT=$(echo "$LIST_JSON" | jq '(.data.recordings // .data.events // []) | length') if [[ "$COUNT" -eq 0 ]]; then echo -e "${RED}No recordings in window.${END}" exit 1 fi echo -e " ${GRN}✓${END} ${COUNT} recording(s) in window" # Dump the raw shape of the first recording so we can see the actual # field names this DSM/SS build returns. Recording objects on different # Surveillance Station versions use any of: startTime, start_time, # recordingTime, recordTime, time, recordTimeBegin, … echo -e "${DIM}→ Raw first recording object (for field inspection):${END}" echo "$LIST_JSON" | jq '(.data.recordings // .data.events // [])[0]' | sed 's/^/ /' echo # DSM/SS on this build doesn't expose a startTime JSON field — instead # the recording's start UTC timestamp (in milliseconds) is embedded in # the filePath, e.g.: # Kommissionierung-1-20260503-002000-1777760400027-7.mp4 # ^^^^^^^^^^^^^ ms # We extract the trailing 13-digit number before -.mp4 and divide # by 1000 to get unix seconds. sizeByte is reported in MB on this build, # not bytes — handy for picking but not load-bearing here. CHOSEN=$(echo "$LIST_JSON" | jq --argjson ts "$TS_UNIX" ' def parse_start_from_path: (capture("(?[0-9]{13})-[0-9]+\\.mp4$") | (.ms | tonumber / 1000 | floor)) // 0; (.data.recordings // .data.events // []) | map({ id: (.id // .recordId), filePath: (.filePath // ""), sizeMB: (.sizeByte // 0), startTime: (try (.filePath | parse_start_from_path) catch 0) }) | map(. + {stopTime: 0}) | sort_by(((.startTime - $ts)) | if . < 0 then -. else . end) | .[0]') REC_ID=$(echo "$CHOSEN" | jq -r '.id') REC_START=$(echo "$CHOSEN" | jq -r '.startTime') REC_PATH=$(echo "$CHOSEN" | jq -r '.filePath') REC_SIZE_MB=$(echo "$CHOSEN" | jq -r '.sizeMB') echo -e " ${CYAN}→ chosen id=${REC_ID}${END}" echo -e " path : ${REC_PATH}" echo -e " start : ${REC_START} ($(date -u -r "$REC_START" 2>/dev/null || date -u -d "@$REC_START"))" echo -e " size : ~${REC_SIZE_MB} MB (per List response)" if [[ "$REC_START" -lt 1000000000 ]]; then echo -e " ${RED}!${END} startTime still looks bogus (${REC_START}). Inspect the dump above and tell me which key holds the start timestamp." echo -e " ${DIM}Skipping slice computation — using offsetTimeMs=0, playTimeMs=${WINDOW_MS} so we can still test that the params are accepted.${END}" OFFSET_MS=0 PLAY_MS=${WINDOW_MS} else SLICE_START=$(( FROM_TS > REC_START ? FROM_TS : REC_START )) OFFSET_MS=$(( (SLICE_START - REC_START) * 1000 )) PLAY_MS=$(( (TO_TS - SLICE_START) * 1000 )) [[ $PLAY_MS -lt 1000 ]] && PLAY_MS=1000 fi echo -e " ${CYAN}→ slice${END} offsetTimeMs=${OFFSET_MS} playTimeMs=${PLAY_MS}" echo # ── 3a) DOWNLOAD FULL FILE (for comparison) ──────────────────────────────── FULL_FILE="${OUT_DIR}/cam${CAMERA_IDS}_id${REC_ID}_FULL.mp4" echo -e "${DIM}→ Downloading FULL file…${END}" curl -sS -G "${NAS_URL}/webapi/entry.cgi" \ --data-urlencode "api=SYNO.SurveillanceStation.Recording" \ --data-urlencode "method=Download" \ --data-urlencode "version=${API_VERSION}" \ --data-urlencode "id=${REC_ID}" \ --data-urlencode "mountId=0" \ --data-urlencode "_sid=${SID}" \ -o "$FULL_FILE" -w " HTTP %{http_code} ${GRN}✓${END} %{size_download} bytes in %{time_total}s\n" # ── 3b) DOWNLOAD SLICED FILE ─────────────────────────────────────────────── SLICE_FILE="${OUT_DIR}/cam${CAMERA_IDS}_id${REC_ID}_SLICE_${OFFSET_MS}_${PLAY_MS}.mp4" echo -e "${DIM}→ Downloading SLICED file (offsetTimeMs=${OFFSET_MS}, playTimeMs=${PLAY_MS})…${END}" curl -sS -G "${NAS_URL}/webapi/entry.cgi" \ --data-urlencode "api=SYNO.SurveillanceStation.Recording" \ --data-urlencode "method=Download" \ --data-urlencode "version=${API_VERSION}" \ --data-urlencode "id=${REC_ID}" \ --data-urlencode "mountId=0" \ --data-urlencode "offsetTimeMs=${OFFSET_MS}" \ --data-urlencode "playTimeMs=${PLAY_MS}" \ --data-urlencode "_sid=${SID}" \ -o "$SLICE_FILE" -w " HTTP %{http_code} ${GRN}✓${END} %{size_download} bytes in %{time_total}s\n" # ── 4) REPORT ────────────────────────────────────────────────────────────── FULL_SIZE=$(wc -c <"$FULL_FILE" | tr -d ' ') SLICE_SIZE=$(wc -c <"$SLICE_FILE" | tr -d ' ') human() { local n=$1 awk -v n="$n" 'BEGIN{ s="B KB MB GB TB"; split(s,u," "); i=1; while (n>=1024 && i<5) {n=n/1024; i++} printf "%.2f %s", n, u[i] }' } echo echo "──────────────────────────────────────────────" echo -e " FULL : $(human "$FULL_SIZE") → $FULL_FILE" echo -e " SLICE : $(human "$SLICE_SIZE") → $SLICE_FILE" if [[ "$FULL_SIZE" -gt 0 ]]; then RATIO=$(awk -v s="$SLICE_SIZE" -v f="$FULL_SIZE" 'BEGIN{printf "%.1f%%", (s/f)*100}') echo -e " Slice is ${CYAN}${RATIO}${END} of full" fi echo "──────────────────────────────────────────────" # Quick sanity check — both files should be playable MP4s file "$FULL_FILE" "$SLICE_FILE" 2>/dev/null || true # If a "download" came back as HTML/JSON/text, dump it so we can see the # Synology-side error message instead of just an opaque byte count. for f in "$FULL_FILE" "$SLICE_FILE"; do if ! file "$f" | grep -qi 'iso media\|mp4\|video'; then echo echo -e "${RED}!${END} $f is NOT an MP4 — server said:" head -c 1500 "$f" | sed 's/^/ /' echo fi done