Compare commits

...

16 Commits

Author SHA1 Message Date
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
10 changed files with 147 additions and 96 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.1"
+60 -21
View File
@@ -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,57 @@ 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)
async with httpx.AsyncClient() as client:
wrapper_account_response = await client.get(wrapper_account_url)
raise_for_status(wrapper_account_response)
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": "*/*",
@@ -133,11 +156,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 +173,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",
+6 -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,
+14 -27
View File
@@ -38,8 +38,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 +140,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
@@ -431,31 +432,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 +497,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 +536,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 +550,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(
+29 -17
View File
@@ -6,7 +6,7 @@ from InquirerPy import inquirer
from InquirerPy.base.control import Choice
from ..interface import AppleMusicInterface
from ..utils import safe_gather
from ..utils import sequential_gather
from .constants import (
ALBUM_MEDIA_TYPE,
ARTIST_MEDIA_TYPE,
@@ -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,
@@ -124,7 +133,7 @@ class AppleMusicDownloader:
for media_metadata in tracks_metadata
]
download_items = await safe_gather(*tasks)
download_items = await sequential_gather(*tasks)
return download_items
async def get_artist_download_items(
@@ -201,7 +210,7 @@ class AppleMusicDownloader:
)
for album_metadata in selected
]
album_responses = await safe_gather(*album_tasks)
album_responses = await sequential_gather(*album_tasks)
track_tasks = [
asyncio.create_task(
@@ -209,7 +218,7 @@ class AppleMusicDownloader:
)
for album_response in album_responses
]
track_results = await safe_gather(*track_tasks)
track_results = await sequential_gather(*track_tasks)
for track_result in track_results:
download_items.extend(track_result)
@@ -250,7 +259,7 @@ class AppleMusicDownloader:
)
for music_video_metadata in selected
]
download_items = await safe_gather(*music_video_tasks)
download_items = await sequential_gather(*music_video_tasks)
return download_items
@@ -368,15 +377,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 +408,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 +452,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:
+6 -5
View File
@@ -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:
+17 -1
View File
@@ -49,7 +49,7 @@ 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,
retries: int = 5,
) -> list[typing.Any]:
semaphore = asyncio.Semaphore(limit)
@@ -69,3 +69,19 @@ async def safe_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
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "gamdl"
version = "2.8"
version = "2.8.1"
description = "A command-line app for downloading Apple Music songs, music videos and post videos."
readme = "README.md"
license = { text = "MIT" }
Generated
+1 -1
View File
@@ -202,7 +202,7 @@ wheels = [
[[package]]
name = "gamdl"
version = "2.7"
version = "2.8.1"
source = { virtual = "." }
dependencies = [
{ name = "async-lru" },