Compare commits

...

22 Commits

Author SHA1 Message Date
Rafael Moraes 73e0b4b48d Mark uploaded Apple Music video as DRM-free 2026-05-23 16:05:37 -03:00
Rafael Moraes 8f82697c14 Bump package version to 3.7 2026-05-23 16:04:25 -03:00
Rafael Moraes 4650391be3 Add FFmpeg requirement and --ffmpeg-path option to README 2026-05-23 16:02:26 -03:00
Rafael Moraes 0519adf693 Clarify supported URL types in README 2026-05-23 15:59:41 -03:00
Rafael Moraes 4fc91bac9f Add get_m3u8_master_url helper and use it 2026-05-23 15:58:20 -03:00
Rafael Moraes cb367049f1 Remove get_tags method from AppleMusicSongInterface 2026-05-23 15:56:42 -03:00
Rafael Moraes 34357ad31e Handle library music videos and fix logging id 2026-05-23 15:54:13 -03:00
Rafael Moraes a7140cb860 Use .get for playParams isLibrary checks 2026-05-23 15:50:24 -03:00
Rafael Moraes aa14693924 Add drm_free and is_library flags to types 2026-05-23 15:47:49 -03:00
Rafael Moraes 76a7c792cd Use API response 'id' for media.media_id 2026-05-23 15:47:39 -03:00
Rafael Moraes c75249bc2d Support Apple Music library songs streaming 2026-05-23 15:47:29 -03:00
Rafael Moraes 001a502a5c Support Apple Music library items 2026-05-23 15:47:12 -03:00
Rafael Moraes 1eba432153 Handle DRM-free tracks in AppleMusic downloader 2026-05-23 15:45:09 -03:00
Rafael Moraes 622661a679 Support songs/music-videos in library URL regex 2026-05-23 15:44:58 -03:00
Rafael Moraes 8200ee0dd1 Refactor AppleMusicBaseInterface metadata parsing 2026-05-23 15:44:48 -03:00
Rafael Moraes a8bf884d8f Handle m3u8 and HttpFD downloads in ytdlp 2026-05-23 15:44:23 -03:00
Rafael Moraes 6d8ecf65b6 Support library tracks in get_webplayback 2026-05-23 15:44:12 -03:00
Rafael Moraes 03fb4a255e Add library song/video APIs and params 2026-05-23 14:42:55 -03:00
Rafael Moraes f8ec2367af Add include param to library endpoints 2026-05-23 14:18:16 -03:00
Rafael Moraes b5432d1344 Add library endpoints and client methods 2026-05-23 13:37:00 -03:00
Rafael Moraes bd59bb7c98 Add ffmpeg_path CLI option and pass to downloader 2026-05-23 12:57:07 -03:00
Rafael Moraes 92b8220c71 Add ffmpeg path option to downloader 2026-05-23 12:56:54 -03:00
17 changed files with 448 additions and 193 deletions
+7 -4
View File
@@ -58,6 +58,8 @@ Use [N_m3u8DL-RE](https://github.com/nilaoda/N_m3u8DL-RE/releases/latest) as a f
If the executable is not available in your system PATH, set its location with `--nm3u8dlre-path` or `nm3u8dlre_path`.
N_m3u8DL-RE also needs FFmpeg. If the FFmpeg executable is not available in your system PATH, set its location with `--ffmpeg-path` or `ffmpeg_path`.
## 📦 Installation
1. **Install Gamdl via pip:**
@@ -81,10 +83,10 @@ gamdl [OPTIONS] URLS...
### Supported URL Types
- Songs
- Albums (Public/Library)
- Playlists (Public/Library)
- Music Videos
- Songs (Catalog/Library)
- Albums (Catalog/Library)
- Playlists (Catalog/Library)
- Music Videos (Catalog/Library)
- Artists
- Post Videos
- Apple Music Classical
@@ -167,6 +169,7 @@ The file is created automatically on first run. Command-line arguments override
| `--output-path`, `-o` | Output directory path | `./Apple Music` |
| `--temp-path` | Temporary directory path | `.` |
| `--nm3u8dlre-path` | N_m3u8DL-RE executable path | `N_m3u8DL-RE` |
| `--ffmpeg-path` | FFmpeg executable path | `ffmpeg` |
| `--download-mode` | Download mode | `ytdlp` |
| **Template Options** | | |
| `--album-folder-template` | Album folder template | `{album_artist}/{album}` |
+1 -1
View File
@@ -1 +1 @@
__version__ = "3.6"
__version__ = "3.7"
+152 -5
View File
@@ -15,10 +15,16 @@ from .constants import (
APPLE_MUSIC_HOMEPAGE_URL,
APPLE_MUSIC_LIBRARY_ALBUM_API_URI,
APPLE_MUSIC_LIBRARY_PLAYLIST_API_URI,
APPLE_MUSIC_LIBRARY_PLAYLISTS_API_URI,
APPLE_MUSIC_LICENSE_API_URL,
APPLE_MUSIC_LIBRARY_MUSIC_VIDEO_API_URI,
APPLE_MUSIC_MUSIC_VIDEO_API_URI,
APPLE_MUSIC_LIBRARY_ALBUMS_API_URI,
APPLE_MUSIC_PLAYLIST_API_URI,
APPLE_MUSIC_SEARCH_API_URI,
APPLE_MUSIC_LIBRARY_MUSIC_VIDEOS_API_URI,
APPLE_MUSIC_LIBRARY_SONG_API_URI,
APPLE_MUSIC_LIBRARY_SONGS_API_URI,
APPLE_MUSIC_SONG_API_URI,
APPLE_MUSIC_UPLOADED_VIDEO_API_URL,
APPLE_MUSIC_WEBPLAYBACK_API_URL,
@@ -426,9 +432,54 @@ class AppleMusicApi:
return artist
async def get_library_song(
self,
song_id: str,
include: str = "catalog",
extend: str = "extendedAssetUrls",
) -> dict:
log = logger.bind(action="get_library_song", song_id=song_id)
song = await self._amp_request(
APPLE_MUSIC_LIBRARY_SONG_API_URI.format(
song_id=song_id,
),
{
"include": include,
"extend": extend,
},
)
log.debug("success", song=song)
return song
async def get_library_music_video(
self,
music_video_id: str,
include: str = "catalog",
) -> dict:
log = logger.bind(
action="get_library_music_video", music_video_id=music_video_id
)
music_video = await self._amp_request(
APPLE_MUSIC_LIBRARY_MUSIC_VIDEO_API_URI.format(
music_video_id=music_video_id,
),
{
"include": include,
},
)
log.debug("success", music_video=music_video)
return music_video
async def get_library_album(
self,
album_id: str,
include: str = "catalog",
extend: str = "extendedAssetUrls",
) -> dict:
log = logger.bind(action="get_library_album", album_id=album_id)
@@ -438,6 +489,7 @@ class AppleMusicApi:
album_id=album_id,
),
{
"include": include,
"extend": extend,
},
)
@@ -449,7 +501,7 @@ class AppleMusicApi:
async def get_library_playlist(
self,
playlist_id: str,
include: str = "tracks",
include: str = "catalog,tracks",
limit: int = 100,
extend: str = "extendedAssetUrls",
) -> dict:
@@ -470,6 +522,92 @@ class AppleMusicApi:
return playlist
async def get_library_songs(
self,
limit: int = 100,
offset: int = 0,
include: str = "catalog",
extend: str = "extendedAssetUrls",
) -> dict:
log = logger.bind(action="get_library_songs")
library_songs = await self._amp_request(
APPLE_MUSIC_LIBRARY_SONGS_API_URI,
{
"limit": limit,
"offset": offset,
"include": include,
"extend": extend,
},
)
log.debug("success", library_songs=library_songs)
return library_songs
async def get_library_music_videos(
self,
limit: int = 100,
offset: int = 0,
include: str = "catalog",
) -> dict:
log = logger.bind(action="get_library_music_videos")
library_music_videos = await self._amp_request(
APPLE_MUSIC_LIBRARY_MUSIC_VIDEOS_API_URI,
{
"limit": limit,
"offset": offset,
"include": include,
},
)
log.debug("success", library_music_videos=library_music_videos)
return library_music_videos
async def get_library_albums(
self,
limit: int = 100,
offset: int = 0,
include: str = "catalog",
) -> dict:
log = logger.bind(action="get_library_albums")
library_albums = await self._amp_request(
APPLE_MUSIC_LIBRARY_ALBUMS_API_URI,
{
"limit": limit,
"offset": offset,
"include": include,
},
)
log.debug("success", library_albums=library_albums)
return library_albums
async def get_library_playlists(
self,
limit: int = 100,
offset: int = 0,
include: str = "catalog",
) -> dict:
log = logger.bind(action="get_library_playlists")
library_playlists = await self._amp_request(
APPLE_MUSIC_LIBRARY_PLAYLISTS_API_URI,
{
"limit": limit,
"offset": offset,
"include": include,
},
)
log.debug("success", library_playlists=library_playlists)
return library_playlists
async def get_search_results(
self,
term: str,
@@ -531,17 +669,26 @@ class AppleMusicApi:
async def get_webplayback(
self,
track_id: str,
is_library: bool = False,
) -> dict:
log = logger.bind(action="get_webplayback", track_id=track_id)
response = None
if is_library:
request_body = {
"universalLibraryId": track_id,
}
else:
request_body = {
"salableAdamId": track_id,
}
request_body["language"] = self.language
try:
response = await self.client.post(
APPLE_MUSIC_WEBPLAYBACK_API_URL,
json={
"salableAdamId": track_id,
"language": self.language,
},
json=request_body,
)
response.raise_for_status()
webplayback = response.json()
+8
View File
@@ -14,9 +14,17 @@ APPLE_MUSIC_UPLOADED_VIDEO_API_URL = (
APPLE_MUSIC_ALBUM_API_URI = "/v1/catalog/{storefront}/albums/{album_id}"
APPLE_MUSIC_PLAYLIST_API_URI = "/v1/catalog/{storefront}/playlists/{playlist_id}"
APPLE_MUSIC_ARTIST_API_URI = "/v1/catalog/{storefront}/artists/{artist_id}"
APPLE_MUSIC_LIBRARY_SONG_API_URI = "/v1/me/library/songs/{song_id}"
APPLE_MUSIC_LIBRARY_MUSIC_VIDEO_API_URI = (
"/v1/me/library/music-videos/{music_video_id}"
)
APPLE_MUSIC_LIBRARY_ALBUM_API_URI = "/v1/me/library/albums/{album_id}"
APPLE_MUSIC_LIBRARY_PLAYLIST_API_URI = "/v1/me/library/playlists/{playlist_id}"
APPLE_MUSIC_SEARCH_API_URI = "/v1/catalog/{storefront}/search"
APPLE_MUSIC_LIBRARY_SONGS_API_URI = "/v1/me/library/songs"
APPLE_MUSIC_LIBRARY_MUSIC_VIDEOS_API_URI = "/v1/me/library/music-videos"
APPLE_MUSIC_LIBRARY_ALBUMS_API_URI = "/v1/me/library/albums"
APPLE_MUSIC_LIBRARY_PLAYLISTS_API_URI = "/v1/me/library/playlists"
APPLE_MUSIC_WEBPLAYBACK_API_URL = (
"https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/webPlayback"
+1
View File
@@ -176,6 +176,7 @@ async def main(config: CliConfig):
output_path=config.output_path,
temp_path=config.temp_path,
nm3u8dlre_path=config.nm3u8dlre_path,
ffmpeg_path=config.ffmpeg_path,
download_mode=config.download_mode,
album_folder_template=config.album_folder_template,
compilation_folder_template=config.compilation_folder_template,
+8
View File
@@ -313,6 +313,14 @@ class CliConfig:
default=base_downloader_sig.parameters["nm3u8dlre_path"].default,
),
]
ffmpeg_path: Annotated[
str,
option(
"--ffmpeg-path",
help="FFmpeg executable path",
default=base_downloader_sig.parameters["ffmpeg_path"].default,
),
]
download_mode: Annotated[
DownloadMode,
option(
+39 -15
View File
@@ -11,6 +11,7 @@ from mutagen.mp4 import MP4, MP4Cover
from yt_dlp import YoutubeDL
from ..interface.enums import CoverFormat
from yt_dlp.downloader.http import HttpFD
from ..interface.interface import AppleMusicInterface
from ..interface.types import MediaTags, PlaylistTags
from ..utils import CustomStringFormatter, async_subprocess
@@ -27,19 +28,34 @@ def _download_ytdlp_process(
result_queue,
) -> None:
try:
with YoutubeDL(
{
"quiet": True,
"no_warnings": True,
"outtmpl": download_path,
"allow_unplayable_formats": True,
"overwrites": True,
"fixup": "never",
"noprogress": silent,
"allowed_extractors": ["generic"],
}
) as ydl:
ydl.download(stream_url)
common_args = {
"quiet": True,
"no_warnings": True,
"noprogress": silent,
}
if stream_url.split("?")[0].endswith(".m3u8"):
with YoutubeDL(
{
**common_args,
"outtmpl": download_path,
"allow_unplayable_formats": True,
"overwrites": True,
"fixup": "never",
"allowed_extractors": ["generic"],
}
) as ydl:
ydl.download(stream_url)
else:
Path(download_path).parent.mkdir(parents=True, exist_ok=True)
with YoutubeDL(common_args) as ydl:
http_downloader = HttpFD(ydl, ydl.params)
http_downloader.download(
download_path,
{
"url": stream_url,
},
)
except Exception as e:
result_queue.put(("error", repr(e), traceback.format_exc()))
@@ -51,6 +67,7 @@ class AppleMusicBaseDownloader:
output_path: str = "./Apple Music",
temp_path: str = ".",
nm3u8dlre_path: str = "N_m3u8DL-RE",
ffmpeg_path: str = "ffmpeg",
download_mode: DownloadMode = DownloadMode.YTDLP,
album_folder_template: str = "{album_artist}/{album}",
compilation_folder_template: str = "Compilations/{album}",
@@ -69,6 +86,7 @@ class AppleMusicBaseDownloader:
self.output_path = output_path
self.temp_path = temp_path
self.nm3u8dlre_path = nm3u8dlre_path
self.ffmpeg_path = ffmpeg_path
self.download_mode = download_mode
self.album_folder_template = album_folder_template
self.compilation_folder_template = compilation_folder_template
@@ -89,10 +107,12 @@ class AppleMusicBaseDownloader:
log = logger.bind(action="initialize_binary_paths")
self.full_nm3u8dlre_path = shutil.which(self.nm3u8dlre_path)
self.full_ffmpeg_path = shutil.which(self.ffmpeg_path)
log = log.debug(
"success",
full_nm3u8dlre_path=self.full_nm3u8dlre_path,
full_ffmpeg_path=self.full_ffmpeg_path,
)
def get_temp_path(
@@ -218,10 +238,12 @@ class AppleMusicBaseDownloader:
action="download_stream", stream_url=stream_url, download_path=download_path
)
if self.download_mode == DownloadMode.YTDLP:
if self.download_mode == DownloadMode.YTDLP or not stream_url.split("?")[
0
].endswith(".m3u8"):
await self._download_ytdlp_async(stream_url, download_path)
if self.download_mode == DownloadMode.NM3U8DLRE:
elif self.download_mode == DownloadMode.NM3U8DLRE:
await self._download_nm3u8dlre(stream_url, download_path)
log.debug("success")
@@ -273,6 +295,8 @@ class AppleMusicBaseDownloader:
"--no-log",
"--log-level",
"off",
"--ffmpeg-binary-path",
self.full_ffmpeg_path,
"--save-name",
download_path_obj.stem,
"--save-dir",
+25 -19
View File
@@ -159,26 +159,32 @@ class AppleMusicSongDownloader:
self,
download_item: DownloadItem,
) -> None:
encrypted_path = self.base.get_temp_path(
download_item.media.media_metadata["id"],
download_item.uuid_,
"encrypted",
".m4a",
)
await self.base.download_stream(
download_item.media.stream_info.audio_track.stream_url,
encrypted_path,
)
if download_item.media.stream_info.audio_track.drm_free:
await self.base.download_stream(
download_item.media.stream_info.audio_track.stream_url,
download_item.staged_path,
)
else:
encrypted_path = self.base.get_temp_path(
download_item.media.media_metadata["id"],
download_item.uuid_,
"encrypted",
".m4a",
)
await self.base.download_stream(
download_item.media.stream_info.audio_track.stream_url,
encrypted_path,
)
await self.stage(
encrypted_path,
download_item.staged_path,
download_item.media.media_id,
download_item.media.decryption_key,
download_item.media.stream_info.audio_track.fairplay_key,
download_item.media.stream_info.audio_track.use_cenc,
download_item.media.stream_info.audio_track.use_single_content_key,
)
await self.stage(
encrypted_path,
download_item.staged_path,
download_item.media.media_id,
download_item.media.decryption_key,
download_item.media.stream_info.audio_track.fairplay_key,
download_item.media.stream_info.audio_track.use_cenc,
download_item.media.stream_info.audio_track.use_single_content_key,
)
cover_bytes = (
await self.base.interface.base.get_cover_bytes(
+16 -11
View File
@@ -56,11 +56,6 @@ class AppleMusicBaseInterface:
) -> bool:
return bool(media_metadata["attributes"].get("playParams"))
@staticmethod
def parse_catalog_media_id(media_metadata: dict) -> str:
play_params = media_metadata["attributes"].get("playParams", {})
return play_params.get("catalogId", media_metadata["id"])
@staticmethod
def parse_media_id_from_url(media_metadata: dict) -> str | None:
media_url = media_metadata["attributes"].get("url")
@@ -118,6 +113,14 @@ class AppleMusicBaseInterface:
template_cover_url,
)
@staticmethod
def get_catalog_metadata_from_library(library_metadata: dict) -> dict | None:
data = library_metadata.get("relationships", {}).get("catalog", {}).get("data")
if not data:
return None
return data[0]
@classmethod
async def create(
cls,
@@ -263,9 +266,7 @@ class AppleMusicBaseInterface:
self,
metadata: dict,
) -> str:
log = logger.bind(
action="get_cover", media_id=self.parse_catalog_media_id(metadata)
)
log = logger.bind(action="get_cover", media_id=metadata["id"])
template_url = self._get_cover_template_url(metadata)
@@ -352,7 +353,9 @@ class AppleMusicBaseInterface:
),
album_sort=asset_data.get("sort-album"),
artist=asset_data["artistName"],
artist_id=int(asset_data["artistId"]),
artist_id=(
int(asset_data["artistId"]) if asset_data.get("artistId") else None
),
artist_sort=asset_data["sort-artist"],
comment=asset_data.get("comments"),
compilation=asset_data.get("compilation"),
@@ -377,7 +380,9 @@ class AppleMusicBaseInterface:
disc_total=asset_data.get("discCount"),
gapless=asset_data.get("gapless"),
genre=asset_data.get("genre"),
genre_id=int(asset_data["genreId"]),
genre_id=(
int(asset_data["genreId"]) if asset_data.get("genreId") else None
),
lyrics=lyrics if lyrics else None,
media_type=(
MediaType.SONG
@@ -385,7 +390,7 @@ class AppleMusicBaseInterface:
else MediaType.MUSIC_VIDEO
),
rating=MediaRating(asset_data["explicit"]),
storefront=asset_data["s"],
storefront=(int(asset_data["s"]) if asset_data.get("s") else None),
title=asset_data["itemName"],
title_id=int(asset_data["itemId"]),
title_sort=asset_data["sort-name"],
+2 -2
View File
@@ -71,8 +71,8 @@ VALID_URL_PATTERN = re.compile(
r"(?:\?i=(?P<sub_id>[0-9]+))?"
r"|"
r"(?:/(?P<library_storefront>[a-z]{2}))?"
r"/library/(?P<library_type>playlist|albums)"
r"/(?P<library_id>p\.[a-zA-Z0-9]+|l\.[a-zA-Z0-9]+)"
r"/library/(?P<library_type>playlist|albums|songs|music-videos)"
r"/(?P<library_id>[pli]\.[a-zA-Z0-9]+)"
r")"
)
+44 -32
View File
@@ -101,19 +101,16 @@ class AppleMusicInterface:
total: int | None = None,
media_metadata: dict | None = None,
playlist_metadata: dict | None = None,
is_library: bool = False,
) -> AsyncGenerator[AppleMusicMedia, None]:
media = AppleMusicMedia(
media_id=media_id,
is_library=is_library,
index=index,
total=total,
media_metadata=media_metadata,
playlist_metadata=playlist_metadata,
)
if index is not None:
media.index = index
if total is not None:
media.total = total
media.media_metadata = media_metadata
media.playlist_metadata = playlist_metadata
try:
async for media in self.song.get_media(media):
yield media
@@ -133,16 +130,17 @@ class AppleMusicInterface:
total: int | None = None,
media_metadata: dict | None = None,
playlist_metadata: dict | None = None,
is_library: bool = False,
) -> AsyncGenerator[AppleMusicMedia, None]:
media = AppleMusicMedia(
media_id=media_id,
is_library=is_library,
index=index,
total=total,
media_metadata=media_metadata,
playlist_metadata=playlist_metadata,
)
if index is not None:
media.index = index
if total is not None:
media.total = total
media.media_metadata = media_metadata
media.playlist_metadata = playlist_metadata
@@ -187,12 +185,14 @@ class AppleMusicInterface:
try:
base_media.media_metadata = (
await self.base.apple_music_api.get_library_album(
media_id,
)
if is_library
else await self.base.apple_music_api.get_album(
media_id,
await (
self.base.apple_music_api.get_library_album(
media_id,
)
if is_library
else self.base.apple_music_api.get_album(
media_id,
)
)
)["data"][0]
@@ -214,6 +214,7 @@ class AppleMusicInterface:
index=index,
total=base_media.media_metadata["attributes"]["trackCount"],
media_metadata=track,
is_library=is_library,
)
if track["type"] in {"songs", "library-songs"}
else self._get_music_video_media(
@@ -221,6 +222,7 @@ class AppleMusicInterface:
index=index,
total=base_media.media_metadata["attributes"]["trackCount"],
media_metadata=track,
is_library=is_library,
)
)
for index, track in enumerate(tracks)
@@ -246,12 +248,14 @@ class AppleMusicInterface:
try:
base_media.media_metadata = (
await self.base.apple_music_api.get_library_playlist(
media_id,
)
if is_library
else await self.base.apple_music_api.get_playlist(
media_id,
await (
self.base.apple_music_api.get_library_playlist(
media_id,
)
if is_library
else self.base.apple_music_api.get_playlist(
media_id,
)
)
)["data"][0]
@@ -283,6 +287,7 @@ class AppleMusicInterface:
index=index,
media_metadata=track,
playlist_metadata=base_media.media_metadata,
is_library=is_library,
)
if track["type"] in {"songs", "library-songs"}
else self._get_music_video_media(
@@ -290,6 +295,7 @@ class AppleMusicInterface:
index=index,
media_metadata=track,
playlist_metadata=base_media.media_metadata,
is_library=is_library,
)
)
for index, track in enumerate(tracks)
@@ -425,32 +431,38 @@ class AppleMusicInterface:
url_info.type,
)
if url_info.type == "song" or url_info.sub_id:
if (
url_info.type == "song"
or url_info.library_type == "songs"
or url_info.sub_id
):
async for media in self._get_song_media(
media_id=url_info.sub_id or url_info.id,
media_id=url_info.sub_id or url_info.id or url_info.library_id,
index=0,
total=1,
is_library=bool(url_info.library_type),
):
yield media
elif url_info.type == "music-video":
elif url_info.type == "music-video" or url_info.library_type == "music-videos":
async for media in self._get_music_video_media(
media_id=url_info.id,
media_id=url_info.id or url_info.library_id,
index=0,
total=1,
is_library=bool(url_info.library_type),
):
yield media
elif url_info.type == "album" or url_info.library_type == "albums":
async for media in self._get_album_media(
media_id=url_info.library_id or url_info.id,
media_id=url_info.id or url_info.library_id,
is_library=bool(url_info.library_type),
):
yield media
elif url_info.type == "playlist" or url_info.library_type == "playlist":
async for media in self._get_playlist_media(
media_id=url_info.library_id or url_info.id,
media_id=url_info.id or url_info.library_id,
is_library=bool(url_info.library_type),
):
yield media
+18 -4
View File
@@ -97,7 +97,7 @@ class AppleMusicMusicVideoInterface:
) -> MediaTags:
log = logger.bind(
action="get_music_video_tags",
media_id=self.base.parse_catalog_media_id(metadata),
media_id=metadata["id"],
)
url_media_id = self.base.parse_media_id_from_url(metadata)
@@ -154,7 +154,7 @@ class AppleMusicMusicVideoInterface:
) -> StreamInfoAv | None:
log = logger.bind(
action="get_music_video_stream_info",
media_id=self.base.parse_catalog_media_id(metadata),
media_id=metadata["id"],
)
url_media_id = self.base.parse_media_id_from_url(metadata)
@@ -394,10 +394,24 @@ class AppleMusicMusicVideoInterface:
) -> AsyncGenerator[AppleMusicMedia, None]:
if not media.media_metadata:
media.media_metadata = (
await self.base.apple_music_api.get_music_video(media.media_id)
await (
self.base.apple_music_api.get_library_music_video(media.media_id)
if media.is_library
else self.base.apple_music_api.get_music_video(media.media_id)
)
)["data"][0]
media.media_id = self.base.parse_catalog_media_id(media.media_metadata)
if media.media_metadata["attributes"]["playParams"].get("isLibrary"):
catalog_metadata = self.base.get_catalog_metadata_from_library(
media.media_metadata
)
if catalog_metadata:
media.media_id = catalog_metadata["id"]
media.is_library = False
media.media_metadata = catalog_metadata
if media.is_library:
raise GamdlInterfaceMediaNotStreamableError(media.media_id)
yield media
+121 -97
View File
@@ -3,7 +3,6 @@ import base64
import datetime
import json
import re
import struct
from typing import AsyncGenerator, Callable
from xml.dom import minidom
from xml.etree import ElementTree
@@ -28,7 +27,6 @@ from .types import (
StreamInfo,
StreamInfoAv,
)
import httpx
logger = structlog.get_logger(__name__)
@@ -56,9 +54,13 @@ class AppleMusicSongInterface:
) -> Lyrics | None:
log = logger.bind(
action="get_lyrics",
song_id=self.base.parse_catalog_media_id(song_metadata),
song_id=song_metadata["id"],
)
if song_metadata["attributes"]["playParams"].get("isLibrary"):
log.debug("library_song_no_lyrics")
return None
if not song_metadata["attributes"]["hasLyrics"]:
log.debug("no_lyrics")
return None
@@ -69,7 +71,7 @@ class AppleMusicSongInterface:
):
song_metadata = (
await self.base.apple_music_api.get_song(
self.base.parse_catalog_media_id(song_metadata)
song_metadata["id"],
)
)["data"][0]
@@ -193,87 +195,66 @@ class AppleMusicSongInterface:
def _get_m3u8_from_playback(self, playback: dict) -> str | None:
return playback["songList"][0].get("hls-playlist-url")
async def get_tags(
async def get_m3u8_master_url(
self,
asset_data: dict,
lyrics: str | None = None,
) -> MediaTags:
log = logger.bind(action="get_song_tags")
playback: dict | None,
song_metadata: dict | None,
) -> str | None:
if playback:
return self._get_m3u8_from_playback(playback)
else:
return await self._get_m3u8_master_url_from_metadata(song_metadata)
tags = MediaTags(
album=asset_data["playlistName"],
album_artist=asset_data["playlistArtistName"],
album_id=int(asset_data["playlistId"]),
album_sort=asset_data["sort-album"],
artist=asset_data["artistName"],
artist_id=int(asset_data["artistId"]),
artist_sort=asset_data["sort-artist"],
comment=asset_data.get("comments"),
compilation=asset_data["compilation"],
composer=asset_data.get("composerName"),
composer_id=(
int(asset_data.get("composerId"))
if asset_data.get("composerId")
else None
),
composer_sort=asset_data.get("sort-composer"),
copyright=asset_data.get("copyright"),
date=(
await self.base.get_media_date(asset_data["playlistId"])
if self.use_album_date
else (
self.base.parse_date(asset_data["releaseDate"])
if asset_data.get("releaseDate")
else None
)
),
disc=asset_data["discNumber"],
disc_total=asset_data["discCount"],
gapless=asset_data["gapless"],
genre=asset_data.get("genre"),
genre_id=int(asset_data["genreId"]),
lyrics=lyrics if lyrics else None,
media_type=MediaType.SONG,
rating=MediaRating(asset_data["explicit"]),
storefront=asset_data["s"],
title=asset_data["itemName"],
title_id=int(asset_data["itemId"]),
title_sort=asset_data["sort-name"],
track=asset_data["trackNumber"],
track_total=asset_data["trackCount"],
xid=asset_data.get("xid"),
async def _get_m3u8_master_url_from_metadata(
self,
song_metadata: dict,
) -> str | None:
log = logger.bind(
action="get_m3u8_master_url_from_metadata",
song_id=song_metadata["id"],
)
log.debug("success", tags=tags)
if song_metadata["attributes"]["playParams"].get("isLibrary"):
log.debug("library_song_no_m3u8_master_url")
return None
return tags
async def _get_m3u8_from_metadata(self, song_metadata: dict) -> str | None:
if "extendedAssetUrls" not in song_metadata["attributes"]:
song_metadata = (
await self.base.apple_music_api.get_song(
self.base.parse_catalog_media_id(song_metadata),
song_metadata["id"],
)
)["data"][0]
return song_metadata["attributes"]["extendedAssetUrls"].get("enhancedHls")
enhanced = song_metadata["attributes"]["extendedAssetUrls"].get("enhancedHls")
if enhanced:
log.debug("success", m3u8_master_url=enhanced)
return enhanced
log.debug("no_m3u8_master_url")
return None
async def get_stream_info(
self,
media_id: str,
is_library: bool,
m3u8_master_url: str | None = None,
webplayback: dict | None = None,
) -> StreamInfoAv:
stream_info = None
for codec in self.codec_priority:
if codec.is_web:
stream_info = await self._get_web_stream_info(webplayback, codec)
else:
stream_info = await self._get_stream_info(m3u8_master_url, codec)
if is_library and webplayback:
stream_info = await self._get_library_stream_info(webplayback)
elif webplayback or m3u8_master_url:
for codec in self.codec_priority:
if codec.is_web:
stream_info = await self._get_web_stream_info(webplayback, codec)
else:
stream_info = await self._get_stream_info(m3u8_master_url, codec)
if stream_info:
break
if stream_info:
break
if not stream_info:
raise GamdlInterfaceFormatNotAvailableError(
@@ -481,16 +462,51 @@ class AppleMusicSongInterface:
return stream_info_av
async def _get_library_stream_info(
self,
webplayback: dict,
) -> StreamInfoAv | None:
log = logger.bind(action="get_library_song_stream_info")
stream_info = StreamInfo(drm_free=True)
if len(webplayback["songList"][0]["assets"]) == 0:
log.debug("no_matching_asset")
return None
asset = webplayback["songList"][0]["assets"][0]
stream_info.stream_url = asset["URL"]
stream_info_av = StreamInfoAv(
media_id=webplayback["songList"][0]["songId"],
audio_track=stream_info,
file_format=MediaFileFormat.M4A,
)
log.debug("success", stream_info=stream_info_av)
return stream_info_av
async def get_media(
self,
media: AppleMusicMedia,
) -> AsyncGenerator[AppleMusicMedia, None]:
if not media.media_metadata:
media.media_metadata = (
await self.base.apple_music_api.get_song(media.media_id)
await (
self.base.apple_music_api.get_library_song(media.media_id)
if media.is_library
else self.base.apple_music_api.get_song(media.media_id)
)
)["data"][0]
media.media_id = self.base.parse_catalog_media_id(media.media_metadata)
if media.media_metadata["attributes"]["playParams"].get("isLibrary"):
catalog_metadata = self.base.get_catalog_metadata_from_library(
media.media_metadata
)
if catalog_metadata:
media.media_id = catalog_metadata["id"]
media.is_library = False
media.media_metadata = catalog_metadata
yield media
@@ -510,45 +526,54 @@ class AppleMusicSongInterface:
media.lyrics = await self.get_lyrics(media.media_metadata)
if self.base.wrapper_api:
playback = await self.base.wrapper_api.get_playback(media.media_id)
media.tags = await self.base.get_tags_from_asset_info(
playback = (
await self.base.wrapper_api.get_playback(media.media_id)
if not media.is_library
else None
)
webplayback = (
await self.base.apple_music_api.get_webplayback(
media.media_id,
media.is_library,
)
if media.is_library
or any(codec.is_web for codec in self.codec_priority)
else None
)
else:
playback = None
webplayback = await self.base.apple_music_api.get_webplayback(
media.media_id,
media.is_library,
)
if playback:
media.tags = self.base.get_tags_from_asset_info(
playback["songList"][0]["assets"][0]["metadata"],
media.lyrics.unsynced if media.lyrics else None,
self.use_album_date,
)
if not self.skip_stream_info:
m3u8_master_url = self._get_m3u8_from_playback(playback)
webplayback = (
await self.base.apple_music_api.get_webplayback(media.media_id)
if any(codec.is_web for codec in self.codec_priority)
else None
)
media.stream_info = await self.get_stream_info(
media.media_id,
m3u8_master_url,
webplayback,
)
else:
webplayback = await self.base.apple_music_api.get_webplayback(
media.media_id
)
media.tags = await self.base.get_tags_from_asset_info(
webplayback["songList"][0]["assets"][0]["metadata"],
media.lyrics.unsynced if media.lyrics else None,
self.use_album_date,
)
if not self.skip_stream_info:
m3u8_master_url = await self._get_m3u8_from_metadata(
media.media_metadata
)
media.stream_info = await self.get_stream_info(
media.media_id,
m3u8_master_url,
webplayback,
)
if media.stream_info:
if (
if not self.skip_stream_info:
m3u8_master_url = await self.get_m3u8_master_url(
playback,
media.media_metadata,
)
media.stream_info = await self.get_stream_info(
media.media_id,
media.is_library,
m3u8_master_url,
webplayback,
)
if media.stream_info.audio_track.drm_free:
pass
elif (
not self.base.wrapper_api
and not media.stream_info.audio_track.widevine_pssh
) or (
@@ -557,7 +582,6 @@ class AppleMusicSongInterface:
and not media.stream_info.audio_track.use_cenc
):
raise GamdlInterfaceDecryptionNotAvailableError(media_id=media.media_id)
elif media.stream_info.audio_track.widevine_pssh:
media.decryption_key = DecryptionKeyAv(
audio_track=await self.base.get_decryption_key(
+2
View File
@@ -122,6 +122,7 @@ class StreamInfo:
codec: str = None
width: int = None
height: int = None
drm_free: bool = False
use_cenc: bool = False
use_single_content_key: bool = True
@@ -156,6 +157,7 @@ class Cover:
@dataclass
class AppleMusicMedia:
media_id: str
is_library: bool = False
index: int = 0
total: int = 0
partial: bool = True
+2 -1
View File
@@ -79,6 +79,7 @@ class AppleMusicUploadedVideoInterface:
file_format=MediaFileFormat.M4V,
video_track=StreamInfo(
stream_url=stream_url,
drm_free=True,
),
)
@@ -113,7 +114,7 @@ class AppleMusicUploadedVideoInterface:
await self.base.apple_music_api.get_uploaded_video(media.media_id)
)["data"][0]
media.media_id = self.base.parse_catalog_media_id(media.media_metadata)
media.media_id = media["id"]
yield media
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "gamdl"
version = "3.6"
version = "3.7"
description = "A command-line app for downloading Apple Music songs, music videos and post videos."
readme = "README.md"
license = "MIT"
Generated
+1 -1
View File
@@ -223,7 +223,7 @@ wheels = [
[[package]]
name = "gamdl"
version = "3.6"
version = "3.7"
source = { virtual = "." }
dependencies = [
{ name = "async-lru" },