mirror of
https://github.com/glomatico/gamdl.git
synced 2026-06-13 20:25:13 +03:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ade78ad7b3 | |||
| 054f636434 | |||
| bf9c74d9d8 | |||
| 3c48618e84 | |||
| c940ee2f47 | |||
| 7f56dfd0c8 | |||
| 7c3112421d | |||
| 55ce7555a9 | |||
| 9c4adbb2c1 | |||
| 1591f0daf2 | |||
| 25d028bea4 | |||
| ebc28a019e | |||
| 690df6e9d7 | |||
| 8039c7c86f | |||
| f67ba37d19 | |||
| 59f247a90f | |||
| 181bdb198d | |||
| 1945342adc | |||
| f19ef4d6dd | |||
| 1ceb7fcf46 | |||
| 23ed14ca04 | |||
| 3e3939d0ee | |||
| 780261a9c8 | |||
| 80cb80e9a2 | |||
| f3b7adaad3 | |||
| fe6a6e308d | |||
| b08bf98759 |
@@ -292,29 +292,27 @@ from gamdl.interface import (
|
||||
AppleMusicUploadedVideoInterface,
|
||||
)
|
||||
|
||||
|
||||
async def main():
|
||||
# Initialize APIs
|
||||
apple_music_api = AppleMusicApi.from_netscape_cookies(cookies_path="cookies.txt")
|
||||
await apple_music_api.setup()
|
||||
|
||||
# Create AppleMusicApi instance (from cookies or wrapper)
|
||||
apple_music_api = await AppleMusicApi.create_from_netscape_cookies(
|
||||
cookies_path="cookies.txt",
|
||||
)
|
||||
itunes_api = ItunesApi(
|
||||
apple_music_api.storefront,
|
||||
apple_music_api.language,
|
||||
)
|
||||
itunes_api.setup()
|
||||
|
||||
# Initialize interfaces
|
||||
# Check subscription
|
||||
assert apple_music_api.active_subscription
|
||||
|
||||
# Set up interfaces
|
||||
interface = AppleMusicInterface(apple_music_api, itunes_api)
|
||||
song_interface = AppleMusicSongInterface(interface)
|
||||
music_video_interface = AppleMusicMusicVideoInterface(interface)
|
||||
uploaded_video_interface = AppleMusicUploadedVideoInterface(interface)
|
||||
|
||||
# Initialize base downloader
|
||||
# Set up base downloader and specialized downloaders
|
||||
base_downloader = AppleMusicBaseDownloader()
|
||||
base_downloader.setup()
|
||||
|
||||
# Initialize specialized downloaders
|
||||
song_downloader = AppleMusicSongDownloader(
|
||||
base_downloader=base_downloader,
|
||||
interface=song_interface,
|
||||
@@ -328,7 +326,7 @@ async def main():
|
||||
interface=uploaded_video_interface,
|
||||
)
|
||||
|
||||
# Create main downloader
|
||||
# Main downloader
|
||||
downloader = AppleMusicDownloader(
|
||||
interface=interface,
|
||||
base_downloader=base_downloader,
|
||||
@@ -338,10 +336,8 @@ async def main():
|
||||
)
|
||||
|
||||
# Download a song
|
||||
url_info = downloader.get_url_info(
|
||||
"https://music.apple.com/us/album/never-gonna-give-you-up-2022-remaster/1624945511?i=1624945512"
|
||||
)
|
||||
|
||||
url = "https://music.apple.com/us/album/never-gonna-give-you-up-2022-remaster/1624945511?i=1624945512"
|
||||
url_info = downloader.get_url_info(url)
|
||||
if url_info:
|
||||
download_queue = await downloader.get_download_queue(url_info)
|
||||
if download_queue:
|
||||
@@ -360,4 +356,3 @@ MIT License - see [LICENSE](LICENSE) file for details
|
||||
## 🤝 Contributing
|
||||
|
||||
Currently, I'm not interested in reviewing pull requests that change or add features. Only critical bug fixes will be considered. However, feel free to open issues for bugs or feature requests.
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
__version__ = "2.8"
|
||||
__version__ = "2.8.2"
|
||||
|
||||
@@ -6,7 +6,7 @@ from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from ..utils import raise_for_status, safe_json
|
||||
from ..utils import get_response, raise_for_status, safe_json
|
||||
from .constants import (
|
||||
AMP_API_URL,
|
||||
APPLE_MUSIC_COOKIE_DOMAIN,
|
||||
@@ -22,20 +22,21 @@ class AppleMusicApi:
|
||||
def __init__(
|
||||
self,
|
||||
storefront: str = "us",
|
||||
media_user_token: str | None = None,
|
||||
token: str | None = None,
|
||||
language: str = "en-US",
|
||||
media_user_token: str | None = None,
|
||||
developer_token: str | None = None,
|
||||
) -> None:
|
||||
self.storefront = storefront
|
||||
self.media_user_token = media_user_token
|
||||
self.token = token
|
||||
self.language = language
|
||||
self.media_user_token = media_user_token
|
||||
self.token = developer_token
|
||||
|
||||
@classmethod
|
||||
def from_netscape_cookies(
|
||||
async def create_from_netscape_cookies(
|
||||
cls,
|
||||
cookies_path: str = "./cookies.txt",
|
||||
language: str = "en-US",
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> "AppleMusicApi":
|
||||
cookies = MozillaCookieJar(cookies_path)
|
||||
cookies.load(ignore_discard=True, ignore_expires=True)
|
||||
@@ -56,35 +57,55 @@ class AppleMusicApi:
|
||||
"and are logged in with an active subscription."
|
||||
)
|
||||
|
||||
return cls(
|
||||
return await cls.create(
|
||||
storefront=None,
|
||||
media_user_token=media_user_token,
|
||||
language=language,
|
||||
developer_token=None,
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_wrapper(
|
||||
async def create_from_wrapper(
|
||||
cls,
|
||||
wrapper_account_url: str = "http://127.0.0.1:30020/",
|
||||
language: str = "en-US",
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> "AppleMusicApi":
|
||||
wrapper_account_response = httpx.get(wrapper_account_url)
|
||||
raise_for_status(wrapper_account_response)
|
||||
wrapper_account_response = await get_response(wrapper_account_url)
|
||||
wrapper_account_info = safe_json(wrapper_account_response)
|
||||
|
||||
return cls(
|
||||
return await cls.create(
|
||||
storefront=None,
|
||||
media_user_token=wrapper_account_info["music_token"],
|
||||
token=wrapper_account_info["dev_token"],
|
||||
language=language,
|
||||
developer_token=wrapper_account_info["dev_token"],
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
async def setup(self) -> None:
|
||||
await self._setup_client()
|
||||
await self._setup_token()
|
||||
await self._setup_account_info()
|
||||
@classmethod
|
||||
async def create(
|
||||
cls,
|
||||
storefront: str | None = "us",
|
||||
language: str = "en-US",
|
||||
media_user_token: str | None = None,
|
||||
developer_token: str | None = None,
|
||||
) -> "AppleMusicApi":
|
||||
api = cls(
|
||||
storefront=storefront,
|
||||
language=language,
|
||||
media_user_token=media_user_token,
|
||||
developer_token=developer_token,
|
||||
)
|
||||
await api.initialize()
|
||||
return api
|
||||
|
||||
async def _setup_client(self) -> None:
|
||||
async def initialize(self) -> None:
|
||||
await self._initialize_client()
|
||||
await self._initialize_token()
|
||||
await self._initialize_account_info()
|
||||
|
||||
async def _initialize_client(self) -> None:
|
||||
self.client = httpx.AsyncClient(
|
||||
headers={
|
||||
"accept": "*/*",
|
||||
@@ -104,7 +125,6 @@ class AppleMusicApi:
|
||||
"l": self.language,
|
||||
},
|
||||
follow_redirects=True,
|
||||
transport=httpx.AsyncHTTPTransport(retries=10),
|
||||
timeout=60.0,
|
||||
)
|
||||
|
||||
@@ -133,11 +153,11 @@ class AppleMusicApi:
|
||||
logger.debug(f"Token: {token}")
|
||||
return token
|
||||
|
||||
async def _setup_token(self) -> None:
|
||||
async def _initialize_token(self) -> None:
|
||||
self.token = self.token or await self._get_token()
|
||||
self.client.headers.update({"authorization": f"Bearer {self.token}"})
|
||||
|
||||
async def _setup_account_info(self) -> None:
|
||||
async def _initialize_account_info(self) -> None:
|
||||
if not self.media_user_token:
|
||||
return
|
||||
|
||||
@@ -150,6 +170,22 @@ class AppleMusicApi:
|
||||
self.account_info = await self.get_account_info()
|
||||
self.storefront = self.account_info["meta"]["subscription"]["storefront"]
|
||||
|
||||
@property
|
||||
def active_subscription(self) -> bool:
|
||||
return (
|
||||
getattr(self, "account_info", {})
|
||||
.get("meta", {})
|
||||
.get("subscription", {})
|
||||
.get("active", False)
|
||||
)
|
||||
|
||||
@property
|
||||
def account_restrictions(self) -> dict | None:
|
||||
data = getattr(self, "account_info", {}).get("data", [])
|
||||
if not data:
|
||||
return None
|
||||
return data[0].get("attributes", {}).get("restrictions")
|
||||
|
||||
async def get_account_info(self, meta: str | None = "subscription") -> dict:
|
||||
response = await self.client.get(
|
||||
f"{AMP_API_URL}/v1/me/account",
|
||||
|
||||
@@ -16,18 +16,19 @@ class ItunesApi:
|
||||
) -> None:
|
||||
self.storefront = storefront
|
||||
self.language = language
|
||||
self.initialize()
|
||||
|
||||
def setup(self) -> None:
|
||||
self._setup_storefront_id()
|
||||
self._setup_session()
|
||||
def initialize(self) -> None:
|
||||
self._initialize_storefront_id()
|
||||
self._initialize_client()
|
||||
|
||||
def _setup_storefront_id(self) -> None:
|
||||
def _initialize_storefront_id(self) -> None:
|
||||
try:
|
||||
self.storefront_id = STOREFRONT_IDS[self.storefront.upper()]
|
||||
except KeyError:
|
||||
raise Exception(f"No storefront id for {self.storefront}")
|
||||
|
||||
def _setup_session(self) -> None:
|
||||
def _initialize_client(self) -> None:
|
||||
self.client = httpx.AsyncClient(
|
||||
params={
|
||||
"country": self.storefront,
|
||||
@@ -36,6 +37,7 @@ class ItunesApi:
|
||||
headers={
|
||||
"X-Apple-Store-Front": f"{self.storefront_id} t:music31",
|
||||
},
|
||||
timeout=60.0,
|
||||
)
|
||||
|
||||
async def get_lookup_result(
|
||||
|
||||
+17
-27
@@ -5,6 +5,7 @@ from functools import wraps
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
import colorama
|
||||
|
||||
from .. import __version__
|
||||
from ..api import AppleMusicApi, ItunesApi
|
||||
@@ -38,8 +39,9 @@ from .utils import Csv, CustomLoggerFormatter, prompt_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
api_from_cookies_sig = inspect.signature(AppleMusicApi.from_netscape_cookies)
|
||||
api_from_wrapper_sig = inspect.signature(AppleMusicApi.from_wrapper)
|
||||
api_from_cookies_sig = inspect.signature(AppleMusicApi.create_from_netscape_cookies)
|
||||
api_from_wrapper_sig = inspect.signature(AppleMusicApi.create_from_wrapper)
|
||||
api_sig = inspect.signature(AppleMusicApi.__init__)
|
||||
base_downloader_sig = inspect.signature(AppleMusicBaseDownloader.__init__)
|
||||
music_video_downloader_sig = inspect.signature(AppleMusicMusicVideoDownloader.__init__)
|
||||
song_downloader_sig = inspect.signature(AppleMusicSongDownloader.__init__)
|
||||
@@ -139,7 +141,7 @@ def make_sync(func):
|
||||
"--language",
|
||||
"-l",
|
||||
type=str,
|
||||
default=api_from_cookies_sig.parameters["language"].default,
|
||||
default=api_sig.parameters["language"].default,
|
||||
help="Metadata language",
|
||||
)
|
||||
# Base Downloader specific options
|
||||
@@ -415,6 +417,8 @@ async def main(
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
colorama.just_fix_windows_console()
|
||||
|
||||
root_logger = logging.getLogger(__name__.split(".")[0])
|
||||
root_logger.setLevel(log_level)
|
||||
root_logger.propagate = False
|
||||
@@ -431,31 +435,29 @@ async def main(
|
||||
logger.info(f"Starting Gamdl {__version__}")
|
||||
|
||||
if use_wrapper:
|
||||
apple_music_api = AppleMusicApi.from_wrapper(
|
||||
apple_music_api = await AppleMusicApi.create_from_wrapper(
|
||||
wrapper_account_url=wrapper_account_url,
|
||||
language=language,
|
||||
)
|
||||
else:
|
||||
cookies_path = prompt_path(cookies_path)
|
||||
apple_music_api = AppleMusicApi.from_netscape_cookies(
|
||||
apple_music_api = await AppleMusicApi.create_from_netscape_cookies(
|
||||
cookies_path=cookies_path,
|
||||
language=language,
|
||||
)
|
||||
await apple_music_api.setup()
|
||||
|
||||
itunes_api = ItunesApi(
|
||||
apple_music_api.storefront,
|
||||
apple_music_api.language,
|
||||
)
|
||||
itunes_api.setup()
|
||||
|
||||
if not apple_music_api.account_info["meta"]["subscription"]["active"]:
|
||||
if not apple_music_api.active_subscription:
|
||||
logger.critical(
|
||||
"No active Apple Music subscription found, you won't be able to download"
|
||||
" anything"
|
||||
)
|
||||
return
|
||||
if apple_music_api.account_info["data"][0]["attributes"].get("restrictions"):
|
||||
if apple_music_api.account_restrictions:
|
||||
logger.warning(
|
||||
"Your account has content restrictions enabled, some content may not be"
|
||||
" downloadable"
|
||||
@@ -498,7 +500,6 @@ async def main(
|
||||
cover_size=cover_size,
|
||||
truncate=truncate,
|
||||
)
|
||||
base_downloader.setup()
|
||||
song_downloader = AppleMusicSongDownloader(
|
||||
base_downloader=base_downloader,
|
||||
interface=song_interface,
|
||||
@@ -538,17 +539,9 @@ async def main(
|
||||
logger.critical(X_NOT_IN_PATH.format("MP4Box", mp4box_path))
|
||||
return
|
||||
|
||||
if (
|
||||
not base_downloader.full_mp4decrypt_path
|
||||
and song_codec
|
||||
not in (
|
||||
SongCodec.AAC_LEGACY,
|
||||
SongCodec.AAC_HE_LEGACY,
|
||||
)
|
||||
or (
|
||||
remux_mode == RemuxMode.MP4BOX
|
||||
and not base_downloader.full_mp4decrypt_path
|
||||
)
|
||||
if not base_downloader.full_mp4decrypt_path and (
|
||||
song_codec not in (SongCodec.AAC_LEGACY, SongCodec.AAC_HE_LEGACY)
|
||||
or remux_mode == RemuxMode.MP4BOX
|
||||
):
|
||||
logger.critical(X_NOT_IN_PATH.format("mp4decrypt", mp4decrypt_path))
|
||||
return
|
||||
@@ -560,12 +553,9 @@ async def main(
|
||||
logger.critical(X_NOT_IN_PATH.format("N_m3u8DL-RE", nm3u8dlre_path))
|
||||
return
|
||||
|
||||
if not base_downloader.full_mp4decrypt_path:
|
||||
logger.warning(
|
||||
X_NOT_IN_PATH.format("mp4decrypt", mp4decrypt_path)
|
||||
+ ", music videos will not be downloaded"
|
||||
)
|
||||
downloader.skip_music_videos = True
|
||||
if use_wrapper and not base_downloader.full_amdecrypt_path:
|
||||
logger.critical(X_NOT_IN_PATH.format("amdecrypt", amdecrypt_path))
|
||||
return
|
||||
|
||||
if not song_codec.is_legacy() and not use_wrapper:
|
||||
logger.warning(
|
||||
|
||||
@@ -24,9 +24,9 @@ from .enums import DownloadMode, RemuxMode
|
||||
from .exceptions import (
|
||||
ExecutableNotFound,
|
||||
FormatNotAvailable,
|
||||
MediaFileExists,
|
||||
NotStreamable,
|
||||
SyncedLyricsOnly,
|
||||
MediaFileExists,
|
||||
)
|
||||
from .types import DownloadItem, UrlInfo
|
||||
|
||||
@@ -81,6 +81,15 @@ class AppleMusicDownloader:
|
||||
) -> DownloadItem:
|
||||
download_item = None
|
||||
|
||||
if not self.base_downloader.is_media_streamable(
|
||||
media_metadata,
|
||||
):
|
||||
return DownloadItem(
|
||||
media_metadata=media_metadata,
|
||||
playlist_metadata=playlist_metadata,
|
||||
error=NotStreamable(media_metadata["id"]),
|
||||
)
|
||||
|
||||
if media_metadata["type"] in SONG_MEDIA_TYPE:
|
||||
download_item = await self.song_downloader.get_download_item(
|
||||
media_metadata,
|
||||
@@ -111,15 +120,13 @@ class AppleMusicDownloader:
|
||||
tracks_metadata.extend(extended_data["data"])
|
||||
|
||||
tasks = [
|
||||
asyncio.create_task(
|
||||
self.get_single_download_item(
|
||||
media_metadata,
|
||||
(
|
||||
collection_metadata
|
||||
if collection_metadata["type"] in PLAYLIST_MEDIA_TYPE
|
||||
else None
|
||||
),
|
||||
)
|
||||
self.get_single_download_item(
|
||||
media_metadata,
|
||||
(
|
||||
collection_metadata
|
||||
if collection_metadata["type"] in PLAYLIST_MEDIA_TYPE
|
||||
else None
|
||||
),
|
||||
)
|
||||
for media_metadata in tracks_metadata
|
||||
]
|
||||
@@ -196,17 +203,13 @@ class AppleMusicDownloader:
|
||||
download_items = []
|
||||
|
||||
album_tasks = [
|
||||
asyncio.create_task(
|
||||
self.interface.apple_music_api.get_album(album_metadata["id"])
|
||||
)
|
||||
self.interface.apple_music_api.get_album(album_metadata["id"])
|
||||
for album_metadata in selected
|
||||
]
|
||||
album_responses = await safe_gather(*album_tasks)
|
||||
|
||||
track_tasks = [
|
||||
asyncio.create_task(
|
||||
self.get_collection_download_items(album_response["data"][0])
|
||||
)
|
||||
self.get_collection_download_items(album_response["data"][0])
|
||||
for album_response in album_responses
|
||||
]
|
||||
track_results = await safe_gather(*track_tasks)
|
||||
@@ -243,10 +246,8 @@ class AppleMusicDownloader:
|
||||
).execute_async()
|
||||
|
||||
music_video_tasks = [
|
||||
asyncio.create_task(
|
||||
self.get_single_download_item(
|
||||
music_video_metadata,
|
||||
)
|
||||
self.get_single_download_item(
|
||||
music_video_metadata,
|
||||
)
|
||||
for music_video_metadata in selected
|
||||
]
|
||||
@@ -368,15 +369,15 @@ class AppleMusicDownloader:
|
||||
download_item: DownloadItem,
|
||||
) -> DownloadItem:
|
||||
try:
|
||||
if download_item.error:
|
||||
raise download_item.error
|
||||
|
||||
if download_item.flat_filter_result:
|
||||
download_item = await self.get_single_download_item_no_filter(
|
||||
download_item.media_metadata,
|
||||
download_item.playlist_metadata,
|
||||
)
|
||||
|
||||
if download_item.error:
|
||||
raise download_item.error
|
||||
|
||||
await self._initial_processing(download_item)
|
||||
await self._download(download_item)
|
||||
await self._final_processing(download_item)
|
||||
@@ -399,11 +400,6 @@ class AppleMusicDownloader:
|
||||
if self.song_downloader.synced_lyrics_only:
|
||||
return
|
||||
|
||||
if not self.base_downloader.is_media_streamable(
|
||||
download_item.media_metadata,
|
||||
):
|
||||
raise NotStreamable(download_item.media_metadata["id"])
|
||||
|
||||
if (
|
||||
Path(download_item.final_path).exists()
|
||||
and not self.base_downloader.overwrite
|
||||
@@ -448,10 +444,18 @@ class AppleMusicDownloader:
|
||||
raise ExecutableNotFound("N_m3u8DL-RE")
|
||||
|
||||
if (
|
||||
not download_item.decryption_key
|
||||
or not download_item.decryption_key.audio_track
|
||||
or not download_item.decryption_key.audio_track.key
|
||||
) and not self.base_downloader.use_wrapper:
|
||||
not download_item.stream_info
|
||||
or not download_item.stream_info.audio_track
|
||||
or not download_item.stream_info.audio_track.stream_url
|
||||
or (
|
||||
(
|
||||
not download_item.decryption_key
|
||||
or not download_item.decryption_key.audio_track
|
||||
or not download_item.decryption_key.audio_track.key
|
||||
)
|
||||
and not self.base_downloader.use_wrapper
|
||||
)
|
||||
):
|
||||
raise FormatNotAvailable(download_item.media_metadata["id"])
|
||||
|
||||
if download_item.media_metadata["type"] in SONG_MEDIA_TYPE:
|
||||
|
||||
@@ -13,7 +13,7 @@ from pywidevine import Cdm, Device
|
||||
from yt_dlp import YoutubeDL
|
||||
|
||||
from ..interface.types import MediaTags, PlaylistTags
|
||||
from ..utils import async_subprocess, raise_for_status
|
||||
from ..utils import async_subprocess, get_response
|
||||
from .constants import (
|
||||
ILLEGAL_CHAR_REPLACEMENT,
|
||||
ILLEGAL_CHARS_RE,
|
||||
@@ -84,19 +84,20 @@ class AppleMusicBaseDownloader:
|
||||
self.cover_size = cover_size
|
||||
self.truncate = truncate
|
||||
self.silent = silent
|
||||
self.initialize()
|
||||
|
||||
def setup(self):
|
||||
self._setup_binary_paths()
|
||||
self._setup_cdm()
|
||||
def initialize(self):
|
||||
self._initialize_binary_paths()
|
||||
self._initialize_cdm()
|
||||
|
||||
def _setup_binary_paths(self):
|
||||
def _initialize_binary_paths(self):
|
||||
self.full_nm3u8dlre_path = shutil.which(self.nm3u8dlre_path)
|
||||
self.full_mp4decrypt_path = shutil.which(self.mp4decrypt_path)
|
||||
self.full_ffmpeg_path = shutil.which(self.ffmpeg_path)
|
||||
self.full_mp4box_path = shutil.which(self.mp4box_path)
|
||||
self.full_amdecrypt_path = shutil.which(self.amdecrypt_path)
|
||||
|
||||
def _setup_cdm(self):
|
||||
def _initialize_cdm(self):
|
||||
if self.wvd_path:
|
||||
self.cdm = Cdm.from_device(Device.load(self.wvd_path))
|
||||
else:
|
||||
@@ -162,13 +163,10 @@ class AppleMusicBaseDownloader:
|
||||
|
||||
@alru_cache()
|
||||
async def get_cover_bytes(self, cover_url: str) -> bytes | None:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(cover_url)
|
||||
raise_for_status(response, {200, 404})
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.content
|
||||
return None
|
||||
response = await get_response(cover_url, {200, 404})
|
||||
if response.status_code == 200:
|
||||
return response.content
|
||||
return None
|
||||
|
||||
def get_sanitized_string(self, dirty_string: str, is_folder: bool) -> str:
|
||||
dirty_string = re.sub(
|
||||
|
||||
@@ -7,7 +7,7 @@ from InquirerPy import inquirer
|
||||
from InquirerPy.base.control import Choice
|
||||
from pywidevine import Cdm
|
||||
|
||||
from ..utils import get_response_text
|
||||
from ..utils import get_response
|
||||
from .constants import MP4_FORMAT_CODECS
|
||||
from .enums import MediaRating, MediaType, MusicVideoCodec, MusicVideoResolution
|
||||
from .interface import AppleMusicInterface
|
||||
@@ -139,7 +139,9 @@ class AppleMusicMusicVideoInterface(AppleMusicInterface):
|
||||
webplayback_response["songList"][0],
|
||||
)
|
||||
|
||||
playlist_master_m3u8_obj = m3u8.loads(await get_response_text(m3u8_master_url))
|
||||
playlist_master_m3u8_obj = m3u8.loads(
|
||||
(await get_response(m3u8_master_url)).text
|
||||
)
|
||||
playlist_master_m3u8_obj.base_uri = m3u8_master_url.rpartition("/")[0]
|
||||
stream_info_video = await self.get_stream_info_video(
|
||||
playlist_master_m3u8_obj,
|
||||
@@ -318,7 +320,9 @@ class AppleMusicMusicVideoInterface(AppleMusicInterface):
|
||||
stream_info.codec = playlist.stream_info.codecs
|
||||
stream_info.width, stream_info.height = playlist.stream_info.resolution
|
||||
|
||||
playlist_m3u8_obj = m3u8.loads(await get_response_text(stream_info.stream_url))
|
||||
playlist_m3u8_obj = m3u8.loads(
|
||||
(await get_response(stream_info.stream_url)).text
|
||||
)
|
||||
stream_info.widevine_pssh = self.get_widevine_pssh(playlist_m3u8_obj)
|
||||
stream_info.fairplay_key = self.get_fairplay_key(playlist_m3u8_obj)
|
||||
stream_info.playready_pssh = self.get_playready_pssh(playlist_m3u8_obj)
|
||||
@@ -343,7 +347,9 @@ class AppleMusicMusicVideoInterface(AppleMusicInterface):
|
||||
stream_info.stream_url = playlist["uri"]
|
||||
stream_info.codec = playlist["group_id"]
|
||||
|
||||
playlist_m3u8_obj = m3u8.loads(await get_response_text(stream_info.stream_url))
|
||||
playlist_m3u8_obj = m3u8.loads(
|
||||
(await get_response(stream_info.stream_url)).text
|
||||
)
|
||||
stream_info.widevine_pssh = self.get_widevine_pssh(playlist_m3u8_obj)
|
||||
stream_info.fairplay_key = self.get_fairplay_key(playlist_m3u8_obj)
|
||||
stream_info.playready_pssh = self.get_playready_pssh(playlist_m3u8_obj)
|
||||
|
||||
@@ -13,7 +13,7 @@ from InquirerPy.base.control import Choice
|
||||
from pywidevine import PSSH, Cdm
|
||||
from pywidevine.license_protocol_pb2 import WidevinePsshData
|
||||
|
||||
from ..utils import get_response_text
|
||||
from ..utils import get_response
|
||||
from .constants import DRM_DEFAULT_KEY_MAPPING, MP4_FORMAT_CODECS, SONG_CODEC_REGEX_MAP
|
||||
from .enums import MediaRating, MediaType, SongCodec, SyncedLyricsFormat
|
||||
from .interface import AppleMusicInterface
|
||||
@@ -229,7 +229,7 @@ class AppleMusicSongInterface(AppleMusicInterface):
|
||||
if not m3u8_master_url:
|
||||
return None
|
||||
|
||||
m3u8_master_obj = m3u8.loads(await get_response_text(m3u8_master_url))
|
||||
m3u8_master_obj = m3u8.loads((await get_response(m3u8_master_url)).text)
|
||||
m3u8_master_data = m3u8_master_obj.data
|
||||
|
||||
if codec == SongCodec.ASK:
|
||||
@@ -273,7 +273,7 @@ class AppleMusicSongInterface(AppleMusicInterface):
|
||||
"com.apple.streamingkeydelivery",
|
||||
)
|
||||
else:
|
||||
m3u8_obj = m3u8.loads(await get_response_text(stream_info.stream_url))
|
||||
m3u8_obj = m3u8.loads((await get_response(stream_info.stream_url)).text)
|
||||
|
||||
stream_info.widevine_pssh = self._get_drm_uri_from_m3u8_keys(
|
||||
m3u8_obj,
|
||||
@@ -384,7 +384,7 @@ class AppleMusicSongInterface(AppleMusicInterface):
|
||||
i for i in webplayback["songList"][0]["assets"] if i["flavor"] == flavor
|
||||
)["URL"]
|
||||
|
||||
m3u8_obj = m3u8.loads(await get_response_text(stream_info.stream_url))
|
||||
m3u8_obj = m3u8.loads((await get_response(stream_info.stream_url)).text)
|
||||
stream_info.widevine_pssh = m3u8_obj.keys[0].uri
|
||||
|
||||
stream_info_av = StreamInfoAv(
|
||||
|
||||
+25
-15
@@ -20,11 +20,14 @@ def safe_json(httpx_response: httpx.Response) -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
async def get_response_text(url: str) -> str:
|
||||
async with httpx.AsyncClient() as client:
|
||||
async def get_response(
|
||||
url: str,
|
||||
valid_responses: set[int] = {200},
|
||||
) -> httpx.Response:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.get(url)
|
||||
raise_for_status(response)
|
||||
return response.text
|
||||
raise_for_status(response, valid_responses)
|
||||
return response
|
||||
|
||||
|
||||
async def async_subprocess(*args: str, silent: bool = False) -> None:
|
||||
@@ -48,24 +51,31 @@ async def async_subprocess(*args: str, silent: bool = False) -> None:
|
||||
|
||||
async def safe_gather(
|
||||
*tasks: typing.Awaitable[typing.Any],
|
||||
limit: int = 3,
|
||||
retries: int = 10,
|
||||
limit: int = 10,
|
||||
) -> list[typing.Any]:
|
||||
semaphore = asyncio.Semaphore(limit)
|
||||
|
||||
async def bounded_task(task: typing.Awaitable[typing.Any]) -> typing.Any:
|
||||
async with semaphore:
|
||||
last_exception = None
|
||||
for attempt in range(retries + 1):
|
||||
try:
|
||||
return await task
|
||||
except Exception as e:
|
||||
last_exception = e
|
||||
if attempt < retries:
|
||||
await asyncio.sleep(2**attempt)
|
||||
return last_exception
|
||||
return await task
|
||||
|
||||
return await asyncio.gather(
|
||||
*(bounded_task(task) for task in tasks),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
|
||||
async def sequential_gather(
|
||||
*tasks: typing.Awaitable[typing.Any],
|
||||
interval: float = 0.5,
|
||||
) -> list[typing.Any]:
|
||||
results = []
|
||||
for i, task in enumerate(tasks):
|
||||
try:
|
||||
result = await task
|
||||
results.append(result)
|
||||
except Exception as e:
|
||||
results.append(e)
|
||||
if interval > 0 and i < len(tasks) - 1:
|
||||
await asyncio.sleep(interval)
|
||||
return results
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "gamdl"
|
||||
version = "2.8"
|
||||
version = "2.8.2"
|
||||
description = "A command-line app for downloading Apple Music songs, music videos and post videos."
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
@@ -8,6 +8,7 @@ requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"async-lru>=2.0.5",
|
||||
"click>=8.3.0",
|
||||
"colorama>=0.4.6",
|
||||
"httpx>=0.28.1",
|
||||
"inquirerpy>=0.3.4",
|
||||
"m3u8>=6.0.0",
|
||||
|
||||
@@ -202,11 +202,12 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "gamdl"
|
||||
version = "2.7"
|
||||
version = "2.8.2"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "async-lru" },
|
||||
{ name = "click" },
|
||||
{ name = "colorama" },
|
||||
{ name = "httpx" },
|
||||
{ name = "inquirerpy" },
|
||||
{ name = "m3u8" },
|
||||
@@ -220,6 +221,7 @@ dependencies = [
|
||||
requires-dist = [
|
||||
{ name = "async-lru", specifier = ">=2.0.5" },
|
||||
{ name = "click", specifier = ">=8.3.0" },
|
||||
{ name = "colorama", specifier = ">=0.4.6" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "inquirerpy", specifier = ">=0.3.4" },
|
||||
{ name = "m3u8", specifier = ">=6.0.0" },
|
||||
|
||||
Reference in New Issue
Block a user