From a1b645d24ad82954c20cae9bd5a9f24c07c946e6 Mon Sep 17 00:00:00 2001 From: Leandro Guedes Date: Sat, 5 Sep 2026 21:03:05 -0300 Subject: [PATCH 1/5] feat: add save cover format and resolution support --- gamdl/interface/base.py | 70 ++++++++++++++++++++++++++----- gamdl/interface/music_video.py | 1 + gamdl/interface/song.py | 1 + gamdl/interface/types.py | 1 + gamdl/interface/uploaded_video.py | 1 + 5 files changed, 64 insertions(+), 10 deletions(-) diff --git a/gamdl/interface/base.py b/gamdl/interface/base.py index 562a75f..b51d1e0 100644 --- a/gamdl/interface/base.py +++ b/gamdl/interface/base.py @@ -32,11 +32,15 @@ def __init__( cover_format: CoverFormat, cover_size: int, cdm: Cdm, + save_cover_format: CoverFormat | 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 @@ -106,10 +110,17 @@ def format_cover( template_cover_url: str, cover_size: int, cover_format: CoverFormat, + 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, ) @@ -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: CoverFormat | None = None, + save_cover_size: int | None = None, ): itunes_api = itunes_api or await ItunesApi.create( storefront=apple_music_api.storefront, @@ -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 @@ -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 | None = None) -> str: + fmt = cover_format or self.cover_format + if fmt == CoverFormat.RAW: cover_template_url = self._get_raw_cover_url( metadata["attributes"]["artwork"]["url"] ) @@ -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 | 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 != CoverFormat.RAW: + return f".{fmt.value}" cover_bytes = await self.get_cover_bytes(cover_url) if cover_bytes is None: @@ -265,21 +283,29 @@ async def _get_cover_file_extension( async def get_cover( self, metadata: dict, + cover_format: CoverFormat | 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 == CoverFormat.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, @@ -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, diff --git a/gamdl/interface/music_video.py b/gamdl/interface/music_video.py index bd23780..a6fec06 100644 --- a/gamdl/interface/music_video.py +++ b/gamdl/interface/music_video.py @@ -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) diff --git a/gamdl/interface/song.py b/gamdl/interface/song.py index 018353c..f1b196b 100644 --- a/gamdl/interface/song.py +++ b/gamdl/interface/song.py @@ -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) diff --git a/gamdl/interface/types.py b/gamdl/interface/types.py index 14f7a64..ab9f66b 100644 --- a/gamdl/interface/types.py +++ b/gamdl/interface/types.py @@ -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 diff --git a/gamdl/interface/uploaded_video.py b/gamdl/interface/uploaded_video.py index a05880b..318ea93 100644 --- a/gamdl/interface/uploaded_video.py +++ b/gamdl/interface/uploaded_video.py @@ -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: From e65f3c8722c8d0e0144a8e608e21c97ffb1370e7 Mon Sep 17 00:00:00 2001 From: Leandro Guedes Date: Sat, 5 Sep 2026 21:27:54 -0300 Subject: [PATCH 2/5] feat: support separate save-cover-format and save-cover-size options --- gamdl/cli/cli.py | 2 ++ gamdl/cli/cli_config.py | 18 ++++++++++++++++++ gamdl/downloader/downloader.py | 5 +++-- gamdl/downloader/music_video.py | 2 +- gamdl/downloader/song.py | 2 +- gamdl/downloader/uploaded_video.py | 2 +- 6 files changed, 26 insertions(+), 5 deletions(-) diff --git a/gamdl/cli/cli.py b/gamdl/cli/cli.py index f8c4fb5..049c0c0 100644 --- a/gamdl/cli/cli.py +++ b/gamdl/cli/cli.py @@ -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( diff --git a/gamdl/cli/cli_config.py b/gamdl/cli/cli_config.py index b860237..1911937 100644 --- a/gamdl/cli/cli_config.py +++ b/gamdl/cli/cli_config.py @@ -465,6 +465,24 @@ class CliConfig: is_flag=True, ), ] + save_cover_format: Annotated[ + CoverFormat | None, + option( + "--save-cover-format", + help="Save cover format", + default=base_interface_create_sig.parameters["save_cover_format"].default, + type=CoverFormat, + ), + ] + 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( diff --git a/gamdl/downloader/downloader.py b/gamdl/downloader/downloader.py index db7782e..df8c736 100644 --- a/gamdl/downloader/downloader.py +++ b/gamdl/downloader/downloader.py @@ -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( diff --git a/gamdl/downloader/music_video.py b/gamdl/downloader/music_video.py index 9e2ba17..d12d287 100644 --- a/gamdl/downloader/music_video.py +++ b/gamdl/downloader/music_video.py @@ -67,7 +67,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 diff --git a/gamdl/downloader/song.py b/gamdl/downloader/song.py index 901ad4c..6070f14 100644 --- a/gamdl/downloader/song.py +++ b/gamdl/downloader/song.py @@ -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 diff --git a/gamdl/downloader/uploaded_video.py b/gamdl/downloader/uploaded_video.py index 0b88cfe..242d155 100644 --- a/gamdl/downloader/uploaded_video.py +++ b/gamdl/downloader/uploaded_video.py @@ -37,7 +37,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 From 5b4a3a9d82f34747a09868e4474174e328900cb1 Mon Sep 17 00:00:00 2001 From: Leandro Guedes Date: Sat, 5 Sep 2026 22:02:19 -0300 Subject: [PATCH 3/5] feat: add dedicated SaveCoverFormat enum --- gamdl/cli/cli_config.py | 5 +++-- gamdl/interface/base.py | 20 ++++++++++---------- gamdl/interface/enums.py | 6 ++++++ 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/gamdl/cli/cli_config.py b/gamdl/cli/cli_config.py index 1911937..f202845 100644 --- a/gamdl/cli/cli_config.py +++ b/gamdl/cli/cli_config.py @@ -23,6 +23,7 @@ AppleMusicUploadedVideoInterface, ArtistMediaType, CoverFormat, + SaveCoverFormat, MusicVideoCodec, MusicVideoResolution, SongCodec, @@ -466,12 +467,12 @@ class CliConfig: ), ] save_cover_format: Annotated[ - CoverFormat | None, + SaveCoverFormat | None, option( "--save-cover-format", help="Save cover format", default=base_interface_create_sig.parameters["save_cover_format"].default, - type=CoverFormat, + type=SaveCoverFormat, ), ] save_cover_size: Annotated[ diff --git a/gamdl/interface/base.py b/gamdl/interface/base.py index b51d1e0..3ec8fce 100644 --- a/gamdl/interface/base.py +++ b/gamdl/interface/base.py @@ -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__) @@ -32,7 +32,7 @@ def __init__( cover_format: CoverFormat, cover_size: int, cdm: Cdm, - save_cover_format: CoverFormat | None = None, + save_cover_format: SaveCoverFormat | None = None, save_cover_size: int | None = None, ) -> None: self.apple_music_api = apple_music_api @@ -109,7 +109,7 @@ 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: @@ -141,7 +141,7 @@ async def create( wvd_path: str | None = None, itunes_api: ItunesApi | None = None, wrapper_api: WrapperApi | None = None, - save_cover_format: CoverFormat | None = None, + save_cover_format: SaveCoverFormat | None = None, save_cover_size: int | None = None, ): itunes_api = itunes_api or await ItunesApi.create( @@ -231,9 +231,9 @@ async def get_cover_bytes(self, cover_url: str) -> bytes | None: return response.content - def _get_cover_template_url(self, metadata: dict, cover_format: CoverFormat | None = None) -> str: + def _get_cover_template_url(self, metadata: dict, cover_format: CoverFormat | SaveCoverFormat | None = None) -> str: fmt = cover_format or self.cover_format - if fmt == CoverFormat.RAW: + if fmt == SaveCoverFormat.RAW: cover_template_url = self._get_raw_cover_url( metadata["attributes"]["artwork"]["url"] ) @@ -261,11 +261,11 @@ def _get_raw_cover_url(self, cover_url_template: str) -> str: async def _get_cover_file_extension( self, cover_url: str, - cover_format: CoverFormat | None = None, + 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 fmt != CoverFormat.RAW: + if fmt != SaveCoverFormat.RAW: return f".{fmt.value}" cover_bytes = await self.get_cover_bytes(cover_url) @@ -283,7 +283,7 @@ async def _get_cover_file_extension( async def get_cover( self, metadata: dict, - cover_format: CoverFormat | None = None, + cover_format: CoverFormat | SaveCoverFormat | None = None, cover_size: int | None = None, ) -> str: log = logger.bind(action="get_cover", media_id=metadata["id"]) @@ -293,7 +293,7 @@ async def get_cover( template_url = self._get_cover_template_url(metadata, cover_format=fmt) - if fmt == CoverFormat.RAW: + if fmt == SaveCoverFormat.RAW: cover_url = template_url else: artwork = metadata.get("attributes", {}).get("artwork", {}) diff --git a/gamdl/interface/enums.py b/gamdl/interface/enums.py index 3150f26..50f09a4 100644 --- a/gamdl/interface/enums.py +++ b/gamdl/interface/enums.py @@ -111,6 +111,12 @@ class CoverFormat(Enum): RAW = "raw" +class SaveCoverFormat(Enum): + JPG = "jpg" + PNG = "png" + RAW = "raw" + + class ArtistMediaType(Enum): MAIN_ALBUMS = "main-albums" COMPILATION_ALBUMS = "compilation-albums" From d24db93dbd2093ec433f732cc2c9ad7e8650a4a5 Mon Sep 17 00:00:00 2001 From: Leandro Guedes Date: Sat, 5 Sep 2026 22:02:51 -0300 Subject: [PATCH 4/5] refactor: restrict CoverFormat to embedded formats --- gamdl/downloader/music_video.py | 9 ++------- gamdl/downloader/song.py | 10 +++------- gamdl/downloader/uploaded_video.py | 9 ++------- gamdl/interface/enums.py | 1 - 4 files changed, 7 insertions(+), 22 deletions(-) diff --git a/gamdl/downloader/music_video.py b/gamdl/downloader/music_video.py index d12d287..520debb 100644 --- a/gamdl/downloader/music_video.py +++ b/gamdl/downloader/music_video.py @@ -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 @@ -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, diff --git a/gamdl/downloader/song.py b/gamdl/downloader/song.py index 6070f14..efd8602 100644 --- a/gamdl/downloader/song.py +++ b/gamdl/downloader/song.py @@ -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 @@ -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, diff --git a/gamdl/downloader/uploaded_video.py b/gamdl/downloader/uploaded_video.py index 242d155..ac4b883 100644 --- a/gamdl/downloader/uploaded_video.py +++ b/gamdl/downloader/uploaded_video.py @@ -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 @@ -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, diff --git a/gamdl/interface/enums.py b/gamdl/interface/enums.py index 50f09a4..fe96b11 100644 --- a/gamdl/interface/enums.py +++ b/gamdl/interface/enums.py @@ -108,7 +108,6 @@ class UploadedVideoQuality(Enum): class CoverFormat(Enum): JPG = "jpg" PNG = "png" - RAW = "raw" class SaveCoverFormat(Enum): From 758616d2dc9b8e059b3acc4cf159222fa1a60fc4 Mon Sep 17 00:00:00 2001 From: Leandro Guedes Date: Sat, 5 Sep 2026 22:08:39 -0300 Subject: [PATCH 5/5] docs: update embedded and save cover format option --- README.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9b82c6f..3ff0760 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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