Last active
March 15, 2024 13:46
-
-
Save andrewmackrodt/e1020095a871f97660667553db88b79c to your computer and use it in GitHub Desktop.
Beachbody On Demand 1080p Downloader
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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/" |
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
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
Does this code work?