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/"
@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