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 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..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, @@ -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( 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..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 @@ -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 @@ -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 901ad4c..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 @@ -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 @@ -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 0b88cfe..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 @@ -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 @@ -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/base.py b/gamdl/interface/base.py index 562a75f..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,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 @@ -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, ) @@ -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, @@ -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 | 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"] ) @@ -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: @@ -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, @@ -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/enums.py b/gamdl/interface/enums.py index 3150f26..fe96b11 100644 --- a/gamdl/interface/enums.py +++ b/gamdl/interface/enums.py @@ -108,6 +108,11 @@ class UploadedVideoQuality(Enum): class CoverFormat(Enum): JPG = "jpg" PNG = "png" + + +class SaveCoverFormat(Enum): + JPG = "jpg" + PNG = "png" RAW = "raw" 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: