mkb79 / audible-cli

A command line interface for audible package. With the cli you can download your Audible books, cover, chapter files.
GNU Affero General Public License v3.0
445 stars 45 forks source link

Atmos Download (Future Feature Request… if possible) #155

Open CoolJoe72 opened 1 year ago

CoolJoe72 commented 1 year ago

So it would be awesome to enable the Audible Dolby Atmos option for downloading I'm not sure if something would need to be added to the api or even what codec format they are using but it seems it can be downloaded in the iOS app and some select android devices.

Digging around the api found this in /1.0/library/B0C66LN3JW but it showed the standard stereo available_codecs

"asset_details": [ "is_spatial": true,"name": "Dolby"]

This is all new to me and I'm not even sure what I'm looking for.

devnoname120 commented 4 months ago

@mkb79 OK so here is a dump of code I have. It should be helpful for your implementation.

I don't remember exactly what works/what doesn't and I don't have time to test but I'm pretty sure that something does run up to the point of initializing Widevine, sending back the challenge to Audible, and Audible complaining that the provisioned certificate inside Widevine is wrong (it's not signed with the Audible cert, which should be extracted as discussed above with @szescxz).

diff --git a/src/audible/client.py b/src/audible/client.py
index 369435e..a4991de 100644
--- a/src/audible/client.py
+++ b/src/audible/client.py
@@ -7,6 +7,22 @@ import httpx
 from httpx import URL
 from httpx._models import Headers, HeaderTypes  # noqa: F401

+LEVEL = logging.DEBUG
+
+logger_httpx = logging.getLogger("httpx")
+logger_httpx.setLevel(LEVEL)
+#logger_httpx.setConsoleLogger(LEVEL)
+# log_helper.set_file_logger(FILENAME, LEVEL)
+#logger_httpx.captureWarnings()
+
+
+
+logging.basicConfig(
+    format="%(levelname)s [%(asctime)s] %(name)s - %(message)s",
+    datefmt="%Y-%m-%d %H:%M:%S",
+    level=logging.DEBUG
+)
+
 from .auth import Authenticator
 from .exceptions import (
     BadRequest,
@@ -88,7 +104,8 @@ class Client:
             default_headers.update(headers)

         self.session = self._SESSION(
-            headers=default_headers, timeout=timeout, auth=auth, **session_kwargs
+            headers=default_headers, timeout=timeout, auth=auth, verify=False, **session_kwargs
+            #headers=default_headers, timeout=timeout, auth=auth, proxies="http://localhost:6617", verify=False, **session_kwargs
         )

         if response_callback is None:
diff --git a/src/audible/login.py b/src/audible/login.py
index 90692da..f39b607 100644
--- a/src/audible/login.py
+++ b/src/audible/login.py
@@ -19,11 +19,10 @@ from .metadata import encrypt_metadata, meta_audible_app
 logger = logging.getLogger("audible.login")

 USER_AGENT = (
-    "Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) "
-    "AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148"
+     "[REDACTED user agent of Android device that supports Audible Atmos]"
 )

-
 def default_captcha_callback(captcha_url: str) -> str:
     """Helper function for handling captcha."""
     captcha = httpx.get(captcha_url).content
@@ -202,8 +201,8 @@ def build_oauth_url(
     if with_username:
         base_url = f"https://www.audible.{domain}/ap/signin"
         return_to = f"https://www.audible.{domain}/ap/maplanding"
-        assoc_handle = f"amzn_audible_ios_lap_{country_code}"
-        page_id = "amzn_audible_ios_privatepool"
+        assoc_handle = f"amzn_audible_android_aui_{country_code}"
+        page_id = f"amzn_audible_android_aui_{country_code}"

     oauth_params = {
         "openid.oa2.response_type": "code",
diff --git a/src/audible/register.py b/src/audible/register.py
index 27a1abf..f29b9a9 100644
--- a/src/audible/register.py
+++ b/src/audible/register.py
@@ -1,10 +1,26 @@
+import base64
+from cryptography.hazmat.primitives import serialization
+
 from datetime import datetime, timedelta
 from typing import Any, Dict, Optional

+import json
 import httpx

 from .login import build_client_id

+def base64_der_to_pkcs1(base64_key):
+    der_private_key = base64.b64decode(base64_key)
+    private_key = serialization.load_der_private_key(der_private_key, password=None)
+
+    pkcs1_private_key = private_key.private_bytes(
+        encoding=serialization.Encoding.PEM,
+        format=serialization.PrivateFormat.TraditionalOpenSSL,
+        encryption_algorithm=serialization.NoEncryption()
+    )
+
+    return pkcs1_private_key.decode('utf-8')#.replace('\n', '\\n')
+

 def register(
     authorization_code: str,
@@ -39,19 +55,30 @@ def register(
             "store_authentication_cookie",
         ],
         "cookies": {"website_cookies": [], "domain": f".amazon.{domain}"},
+        "device_metadata": {
+            "device_os_family": "android",
+            "device_serial": "[REDACTED]",
+            "device_type": "A10KISP2GWF0E4",
+            "manufacturer": "[REDACTED]",
+            "model": "[REDACTED]",
+            "os_version": "25",
+            "product": "[REDACTED]",
+        },
         "registration_data": {
-            "domain": "Device",
-            "app_version": "3.56.2",
-            "device_serial": serial,
-            "device_type": "A2CZJZGLK2JJVM",
+            "domain": "DeviceLegacy",
+            # "app_version": "3.59.0",
+            "app_version": "139018",
+            "device_serial": "[REDACTED]",
+            "device_type": "A10KISP2GWF0E4",
             "device_name": (
-                "%FIRST_NAME%%FIRST_NAME_POSSESSIVE_STRING%%DUPE_"
-                "STRATEGY_1ST%Audible for iPhone"
+                "%FIRST_NAME%%FIRST_NAME_POSSESSIVE_STRING%%DUPE_STRATEGY_1ST%Audible for Android"
             ),
-            "os_version": "15.0.0",
-            "software_version": "35602678",
-            "device_model": "iPhone",
-            "app_name": "Audible",
+            "os_version": "[REDACTED an Android release-keys string]",
+            # "software_version": "35900789",
+            "software_version": "130050002",
+            "device_model": "[REDACTED]",
+            "app_name": "com.audible.application",
         },
         "auth_data": {
             "client_id": build_client_id(serial),
@@ -71,11 +98,15 @@ def register(
     if resp.status_code != 200:
         raise Exception(resp_json)

+    print(json.dumps(resp_json))
+
     success_response = resp_json["response"]["success"]

     tokens = success_response["tokens"]
     adp_token = tokens["mac_dms"]["adp_token"]
-    device_private_key = tokens["mac_dms"]["device_private_key"]
+    device_private_key = base64_der_to_pkcs1(tokens["mac_dms"]["device_private_key"])
+    print(device_private_key)
+    # device_private_key = "-----BEGIN RSA PRIVATE KEY-----\n" + device_private_key + "-----END RSA PRIVATE KEY-----\n"
     store_authentication_cookie = tokens["store_authentication_cookie"]
     access_token = tokens["bearer"]["access_token"]
     refresh_token = tokens["bearer"]["refresh_token"]

device_register.py:

from audible import Authenticator, Client, log_helper

def prompt_captcha_callback(captcha_url: str) -> str:
    """Helper function for handling captcha."""

    print("Captcha found")
    print(captcha_url)

    guess = input("Answer for CAPTCHA")
    return str(guess).strip().lower()

def prompt_otp_callback() -> str:
    """Helper function for handling 2-factor authentication."""

    print("2FA is activated for this account.")
    guess = input("Please enter OTP Code")
    return str(guess).strip().lower()

auth = Authenticator.from_login(
    username="redacted@example.com",
    password="[REDACTED]",
    locale="fr",
    captcha_callback=prompt_captcha_callback,
    otp_callback=prompt_otp_callback)

device_name = auth.device_info["device_name"]
print(f"Successfully registered {device_name}.")

ab = auth.get_activation_bytes()
print(ab)

auth.to_file(filename="/Users/paul/.audible/android.json")

ec3-download.py:

#!/usr/bin/env python3

# widevine imports
import base64, requests, sys, xmltodict
import logging
import json
# from urllib.parse import urlparse

from pywidevine.cdm import Cdm
from pywidevine.device import Device
from pywidevine.pssh import PSSH

# from cdm import cdm, deviceconfig
from getPSSH import get_pssh
# from wvdecryptcustom import WvDecrypt
# from cdm.formats import wv_proto2_pb2 as wv_proto2
from base64 import b64encode

# import json

from audible import Authenticator, Client, log_helper
# import logging
from http.client import HTTPConnection

LEVEL = "debug"
HTTPConnection.debuglevel = 1

log_helper.set_level(LEVEL)
log_helper.set_console_logger(LEVEL)
# log_helper.set_file_logger(FILENAME, LEVEL)
log_helper.capture_warnings()
requests_log = logging.getLogger("requests.packages.urllib3")
requests_log.setLevel(logging.DEBUG)
requests_log.propagate = True

# pywidevine stuff
device = Device.load("/Users/paul/dev/Audible/my_audible_l3.wvd")
cdm = Cdm.from_device(device)
session_id = cdm.open()

# Audible stuff
auth_file_path = "/Users/paul/.audible/atmos-test-3"  # FILL OUT
asin = "[REDACTED]"  # FILL OUT

auth = Authenticator.from_file(auth_file_path)

with Client(auth) as client:

    body = {
        "consumption_type": "Download",
        "quality": "High",
        "response_groups": "content_reference,chapter_info,pdf_url,ad_insertion",
        "spatial": "true",
        "supported_media_features": {
            "chapter_titles_type": "Tree",
            "codecs": [
                "mp4a.40.2",
                "ec+3"
            ],
            "drm_types": [
                "Widevine",
                "Adrm",
                "Mpeg"
            ]
        }
    }
    #   body = {
    #       "quality": "High",
    #       "response_groups": "chapter_info,content_reference,last_position_heard,pdf_url",
    #       "consumption_type": "Download",
    #       "supported_media_features":
    #           {
    #               "codecs": [
    #                   "mp4a.40.2",
    #                   "mp4a.40.42",
    #                   "ec+3"
    #               ],
    #               "drm_types": [
    #                   "Mpeg",
    #                   "Adrm",
    #                   "FairPlay"
    #               ]
    #           },
    #       "spatial": True
    #   }

    lr = client.post(
        f"content/{asin}/licenserequest",
        body=body,
        headers={"user-agent": "[REDACTED audible user agent of Android device with Dolby Atmos that has hardware support]"}
    )
    print(json.dumps(lr, indent=4))

    mpd_url = lr['content_license']['license_response']
    print('mpd_url: ' + mpd_url)

#    mpd_url = 'https://[REDACTED].cloudfront.net/[REDACTED]-ec3_v8.master.mpd?ss_sec=10&iss_sec=10&isc=1&use_token_based_signing=true'

    #lic_url = 'https://api.audible.fr/1.0/content/[REDACTED]/drmlicense'
    pssh_raw = get_pssh(mpd_url)
    # pssh = '[REDACTED base64]'
    # params from mpd_url:
    # ottsession=[REDACTED hexadecimal]&
    # puid=[REDACTED integer]&
    # video_content_id=[REDACTED hexadecimal]&

    print(f'{chr(10)}PSSH obtained:\n{pssh_raw}')

    pssh = PSSH(pssh_raw)
    challenge = cdm.get_license_challenge(session_id, pssh, license_type="OFFLINE", privacy_mode=False)
    challenge_b64 = b64encode(challenge)
    print(f'Got challenge:\n{challenge_b64}')
    response = client.post(
        f"content/{asin}/drmlicense",
        body={
            "consumption_type": "Download",
            "drm_type": "Widevine",
            "licenseChallenge": str(challenge_b64, "utf-8" ),
        },
        headers={"user-agent": "[REDACTED audible user agent of Android device with Dolby Atmos that has hardware support]"}
    )

    print('response.license: ' + response['license'])
    with open("widevine_license.txt", "w") as lic_file:
        lic_file.write(response.license)

    cdm.parse_license(session_id, response['license'])
    for key in cdm.get_keys(session_id):
        print(f"[{key.type}] {key.kid.hex}:{key.key.hex()}")

    # close session, disposes of session data
    cdm.close(session_id)

    def widevine_get_keys(pssh, cert_b64=None):
        """main func, emulates license request and then decrypt obtained license
        fileds that changes every new request is signature, expirationTimestamp, watchSessionId, puid, and rawLicenseRequestBase64 """
        wvdecrypt = WvDecrypt(init_data_b64=pssh, cert_data_b64=cert_b64, device=deviceconfig.device_android_generic)
        raw_request = wvdecrypt.get_challenge()
        request = b64encode(raw_request)
        signature = cdm.hash_object

        # audible
        response = client.post(
            f"content/{asin}/drmlicense",
            body={
                "consumption_type": "download",
                "drm_type": "widevine",
                "licensechallenge": str(request, "utf-8" ),
            },
            headers={"user-agent": "[REDACTED audible user agent of Android device with Dolby Atmos that has hardware support]"}
        )

        print('response.license: ' + response['license'])

        with open("widevine_license.txt", "w") as lic_file:
            lic_file.write(response.license)

        license_b64 = response.license

    #
    #    license_b64 = "[REDACTED very long base64 string]"

        wvdecrypt.update_license(license_b64)
        Correct, keyswvdecrypt = wvdecrypt.start_process()
        if Correct:
            return Correct, keyswvdecrypt

    # correct, keys = widevine_get_keys(pssh)

    # print('is correct? ' + correct)
    # for key in keys:
    #     print('KID:KEY -> ' + key)

Then some other files (I'm not sure anymore which is used/is useful/works but at least you have them at hand):

wvdecryptcustom.py:

# uncompyle6 version 3.7.3
# Python bytecode 3.6 (3379)
# Decompiled from: Python 3.7.8 (tags/v3.7.8:4b47a5b6ba, Jun 28 2020, 08:53:46) [MSC v.1916 64 bit (AMD64)]
# Embedded file name: pywidevine\decrypt\wvdecryptcustom.py
import logging, subprocess, re, base64
from cdm import cdm, deviceconfig

class WvDecrypt(object):
    WV_SYSTEM_ID = [
     237, 239, 139, 169, 121, 214, 74, 206, 163, 200, 39, 220, 213, 29, 33, 237]

    def __init__(self, init_data_b64, cert_data_b64, device):
        self.init_data_b64 = init_data_b64
        self.cert_data_b64 = cert_data_b64
        self.device = device
        self.cdm = cdm.Cdm()

        def check_pssh(pssh_b64):
            pssh = base64.b64decode(pssh_b64)
            if not pssh[12:28] == bytes(self.WV_SYSTEM_ID):
                new_pssh = bytearray([0, 0, 0])
                new_pssh.append(32 + len(pssh))
                new_pssh[4:] = bytearray(b'pssh')
                new_pssh[8:] = [0, 0, 0, 0]
                new_pssh[13:] = self.WV_SYSTEM_ID
                new_pssh[29:] = [0, 0, 0, 0]
                new_pssh[31] = len(pssh)
                new_pssh[32:] = pssh
                return base64.b64encode(new_pssh)
            else:
                return pssh_b64

        self.session = self.cdm.open_session(check_pssh(self.init_data_b64), deviceconfig.DeviceConfig(self.device), None, True)
        if self.cert_data_b64:
            self.cdm.set_service_certificate(self.session, self.cert_data_b64)

    def log_message(self, msg):
        return '{}'.format(msg)

    def start_process(self):
        keyswvdecrypt = []
        try:
            for key in self.cdm.get_keys(self.session):
                if key.type == 'CONTENT':
                    keyswvdecrypt.append(self.log_message('{}:{}'.format(key.kid.hex(), key.key.hex())))

        except Exception:
            return (
             False, keyswvdecrypt)
        else:
            return (
             True, keyswvdecrypt)

    def get_challenge(self):
        return self.cdm.get_license_request(self.session)

    def update_license(self, license_b64):
        self.cdm.provide_license(self.session, license_b64)
        return True

getPSSH.py


import requests, xmltodict

def get_pssh(mpd_url):
    # pssh = '[REDACTED]'
    pssh = ''
    try:
        r = requests.get(url=mpd_url)
        r.raise_for_status()
        xml = xmltodict.parse(r.text)
        mpd = xml
        periods = mpd['MPD']['Period']
    except Exception as e:
        pssh = input(f'\nUnable to find PSSH in MPD: {e}. \nEdit getPSSH.py or enter PSSH manually: ')
        return pssh
    if isinstance(periods, list):
        print('Period is a list')
        for idx, period in enumerate(periods):
            if isinstance(period['AdaptationSet'], list):
                print('AdaptationSet is a list')
                for ad_set in period['AdaptationSet']:
                    if True or ad_set['@mimeType'] in ['video/mp4', 'audio/mp4']:
                        print('Found appropriate mime')
                        # try:
                        for t in ad_set['ContentProtection']:
                            print(t['@schemeIdUri'].lower())
                            if t['@schemeIdUri'].lower() == "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed":
                                print('Found appropriate scheme id')
                                pssh = t["cenc:pssh"]
                                print('Found pssh: ' + pssh)
                        # except Exception:
                        #    pass
            else:
                print('AdaptationSet is a single item')
                if True or period['AdaptationSet']['@mimeType'] in ['video/mp4', 'audio/mp4']:
                    print('Found appropriate mime')
                    # try:
                    for t in period['AdaptationSet']['ContentProtection']:
                        print(t['@schemeIdUri'].lower())
                        if t['@schemeIdUri'].lower() == "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed":
                            print('Found appropriate scheme id')
                            pssh = t["cenc:pssh"]
                            print('Found pssh: ' + pssh)
                    # except Exception:
                        # pass
    else:
        print('Period is a single item')
        for ad_set in periods['AdaptationSet']:
                if True or ad_set['@mimeType'] in ['video/mp4', 'audio/mp4']:
                    print('Found appropriate mime')
                    # try:
                    for t in ad_set['ContentProtection']:
                        print(t['@schemeIdUri'].lower())
                        if t['@schemeIdUri'].lower() == "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed":
                            print('Found appropriate scheme id')
                            pssh = t["cenc:pssh"]
                            print('Found pssh: ' + pssh)
                    # except Exception:
                    #     pass
    if pssh == '':
        pssh = input('Unable to find PSSH in mpd. Edit getPSSH.py or enter PSSH manually: ')
    return pssh

13.py:

# -*- coding: utf-8 -*-
# Module: widevine_keys
# Created on: 10.12.2021
# Authors: medvm
# Version: 2.1.0

import base64, requests, sys, xmltodict
# import headers
# import cookies
import json
from cdm import cdm, deviceconfig
from base64 import b64encode
from getPSSH import get_pssh
from wvdecryptcustom import WvDecrypt
from cdm.formats import wv_proto2_pb2 as wv_proto2
from urllib.parse import urlparse
import logging
# logging.basicConfig(level=logging.DEBUG)
# MDP_URL = input('\nInput MPD URL: ')
MDP_URL = 'https://[REDACTED].cloudfront.net/[REDACTED]-ec3_v8.master.mpd?ss_sec=10&iss_sec=10&isc=1&use_token_based_signing=true'

lic_url = 'https://api.audible.fr/1.0/content/[REDACTED]/drmlicense'
responses = []
license_b64 = ''
pssh = get_pssh(MDP_URL)
params = None
params = urlparse(lic_url).query
# pssh = '[REDACTED]'
# params from mdp_url:
# ottsession=[REDACTED hexadecimal number]&
# puid=[REDACTED integer number]&
# video_content_id=[REDACTED hexadecimal number]&

print(f'{chr(10)}PSSH obtained.\n{pssh}')

def widevine_get_keys(pssh, lic_url, cert_b64=None):
    """main func, emulates license request and then decrypt obtained license
    fileds that changes every new request is signature, expirationTimestamp, watchSessionId, puid, and rawLicenseRequestBase64 """
    wvdecrypt = WvDecrypt(init_data_b64=pssh, cert_data_b64=cert_b64, device=deviceconfig.device_android_generic)
    raw_request = wvdecrypt.get_challenge()
    request = b64encode(raw_request)
    signature = cdm.hash_object

    # audible
    response = requests.post(url=lic_url, headers=headers.headers, params=params,
        json={
            "consumption_type": "Download",
            "drm_type": "Widevine",
            "licenseChallenge": str(request, "utf-8" ),
        }
    ))

    try:
        str(response.content, "utf-8")
    except UnicodeDecodeError:
        widevine_license = response
        print(f'{chr(10)}license response status: {widevine_license}{chr(10)}')
        break
    else:
        if len(str(response.content, "utf-8")) > 500:
            widevine_license = response
            print(f'{chr(10)}license response status: {widevine_license}{chr(10)}')
            break
    if idx == len(responses) - 1:
        print(f'{chr(10)}license response status: {response}')
        print(f'server reports: {str(response.content, "utf-8")}')
        print(f'server did not issue license, make sure you have correctly pasted all the required headers in the headers.py. Also check json/raw params of POST request.{chr(10)}')
        exit()

    lic_field_names = ['license', 'payload', 'getWidevineLicenseResponse']
    lic_field_names2 = ['license']

    open('license_content.bin', 'wb').write(widevine_license.content)

    try:
        if str(widevine_license.content, 'utf-8').find(':'):
            for key in lic_field_names:
                try:
                    license_b64 = json.loads(widevine_license.content.decode())[key]
                except:
                    pass
                else:
                    for key2 in lic_field_names2:
                        try:
                            license_b64 = json.loads(widevine_license.content.decode())[key][key2]
                        except:
                            pass
        else:
            license_b64 = widevine_license.content
    except:
        license_b64 = b64encode(widevine_license.content)
#
#    license_b64 = "[REDACTED]"
    wvdecrypt.update_license(license_b64)
    Correct, keyswvdecrypt = wvdecrypt.start_process()
    if Correct:
        return Correct, keyswvdecrypt

correct, keys = widevine_get_keys(pssh, lic_url)

print('is correct? ' + correct)
for key in keys:
    print('KID:KEY -> ' + key)
mkb79 commented 4 months ago

@devnoname120 Thank you very much. This helps a lot. I could convert the private key to the right format now. But when I try to make a licenserequest to an Atmos title, I've received an 404er error. Which user-agent do you use?

Update: Maybe the reason for my issue is, that I only changed the body of the registration request to an Android device. These seams to be not enough or the device does not support Atmos natively. I'll change the login form to an Android device now and then I'll see, if this works.

mkb79 commented 4 months ago

@devnoname120

I'm writing my packages using Pythonista on my iOS device most of the time. On Pythonista I can't use the cryptography package. So I rewrote your code to convert the Android private key. If you are interested, the code can be found below.

def base64_der_to_pkcs1(base64_key):
    import base64

    import rsa
    from pyasn1.codec.der import decoder
    from pyasn1.type import univ, namedtype

    class PrivateKeyAlgorithm(univ.Sequence):
        componentType = namedtype.NamedTypes(
            namedtype.NamedType("algorithm", univ.ObjectIdentifier()),
            namedtype.NamedType("parameters", univ.Any()),
        )

    class PrivateKeyInfo(univ.Sequence):
        componentType = namedtype.NamedTypes(
            namedtype.NamedType("version", univ.Integer()),
            namedtype.NamedType("pkalgo", PrivateKeyAlgorithm()),
            namedtype.NamedType("key", univ.OctetString()),
        )

    der_pk = base64.b64decode(base64_key)
    (priv, _) = decoder.decode(der_pk, asn1Spec=PrivateKeyInfo())

    key = rsa.PrivateKey.load_pkcs1(priv["key"], format="DER")
    return key.save_pkcs1().decode("utf-8")
devnoname120 commented 4 months ago

@mkb79 Here is a list of Android devices that support true Dolby Atmos (≠ just has the Dolby Atmos equalizer app).

For example for the OnePlus 8 the USER_AGENT would look like something like that:

Mozilla/5.0 (Linux; Android 11; ONEPLUS IN2013 Build/NMF26F; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/83.0.4103.96 Mobile Safari/537.36
szescxz commented 4 months ago

Also note that I was able to do licenserequest on an unsupported device with the Frida hook above. As long as supported_media_features.drm_types includes Widevine, supported_media_features.codecs includes ec+3 and/or ac-4 (although I never found any players supporting ac-4 playback), and spatial is set to true, this should be sufficient to request for the spatial audio manifest (compared to normal ones).

BTW, I think the User-Agent string on my side is something like

Dalvik/2.1.0 (Linux; U; Android 11; REDACTED_DEVICE_MODEL_NAME Build/REDACTED_BUILD_NUMBER); com.audible.application 3.73.0 b:154017
devnoname120 commented 4 months ago

@mkb79 And the release-keys can be found in the build.props of the device (here OnePlus 8):

OnePlus/OnePlus8/OnePlus8:11/RP1A.201005.001/2110102308:user/release-keys

And with the rest of the information from the build.props of that device the registration body would look like:

body = {
    "requested_token_type": [
        "bearer",
        "mac_dms",
        "website_cookies",
        "store_authentication_cookie",
    ],
    "cookies": {"website_cookies": [], "domain": f".amazon.{domain}"},
    "device_metadata": {
        "device_os_family": "android",
        "device_serial": "9f25e8f5e3d8ed7b9415",
        "device_type": "A10KISP2GWF0E4",
        "manufacturer": "OnePlus",
        "model": "IN2013",
        "os_version": "30",
        "product": "OnePlus8",
    },
    "registration_data": {
        "domain": "DeviceLegacy",
        "app_version": "139018",
        "device_serial": "9f25e8f5e3d8ed7b9415",
        "device_type": "A10KISP2GWF0E4",
        "device_name": (
            "%FIRST_NAME%%FIRST_NAME_POSSESSIVE_STRING%%DUPE_STRATEGY_1ST%Audible for Android"
        ),
        "os_version": "OnePlus/OnePlus8/OnePlus8:11/RP1A.201005.001/2110102308:user/release-keys",
        "software_version": "110090009",
        "device_model": "IN2013",
        "app_name": "com.audible.application",
    },
    "auth_data": {
        "client_id": build_client_id(serial),
        "authorization_code": authorization_code,
        "code_verifier": code_verifier.decode(),
        "code_algorithm": "SHA-256",
        "client_domain": "DeviceLegacy",
    },
    "requested_extensions": ["device_info", "customer_info"],
}

Note: I generated a random device_serial of 20 characters.


@mkb79 Also, it may have changed but the code I gave you is all I needed to make registration/login/download work. So you may want to double-check your changes to see if you missed something.

devnoname120 commented 4 months ago

@szescxz ac-4 is used for iPhones AFAIK

szescxz commented 4 months ago

@szescxz ac-4 is used for iPhones AFAIK

Well, server did issue a Widevine license for me to decrypt the file using Android tokens/identifiers. No way to verify the result since I don't have a supported decoder, though. ec+3 works fine.

mkb79 commented 4 months ago

@devnoname120

@mkb79 And the release-keys can be found in the build.props of the device (here OnePlus 8):

OnePlus/OnePlus8/OnePlus8:11/RP1A.201005.001/2110102308:user/release-keys

And with the rest of the information from the build.props of that device the registration body would look like:

body = {
    "requested_token_type": [
        "bearer",
        "mac_dms",
        "website_cookies",
        "store_authentication_cookie",
    ],
    "cookies": {"website_cookies": [], "domain": f".amazon.{domain}"},
    "device_metadata": {
        "device_os_family": "android",
        "device_serial": "9f25e8f5e3d8ed7b9415",
        "device_type": "A10KISP2GWF0E4",
        "manufacturer": "OnePlus",
        "model": "IN2013",
        "os_version": "30",
        "product": "OnePlus8",
    },
    "registration_data": {
        "domain": "DeviceLegacy",
        "app_version": "139018",
        "device_serial": "9f25e8f5e3d8ed7b9415",
        "device_type": "A10KISP2GWF0E4",
        "device_name": (
            "%FIRST_NAME%%FIRST_NAME_POSSESSIVE_STRING%%DUPE_STRATEGY_1ST%Audible for Android"
        ),
        "os_version": "OnePlus/OnePlus8/OnePlus8:11/RP1A.201005.001/2110102308:user/release-keys",
        "software_version": "110090009",
        "device_model": "IN2013",
        "app_name": "com.audible.application",
    },
    "auth_data": {
        "client_id": build_client_id(serial),
        "authorization_code": authorization_code,
        "code_verifier": code_verifier.decode(),
        "code_algorithm": "SHA-256",
        "client_domain": "DeviceLegacy",
    },
    "requested_extensions": ["device_info", "customer_info"],
}

Note: I generated a random device_serial of 20 characters.

@mkb79 Also, it may have changed but the code I gave you is all I needed to make registration/login/download work. So you may want to double-check your changes to see if you missed something.

I'm registered a new device and only changed the body with your suggestion, but I can’t download Widevine content. It still uses Adrm. Maybe changing the registration body is not enough. I'll try again by changing the login part too and will report back.

mkb79 commented 4 months ago

@devnoname120 I've changed the login part now. But it does not work. Maybe the registration data are incorrect so Audible does not know that this is an Atmos compatible device.

devnoname120 commented 4 months ago

@szescxz But how did you get the decryption key? This is the part where I'm confused. Did you just hook the right functions to grab it from the fangs of Widevine?

devnoname120 commented 4 months ago

@mkb79 Can you give me your code diff so that I can double-check?

mkb79 commented 4 months ago

@devnoname120 I've created this branch with my rework. This includes a new registration request body and a conversion of the private key.

devnoname120 commented 4 months ago

@mkb79 I don't see any changes to src/audible/login.py in your branch. Neither do I see any changes to the f"content/{asin}/licenserequest request.

mkb79 commented 4 months ago

@devnoname120 I've merged my changes to login.py to the same branch now.

device_register.py


from audible import Authenticator

r = Authenticator.from_login(
    "[REDACTED]",
    "[REDACTED]",
    "de"
)
r.to_file("credentials-android.json")

licenserequest.py

import json

import audible

auth_file_android = "credentials-android.json"
auth_file_iphone = "credentials-iphone.json"

auth = audible.Authenticator.from_file(auth_file_android)

with audible.Client(auth=auth, country_code='us') as client:
    asin = "B0C66LN3JW"
    body = {
        "supported_media_features": {
            "drm_types": [
              "Widevine",
              "Adrm",
              "Mpeg",
              "FairPlay"
            ],
            "codecs": [
              "mp4a.40.2",
              "mp4a.40.42",
              "ec+3",
              "ac-4"
            ],
            "chapter_titles_type": "Tree"
        },
        "spatial": True,
        "consumption_type": "Download",
        "quality": "High",
        "response_groups": "content_reference,chapter_info,pdf_url,ad_insertion"
    }
    r = client.post(f'content/{asin}/licenserequest', body=body)

    # print(json.dumps(r, indent=4))
    drm_type = r["content_license"]["drm_type"]
    cr = r["content_license"]["content_metadata"]["content_reference"]
    codec = cr["codec"]
    content_format = cr["content_format"]

    print(f"DRM TYPE: {drm_type}")
    print(f"CODEC {codec}")
    print(f"FORMAT: {content_format}")

When I run the licenserequest with my iPhone profile, i've got FairPlay content. With my Android profile it is ADRM

Edit: I've registered a new device on US market to make sure, it's not marketplace related. It makes no difference.

szescxz commented 4 months ago

Can reproduce on my end. But if changing consumption_type to Streaming (as well as changing other related parameters) then the server will return Widevine content.

Credential from a real device is still able to request Widevine content with consumption_type set to Download, though.

mkb79 commented 4 months ago

@szescxz If I set the body in my licenserequest.py to

body = {
        "supported_media_features": {
            "drm_types": [
              "Widevine",
              "Mpeg",
              "FairPlay",
              #"Adrm",
            ],
            "codecs": [
              "mp4a.40.2",
              "mp4a.40.42",
              "ec+3",
              "ac-4"
            ],
            "chapter_titles_type": "Tree"
        },
        "spatial": True,
        "consumption_type": "Streaming",
        "quality": "High",
        "response_groups": "content_reference,chapter_info,pdf_url,ad_insertion"
    }

it results in

DRM TYPE: Widevine
CODEC ac-4
FORMAT: M4A_AC4

When I uncomment "Adrm" the result is

Bad Request (400): Only Dash, HlsCmaf, Hls, and Mpeg can be requested with Streaming consumption_type

Credential from a real device is still able to request Widevine content with consumption_type set to Download, though.

Maybe the correct body of the registration request makes the difference. Can you provide your body (without the serial?

mkb79 commented 4 months ago

If I use my iPhone credentials and the streaming request, the result is

DRM TYPE: FairPlay
CODEC ec+3
FORMAT: M4A_EC3

The codec and format differ between the android and iPhone credentials.

devnoname120 commented 4 months ago

Any news?

mkb79 commented 4 months ago

Last status on my part: I'm able to register an Android Audible device. But I can only stream Widevine/AC-4 content. Streaming EC3 content or downloading AC-4/EC3 content is not possible. But this is still okay. I can get the full content with a streaming request. But don’t in EC3.

DrJapan commented 1 month ago

I was wondering if there was a way to piggy-back, off of this implementation, that allows for the downloading of EC-3 files, from Apple Music.

https://github.com/alacleaker/apple-music-alac-downloader

I've been able to use this script, and was able to acquire some DRM free, Dolby Atmos files.

mkb79 commented 1 month ago

I was wondering if there was a way to piggy-back, off of this implementation, that allows for the downloading of EC-3 files, from Apple Music.

https://github.com/alacleaker/apple-music-alac-downloader

I've been able to use this script, and was able to acquire some DRM free, Dolby Atmos files.

They are using a virtual machine and "patch" the system via an agent on the go to handle Apples FPS. If you find out, how Android or Apple build and encrypt the license challenge request, then you where able to download and decrypt FPS content. The FairPlay cert can be requested using this endpoint.

DrJapan commented 1 month ago

So are you saying this could work? I'm wondering if there is a way to route the downloaded mp4 EC-3 files, through the script?

Here's another version of the same process, but this one uses .go files, to accomplish the task.

https://telegra.ph/Apple-Music-Alac高解析度无损音乐下载教程-04-02-2

szescxz commented 1 month ago

Sorry for the wait, here goes my full script of (roughly) the entire process:

import base64
import hashlib
import json
import os
import secrets
import subprocess
import uuid

from datetime import datetime, timedelta, timezone
from email.utils import parsedate_to_datetime
from urllib.parse import parse_qs, urlencode, urlparse
import xml.etree.ElementTree as ET

# pip install requests
import requests

# pip install cryptography
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding

# pip install pywidevine
from pywidevine.cdm import Cdm
from pywidevine.device import Device
from pywidevine.pssh import PSSH

class Audible:
    APP_NAME = "com.audible.application"

    APK_VERSION_NAME = "3.79.0"
    APK_VERSION_CODE = "160008"
    APP_VERSION = "130050002"
    MAP_VERSION = "20240412N"

    REGION_CONFIGS = {
        "US": {
            "domain": "amazon.com",
            "region": "NA",
            "base_url": "https://www.amazon.com/ap/signin",
            "return_to": "https://www.audible.com/ap/maplanding",
            "assoc_handle": "amzn_audible_android_experiment_us",
            "register_url": "https://api.audible.com/auth/register"
        }
    }

    @staticmethod
    def generate_request_id():
        return str(uuid.uuid4())

    @staticmethod
    def sign_request(adp_token, device_private_key, method, url, params={}, data=""):
        request_date = datetime.now(timezone.utc)

        parsed_url = urlparse(url)
        query_string = urlencode(params)
        payload = f"{method}\n{parsed_url.path if parsed_url.path != '' else '/'}{'?' + query_string if query_string != '' else ''}\n{request_date.strftime('%Y-%m-%dT%H:%M:%SZ')}\n{data}\n{adp_token}"
        signature = base64.b64encode(
            device_private_key.sign(
                payload.encode(),
                padding.PKCS1v15(),
                hashes.SHA256()
            )
        ).decode()

        return {
            "x-adp-token": adp_token,
            "x-adp-alg": "SHA256WithRSA:1.0",
            "x-adp-signature": f"{signature}:{request_date.strftime('%Y-%m-%dT%H:%M:%SZ')}"
        }

    def __init__(self, region, device_properties):
        self.device_properties = device_properties

        self.region = region

        self.user_agent = "Dalvik/2.1.0 (Linux; U; Android {release}; {model} Build/{build_id})".format_map({
            "release": device_properties["ro.build.version.release"],
            "model": device_properties["ro.product.model"],
            "build_id": device_properties["ro.build.id"]
        })

        self.device_metadata = {
            "device_os_family": "android",
            "device_type": "A10KISP2GWF0E4",
            "device_serial": None,
            "manufacturer": device_properties["ro.product.manufacturer"],
            "model": device_properties["ro.product.model"],
            "os_version": device_properties["ro.product.build.version.sdk"],
            "product": device_properties["ro.product.name"]
        }

        self.registration_data = {
            "domain": "DeviceLegacy",
            "device_type": self.device_type_id,
            "device_name": "%FIRST_NAME%%FIRST_NAME_POSSESSIVE_STRING%%DUPE_STRATEGY_1ST%Audible for Android",
            "app_name": self.APP_NAME,
            "app_version": self.APK_VERSION_CODE,
            "device_model": self.device_properties["ro.product.model"],
            "os_version": self.device_properties["ro.build.fingerprint"],
            "software_version": "0"
        }

        self.refresh_token = None
        self.access_token = None
        self.access_token_expiry = None

        self.session = requests.Session()

        # uncomment for debugging with mitmproxy
        #self.session.proxies.update({"https": "http://localhost:8080"})
        #self.session.verify = False

    @property
    def device_type_id(self):
        return self.device_metadata["device_type"]

    @property
    def device_serial(self):
        return self.device_metadata["device_serial"]

    def start_login(self):
        def build_device_serial() -> str:
            return uuid.uuid4().hex.lower()[:20]

        def create_s256_code_challenge(verifier: bytes) -> bytes:
            m = hashlib.sha256(verifier)
            return base64.urlsafe_b64encode(m.digest()).rstrip(b"=")

        def build_client_id(serial: str) -> str:
            client_id = serial.encode() + b"#" + self.device_type_id.encode()
            return client_id.hex()

        def create_code_verifier(length: int = 32) -> bytes:
            verifier = secrets.token_bytes(length)
            return base64.urlsafe_b64encode(verifier).rstrip(b"=")

        self.device_metadata["device_serial"] = build_device_serial()
        self.registration_data["device_serial"] = self.device_serial

        client_id = build_client_id(self.device_serial)
        code_verifier = create_code_verifier()
        code_challenge = create_s256_code_challenge(code_verifier)

        base_url = self.REGION_CONFIGS[self.region]["base_url"]
        return_to = self.REGION_CONFIGS[self.region]["return_to"]
        assoc_handle = self.REGION_CONFIGS[self.region]["assoc_handle"]
        page_id = "amzn_audible_android_aui_v2_dark_us"

        oauth_params = {
            "openid.oa2.response_type": "code",
            "openid.oa2.code_challenge_method": "S256",
            "openid.oa2.code_challenge": code_challenge,
            "openid.return_to": return_to,
            "openid.assoc_handle": assoc_handle,
            "openid.identity": "http://specs.openid.net/auth/2.0/" "identifier_select",
            "pageId": page_id,
            "accountStatusPolicy": "P1",
            "openid.claimed_id": "http://specs.openid.net/auth/2.0/" "identifier_select",
            "openid.mode": "checkid_setup",
            "openid.ns.oa2": "http://www.amazon.com/ap/ext/oauth/2",
            "openid.oa2.client_id": f"device:{client_id}",
            "openid.ns.pape": "http://specs.openid.net/extensions/pape/1.0",
            "openid.oa2.scope": "device_auth_access",
            "openid.ns": "http://specs.openid.net/auth/2.0",
            "openid.pape.max_auth_age": "0",
        }

        self.auth_data = {
            "client_domain": self.registration_data["domain"],
            "client_id": client_id,
            "code_algorithm": "SHA-256",
            "code_verifier": code_verifier.decode(),
            "use_global_authentication": "true"
        }

        return f"{base_url}?{urlencode(oauth_params)}"

    def load_credentials(self, credentials):
        self.device_metadata["device_type"] = credentials["extensions"]["device_info"]["device_type"]
        self.device_metadata["device_serial"] = credentials["extensions"]["device_info"]["device_serial_number"]

        self.adp_token = credentials["tokens"]["mac_dms"]["adp_token"]
        self.device_private_key = serialization.load_der_private_key(base64.b64decode(credentials["tokens"]["mac_dms"]["device_private_key"]), password=None)

        self.refresh_access_token()

    def register_device(self, response_url):
        self.auth_data.update({
            "authorization_code": parse_qs(urlparse(response_url).query)["openid.oa2.authorization_code"][0]
        })

        resp = self.session.post(
            self.REGION_CONFIGS[self.region]["register_url"],
            json={
                "auth_data": self.auth_data,
                "cookies": {
                    "domain": f'www.{self.REGION_CONFIGS[self.region]["domain"]}',
                    "website_cookies": []
                },
                "device_metadata": self.device_metadata,
                "registration_data": self.registration_data,
                "requested_extensions": [
                    "device_info",
                    "customer_info"
                ],
                "requested_token_type": [
                    "bearer",
                    "mac_dms",
                    "store_authentication_cookie",
                    "website_cookies"
                ]
            },
            headers={
                "User-Agent": self.user_agent,
                "x-amzn-identity-auth-domain": self.REGION_CONFIGS[self.region]["register_url"].split("/")[2],
                "X-Amzn-RequestId": self.generate_request_id()
            }
        )
        resp.raise_for_status()
        credentials = resp.json()["response"]["success"]
        self.load_credentials(credentials)
        return credentials

    def audibleapi_request(self, method: str, uri, params={}, data={}):
        method = method.upper()

        headers = {
            "x-amzn-identity-auth-domain": f"www.audible.com",
            "X-Amzn-RequestId": self.generate_request_id()
        }

        if data:
            data = json.dumps(data, separators=(",", ":"), ensure_ascii=False)
            headers["Content-Type"] = "application/json"
        else:
            data = ""

        url = f"https://api.audible.com{uri}"

        if self.refresh_token:
            headers.update({
                "User-Agent": f'AmazonWebView/MAPClientLib/{self.APP_VERSION}/Android/{self.device_properties["ro.build.version.release"]}/{self.device_metadata["model"]}',
                "Authorization": f"Bearer {self.access_token}"
            })
        else:
            headers["User-Agent"] = self.user_agent
            headers.update(self.sign_request(
                self.adp_token,
                self.device_private_key,
                method,
                url,
                data=data
            ))

        return self.session.request(
            method,
            url,
            params=params,
            data=data.encode("utf-8"),
            headers=headers
        )

    def refresh_access_token(self):
        data = {
            "app_name": self.APP_NAME,
            "app_version": self.APP_VERSION,
            "device_metadata": self.device_metadata,
            "map_version": {
                "client_metrics_integrated": True,
                "current_version": self.MAP_VERSION,
                "package_name": self.APP_NAME,
                "platform": "Android"
            }
        }
        if self.refresh_token:
            data.update({
                "requested_token_type": "access_token",
                "source_token": self.refresh_token,
                "source_token_type": "refresh_token"
            })
        else:
            data.update({
                "requested_token_type": "refresh_token",
                "source_token": "source_token",
                "source_token_type": "dms_token"
            })

        resp = self.audibleapi_request(
            "POST",
            "/auth/token",
            data=data
        )
        if not resp.ok:
            print(resp.json()["error_description"])
        resp.raise_for_status()
        now = parsedate_to_datetime(resp.headers["X-Amz-Date"])
        resp = resp.json()

        self.refresh_token = resp.get("refresh_token", self.refresh_token)
        self.access_token = resp["access_token"]
        self.access_token_expiry = now + timedelta(seconds=resp["expires_in"])

    @property
    def is_access_token_expired(self):
        return datetime.now(timezone.utc) >= self.access_token_expiry

    def licenserequest(self, asin, consumption_type="Download", spatial=False):
        assert consumption_type in ["Download", "Streaming"]
        codecs = [
            "mp4a.40.2"
        ]
        if spatial:
            codecs += [
                "ec+3",
                "ac-4"
            ]

        resp = self.audibleapi_request(
            "POST",
            f"/1.0/content/{asin}/licenserequest",
            data={
                "supported_media_features": {
                    "drm_types": [
                        "Widevine",
                        "Adrm",
                        "Mpeg"
                    ],
                    "codecs": codecs,
                    "chapter_titles_type": "Tree",
                    "previews": False,
                    "catalog_samples": False
                },
                "spatial": spatial,
                "consumption_type": consumption_type,
                "quality": "High",
                "response_groups": "content_reference,chapter_info,pdf_url,ad_insertion,narration_speed"
            }
        )
        resp.raise_for_status()
        return resp.json()

    def drmlicense_widevine(self, asin: str, challenge: bytes, consumption_type: str="Download") -> bytes:
        assert consumption_type in ["Download", "Streaming"]

        resp = self.audibleapi_request(
            "POST",
            f"/1.0/content/{asin}/drmlicense",
            data={
                "consumption_type": consumption_type,
                "drm_type": "Widevine",
                "licenseChallenge": base64.b64encode(challenge).decode()
            }
        )
        resp.raise_for_status()
        return base64.b64decode(resp.json()["license"])

def extract_widevine_pssh(client: Audible, manifest_url):
    user_agent = f'com.audible.playersdk.player/{client.APK_VERSION_NAME} (Linux;Android {client.device_properties["ro.build.version.release"]}) AndroidXMedia3/1.3.0'
    nsmap = {"mpd": "urn:mpeg:dash:schema:mpd:2011", "cenc": "urn:mpeg:cenc:2013"}

    resp = client.session.get(manifest_url, headers={"User-Agent": user_agent})
    resp.raise_for_status()
    manifest = ET.fromstring(resp.content)

    widevine_scheme_id_uri = Cdm.urn
    widevine_psshs = manifest.findall(f".//mpd:ContentProtection[@schemeIdUri='{widevine_scheme_id_uri}']/cenc:pssh", namespaces=nsmap)
    widevine_psshs = set([i.text.strip() for i in widevine_psshs])

    return widevine_psshs

if __name__ == "__main__":
    from pathlib import Path

    # adb shell getprop or whatever
    device_props = {
        "ro.build.fingerprint": "OnePlus/OnePlus8/OnePlus8:11/RP1A.201005.001/2110102308:user/release-keys",
        "ro.build.id": "RP1A.201005.001",
        "ro.build.version.release": "11",
        "ro.product.build.version.sdk": "30",
        "ro.product.manufacturer": "OnePlus",
        "ro.product.model": "IN2013",
        "ro.product.name": "OnePlus8"
    }
    region = "US"
    asin = "B0CY635C64"
    consumption_type = "Download"

    client = Audible(region, device_props)

    session_file_name = os.path.join(os.path.dirname(os.path.realpath(__file__)), f"{Path(__file__).stem}_{region.lower()}.session")

    # register a new device if necessary
    try:
        with open(session_file_name, "r") as f:
            session_data = json.load(f)

        client.load_credentials(session_data["audible"]["credentials"])
    except FileNotFoundError:
        login_url = client.start_login()
        credentials = client.register_device(
            input(
                f'Visit the following URL:\n{login_url}\nPaste the final URL after finishing sign in flow: '
            )
        )

        with open(session_file_name, "w") as f:
            json.dump({
                "audible": {
                    "credentials": credentials
                }
            }, f)

    resp = client.licenserequest(asin, consumption_type=consumption_type, spatial=True)

    content_license = resp["content_license"]
    codec = content_license["content_metadata"]["content_reference"]["codec"]
    manifest_url = content_license["license_response"]
    print(f'Manifest URL: {manifest_url}')

    assert content_license["drm_type"] == "Widevine"
    widevine_psshs = extract_widevine_pssh(client, manifest_url)

    # You will need an accepted CDM to run the code below
    cdm = Cdm.from_device(Device.load("audible.wvd"))
    assert cdm.system_id == 22435 # app does not seem to use L1 even if the device supports it
    session_id = cdm.open()

    challenge = cdm.get_license_challenge(
        session_id=session_id,
        pssh=PSSH(widevine_psshs.pop()),
        license_type="OFFLINE" if consumption_type == "Download" else "STREAMING",
        privacy_mode=False # matches with the actual behavior
    )

    print("Requesting decryption keys")
    license = client.drmlicense_widevine(asin, challenge, consumption_type=consumption_type)
    cdm.parse_license(session_id, license)
    keys = cdm.get_keys(session_id)
    keys = [key for key in keys if key.type == "CONTENT"]
    if keys:
        print("Keys:")
        print("\n".join([f"{key.kid.hex}:{key.key.hex()}" for key in keys]))

        # Usage of N_m3u8DL-RE is for demonstration only
        # consider implement the download + merge part in Python with multi-threaded download support
        # and use shaka-packager for decryption
        # FFmpeg does not support AC4 plus it cannot disable DASH probing (since the server requires the Range header but rejects "Range: bytes=0-")
        # so not recommended here
        print(f"Downloading and decrypting with N_m3u8DL-RE")
        subprocess.run(" ".join([
            "N_m3u8DL-RE", # Download from https://github.com/nilaoda/N_m3u8DL-RE/releases/latest and put it in PATH
            "--header",
            '"Range: bytes"', # HTTP 403 hackaround
            "--save-name",
            f"{asin}.{codec}",
            "--use-shaka-packager" # Download from https://github.com/shaka-project/shaka-packager/releases/latest and put it in PATH
        ] + [f"--key {key.kid.hex}:{key.key.hex()}" for key in keys] + [f"'{manifest_url}'"]), shell=True).check_returncode()

Due to the aforementioned reasons I'm not going to share the CDM in public (and by this design users are supposed to extract their own CDM; I prefer not to share any tutorials on this for now). But everything until the DRM part should be now reproducible and hopefully this should help establishing a PR (I prefer to stay away from getting credited on this so not me).

mkb79 commented 1 month ago

@szescxz Thank you for your hard work and that you share your findings with us. I‘m working at a new feature , that will register and use an Android or iOS device of your choice. Your Script can work for Apple FairPlay too, if we can create a challenge for an Apple device, how do you do it in your code at this line challenge = cdm.get_license_challenge.

FYI: I'm currently on holiday. So my reaction can take some time.

szescxz commented 1 month ago

Your Script can work for Apple FairPlay too, if we can create a challenge for an Apple device, how do you do it in your code at this line challenge = cdm.get_license_challenge.

DASH manifests given to the Android side does not seem to contain FairPlay's UUID. The only available ones are Widevine and PlayReady (see https://dashif.org/identifiers/content_protection/ for a list of UUIDs), so I believe FairPlay DRM is restricted to HLS manifests only which means everything is within Apple's ecosystem. The concept should be somewhat similar, though, if your goal is to refactor the codebase to support various DRM systems via external modules/libraries.

I picked Widevine because

Therefore I'm unable to offer help on other DRM systems.

DrJapan commented 1 month ago

I've used an application, such as Downie, to download the full mp4 file, but they are all encrypted. I'm not sure if this helps, or make anything easier.