Compare commits

..

6 Commits

Author SHA1 Message Date
WorldObservationLog b79686f90e Merge remote-tracking branch 'origin/master' 6 months ago
WorldObservationLog d5c077a263 feat: use uvloop or winloop to speed up asyncio 6 months ago
WorldObservationLog 557a1f7ea8 feat: use uvloop or winloop to speed up asyncio 6 months ago
WorldObservationLog cc65d4782a feat: timeit in debug log 6 months ago
WorldObservationLog c84c3818e8 feat: hyper decryption device 6 months ago
WorldObservationLog 356a5b2478 fix: way to re-inject Apple Music 6 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 poetry run python -m pip install nuitka
- name: Build - name: Build
run: | 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 - name: Rename
run: | run: |
ren main.dist AppleMusicDecrypt 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" # 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 # 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" 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] [m3u8Api]
# Use zhaarey's m3u8 api to get higher song m3u8. # Use zhaarey's m3u8 api to get higher song m3u8.

@ -1,8 +1,15 @@
import asyncio import asyncio
import sys
from src.cmd import NewInteractiveShell from src.cmd import NewInteractiveShell
if __name__ == '__main__': if __name__ == '__main__':
if sys.platform in ('win32', 'cygwin', 'cli'):
import winloop
winloop.install()
else:
import uvloop
uvloop.install()
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
cmd = NewInteractiveShell(loop) cmd = NewInteractiveShell(loop)
try: 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" prompt-toolkit = "^3.0.43"
mitmproxy = "^10.3.0" mitmproxy = "^10.3.0"
async-lru = "^2.0.4" 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] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]

@ -14,8 +14,24 @@ from src.exceptions import ADBConnectException, FailedGetAuthParamException, \
from src.types import AuthParams 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: class Device:
host: str host: str
serial: str
client: AdbClient client: AdbClient
device: AdbDevice device: AdbDevice
fridaPort: int fridaPort: int
@ -25,6 +41,7 @@ class Device:
authParams: AuthParams = None authParams: AuthParams = None
suMethod: str suMethod: str
decryptLock: asyncio.Lock decryptLock: asyncio.Lock
hyperDecryptDevices: list[HyperDecryptDevice] = []
def __init__(self, host="127.0.0.1", port=5037, su_method: str = "su -c"): def __init__(self, host="127.0.0.1", port=5037, su_method: str = "su -c"):
self.client = AdbClient(host, port) self.client = AdbClient(host, port)
@ -41,6 +58,7 @@ class Device:
if not status: if not status:
raise ADBConnectException raise ADBConnectException
self.device = self.client.device(f"{host}:{port}") 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]: def _execute_command(self, cmd: str, su: bool = False, sh: bool = False) -> Optional[str]:
whoami = self.device.shell("whoami") whoami = self.device.shell("whoami")
@ -84,12 +102,9 @@ class Device:
def restart_inject_frida(self): def restart_inject_frida(self):
self.fridaSession.detach() self.fridaSession.detach()
self._kill_apple_music() self.fridaDevice.kill(self.pid)
self._inject_frida(self.fridaPort) 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): def start_inject_frida(self, frida_port):
if not self._if_frida_running(): if not self._if_frida_running():
# self._start_remote_frida() # self._start_remote_frida()
@ -146,3 +161,22 @@ class Device:
self.authParams = AuthParams(dsid=dsid, accountToken=token, self.authParams = AuthParams(dsid=dsid, accountToken=token,
accountAccessToken=access_token, storefront=storefront) accountAccessToken=access_token, storefront=storefront)
return self.authParams 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()): if not self.storefront_device_mapping.get(auth_params.storefront.lower()):
self.storefront_device_mapping.update({auth_params.storefront.lower(): []}) self.storefront_device_mapping.update({auth_params.storefront.lower(): []})
self.storefront_device_mapping[auth_params.storefront.lower()].append(device) 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): async def command_parser(self, cmd: str):
if not cmd.strip(): if not cmd.strip():

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

