Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ A command-line app for downloading Apple Music songs, music videos and post vide
- 🏷️ **Extended Industry Identifiers** - Official ISRC (`isrc`), UPC/Barcode (`barcode`), and Record Label (`label`) tagging
- 💿 **Release Type Classification** - Accurate categorization into `single`, `ep`, `compilation`, and `album` (`releasetype`)
- 🌐 **Audio Language & Album Date** - Native BCP 47 `language` tagging (`audioLocale`) and album-level `releasedate`
- 🖼️ **Flexible Cover Artwork** - Save uncompressed RAW or full-resolution master covers separately while keeping optimized artwork embedded in media files

## ✨ Features

Expand Down Expand Up @@ -189,6 +190,8 @@ The file is created automatically on first run. Command-line arguments override
| **File Output Options** | | |
| `--overwrite` | Overwrite existing files | `false` |
| `--save-cover`, `-s` | Save cover as separate file | `false` |
| `--save-cover-format` | Save cover format | - |
| `--save-cover-size` | Save cover size in pixels (0 for max native resolution) | - |
| `--save-playlist` | Save M3U8 playlist file | `false` |

### Template Variables
Expand Down Expand Up @@ -223,11 +226,19 @@ The file is created automatically on first run. Command-line arguments override
>
> - **yt-dlp is only used as a file download library**. Media is still fetched directly from Apple Music's servers, and yt-dlp is only responsible for handling the file download process.

### Cover Format
### Embedded Cover Format

- `jpg`
- `png`
- `raw` - Raw format as provided by the artist (requires `save_cover` to be enabled as it doesn't embed covers into files)

### Save Cover Format

- `jpg`
- `png`
- `raw` - Original uncompressed master file provided by the artist

> [!NOTE]
> `--save-cover-size 0` requests the maximum native resolution without resizing (applies to `jpg` and `png`).

### Metadata Language

Expand Down
2 changes: 2 additions & 0 deletions gamdl/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ async def main(config: CliConfig):
cover_size=config.cover_size,
wvd_path=config.wvd_path,
wrapper_api=wrapper_api,
save_cover_format=config.save_cover_format,
save_cover_size=config.save_cover_size,
)

