mirror of
https://github.com/glomatico/gamdl.git
synced 2026-06-14 04:35:23 +03:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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.1"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user