Compare commits

...

27 Commits

Author SHA1 Message Date
Rafael Moraes ade78ad7b3 Bump version to 2.8.2 2025-12-21 16:17:01 -03:00
Rafael Moraes 054f636434 Bump version to 2.8.2 2025-12-21 16:07:48 -03:00
Rafael Moraes bf9c74d9d8 Increase concurrency limit in safe_gather to 10 2025-12-21 16:07:33 -03:00
Rafael Moraes 3c48618e84 Remove custom transport retries from AppleMusicApi 2025-12-21 15:56:30 -03:00
Rafael Moraes c940ee2f47 Replace sequential_gather with safe_gather in downloader 2025-12-21 15:55:07 -03:00
Rafael Moraes 7f56dfd0c8 Remove retry logic from safe_gather utility 2025-12-21 15:54:52 -03:00
Rafael Moraes 7c3112421d Refactor AppleMusicApi.create_from_wrapper to use get_response utility 2025-12-21 01:48:16 -03:00
Rafael Moraes 55ce7555a9 Add timeout to iTunes API search request 2025-12-21 01:36:03 -03:00
Rafael Moraes 9c4adbb2c1 Refactor HTTP response handling for m3u8 and cover fetch 2025-12-21 01:34:09 -03:00
Rafael Moraes 1591f0daf2 Set httpx.AsyncClient timeout to 60 seconds 2025-12-18 14:21:56 -03:00
Rafael Moraes 25d028bea4 Add colorama for improved Windows console support 2025-12-14 19:13:30 -03:00
Rafael Moraes ebc28a019e Bump version to 2.8.1 2025-12-10 01:23:32 -03:00
Rafael Moraes 690df6e9d7 Update README example for AppleMusicApi usage 2025-12-10 01:12:52 -03:00
Rafael Moraes 8039c7c86f Reorder error check in AppleMusicDownloader 2025-12-10 01:08:13 -03:00
Rafael Moraes f67ba37d19 Check streamability before downloading media 2025-12-09 23:26:53 -03:00
Rafael Moraes 59f247a90f Fix default language option in CLI 2025-12-06 15:41:44 -03:00
Rafael Moraes 181bdb198d Refactor AppleMusicApi init and factory methods 2025-12-06 15:40:45 -03:00
Rafael Moraes 1945342adc Improve audio track validation in AppleMusicDownloader 2025-12-05 01:11:31 -03:00
Rafael Moraes f19ef4d6dd Fix audio track validation in AppleMusicDownloader 2025-12-05 01:05:44 -03:00
Rafael Moraes 1ceb7fcf46 Instantiate ItunesApi directly in CLI 2025-12-04 17:28:23 -03:00
Rafael Moraes 23ed14ca04 Refactor ItunesApi instantiation and initialization 2025-12-04 17:27:59 -03:00
Rafael Moraes 3e3939d0ee Refactor downloader setup to initialization method 2025-12-04 17:26:35 -03:00
Rafael Moraes 780261a9c8 Update API instantiation to use async factory methods 2025-12-04 17:24:41 -03:00
Rafael Moraes 80cb80e9a2 Refactor AppleMusicApi and ItunesApi initialization 2025-12-04 17:24:32 -03:00
Rafael Moraes f3b7adaad3 Replace safe_gather with sequential_gather in downloader 2025-12-04 16:52:34 -03:00
Rafael Moraes fe6a6e308d Refactor mp4decrypt and amdecrypt path checks in CLI 2025-11-29 14:27:28 -03:00
Rafael Moraes b08bf98759 Reduce retry count in safe_gather utility 2025-11-29 14:24:58 -03:00
12 changed files with 188 additions and 144 deletions
+12 -17
View File
@@ -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
View File
@@ -1 +1 @@
__version__ = "2.8"
__version__ = "2.8.2"
+60 -24
View File
@@ -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",
+7 -5
View File
@@ -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
View File
@@ -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(
+36 -32
View File
@@ -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:
+11 -13
View File
@@ -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(
+10 -4
View File
@@ -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)
+4 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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",
Generated
+3 -1
View File
@@ -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" },