song_interface = AppleMusicSongInterface(
Expand Down
19 changes: 19 additions & 0 deletions gamdl/cli/cli_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
AppleMusicUploadedVideoInterface,
ArtistMediaType,
CoverFormat,
SaveCoverFormat,
MusicVideoCodec,
MusicVideoResolution,
SongCodec,
Expand Down Expand Up @@ -465,6 +466,24 @@ class CliConfig:
is_flag=True,
),
]
save_cover_format: Annotated[
SaveCoverFormat | None,
option(
"--save-cover-format",
help="Save cover format",
default=base_interface_create_sig.parameters["save_cover_format"].default,
type=SaveCoverFormat,
),
]
save_cover_size: Annotated[
int | None,
option(
"--save-cover-size",
help="Save cover size in pixels",
default=base_interface_create_sig.parameters["save_cover_size"].default,
type=int,
),
]
save_playlist: Annotated[
bool,
option(
Expand Down
5 changes: 3 additions & 2 deletions gamdl/downloader/downloader.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,9 +164,10 @@ async def _initial_processing(self, item: DownloadItem) -> None:
item.media.playlist_tags.track,
)

if item.cover_path and self.save_cover and item.media.cover.url:
target_cover = item.media.save_cover or item.media.cover
if item.cover_path and self.save_cover and target_cover and target_cover.url:
cover_bytes = await self.base.interface.base.get_cover_bytes(
item.media.cover.url,
target_cover.url,
)
if cover_bytes and (self.overwrite or not Path(item.cover_path).exists()):
self._write_cover(
Expand Down
11 changes: 3 additions & 8 deletions gamdl/downloader/music_video.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from pathlib import Path

from ..interface.enums import CoverFormat
from ..interface.types import AppleMusicMedia, DecryptionKeyAv
from .ammuxer import decrypt_and_mux_hex
from .base import AppleMusicBaseDownloader
Expand Down Expand Up @@ -67,7 +66,7 @@ async def get_download_item(

download_item.cover_path = self.get_cover_path(
download_item.final_path,
media.cover.file_extension,
(media.save_cover or media.cover).file_extension,
)

return download_item
Expand Down Expand Up @@ -106,12 +105,8 @@ async def download(
download_item.staged_path.endswith(".m4v"),
)

cover_bytes = (
await self.base.interface.base.get_cover_bytes(
download_item.media.cover.url
)
if self.base.interface.base.cover_format != CoverFormat.RAW
else None
cover_bytes = await self.base.interface.base.get_cover_bytes(
download_item.media.cover.url
)
await self.base.apply_tags(
download_item.staged_path,
Expand Down
12 changes: 4 additions & 8 deletions gamdl/downloader/song.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import structlog

from ..interface.enums import CoverFormat, SyncedLyricsFormat
from ..interface.enums import SyncedLyricsFormat
from ..interface.types import AppleMusicMedia, DecryptionKeyAv
from .ammuxer import decrypt_and_mux_hex, decrypt_and_mux_wrapper
from .base import AppleMusicBaseDownloader
Expand Down Expand Up @@ -51,7 +51,7 @@ async def get_download_item(self, media: AppleMusicMedia) -> DownloadItem:

download_item.cover_path = self.get_cover_path(
download_item.final_path,
media.cover.file_extension,
(media.save_cover or media.cover).file_extension,
)

return download_item
Expand Down Expand Up @@ -196,12 +196,8 @@ async def download(
download_item.media.stream_info.audio_track.use_single_content_key,
)

cover_bytes = (
await self.base.interface.base.get_cover_bytes(
download_item.media.cover.url
)
if self.base.interface.base.cover_format != CoverFormat.RAW
else None
cover_bytes = await self.base.interface.base.get_cover_bytes(
download_item.media.cover.url
)
await self.base.apply_tags(
download_item.staged_path,
Expand Down
11 changes: 3 additions & 8 deletions gamdl/downloader/uploaded_video.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from pathlib import Path

from ..interface.enums import CoverFormat
from ..interface.types import AppleMusicMedia
from .base import AppleMusicBaseDownloader
from .types import DownloadItem
Expand Down Expand Up @@ -37,7 +36,7 @@ async def get_download_item(

download_item.cover_path = self.get_cover_path(
download_item.final_path,
media.cover.file_extension,
(media.save_cover or media.cover).file_extension,
)

return download_item
Expand All @@ -51,12 +50,8 @@ async def download(
download_item.staged_path,
)

cover_bytes = (
await self.base.interface.base.get_cover_bytes(
download_item.media.cover.url
)
if self.base.interface.base.cover_format != CoverFormat.RAW
else None
cover_bytes = await self.base.interface.base.get_cover_bytes(
download_item.media.cover.url
)
await self.base.apply_tags(
download_item.staged_path,
Expand Down
74 changes: 62 additions & 12 deletions gamdl/interface/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from ..api.itunes import ItunesApi
from ..api.wrapper import WrapperApi
from .constants import IMAGE_FILE_EXTENSION_MAP
from .enums import CoverFormat
from .enums import CoverFormat, SaveCoverFormat
from .types import Cover, DecryptionKey, MediaRating, MediaTags, MediaType, PlaylistTags

logger = structlog.get_logger(__name__)
Expand All @@ -32,11 +32,15 @@ def __init__(
cover_format: CoverFormat,
cover_size: int,
cdm: Cdm,
save_cover_format: SaveCoverFormat | None = None,
save_cover_size: int | None = None,
) -> None:
self.apple_music_api = apple_music_api
self.itunes_api = itunes_api
self.cover_format = cover_format
self.cover_size = cover_size
self.save_cover_format = save_cover_format
self.save_cover_size = save_cover_size
self.cdm = cdm
self.wrapper_api = wrapper_api

Expand Down Expand Up @@ -105,11 +109,18 @@ async def get_response(
def format_cover(
template_cover_url: str,
cover_size: int,
cover_format: CoverFormat,
cover_format: CoverFormat | SaveCoverFormat,
width: int | None = None,
height: int | None = None,
) -> str:
if cover_size == 0 and width and height:
size_str = f"{width}x{height}"
else:
actual_size = cover_size if cover_size != 0 else 1200
size_str = f"{actual_size}x{actual_size}"
return re.sub(
r"/\{w\}x\{h\}([a-z]{2})\.jpg",
f"/{cover_size}x{cover_size}bb.{cover_format.value}",
f"/{size_str}bb.{cover_format.value}",
template_cover_url,
)

Expand All @@ -130,6 +141,8 @@ async def create(
wvd_path: str | None = None,
itunes_api: ItunesApi | None = None,
wrapper_api: WrapperApi | None = None,
save_cover_format: SaveCoverFormat | None = None,
save_cover_size: int | None = None,
):
itunes_api = itunes_api or await ItunesApi.create(
storefront=apple_music_api.storefront,
Expand All @@ -149,6 +162,8 @@ async def create(
cover_size=cover_size,
cdm=cdm,
wrapper_api=wrapper_api,
save_cover_format=save_cover_format,
save_cover_size=save_cover_size,
)
return base

Expand Down Expand Up @@ -216,8 +231,9 @@ async def get_cover_bytes(self, cover_url: str) -> bytes | None:

return response.content

def _get_cover_template_url(self, metadata: dict) -> str:
if self.cover_format == CoverFormat.RAW:
def _get_cover_template_url(self, metadata: dict, cover_format: CoverFormat | SaveCoverFormat | None = None) -> str:
fmt = cover_format or self.cover_format
if fmt == SaveCoverFormat.RAW:
cover_template_url = self._get_raw_cover_url(
metadata["attributes"]["artwork"]["url"]
)
Expand Down Expand Up @@ -245,10 +261,12 @@ def _get_raw_cover_url(self, cover_url_template: str) -> str:
async def _get_cover_file_extension(
self,
cover_url: str,
cover_format: CoverFormat | SaveCoverFormat | None = None,
) -> str | None:
fmt = cover_format or self.cover_format
log = logger.bind(action="get_cover_file_extension", cover_url=cover_url)
if self.cover_format != CoverFormat.RAW:
return f".{self.cover_format.value}"
if fmt != SaveCoverFormat.RAW:
return f".{fmt.value}"

cover_bytes = await self.get_cover_bytes(cover_url)
if cover_bytes is None:
Expand All @@ -265,21 +283,29 @@ async def _get_cover_file_extension(
async def get_cover(
self,
metadata: dict,
cover_format: CoverFormat | SaveCoverFormat | None = None,
cover_size: int | None = None,
) -> str:
log = logger.bind(action="get_cover", media_id=metadata["id"])

template_url = self._get_cover_template_url(metadata)
fmt = cover_format or self.cover_format
size = self.cover_size if cover_size is None else cover_size

template_url = self._get_cover_template_url(metadata, cover_format=fmt)

if self.cover_format == CoverFormat.RAW:
if fmt == SaveCoverFormat.RAW:
cover_url = template_url
else:
artwork = metadata.get("attributes", {}).get("artwork", {})
cover_url = self.format_cover(
template_url,
self.cover_size,
self.cover_format,
size,
fmt,
width=artwork.get("width"),
height=artwork.get("height"),
)

cover_file_extension = await self._get_cover_file_extension(cover_url)
cover_file_extension = await self._get_cover_file_extension(cover_url, cover_format=fmt)

cover = Cover(
template_url=template_url,
Expand All @@ -291,6 +317,30 @@ async def get_cover(

return cover

async def get_save_cover(
self,
metadata: dict,
) -> Cover:
if self.save_cover_format is None and self.save_cover_size is None:
return await self.get_cover(metadata)

fmt = (
self.save_cover_format
if self.save_cover_format is not None
else self.cover_format
)
size = (
self.save_cover_size
if self.save_cover_size is not None
else self.cover_size
)

return await self.get_cover(
metadata,
cover_format=fmt,
cover_size=size,
)

@alru_cache()
async def get_media_date(
self,
Expand Down
5 changes: 5 additions & 0 deletions gamdl/interface/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,11 @@ class UploadedVideoQuality(Enum):
class CoverFormat(Enum):
JPG = "jpg"
PNG = "png"


class SaveCoverFormat(Enum):
JPG = "jpg"
PNG = "png"
RAW = "raw"


Expand Down
1 change: 1 addition & 0 deletions gamdl/interface/music_video.py
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,7 @@ async def get_media(
)

media.cover = await self.base.get_cover(media.media_metadata)
media.save_cover = await self.base.get_save_cover(media.media_metadata)

itunes_page_metadata = await self.get_itunes_page_metadata(media.media_metadata)

Expand Down
1 change: 1 addition & 0 deletions gamdl/interface/song.py
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,7 @@ async def get_media(
)

media.cover = await self.base.get_cover(media.media_metadata)
media.save_cover = await self.base.get_save_cover(media.media_metadata)

media.lyrics = await self.get_lyrics(media.media_metadata)

Expand Down
1 change: 1 addition & 0 deletions gamdl/interface/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ class AppleMusicMedia:
playlist_tags: PlaylistTags | None = None
extra_tags: dict | None = None
cover: Cover | None = None
save_cover: Cover | None = None
lyrics: Lyrics | None = None
tags: MediaTags | None = None
stream_info: StreamInfoAv | None = None
Expand Down
1 change: 1 addition & 0 deletions gamdl/interface/uploaded_video.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ async def get_media(
raise GamdlInterfaceMediaNotStreamableError(media.media_id)

media.cover = await self.base.get_cover(media.media_metadata)
media.save_cover = await self.base.get_save_cover(media.media_metadata)

media.stream_info = await self.get_stream_info(media.media_metadata)
if not media.stream_info:
Expand Down
Loading