Compare commits

...

6 Commits

Author SHA1 Message Date
WorldObservationLog b79686f90e Merge remote-tracking branch 'origin/master' 4 months ago
WorldObservationLog d5c077a263 feat: use uvloop or winloop to speed up asyncio 4 months ago
WorldObservationLog 557a1f7ea8 feat: use uvloop or winloop to speed up asyncio 4 months ago
WorldObservationLog cc65d4782a feat: timeit in debug log 4 months ago
WorldObservationLog c84c3818e8 feat: hyper decryption device 4 months ago
WorldObservationLog 356a5b2478 fix: way to re-inject Apple Music 4 months ago
  1. 2
      .github/workflows/main.yml
  2. 4
      config.example.toml
  3. 7
      main.py
  4. 1772
      poetry.lock
  5. 3
      pyproject.toml
  6. 42
      src/adb.py
  7. 5
      src/cmd.py
  8. 2
      src/config.py
  9. 17
      src/decrypt.py
  10. 18
      src/rip.py
  11. 14
      src/utils.py

@ -21,7 +21,7 @@ jobs:
poetry run python -m pip install nuitka
- name: Build
run: |
poetry run python -m nuitka main.py --assume-yes-for-downloads --standalone --follow-imports --include-data-dir=assets=assets --include-data-files=config.example.toml=config.toml --include-data-files=agent.js=agent.js --include-module=mitmproxy_windows
poetry run python -m nuitka main.py --assume-yes-for-downloads --standalone --follow-imports --include-data-dir=assets=assets --include-data-files=config.example.toml=config.toml --include-data-files=agent.js=agent.js --include-module=mitmproxy_windows --include-module=winloop
- name: Rename
run: |
ren main.dist AppleMusicDecrypt

@ -18,6 +18,10 @@ agentPort = 10020
# For Magisk user, the recommend value is "su -c". For other Root solutions, the recommend value is "su 0"
# If not sure which method to use, execute “su 0 ls /” and “su -c ls /” respectively in adb shell and choose the output method
suMethod = "su -c"
# Inject multiple scripts into devices to simulate multi-device decryption, which can speed up decryption
# Experimental feature
hyperDecrypt = false
hyperDecryptNum = 2
[m3u8Api]
# Use zhaarey's m3u8 api to get higher song m3u8.

@ -1,8 +1,15 @@
import asyncio
import sys
from src.cmd import NewInteractiveShell
if __name__ == '__main__':
if sys.platform in ('win32', 'cygwin', 'cli'):
import winloop
winloop.install()
else:
import uvloop
uvloop.install()
loop = asyncio.get_event_loop()
cmd = NewInteractiveShell(loop)
try:

1772
poetry.lock generated

File diff suppressed because it is too large Load Diff

@ -22,6 +22,9 @@ tenacity = "^8.2.3"
prompt-toolkit = "^3.0.43"
mitmproxy = "^10.3.0"
async-lru = "^2.0.4"
winloop = {version = "^0.1.3", platform = "win32"}
uvloop = [{version = "^0.19.0", platform = "linux"},
{version = "^0.19.0", platform = "darwin"}]
[build-system]
requires = ["poetry-core"]

@ -14,8 +14,24 @@ from src.exceptions import ADBConnectException, FailedGetAuthParamException, \
from src.types import AuthParams
class HyperDecryptDevice:
host: str
fridaPort: int
decryptLock: asyncio.Lock
serial: str
_father_device = None
def __init__(self, host: str, port: int, father_device):
self.host = host
self.fridaPort = port
self.decryptLock = asyncio.Lock()
self.serial = f"{host}:{port}"
self._father_device = father_device
class Device:
host: str
serial: str
client: AdbClient
device: AdbDevice
fridaPort: int
@ -25,6 +41,7 @@ class Device:
authParams: AuthParams = None
suMethod: str
decryptLock: asyncio.Lock
hyperDecryptDevices: list[HyperDecryptDevice] = []
def __init__(self, host="127.0.0.1", port=5037, su_method: str = "su -c"):
self.client = AdbClient(host, port)
@ -41,6 +58,7 @@ class Device:
if not status:
raise ADBConnectException
self.device = self.client.device(f"{host}:{port}")
self.serial = self.device.serial
def _execute_command(self, cmd: str, su: bool = False, sh: bool = False) -> Optional[str]:
whoami = self.device.shell("whoami")
@ -84,12 +102,9 @@ class Device:
def restart_inject_frida(self):
self.fridaSession.detach()
self._kill_apple_music()
self.fridaDevice.kill(self.pid)
self._inject_frida(self.fridaPort)
def _kill_apple_music(self):
self._execute_command(f"kill -9 {self.pid}", su=True)
def start_inject_frida(self, frida_port):
if not self._if_frida_running():
# self._start_remote_frida()
@ -146,3 +161,22 @@ class Device:
self.authParams = AuthParams(dsid=dsid, accountToken=token,
accountAccessToken=access_token, storefront=storefront)
return self.authParams
def hyper_decrypt(self, ports: list[int]):
if not self._if_frida_running():
raise FridaNotRunningException
logger.debug("injecting agent script with hyper decrypt")
self.fridaPort = ports[0]
if not self.fridaDevice:
frida.get_device_manager().add_remote_device(self.device.serial)
self.fridaDevice = frida.get_device_manager().get_device(self.device.serial)
self.pid = self.fridaDevice.spawn("com.apple.android.music")
self.fridaSession = self.fridaDevice.attach(self.pid)
for port in ports:
self._start_forward(port, port)
with open("agent.js", "r") as f:
agent = f.read().replace("2147483647", str(port))
script: frida.core.Script = self.fridaSession.create_script(agent)
script.load()
self.hyperDecryptDevices.append(HyperDecryptDevice(host=self.host, port=port, father_device=self))
self.fridaDevice.resume(self.pid)

