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/"
@FuzzyTrutle
Copy link

I'm new here and want to use this code. How do I use it to download my videos?

@andrewmackrodt
Copy link
Author

Hi @FuzzyTrutle, I've actually not used this since writing it over a year ago. BBoD appear to have updated their player so this will only be of use to you if you have an older v1 BBOD_API_KEY already. If you do have that, you will require an active BBoD subscription and a local installation of ffmpeg.

You'll need to set these environment variables:

Environment JavaScript
BBOD_API_KEY n/a
BBOD_PROFILE window.utag_data.bod_profile_id
BBOD_USER_AGENT navigator.userAgent

Once that's configured, find the name of the program you want, e.g. 21-day-fix-real-time. Navigate to https://d1m0rv1xg9oqxv.cloudfront.net/v4/programs/21-day-fix-real-time?language=english&purchaseRegion= (replace the program name with whatever you want). Locate the guid of whichever workout you're looking for, e.g. 21DRT005 then run ./bbod-dl 21DRT005.

@marvinhecht
Copy link

Hi - I won't be trying to use this code. However I am experimenting with ways to download BBoD videos. Are all (or most recent ones) programs shot and available in 1920x1080p? One program is able to download LIIFT4 in 1920x1080p, but even this program doesn't always do that. Is there some flag to set, or some trick to get 1080p? Most methods I am experimenting with can only detect a stream in 1056x594 (not even 1280x720).

@andrewmackrodt
Copy link
Author

@marvinhecht I have LIIFT4 and it is in 1080p; your comment made me remember how I got my api key, I proxied the Android application through Charles Proxy as I had noticed that mobile videos were always 1080p but via a browser they were often around 720p.

@FuzzyTrutle
Copy link

FuzzyTrutle commented Apr 14, 2020 via email

@FevLoad
Copy link

FevLoad commented Apr 15, 2020

@marvinhecht I have LIIFT4 and it is in 1080p; your comment made me remember how I got my api key, I proxied the Android application through Charles Proxy as I had noticed that mobile videos were always 1080p but via a browser they were often around 720p.

Please I would like to get LIFT4 1080p copy if you're willing to share it with me.
also I'm willing to sponsor if this script can be fix with well guide on HOW TO USE
thanks in advance mate.

@FuzzyTrutle
Copy link

FuzzyTrutle commented Apr 15, 2020 via email

@FuzzyTrutle
Copy link

FuzzyTrutle commented Apr 19, 2020 via email

@lbigtonyl
Copy link

On the mobile app, you can download workouts offline. I wonder if you/we could use NOX or Bluestacks Android emulator to install the app and download the videos. Then use some kind of script? to combine all the video pieces together and watch them.

@FuzzyTrutle
Copy link

FuzzyTrutle commented Oct 10, 2020 via email

@unicyco
Copy link

unicyco commented Jan 9, 2021

I'd like to try and make this work again. From what I'm seeing, any HLS downloaders only see a maximum resolution of 1056x594. I'm not sure how @FuzzyTrutle was able to get the 1080p versions using HLS Downloader on a PC. The beachbody app has a selection for 1080p, and so I think the approach of getting the app's API key as described by @andrewmackrodt is necessary. Has anyone tried this on the current version of the app and web player? I've never used Charles Proxy but understand the concept of proxying the phone's traffic to snoop the API key. I'm hoping that simulating the app on a computer would allow for downloading the files using the script above. Any insights?

@oNVeLEck
Copy link

oNVeLEck commented Feb 9, 2021

BBOD_PROFILE="" # bbConfig.profileID
What is the ID, the username, Login email ...?

@zeluisao
Copy link

I too want to download some videos before my sub runs out. I don't have the slightest clue where to begin. What program do I need to run this code?

@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