Skip to content

Instantly share code, notes, and snippets.

@andrewmackrodt
Last active March 15, 2024 13:46
Show Gist options
  • Save andrewmackrodt/e1020095a871f97660667553db88b79c to your computer and use it in GitHub Desktop.
Save andrewmackrodt/e1020095a871f97660667553db88b79c to your computer and use it in GitHub Desktop.
Beachbody On Demand 1080p Downloader
#!/bin/bash
IFS=$'\n\t'
set -eou pipefail
# configure credentials
BBOD_API_KEY="" # bbConfig.configData.bbVideoDataMode
BBOD_PROFILE="" # bbConfig.profileID
BBOD_USER_AGENT="" # can probably be anything
# check whether the content id has been specified
if [[ $# == 0 ]] || [[ "$1" == "" ]]; then
echo "ERR the first argument is required" >&2
echo "Usage: ./bbod-dl <ContentGUID>" >&2
exit 1
fi
CONTENT_GUID=$1
OUTPUT_FILENAME=${2:-${CONTENT_GUID}.mp4}
echo "Downloading $CONTENT_GUID to $OUTPUT_FILENAME" >&2
if [[ -f "$OUTPUT_FILENAME" ]]; then
echo "ERR file exists $OUTPUT_FILENAME" >&2
exit 1
fi
fetch_media_data () {
curl -sS --no-keepalive --http1.1 \
-o mediaData.json \
-H "x-api-key: $BBOD_API_KEY" \
-H "Content-Type: application/json" \
-H "User-Agent: $BBOD_USER_AGENT" \
"https://video-data.api.beachbodyondemand.com/v1/mediaData?platform=android&guid=$CONTENT_GUID&fields=default,thumbnail,audio,ads&profile=$BBOD_PROFILE"
}
fetch_streams () {
curl -sS --no-keepalive --http1.1 \
-o "${CONTENT_GUID}.m3u8" \
-L \
-H "User-Agent: $BBOD_USER_AGENT" \
"$1"
}
fetch_file () {
curl -sS --no-keepalive --http1.1 \
-o "$2" \
-L \
-H "User-Agent: $BBOD_USER_AGENT" \
"$1"
}
fetch_aes_key () {
local CONTENT_HOST=$(echo "$1" | sed -E 's#^https?://([^/]+).*#\1#')
local VIDEO_ID=$(echo "$1" | sed -E 's#^.+?/([^/]+)/[^/.]\.m3u8.*#\1#')
local PBS=$(echo "$1" | sed -E 's#^.+?&pbs=([^&]+).*#\1#')
curl -sS --no-keepalive --http1.1 \
-o "$2.key" \
-H "User-Agent: $BBOD_USER_AGENT" \
"https://$CONTENT_HOST/check2?b=$VIDEO_ID&v=$VIDEO_ID&r=$2&pbs=$PBS"
}
WORKSPACE_PATH=$PWD/$CONTENT_GUID.tmp
[[ -d "$WORKSPACE_PATH" ]] || mkdir "$WORKSPACE_PATH"
pushd "$WORKSPACE_PATH" >/dev/null
# fetch the mediaData to extract urls.playbackStandard
if [[ ! -f mediaData.json ]]; then
echo "Fetching mediaData.json" >&2
fetch_media_data
fi
streamcount=1
# fetch first english caption
CAPTIONS_URL=$(jq -r '.captions[]|select((.language=="") or (.language=="en"))|.url' mediaData.json | sort | uniq | head -n1 || true)
if [[ "$CAPTIONS_URL" != "" ]]; then
echo "Fetching subtitle k.srt" >&2
if [[ ! -f k.srt ]]; then
fetch_file "$CAPTIONS_URL" k.srt
fi
streamcount=$(($streamcount + 1))
fi
# fetch streams metadata using the extracted url
if [[ ! -f "${CONTENT_GUID}.m3u8" ]]; then
echo "Fetching stream metadata ${CONTENT_GUID}.m3u8" >&2
fetch_streams $(grep 'playbackStandard' mediaData.json | sed -E 's/^.+?"playbackStandard": "([^"]+)".*$/\1/')
fi
PLAYLIST_META_LINE=$(grep -nE 'RESOLUTION=1920x1080' "${CONTENT_GUID}.m3u8" | cut -f1 -d:)
PLAYLIST_URL=$(sed $(($PLAYLIST_META_LINE + 1))'!d' "${CONTENT_GUID}.m3u8")
STREAM_CODE="$(echo $PLAYLIST_URL | sed -E 's#^.+?/([^./]+)\.m3u8\b.*$#\1#')"
# fetch the playlist for the 1080p stream
if [[ ! -f k.m3u8 ]]; then
echo "Fetching playlist k.m3u8" >&2
fetch_file "$PLAYLIST_URL" k.m3u8
fi
# fetch the decryption key
if [[ ! -f "$STREAM_CODE.key" ]]; then
echo "Fetching aes key $STREAM_CODE.key" >&2
fetch_aes_key "$PLAYLIST_URL" "$STREAM_CODE"
fi
# download each video slice
echo "Downloading video chunks" >&2
[[ -d chunks/k ]] || mkdir -p chunks/k
for url in $(cat k.m3u8 | grep -E '^https?:[^?]+\.ts' | sed -E 's/\?.+//'); do
filename=$(basename "$url" | sed -E 's/\?.*$//')
if [[ ! -f "chunks/k/$filename" ]]; then
while [[ $(jobs -p | wc -l) -ge 8 ]]; do
sleep 0.1
done
curl -sS -o "chunks/k/$filename" "$url" &
fi
done
# combine the ts file
echo "Combining video chunks -> k.ts" >&2
if [[ ! -f k.ts ]]; then
sed -E 's/^(https?:[^?]+)\?.*/\1/' 'k.m3u8' \
| sed -E 's/^(#EXT-X-KEY:.*URI)="[^"]+"/\1="'$STREAM_CODE'.key"/' \
| sed -E 's/^https?:.+?\/([A-Za-z0-9]+\.ts)$/chunks\/k\/\1/' > 'k-local.m3u8'
ffmpeg -loglevel warning -allowed_extensions key,ts -i 'k-local.m3u8' -c copy 'k.ts'
fi
# fetch alt audio playlists
alttracks=$(grep -E '^#EXT-X-MEDIA:' "${CONTENT_GUID}.m3u8" | grep 'TYPE=AUDIO' | grep 'LANGUAGE="en"' | grep 'DEFAULT=NO' | grep 'URI="' || true)
altcount=0
for alt in $alttracks; do
ALT_PLAYLIST_NAME=$(echo "$alt" | sed -E 's/.+NAME="([^"]+)".*/\1/')
ALT_PLAYLIST_URL=$(echo "$alt" | sed -E 's/.+URI="([^"]+)".*/\1/')
altcount=$(($altcount + 1))
streamcount=$(($streamcount + 1))
if [[ ! -f "a${altcount}.m3u8" ]]; then
echo "Fetching alt audio playlist a${altcount}.m3u8" >&2
fetch_file "$ALT_PLAYLIST_URL" "a${altcount}.m3u8"
fi
done
# fetch alt audio decryption key
if [[ "$alttracks" != "" ]] && [[ ! -f a.key ]]; then
echo "Fetching alt audio aes key a.key" >&2
fetch_aes_key "$PLAYLIST_URL" a
fi
# download alt audio chunks
for i in $(seq 1 $altcount); do
ALT_PLAYLIST_NAME=$(echo "$alttracks" | tail -n+$i | head -n1 | sed -E 's/.+NAME="([^"]+)".*/\1/')
echo "Downloading alt audio chunks [$i] $ALT_PLAYLIST_NAME" >&2
[[ -d "chunks/a$i" ]] || mkdir -p "chunks/a$i"
for url in $(cat "a$i.m3u8" | grep -E '^https?:[^?]+\.aac' | sed -E 's/\?.+//'); do
filename=$(basename "$url" | sed -E 's/\?.*$//')
if [[ ! -f "chunks/a$i/$filename" ]]; then
while [[ $(jobs -p | wc -l) -ge 8 ]]; do
sleep 0.1
done
curl -sS -o "chunks/a$i/$filename" "$url"
fi
done
done
# combine alt audio files
for i in $(seq 1 $altcount); do
if [[ -f "a$i.aac" ]]; then
continue
fi
sed -E 's/^(https?:[^?]+)\?.*/\1/' "a$i.m3u8" \
| sed -E 's/^(#EXT-X-KEY:.*URI)="[^"]+"/\1="a.key"/' \
| sed -E 's/^https?:.+?\/([A-Za-z0-9]+\.aac)$/chunks\/a'$i'\/\1/' > "a$i-local.m3u8"
ffmpeg -loglevel warning -allowed_extensions key,aac -i "a$i-local.m3u8" -c copy "a$i.aac"
done
popd >/dev/null
# create the combined mp4 file
echo "Creating mp4 file $OUTPUT_FILENAME" >&2
command=( "ffmpeg" "-i" "${WORKSPACE_PATH}/k.ts" )
for i in $(seq 1 $altcount); do
command=( "${command[@]}" "-i" "${WORKSPACE_PATH}/a$i.aac" )
done
if [[ "$CAPTIONS_URL" != "" ]]; then
command=( "${command[@]}" "-i" "${WORKSPACE_PATH}/k.srt" )
fi
for i in $(seq 0 $altcount); do
command=( "${command[@]}" "-map" "$i" )
command=( "${command[@]}" "-metadata:s:a:$i" "language=eng" )
done
if [[ "$CAPTIONS_URL" != "" ]]; then
command=( "${command[@]}" "-map" "$(($streamcount - 1))" )
command=( "${command[@]}" "-metadata:s:s:0" "language=eng" )
command=( "${command[@]}" "-metadata:s:s:0" "handler=English" )
fi
DEFAULT_TRACK_NAME=$(grep -E '^#EXT-X-MEDIA:' "${WORKSPACE_PATH}/${CONTENT_GUID}.m3u8" | grep 'TYPE=AUDIO' | grep 'DEFAULT=YES' | grep -v 'URI="' | sed -E 's/.+NAME="([^"]+)".*/\1/' || true)
if [[ "$DEFAULT_TRACK_NAME" != "" ]]; then
command=( "${command[@]}" "-metadata:s:a:0" "handler=$DEFAULT_TRACK_NAME" )
fi
for i in $(seq 1 $altcount); do
ALT_PLAYLIST_NAME=$(echo "$alttracks" | tail -n+$i | head -n1 | sed -E 's/.+NAME="([^"]+)".*/\1/')
command=( "${command[@]}" "-metadata:s:a:$i" "handler=$ALT_PLAYLIST_NAME" )
done
command=( "${command[@]}" "-c" "copy" )
if [[ "$CAPTIONS_URL" != "" ]]; then
command=( "${command[@]}" "-c:s" "mov_text" )
fi
command=( "${command[@]}" "$OUTPUT_FILENAME" )
"${command[@]}"
# perform clean up
echo "Removing working directory" >&2
rm -rf "$WORKSPACE_PATH/"
@spoonys
Copy link

spoonys commented Mar 26, 2022

Does this code work?

@gibson3659
Copy link

I'm about 50% to a generic solution. Your api-key, the current bearer token, and transaction_id(not sure if this matters) can be found via the developer tools. I can automate the full flow if I can figure out the oauth flow to get the bearer token.

for x in {04..31}; do 

curl -s "https://cocoa-prod.prod.cd.beachbodyondemand.com/v1.0/playback/CWCW00${x}" \
  -X 'OPTIONS' \
  -H 'authority: cocoa-prod.prod.cd.beachbodyondemand.com' \
  -H 'accept: */*' \
  -H 'accept-language: en-US,en;q=0.9' \
  -H 'access-control-request-headers: accept-language,authorization,bodweb,platform,platform-version,x-api-key,x-transaction-id' \
  -H 'access-control-request-method: GET' \
  -H 'dnt: 1' \
  -H 'origin: https://www.beachbodyondemand.com' \
  -H 'referer: https://www.beachbodyondemand.com/' \
  -H 'sec-fetch-dest: empty' \
  -H 'sec-fetch-mode: cors' \
  -H 'sec-fetch-site: same-site' \
  -H 'sec-gpc: 1' \
  -H 'user-agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36' \
  --compressed

meta=$(curl -s "https://cocoa-prod.prod.cd.beachbodyondemand.com/v1.0/playback/CWCW00${x}" \
  -H 'authority: cocoa-prod.prod.cd.beachbodyondemand.com' \
  -H 'accept: */*' \
  -H 'accept-language: en_us' \
  -H 'authorization: Bearer $bearer' \
  -H 'bodweb: true' \
  -H 'dnt: 1' \
  -H 'origin: https://www.beachbodyondemand.com' \
  -H 'platform: webvideoplayer' \
  -H 'platform-version: v2.47.0-RC2' \
  -H 'referer: https://www.beachbodyondemand.com/' \
  -H 'sec-ch-ua: "Not A(Brand";v="99", "Google Chrome";v="121", "Chromium";v="121"' \
  -H 'sec-ch-ua-mobile: ?0' \
  -H 'sec-ch-ua-platform: "Linux"' \
  -H 'sec-fetch-dest: empty' \
  -H 'sec-fetch-mode: cors' \
  -H 'sec-fetch-site: same-site' \
  -H 'sec-gpc: 1' \
  -H 'user-agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36' \
  -H 'x-api-key: $api-key' \
  -H 'x-transaction-id: $tid' \
  --compressed)
guid=$(echo $meta | jq -r ".metadata.guid")
title=$(echo $meta | jq -r ".metadata.title")
playbackStandard=$(echo $meta| jq -r ".urls.playbackStandard")
echo $title - $guid - $playbackStandard

test ! -f "$title.mp4" && youtube-dl -o "$title.mp4" $playbackStandard  \
        --add-header 'authority: d197pzlrcwv1zr.cloudfront.net' \
      --add-header 'referer: https://www.beachbodyondemand.com/' \
      --user-agent 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36'

done

@spoonys
Copy link

spoonys commented Mar 15, 2024

I'm about 50% to a generic solution. Your api-key, the current bearer token, and transaction_id(not sure if this matters) can be found via the developer tools. I can automate the full flow if I can figure out the oauth flow to get the bearer token.

for x in {04..31}; do 

curl -s "https://cocoa-prod.prod.cd.beachbodyondemand.com/v1.0/playback/CWCW00${x}" \
  -X 'OPTIONS' \
  -H 'authority: cocoa-prod.prod.cd.beachbodyondemand.com' \
  -H 'accept: */*' \
  -H 'accept-language: en-US,en;q=0.9' \
  -H 'access-control-request-headers: accept-language,authorization,bodweb,platform,platform-version,x-api-key,x-transaction-id' \
  -H 'access-control-request-method: GET' \
  -H 'dnt: 1' \
  -H 'origin: https://www.beachbodyondemand.com' \
  -H 'referer: https://www.beachbodyondemand.com/' \
  -H 'sec-fetch-dest: empty' \
  -H 'sec-fetch-mode: cors' \
  -H 'sec-fetch-site: same-site' \
  -H 'sec-gpc: 1' \
  -H 'user-agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36' \
  --compressed

meta=$(curl -s "https://cocoa-prod.prod.cd.beachbodyondemand.com/v1.0/playback/CWCW00${x}" \
  -H 'authority: cocoa-prod.prod.cd.beachbodyondemand.com' \
  -H 'accept: */*' \
  -H 'accept-language: en_us' \
  -H 'authorization: Bearer $bearer' \
  -H 'bodweb: true' \
  -H 'dnt: 1' \
  -H 'origin: https://www.beachbodyondemand.com' \
  -H 'platform: webvideoplayer' \
  -H 'platform-version: v2.47.0-RC2' \
  -H 'referer: https://www.beachbodyondemand.com/' \
  -H 'sec-ch-ua: "Not A(Brand";v="99", "Google Chrome";v="121", "Chromium";v="121"' \
  -H 'sec-ch-ua-mobile: ?0' \
  -H 'sec-ch-ua-platform: "Linux"' \
  -H 'sec-fetch-dest: empty' \
  -H 'sec-fetch-mode: cors' \
  -H 'sec-fetch-site: same-site' \
  -H 'sec-gpc: 1' \
  -H 'user-agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36' \
  -H 'x-api-key: $api-key' \
  -H 'x-transaction-id: $tid' \
  --compressed)
guid=$(echo $meta | jq -r ".metadata.guid")
title=$(echo $meta | jq -r ".metadata.title")
playbackStandard=$(echo $meta| jq -r ".urls.playbackStandard")
echo $title - $guid - $playbackStandard

test ! -f "$title.mp4" && youtube-dl -o "$title.mp4" $playbackStandard  \
        --add-header 'authority: d197pzlrcwv1zr.cloudfront.net' \
      --add-header 'referer: https://www.beachbodyondemand.com/' \
      --user-agent 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36'

done

Looks interesting. I managed a very manual workaround. I would be interested in working this through with you. If you had telegram or discord for a quick message or two?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment