├── README.md └── overdrive.sh /README.md: -------------------------------------------------------------------------------- 1 | # OverDrive 2 | 3 | _**Update:**_ as of early 2025, OverDrive is finally _dead_ dead 😞 4 | 5 | * This repo is not called `libby` and will not be retrofitted to accommodate Libby. 6 | * This project has no interest in circumventing DRM or aiding others to circumvent DRM. Never has, never will. 7 | * Discussion page for alternatives and workarounds: https://github.com/chbrown/overdrive/discussions/70 8 | * To read the old README: https://github.com/chbrown/overdrive/blob/2.4.1/README.md 9 | 10 | ## License 11 | 12 | Copyright © 2017–2021 Christopher Brown. 13 | [MIT Licensed](https://chbrown.github.io/licenses/MIT/#2017-2021). 14 | -------------------------------------------------------------------------------- /overdrive.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e # exit immediately on first error 4 | set -o pipefail # propagate intermediate pipeline errors 5 | 6 | # should match `git describe --tags` with clean working tree 7 | VERSION=2.4.1 8 | 9 | OMC=1.2.0 10 | OS=10.11.6 11 | # use same user agent as mobile app 12 | UserAgent='OverDrive Media Console' 13 | 14 | usage() { 15 | >&2 cat <&2 printf '%s\n' "$VERSION" 47 | exit 0 48 | ;; 49 | -v|--verbose) 50 | >&2 printf 'Entering debug (verbose) mode\n' 51 | set -x 52 | CURLOPTS=("${CURLOPTS[@]:1}") # slice off the '-s' 53 | ;; 54 | --insecure) 55 | CURLOPTS+=("$1") 56 | ;; 57 | -o|--output) 58 | shift 59 | DIR_FORMAT=$1 60 | ;; 61 | *.odm) 62 | if [[ ! -e $1 ]]; then 63 | >&2 printf 'Specified media file does not exist: %s\n' "$1" 64 | exit 2 # ENOENT 2 No such file or directory 65 | fi 66 | MEDIA+=("$1") 67 | ;; 68 | download|return|info|metadata) 69 | COMMANDS+=("$1") 70 | ;; 71 | *) 72 | >&2 printf 'Unrecognized argument: %s\n' "$1" 73 | exit 1 74 | ;; 75 | esac 76 | shift 77 | done 78 | 79 | if [[ ${#MEDIA[@]} -eq 0 || ${#COMMANDS[@]} -eq 0 ]]; then 80 | usage 81 | >&2 printf '\n' 82 | [[ ${#COMMANDS[@]} -eq 0 ]] && >&2 printf 'You must supply at least one command.\n' 83 | [[ ${#MEDIA[@]} -eq 0 ]] && >&2 printf 'You must supply at least one media file (the .odm extension is required).\n' 84 | exit 1 85 | fi 86 | 87 | for PREREQ in curl uuidgen xmllint iconv openssl base64; do 88 | if ! command -v $PREREQ >/dev/null 2>&1; then 89 | >&2 printf 'Cannot locate required executable "%s". ' $PREREQ 90 | >&2 printf 'This will likely result in an error later on, ' 91 | >&2 printf 'which might leave you in an inconsistent failure state, ' 92 | >&2 printf 'but continuing anyway.\n' 93 | fi 94 | done 95 | 96 | _sanitize() { 97 | # Usage: printf 'Hello, world!\n' | _sanitize 98 | # 99 | # Replace filename-unfriendly characters with a hyphen and trim leading/trailing hyphens/spaces 100 | tr -Cs '[:alnum:] ._-' - | sed -e 's/^[- ]*//' -e 's/[- ]*$//' 101 | } 102 | 103 | _xmllint_iter_xpath() { 104 | # Usage: _xmllint_iter_xpath /xpath/to/list file.xml [/path/to/value] 105 | # 106 | # Iterate over each XPath match, ensuring each ends with exactly one newline. 107 | count=$(xmllint --xpath "count($1)" "$2") 108 | if [[ $count -gt 0 ]]; then 109 | for i in $(seq 1 "$count"); do 110 | # xmllint does not reliably emit newlines, so we use command substitution to 111 | # trim trailing newlines, if there are any, and printf to add exactly one. 112 | printf '%s\n' "$(xmllint --xpath "string($1[position()=$i]$3)" "$2")" 113 | done 114 | fi 115 | } 116 | 117 | acquire_license() { 118 | # Usage: acquire_license book.odm book.license 119 | # 120 | # Read the license signature from book.license if it exists; if it doesn't, 121 | # acquire a license from the OverDrive server and write it to book.license. 122 | # We store the license in a file because OverDrive will only grant one license per `.odm` file, 123 | # so that if something goes wrong later on, like your internet cuts out mid-download, 124 | # it's easy to resume/recover where you left off. 125 | if [[ -s $2 ]]; then 126 | >&2 printf 'License already acquired: %s\n' "$2" 127 | else 128 | # generate random Client (GU)ID 129 | ClientID=$(uuidgen | tr '[:lower:]' '[:upper:]') 130 | >&2 printf 'Generating random ClientID=%s\n' "$ClientID" 131 | 132 | # first extract the "AcquisitionUrl" 133 | AcquisitionUrl=$(xmllint --xpath '/OverDriveMedia/License/AcquisitionUrl/text()' "$1") 134 | >&2 printf 'Using AcquisitionUrl=%s\n' "$AcquisitionUrl" 135 | # along with the only other important (for getting the license) field, "MediaID" 136 | MediaID=$(xmllint --xpath 'string(/OverDriveMedia/@id)' "$1") 137 | >&2 printf 'Using MediaID=%s\n' "$MediaID" 138 | 139 | # Compute the Base64-encoded SHA-1 hash from a few `|`-separated values 140 | # and a suffix of `OVERDRIVE*MEDIA*CONSOLE`, but backwards. 141 | # Thanks to https://github.com/jvolkening/gloc/blob/v0.601/gloc#L1523-L1531 142 | # for somehow figuring out how to construct that hash! 143 | RawHash="$ClientID|$OMC|$OS|ELOSNOC*AIDEM*EVIRDREVO" 144 | >&2 printf 'Using RawHash=%s\n' "$RawHash" 145 | Hash=$(echo -n "$RawHash" | iconv -f ASCII -t UTF-16LE | openssl dgst -binary -sha1 | base64) 146 | >&2 printf 'Using Hash=%s\n' "$Hash" 147 | 148 | # Submit a request to the OverDrive server to get the full license for this book, 149 | # which is a small XML file with a root element , 150 | # which contains a long Base64-encoded , 151 | # which is subsequently used to retrieve the content files. 152 | http_code=$(curl "${CURLOPTS[@]}" -o "$2" -w '%{http_code}' \ 153 | "$AcquisitionUrl?MediaID=$MediaID&ClientID=$ClientID&OMC=$OMC&OS=$OS&Hash=$Hash") 154 | # if server responded with something besides an HTTP 200 OK (or other 2** success code), 155 | # print the failure response to stderr and delete the (invalid) file 156 | if [[ $http_code != 2?? ]]; then 157 | >&2 cat "$2" 158 | rm "$2" 159 | exit 22 # curl's exit code for "HTTP page not retrieved" 160 | fi 161 | fi 162 | } 163 | 164 | extract_metadata() { 165 | # Usage: extract_metadata book.odm book.metadata 166 | # 167 | # The Metadata XML is nested as CDATA inside the the root OverDriveMedia element; 168 | # luckily, it's the only text content at that level 169 | # sed: delete CDATA prefix from beginning of first line and suffix from end of last line, 170 | # replace unescaped & characters with & entities, 171 | # and convert a selection of named HTML entities to their decimal code points 172 | if [[ -s $2 ]]; then 173 | : # >&2 printf 'Metadata already extracted: %s\n' "$2" 174 | else 175 | xmllint --noblanks --xpath '/OverDriveMedia/text()' "$1" \ 176 | | sed -e '1s/^$//' \ 177 | -e 's/ & / \& /g' \ 178 | -e 's/ /\ /g' \ 179 | -e 's/¡/\¡/g' \ 180 | -e 's/¢/\¢/g' \ 181 | -e 's/£/\£/g' \ 182 | -e 's/¥/\¥/g' \ 183 | -e 's/§/\§/g' \ 184 | -e 's/©/\©/g' \ 185 | -e 's/ª/\ª/g' \ 186 | -e 's/«/\«/g' \ 187 | -e 's/®/\®/g' \ 188 | -e 's/°/\°/g' \ 189 | -e 's/²/\²/g' \ 190 | -e 's/³/\³/g' \ 191 | -e 's/¶/\¶/g' \ 192 | -e 's/º/\º/g' \ 193 | -e 's/»/\»/g' \ 194 | -e 's/¿/\¿/g' \ 195 | -e 's/À/\À/g' \ 196 | -e 's/Á/\Á/g' \ 197 | -e 's/Å/\Å/g' \ 198 | -e 's/Æ/\Æ/g' \ 199 | -e 's/Ç/\Ç/g' \ 200 | -e 's/È/\È/g' \ 201 | -e 's/É/\É/g' \ 202 | -e 's/Ì/\Ì/g' \ 203 | -e 's/Í/\Í/g' \ 204 | -e 's/Ò/\Ò/g' \ 205 | -e 's/Ó/\Ó/g' \ 206 | -e 's/Ö/\Ö/g' \ 207 | -e 's/×/\×/g' \ 208 | -e 's/Ø/\Ø/g' \ 209 | -e 's/Ù/\Ù/g' \ 210 | -e 's/Ú/\Ú/g' \ 211 | -e 's/Ü/\Ü/g' \ 212 | -e 's/Ý/\Ý/g' \ 213 | -e 's/à/\à/g' \ 214 | -e 's/á/\á/g' \ 215 | -e 's/è/\è/g' \ 216 | -e 's/é/\é/g' \ 217 | -e 's/ì/\ì/g' \ 218 | -e 's/í/\í/g' \ 219 | -e 's/ò/\ò/g' \ 220 | -e 's/ó/\ó/g' \ 221 | -e 's/ö/\ö/g' \ 222 | -e 's/ù/\ù/g' \ 223 | -e 's/ú/\ú/g' \ 224 | -e 's/ü/\ü/g' \ 225 | -e 's/ý/\ý/g' \ 226 | -e 's/þ/\þ/g' \ 227 | -e 's/Š/\Š/g' \ 228 | -e 's/š/\š/g' \ 229 | -e 's/–/\–/g' \ 230 | -e 's/—/\—/g' \ 231 | -e 's/‘/\‘/g' \ 232 | -e 's/’/\’/g' \ 233 | -e 's/“/\“/g' \ 234 | -e 's/”/\”/g' \ 235 | -e 's/•/\•/g' \ 236 | -e 's/…/\…/g' \ 237 | -e 's/€/\€/g' \ 238 | > "$2" 239 | fi 240 | } 241 | 242 | extract_author() { 243 | # Usage: extract_author book.odm.metadata 244 | # Most Creator/@role values for authors are simply "Author" but some are "Author and narrator" 245 | xmllint --xpath "string(//Creator[starts-with(@role, 'Author')])" "$1" 246 | } 247 | 248 | extract_title() { 249 | # Usage: extract_title book.odm.metadata 250 | xmllint --xpath '//Title/text()' "$1" \ 251 | | _sanitize 252 | } 253 | 254 | extract_duration() { 255 | # Usage: extract_duration book.odm 256 | # 257 | # awk: `-F :` split on colons; for MM:SS, MM=>$1, SS=>$2 258 | # `$1*60 + $2` converts MM:SS into seconds 259 | # `{sum += ...} END {print sum}` output total sum (seconds) 260 | _xmllint_iter_xpath '//Part' "$1" '/@duration' \ 261 | | awk -F : '{sum += $1*60 + $2} END {print sum}' 262 | } 263 | 264 | extract_filenames() { 265 | # Usage: extract_filenames book.odm 266 | _xmllint_iter_xpath '//Part' "$1" '/@filename' \ 267 | | sed -e "s/{/%7B/" -e "s/}/%7D/" 268 | } 269 | 270 | extract_coverUrl() { 271 | # Usage: extract_coverUrl book.odm.metadata 272 | _xmllint_iter_xpath '//CoverUrl' "$1" '/text()' \ 273 | | sed -e "s/{/%7B/" -e "s/}/%7D/" 274 | } 275 | 276 | download() { 277 | # Usage: download book.odm 278 | # 279 | license_path=$1.license 280 | acquire_license "$1" "$license_path" 281 | >&2 printf 'Using License=%s\n' "$(cat "$license_path")" 282 | 283 | # the license XML specifies a default namespace, so the XPath is a bit awkward 284 | ClientID=$(xmllint --xpath '//*[local-name()="ClientID"]/text()' "$license_path") 285 | >&2 printf 'Using ClientID=%s from License\n' "$ClientID" 286 | 287 | # extract metadata 288 | metadata_path=$1.metadata 289 | extract_metadata "$1" "$metadata_path" 290 | 291 | # extract the author and title from the metadata 292 | Author=$(extract_author "$metadata_path") 293 | >&2 printf 'Using Author=%s\n' "$Author" 294 | Title=$(extract_title "$metadata_path") 295 | >&2 printf 'Using Title=%s\n' "$Title" 296 | 297 | # prepare to download the parts 298 | baseurl=$(xmllint --xpath 'string(//Protocol[@method="download"]/@baseurl)' "$1") 299 | 300 | # process substitutions in output directory pattern 301 | dir="${DIR_FORMAT//@AUTHOR/$Author}" 302 | dir="${dir//@TITLE/$Title}" 303 | >&2 printf 'Creating directory %s\n' "$dir" 304 | mkdir -p "$dir" 305 | 306 | # For each of the parts of the book listed in `Novel.odm`, make a request to another OverDrive endpoint, 307 | # which will validate the request and redirect to the actual MP3 file on their CDN, 308 | # and save the result into a folder in the current directory, named like `dir/Part0N.mp3`. 309 | for path in $(extract_filenames "$1"); do 310 | # delete from path up until the last hyphen to the get Part0N.mp3 suffix 311 | suffix=${path##*-} 312 | output="$dir/$suffix" 313 | if [[ -e $output ]]; then 314 | >&2 printf 'Output already exists: %s\n' "$output" 315 | else 316 | >&2 printf 'Downloading %s\n' "$output" 317 | if curl "${CURLOPTS[@]}" \ 318 | -H "License: $(cat "$license_path")" \ 319 | -H "ClientID: $ClientID" \ 320 | -o "$output" \ 321 | "$baseurl/$path"; then 322 | >&2 printf 'Downloaded %s successfully\n' "$output" 323 | else 324 | STATUS=$? 325 | >&2 printf 'Failed trying to download %s\n' "$output" 326 | rm -f "$output" 327 | return $STATUS 328 | fi 329 | fi 330 | done 331 | 332 | # Loop over CoverUrl(s), since there may be none 333 | for CoverUrl in $(extract_coverUrl "$metadata_path" | head -1); do 334 | >&2 printf 'Using CoverUrl=%s\n' "$CoverUrl" 335 | if [[ -n "$CoverUrl" ]]; then 336 | cover_output=$dir/folder.jpg 337 | >&2 printf 'Downloading %s\n' "$cover_output" 338 | if curl "${CURLOPTS[@]}" \ 339 | -o "$cover_output" \ 340 | "$CoverUrl"; then 341 | >&2 printf 'Downloaded cover image successfully\n' 342 | else 343 | STATUS=$? 344 | >&2 printf 'Failed trying to download cover image\n' 345 | rm -f "$cover_output" 346 | return $STATUS 347 | fi 348 | else 349 | >&2 printf 'Cover image not available\n' 350 | fi 351 | done 352 | } 353 | 354 | early_return() { 355 | # Usage: early_return book.odm 356 | # 357 | # return is a bash keyword, so we can't use that as the name of the function :( 358 | 359 | # Read the EarlyReturnURL tag from the input odm file 360 | EarlyReturnURL=$(xmllint --xpath '/OverDriveMedia/EarlyReturnURL/text()' "$1") 361 | >&2 printf 'Using EarlyReturnURL=%s\n' "$EarlyReturnURL" 362 | 363 | # now all we have to do is hit that URL 364 | curl "${CURLOPTS[@]}" "$EarlyReturnURL" 365 | # that response doesn't have a newline, so one more superfluous log to clean up: 366 | >&2 printf '\nFinished returning book\n' 367 | } 368 | 369 | HEADER_PRINTED= 370 | info() { 371 | # Usage: info book.odm 372 | if [[ -z $HEADER_PRINTED ]]; then 373 | printf '%s\t%s\t%s\n' author title duration 374 | HEADER_PRINTED=1 375 | fi 376 | metadata_path=$1.metadata 377 | extract_metadata "$1" "$metadata_path" 378 | printf '%s\t%s\t%d\n' "$(extract_author "$metadata_path")" "$(extract_title "$metadata_path")" "$(extract_duration "$1")" 379 | } 380 | 381 | metadata() { 382 | # Usage: metadata book.odm 383 | metadata_path=$1.metadata 384 | extract_metadata "$1" "$metadata_path" 385 | xmllint --format "$metadata_path" | sed 1d 386 | } 387 | 388 | # now actually loop over the media files and commands 389 | for ODM in "${MEDIA[@]}"; do 390 | for COMMAND in "${COMMANDS[@]}"; do 391 | case $COMMAND in 392 | download) 393 | download "$ODM" 394 | ;; 395 | return) 396 | early_return "$ODM" 397 | ;; 398 | info) 399 | info "$ODM" 400 | ;; 401 | metadata) 402 | metadata "$ODM" 403 | ;; 404 | *) 405 | >&2 printf 'Unrecognized command: %s\n' "$COMMAND" 406 | exit 1 407 | ;; 408 | esac 409 | done 410 | done 411 | --------------------------------------------------------------------------------