@ -67,7 +67,10 @@ class NewInteractiveShell:
if not self.storefront_device_mapping.get(auth_params.storefront.lower()):
self.storefront_device_mapping.update({auth_params.storefront.lower(): []})
self.storefront_device_mapping[auth_params.storefront.lower()].append(device)
device.start_inject_frida(device_info.agentPort)
if device_info.hyperDecrypt:
device.hyper_decrypt(list(range(device_info.agentPort, device_info.agentPort + device_info.hyperDecryptNum)))
else:
device.start_inject_frida(device_info.agentPort)
async def command_parser(self, cmd: str):
if not cmd.strip():

@ -13,6 +13,8 @@ class Device(BaseModel):
port: int
agentPort: int
suMethod: str
hyperDecrypt: bool
hyperDecryptNum: int
class M3U8Api(BaseModel):

@ -2,26 +2,31 @@ import asyncio
import logging
from loguru import logger
from tenacity import retry, retry_if_exception_type, stop_after_attempt, before_sleep_log, RetryCallState
from tenacity import retry, retry_if_exception_type, stop_after_attempt, before_sleep_log
from src.adb import Device
from src.adb import Device, HyperDecryptDevice
from src.exceptions import DecryptException, RetryableDecryptException
from src.models.song_data import Datum
from src.mp4 import SongInfo, SampleInfo
from src.types import defaultId, prefetchKey
from src.utils import timeit
retry_count = {}
@retry(retry=retry_if_exception_type(RetryableDecryptException), stop=stop_after_attempt(3),
before_sleep=before_sleep_log(logger, logging.WARN))
async def decrypt(info: SongInfo, keys: list[str], manifest: Datum, device: Device) -> bytes:
@timeit
async def decrypt(info: SongInfo, keys: list[str], manifest: Datum, device: Device | HyperDecryptDevice) -> bytes:
async with device.decryptLock:
logger.info(f"Decrypting song: {manifest.attributes.artistName} - {manifest.attributes.name}")
if isinstance(device, HyperDecryptDevice):
logger.info(f"Using hyperDecryptDevice {device.serial} to decrypt song: {manifest.attributes.artistName} - {manifest.attributes.name}")
else:
logger.info(f"Using device {device.serial} to decrypt song: {manifest.attributes.artistName} - {manifest.attributes.name}")
try:
reader, writer = await asyncio.open_connection(device.host, device.fridaPort)
except ConnectionRefusedError:
logger.warning(f"Failed to connect to device {device.device.serial}, re-injecting")
logger.warning(f"Failed to connect to device {device.serial}, re-injecting")
device.restart_inject_frida()
raise RetryableDecryptException
decrypted = bytes()
@ -42,7 +47,7 @@ async def decrypt(info: SongInfo, keys: list[str], manifest: Datum, device: Devi
try:
result = await decrypt_sample(writer, reader, sample)
except RetryableDecryptException as e:
if 0 <= retry_count.get(device.device.serial, 0) < 3 or 4 <= retry_count.get(device.device.serial, 0) < 6:
if 0 <= retry_count.get(device.serial, 0) < 3 or 4 <= retry_count.get(device.serial, 0) < 6:
logger.warning(f"Failed to decrypt song: {manifest.attributes.artistName} - {manifest.attributes.name}, retrying")
writer.write(bytes([0, 0, 0, 0]))
writer.close()

@ -1,4 +1,5 @@
import asyncio
import random
import subprocess
from loguru import logger
@ -15,12 +16,13 @@ from src.mp4 import extract_media, extract_song, encapsulate, write_metadata, fi
from src.save import save
from src.types import GlobalAuthParams, Codec
from src.url import Song, Album, URLType, Artist, Playlist
from src.utils import check_song_exists, if_raw_atmos, playlist_write_song_index, get_codec_from_codec_id
from src.utils import check_song_exists, if_raw_atmos, playlist_write_song_index, get_codec_from_codec_id, timeit
task_lock = asyncio.Semaphore(16)
@logger.catch
@timeit
async def rip_song(song: Song, auth_params: GlobalAuthParams, codec: str, config: Config, device: Device,
force_save: bool = False, specified_m3u8: str = "", playlist: PlaylistInfo = None):
async with task_lock:
@ -80,7 +82,16 @@ async def rip_song(song: Song, auth_params: GlobalAuthParams, codec: str, config
codec = get_codec_from_codec_id(codec_id)
raw_song = await download_song(song_uri)
song_info = await extract_song(raw_song, codec)
decrypted_song = await decrypt(song_info, keys, song_data, device)
if device.hyperDecryptDevices:
if all([hyper_device.decryptLock.locked() for hyper_device in device.hyperDecryptDevices]):
decrypted_song = await decrypt(song_info, keys, song_data, random.choice(device.hyperDecryptDevices))
else:
for hyperDecryptDevice in device.hyperDecryptDevices:
if not hyperDecryptDevice.decryptLock.locked():
decrypted_song = await decrypt(song_info, keys, song_data, hyperDecryptDevice)
break
else:
decrypted_song = await decrypt(song_info, keys, song_data, device)
song = await encapsulate(song_info, decrypted_song, config.download.atmosConventToM4a)
if not if_raw_atmos(codec, config.download.atmosConventToM4a):
metadata_song = await write_metadata(song, song_metadata, config.metadata.embedMetadata, config.download.coverFormat)
@ -96,6 +107,7 @@ async def rip_song(song: Song, auth_params: GlobalAuthParams, codec: str, config
@logger.catch
@timeit
async def rip_album(album: Album, auth_params: GlobalAuthParams, codec: str, config: Config, device: Device,
force_save: bool = False):
album_info = await get_album_info(album.id, auth_params.anonymousAccessToken, album.storefront,
@ -115,6 +127,7 @@ async def rip_album(album: Album, auth_params: GlobalAuthParams, codec: str, con
@logger.catch
@timeit
async def rip_playlist(playlist: Playlist, auth_params: GlobalAuthParams, codec: str, config: Config, device: Device,
force_save: bool = False):
playlist_info = await get_playlist_info_and_tracks(playlist.id, auth_params.anonymousAccessToken,
@ -133,6 +146,7 @@ async def rip_playlist(playlist: Playlist, auth_params: GlobalAuthParams, codec:
@logger.catch
@timeit
async def rip_artist(artist: Artist, auth_params: GlobalAuthParams, codec: str, config: Config, device: Device,
force_save: bool = False, include_participate_in_works: bool = False):
artist_info = await get_artist_info(artist.id, artist.storefront, auth_params.anonymousAccessToken,

@ -7,6 +7,7 @@ from pathlib import Path
import m3u8
import regex
from bs4 import BeautifulSoup
from loguru import logger
from src.config import Download
from src.exceptions import NotTimeSyncedLyricsException
@ -51,21 +52,14 @@ def chunk(it, size):
def timeit(func):
async def process(func, *args, **params):
if asyncio.iscoroutinefunction(func):
print('this function is a coroutine: {}'.format(func.__name__))
return await func(*args, **params)
else:
print('this is not a coroutine')
return func(*args, **params)
async def helper(*args, **params):
print('{}.time'.format(func.__name__))
start = time.time()
result = await process(func, *args, **params)
# Test normal function route...
# result = await process(lambda *a, **p: print(*a, **p), *args, **params)
print('>>>', time.time() - start)
logger.debug(f'{func.__name__}: {time.time() - start}')
return result
return helper
@ -150,13 +144,15 @@ def playlist_metadata_to_params(playlist: PlaylistInfo):
return {"playlistName": playlist.data[0].attributes.name,
"playlistCuratorName": playlist.data[0].attributes.curatorName}
def get_path_safe_dict(param: dict):
new_param = deepcopy(param)
for key, val in new_param.items():
if isinstance(val, str):
if isinstance(val, str):
new_param[key] = get_valid_filename(str(val))
return new_param
def get_song_name_and_dir_path(codec: str, config: Download, metadata, playlist: PlaylistInfo = None):
if playlist:
safe_meta = get_path_safe_dict(metadata.model_dump())

Loading…
Cancel
Save