@ -2,26 +2,31 @@ import asyncio
import logging import logging
from loguru import logger 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.exceptions import DecryptException, RetryableDecryptException
from src.models.song_data import Datum from src.models.song_data import Datum
from src.mp4 import SongInfo, SampleInfo from src.mp4 import SongInfo, SampleInfo
from src.types import defaultId, prefetchKey from src.types import defaultId, prefetchKey
from src.utils import timeit
retry_count = {} retry_count = {}
@retry(retry=retry_if_exception_type(RetryableDecryptException), stop=stop_after_attempt(3), @retry(retry=retry_if_exception_type(RetryableDecryptException), stop=stop_after_attempt(3),
before_sleep=before_sleep_log(logger, logging.WARN)) 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: 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: try:
reader, writer = await asyncio.open_connection(device.host, device.fridaPort) reader, writer = await asyncio.open_connection(device.host, device.fridaPort)
except ConnectionRefusedError: 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() device.restart_inject_frida()
raise RetryableDecryptException raise RetryableDecryptException
decrypted = bytes() decrypted = bytes()
@ -42,7 +47,7 @@ async def decrypt(info: SongInfo, keys: list[str], manifest: Datum, device: Devi
try: try:
result = await decrypt_sample(writer, reader, sample) result = await decrypt_sample(writer, reader, sample)
except RetryableDecryptException as e: 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") logger.warning(f"Failed to decrypt song: {manifest.attributes.artistName} - {manifest.attributes.name}, retrying")
writer.write(bytes([0, 0, 0, 0])) writer.write(bytes([0, 0, 0, 0]))
writer.close() writer.close()

@ -1,4 +1,5 @@
import asyncio import asyncio
import random
import subprocess import subprocess
from loguru import logger 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.save import save
from src.types import GlobalAuthParams, Codec from src.types import GlobalAuthParams, Codec
from src.url import Song, Album, URLType, Artist, Playlist 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) task_lock = asyncio.Semaphore(16)
@logger.catch @logger.catch
@timeit
async def rip_song(song: Song, auth_params: GlobalAuthParams, codec: str, config: Config, device: Device, 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): force_save: bool = False, specified_m3u8: str = "", playlist: PlaylistInfo = None):
async with task_lock: 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) codec = get_codec_from_codec_id(codec_id)
raw_song = await download_song(song_uri) raw_song = await download_song(song_uri)
song_info = await extract_song(raw_song, codec) 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) song = await encapsulate(song_info, decrypted_song, config.download.atmosConventToM4a)
if not if_raw_atmos(codec, 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) 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 @logger.catch
@timeit
async def rip_album(album: Album, auth_params: GlobalAuthParams, codec: str, config: Config, device: Device, async def rip_album(album: Album, auth_params: GlobalAuthParams, codec: str, config: Config, device: Device,
force_save: bool = False): force_save: bool = False):
album_info = await get_album_info(album.id, auth_params.anonymousAccessToken, album.storefront, 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 @logger.catch
@timeit
async def rip_playlist(playlist: Playlist, auth_params: GlobalAuthParams, codec: str, config: Config, device: Device, async def rip_playlist(playlist: Playlist, auth_params: GlobalAuthParams, codec: str, config: Config, device: Device,
force_save: bool = False): force_save: bool = False):
playlist_info = await get_playlist_info_and_tracks(playlist.id, auth_params.anonymousAccessToken, 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 @logger.catch
@timeit
async def rip_artist(artist: Artist, auth_params: GlobalAuthParams, codec: str, config: Config, device: Device, 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): force_save: bool = False, include_participate_in_works: bool = False):
artist_info = await get_artist_info(artist.id, artist.storefront, auth_params.anonymousAccessToken, artist_info = await get_artist_info(artist.id, artist.storefront, auth_params.anonymousAccessToken,

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

Loading…
Cancel
Save