mirror of
https://github.com/glomatico/gamdl.git
synced 2026-06-13 20:25:13 +03:00
Compare commits
141 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 744300e36b | |||
| e86f990395 | |||
| abc2f8f2f2 | |||
| 2dabb1c6fe | |||
| 8b80b0c6c5 | |||
| eef659bac8 | |||
| 0f7c3795a7 | |||
| c84b1137c2 | |||
| ebdc82d68b | |||
| 85c1fdbfbb | |||
| 5990e5f722 | |||
| b8bd406d74 | |||
| 57ee6e1db8 | |||
| a20feb2aa7 | |||
| 60db7e0339 | |||
| 575d2ee154 | |||
| f5bb56cab7 | |||
| ecc7979d7e | |||
| d129551b55 | |||
| 08a5ac00d8 | |||
| 628c9786d5 | |||
| 7de12c3da7 | |||
| 39d724c488 | |||
| 79e00e5e19 | |||
| e90fd24af0 | |||
| d68edd5393 | |||
| 5acefd9a06 | |||
| 93b62cdde9 | |||
| fc61a51da2 | |||
| 81b44a808d | |||
| 24f3af1a5e | |||
| 4a469d74d3 | |||
| 6122835caa | |||
| be597f0de4 | |||
| b10ab5332d | |||
| 080413b183 | |||
| f6443081ae | |||
| 8dcf10c221 | |||
| 6f5efd1779 | |||
| 06e43fdbbe | |||
| 646125b93f | |||
| ea281766ba | |||
| a1b0ad35ee | |||
| 2f715b3d9d | |||
| 461fcedf30 | |||
| 6d7cb3ada4 | |||
| b87d406ffa | |||
| f6efdb3332 | |||
| 29a006c304 | |||
| a49a9c90cc | |||
| 43fd1dd2e3 | |||
| 2ed6ac05ba | |||
| 0f0e17f4cd | |||
| 8c4d2713f7 | |||
| 1baca4151b | |||
| 6f08a4b2f9 | |||
| 38f708e2e9 | |||
| f27adf98df | |||
| 9f0b25e1d1 | |||
| 3590d99063 | |||
| b200dade5a | |||
| 118d23e9db | |||
| 7bc8c6668f | |||
| 0e9fb3702d | |||
| e706f0fa82 | |||
| 3014fb112d | |||
| d7f17b8b6f | |||
| 947e2df81a | |||
| c8fe96b31d | |||
| 83a3efc1fa | |||
| 345afbf174 | |||
| c35051a7ec | |||
| b286ee84e2 | |||
| 9094f2c7b4 | |||
| 5feb5b274a | |||
| 1375af929c | |||
| d280f1fad2 | |||
| 3a04d7927e | |||
| 942a812308 | |||
| 66e01293e6 | |||
| c0561da592 | |||
| 56d238fb1b | |||
| d3a53bf93b | |||
| bb7a3ff77e | |||
| bf6293a0a0 | |||
| c421b3e855 | |||
| 7e495300f9 | |||
| ccef00e39f | |||
| 94cdba313c | |||
| d7b19e8c67 | |||
| a6809df2ef | |||
| 40f3616bc3 | |||
| 104100e091 | |||
| ce4a7d7880 | |||
| ec09bacd39 | |||
| e0d3f46159 | |||
| 0bfb4d80b8 | |||
| 54d6d93967 | |||
| c13160b999 | |||
| ea4d574810 | |||
| 9da35c3f57 | |||
| ebb7ec1da7 | |||
| d155a42e3a | |||
| 8f18562e1c | |||
| 25ed506b82 | |||
| ba76241032 | |||
| c6f7e99135 | |||
| dbaa1faa6b | |||
| a61a9c4975 | |||
| 8ffe5c86ca | |||
| 88bdf64825 | |||
| 3849df9adb | |||
| a989ff6c34 | |||
| f3d583aab2 | |||
| 7ac3d3e400 | |||
| 085e8f1b5d | |||
| 4df36e60d9 | |||
| f6d726e466 | |||
| 61b1bf1e55 | |||
| 3ae6709ccb | |||
| 1f00e4fb9f | |||
| 714d47bb13 | |||
| 46e3a92d4f | |||
| 42b536d271 | |||
| dac8d5eed9 | |||
| 2956f20dfa | |||
| 8f76743a3b | |||
| 3096bbc79d | |||
| ed49d7bd5f | |||
| 0ea72d0b78 | |||
| ae490320ad | |||
| e40668e6ec | |||
| 62c695b5ff | |||
| 5d9c8c1f0b | |||
| 54d640230a | |||
| 3d272a6891 | |||
| e99ed0eb5a | |||
| 86b5029773 | |||
| 3df0a91d3f | |||
| d356596cf4 | |||
| cbd2df79b7 |
@@ -0,0 +1 @@
|
||||
ko_fi: glomatico
|
||||
@@ -1,17 +1,20 @@
|
||||
# Glomatico's Apple Music Downloader
|
||||
A Python script to download Apple Music songs/music videos/albums/playlists/post videos.
|
||||
A Python CLI app for downloading Apple Music songs/music videos/posts.
|
||||
|
||||
**Discord Server:** https://discord.gg/aBjMEZ9tnq
|
||||
|
||||
## Features
|
||||
* Download songs in AAC/Spatial AAC/Dolby Atmos/ALAC*
|
||||
* Download songs in AAC 256kbps and other codecs
|
||||
* Download music videos up to 4K
|
||||
* Download synced lyrics in LRC, SRT or TTML
|
||||
* Choose between FFmpeg and MP4Box for remuxing
|
||||
* Choose between yt-dlp and N_m3u8DL-RE for downloading
|
||||
* Highly customizable
|
||||
* Use artist links to download all of their albums or music videos
|
||||
|
||||
## Prerequisites
|
||||
* Python 3.8 or higher
|
||||
* The cookies file of your Apple Music account (requires an active subscription)
|
||||
* The cookies file of your Apple Music browser session (requires an active subscription)
|
||||
* You can get your cookies by using one of the following extensions on your browser of choice at the Apple Music website with your account signed in:
|
||||
* Firefox: https://addons.mozilla.org/addon/export-cookies-txt
|
||||
* Chromium based browsers: https://chrome.google.com/webstore/detail/gdocmgbfkjnnpapoeobnolbbkoibbcif
|
||||
@@ -29,57 +32,74 @@ A Python script to download Apple Music songs/music videos/albums/playlists/post
|
||||
```bash
|
||||
pip install gamdl
|
||||
```
|
||||
2. Place your cookies in the same directory you will run the script from and name it as `cookies.txt`
|
||||
2. Place your cookies file in the directory from which you will be running gamdl and name it `cookies.txt`.
|
||||
|
||||
## Usage
|
||||
```bash
|
||||
gamdl [OPTIONS] URLS...
|
||||
```
|
||||
|
||||
### Examples
|
||||
* Download a song
|
||||
```bash
|
||||
gamdl "https://music.apple.com/us/album/never-gonna-give-you-up-2022-remaster/1626265761?i=1626265765"
|
||||
gamdl "https://music.apple.com/us/album/never-gonna-give-you-up-2022-remaster/1624945511?i=1624945512"
|
||||
```
|
||||
* Download an album
|
||||
```bash
|
||||
gamdl "https://music.apple.com/us/album/whenever-you-need-somebody-2022-remaster/1626265761"
|
||||
gamdl "https://music.apple.com/us/album/whenever-you-need-somebody-2022-remaster/1624945511"
|
||||
```
|
||||
* Choose which albums or music videos to download from an artist
|
||||
```bash
|
||||
gamdl "https://music.apple.com/us/artist/rick-astley/669771"
|
||||
```
|
||||
|
||||
### Interactive prompt controls
|
||||
* Arrow keys - Move selection
|
||||
* Space - Toggle selection
|
||||
* Ctrl + A - Select all
|
||||
* Enter - Confirm selection
|
||||
|
||||
## Configuration
|
||||
You can configure gamdl by using the command line arguments or the config file. The config file is created automatically when you run gamdl for the first time at `~/.gamdl/config.json` on Linux and `%USERPROFILE%\.gamdl\config.json` on Windows. Config file values can be overridden using command line arguments.
|
||||
| Command line argument / Config file key | Description | Default value |
|
||||
| --------------------------------------------------------------- | ------------------------------------------------------------------ | -------------------------------------------- |
|
||||
| `--disable-music-video-skip` / `disable_music_video_skip` | Don't skip downloading music videos in albums/playlists. | `false` |
|
||||
| `--save-cover`, `-s` / `save_cover` | Save cover as a separate file. | `false` |
|
||||
| `--overwrite` / `overwrite` | Overwrite existing files. | `false` |
|
||||
| `--read-urls-as-txt`, `-r` / - | Interpret URLs as paths to text files containing URLs. | `false` |
|
||||
| `--synced-lyrics-only` / `synced_lyrics_only` | Download only the synced lyrics. | `false` |
|
||||
| `--no-synced-lyrics` / `no_synced_lyrics` | Don't download the synced lyrics. | `false` |
|
||||
| `--config-path` / - | Path to config file. | `<home>/.spotify-web-downloader/config.json` |
|
||||
| `--log-level` / `log_level` | Log level. | `INFO` |
|
||||
| `--print-exceptions` / `print_exceptions` | Print exceptions. | `false` |
|
||||
| `--cookies-path`, `-c` / `cookies_path` | Path to .txt cookies file. | `./cookies.txt` |
|
||||
| `--output-path`, `-o` / `output_path` | Path to output directory. | `./Apple Music` |
|
||||
| `--temp-path` / `temp_path` | Path to temporary directory. | `./temp` |
|
||||
| `--wvd-path` / `wvd_path` | Path to .wvd file. | `null` |
|
||||
| `--nm3u8dlre-path` / `nm3u8dlre_path` | Path to N_m3u8DL-RE binary. | `N_m3u8dl-RE` |
|
||||
| `--mp4decrypt-path` / `mp4decrypt_path` | Path to mp4decrypt binary. | `mp4decrypt` |
|
||||
| `--ffmpeg-path` / `ffmpeg_path` | Path to FFmpeg binary. | `ffmpeg` |
|
||||
| `--mp4box-path` / `mp4box_path` | Path to MP4Box binary. | `MP4Box` |
|
||||
| `--download-mode` / `download_mode` | Download mode. | `ytdlp` |
|
||||
| `--remux-mode` / `remux_mode` | Remux mode. | `ffmpeg` |
|
||||
| `--cover-format` / `cover_format` | Cover format. | `jpg` |
|
||||
| `--template-folder-album` / `template_folder_album` | Template folder for tracks that are part of an album. | `{album_artist}/{album}` |
|
||||
| `--template-folder-compilation` / `template_folder_compilation` | Template folder for tracks that are part of a compilation album. | `Compilations/{album}` |
|
||||
| `--template-file-single-disc` / `template_file_single_disc` | Template file for the tracks that are part of a single-disc album. | `{track:02d} {title}` |
|
||||
| `--template-file-multi-disc` / `template_file_multi_disc` | Template file for the tracks that are part of a multi-disc album. | `{disc}-{track:02d} {title}` |
|
||||
| `--template-folder-no-album` / `template_folder_no_album` | Template folder for the tracks that are not part of an album. | `{artist}/Unknown Album` |
|
||||
| `--template-file-no-album` / `template_file_no_album` | Template file for the tracks that are not part of an album. | `{title}` |
|
||||
| `--template-date` / `template_date` | Date tag template. | `%Y-%m-%dT%H:%M:%SZ` |
|
||||
| `--exclude-tags` / `exclude_tags` | Comma-separated tags to exclude. | `null` |
|
||||
| `--cover-size` / `cover_size` | Cover size. | `1200` |
|
||||
| `--truncate` / `truncate` | Maximum length of the file/folder names. | `40` |
|
||||
| `--codec-song` / `codec_song` | Song codec. | `aac-legacy` |
|
||||
| `--synced-lyrics-format` / `synced_lyrics_format` | Synced lyrics format. | `lrc` |
|
||||
| `--codec-music-video` / `codec_music_video` | Music video codec. | `h264-best` |
|
||||
| `--quality-post` / `quality_post` | Post video quality. | `best` |
|
||||
| `--no-config-file`, `-n` / - | Do not use a config file. | `false` |
|
||||
gamdl can be configured by using the command line arguments or the config file. The config file is created automatically when you run gamdl for the first time at `~/.gamdl/config.json` on Linux and `%USERPROFILE%\.gamdl\config.json` on Windows. Config file values can be overridden using command line arguments.
|
||||
| Command line argument / Config file key | Description | Default value |
|
||||
| --------------------------------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------- |
|
||||
| `--disable-music-video-skip` / `disable_music_video_skip` | Don't skip downloading music videos in albums/playlists. | `false` |
|
||||
| `--save-cover`, `-s` / `save_cover` | Save cover as a separate file. | `false` |
|
||||
| `--overwrite` / `overwrite` | Overwrite existing files. | `false` |
|
||||
| `--read-urls-as-txt`, `-r` / - | Interpret URLs as paths to text files containing URLs separated by newlines. | `false` |
|
||||
| `--synced-lyrics-only` / `synced_lyrics_only` | Download only the synced lyrics. | `false` |
|
||||
| `--no-synced-lyrics` / `no_synced_lyrics` | Don't download the synced lyrics. | `false` |
|
||||
| `--config-path` / - | Path to config file. | `<home>/.gamdl/config.json` |
|
||||
| `--log-level` / `log_level` | Log level. | `INFO` |
|
||||
| `--print-exceptions` / `print_exceptions` | Print exceptions. | `false` |
|
||||
| `--cookies-path`, `-c` / `cookies_path` | Path to .txt cookies file. | `./cookies.txt` |
|
||||
| `--language`, `-l` / `language` | Metadata language as an ISO-2A language code (don't always work for videos). | `en-US` |
|
||||
| `--output-path`, `-o` / `output_path` | Path to output directory. | `./Apple Music` |
|
||||
| `--temp-path` / `temp_path` | Path to temporary directory. | `./temp` |
|
||||
| `--wvd-path` / `wvd_path` | Path to .wvd file. | `null` |
|
||||
| `--nm3u8dlre-path` / `nm3u8dlre_path` | Path to N_m3u8DL-RE binary. | `N_m3u8DL-RE` |
|
||||
| `--mp4decrypt-path` / `mp4decrypt_path` | Path to mp4decrypt binary. | `mp4decrypt` |
|
||||
| `--ffmpeg-path` / `ffmpeg_path` | Path to FFmpeg binary. | `ffmpeg` |
|
||||
| `--mp4box-path` / `mp4box_path` | Path to MP4Box binary. | `MP4Box` |
|
||||
| `--download-mode` / `download_mode` | Download mode. | `ytdlp` |
|
||||
| `--remux-mode` / `remux_mode` | Remux mode. | `ffmpeg` |
|
||||
| `--cover-format` / `cover_format` | Cover format. | `jpg` |
|
||||
| `--template-folder-album` / `template_folder_album` | Template folder for tracks that are part of an album. | `{album_artist}/{album}` |
|
||||
| `--template-folder-compilation` / `template_folder_compilation` | Template folder for tracks that are part of a compilation album. | `Compilations/{album}` |
|
||||
| `--template-file-single-disc` / `template_file_single_disc` | Template file for the tracks that are part of a single-disc album. | `{track:02d} {title}` |
|
||||
| `--template-file-multi-disc` / `template_file_multi_disc` | Template file for the tracks that are part of a multi-disc album. | `{disc}-{track:02d} {title}` |
|
||||
| `--template-folder-no-album` / `template_folder_no_album` | Template folder for the tracks that are not part of an album. | `{artist}/Unknown Album` |
|
||||
| `--template-file-no-album` / `template_file_no_album` | Template file for the tracks that are not part of an album. | `{title}` |
|
||||
| `--template-date` / `template_date` | Date tag template. | `%Y-%m-%dT%H:%M:%SZ` |
|
||||
| `--exclude-tags` / `exclude_tags` | Comma-separated tags to exclude. | `null` |
|
||||
| `--cover-size` / `cover_size` | Cover size. | `1200` |
|
||||
| `--truncate` / `truncate` | Maximum length of the file/folder names. | `40` |
|
||||
| `--codec-song` / `codec_song` | Song codec. | `aac-legacy` |
|
||||
| `--synced-lyrics-format` / `synced_lyrics_format` | Synced lyrics format. | `lrc` |
|
||||
| `--codec-music-video` / `codec_music_video` | Music video codec. | `h264` |
|
||||
| `--quality-post` / `quality_post` | Post video quality. | `best` |
|
||||
| `--no-config-file`, `-n` / - | Do not use a config file. | `false` |
|
||||
|
||||
|
||||
### Tags variables
|
||||
The following variables can be used in the template folders/files and/or in the `exclude_tags` list:
|
||||
@@ -136,29 +156,32 @@ The following download modes are available:
|
||||
The following codecs are available:
|
||||
* `aac-legacy`
|
||||
* `aac-he-legacy`
|
||||
|
||||
The following codecs are also available, **but are not guaranteed to work**, as currently most (or all) of the songs fails to be downloaded when using them:
|
||||
* `aac`
|
||||
* `aac-he`
|
||||
* `aac-binaural`
|
||||
* `aac-downmix`
|
||||
* `aac-he-binaural`
|
||||
* `aac-he-downmix`
|
||||
* `alac`
|
||||
* `atmos`
|
||||
|
||||
**Support for non-legacy codecs are not guaranteed, as most of the songs cannot be decrypted when using non-legacy codecs.**
|
||||
* `ac3`
|
||||
* `alac`
|
||||
* `ask`
|
||||
* When using this option, gamdl will ask you which codec from this list to use that is available for the song.
|
||||
|
||||
### Music videos codecs
|
||||
The following codecs are available:
|
||||
* `h264-best` (with AAC 256kbps, up to 1080p)
|
||||
* `h265-best` (With AAC 256kpbs, up to 2160p)
|
||||
* `h264` (up to 1080p, with AAC 256kbps)
|
||||
* `h265` (up to 2160p, with AAC 256kpbs)
|
||||
* `ask`
|
||||
* When using this option, the script will ask you which audio and video codec to use.
|
||||
* When using this option, gamdl will ask you which audio and video codec to use that is available for the music video.
|
||||
|
||||
### Post videos/extra videos qualities
|
||||
The following qualities are available:
|
||||
* `best` (with AAC 256kbps, up to 1080p)
|
||||
* `best` (up to 1080p, with AAC 256kbps)
|
||||
* `ask`
|
||||
* When using this option, the script will ask you which video quality to use.
|
||||
* When using this option, gamdl will ask you which video quality to use that is available for the video.
|
||||
|
||||
Post videos doesn't require remuxing and are limited to `ytdlp` download mode.
|
||||
|
||||
@@ -168,10 +191,12 @@ The following synced lyrics formats are available:
|
||||
* `srt`
|
||||
* `ttml`
|
||||
* Native format for Apple Music synced lyrics.
|
||||
* Highly unsupported by media players.
|
||||
* Highly unsupported by most media players.
|
||||
|
||||
### Cover formats
|
||||
The following cover formats are available:
|
||||
* `jpg`
|
||||
* `png`
|
||||
|
||||
* `raw`
|
||||
* This format gets the raw cover without any processing.
|
||||
* Note that when using this format, the cover image will not be embedded within the files. To address this, you can enable `save_cover` option to save the cover as a separate file.
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
__version__ = "2.0"
|
||||
__version__ = "2.2.6"
|
||||
|
||||
+46
-18
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import functools
|
||||
import re
|
||||
import time
|
||||
import typing
|
||||
from http.cookiejar import MozillaCookieJar
|
||||
from pathlib import Path
|
||||
|
||||
@@ -85,6 +86,31 @@ class AppleMusicApi:
|
||||
):
|
||||
self._raise_response_exception(response)
|
||||
|
||||
def get_artist(
|
||||
self,
|
||||
artist_id: str,
|
||||
include: str = "albums,music-videos",
|
||||
limit: int = 100,
|
||||
fetch_all: bool = True,
|
||||
) -> dict:
|
||||
response = self.session.get(
|
||||
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/artists/{artist_id}",
|
||||
params={
|
||||
"include": include,
|
||||
**{f"limit[{_include}]": limit for _include in include.split(",")},
|
||||
},
|
||||
)
|
||||
self._check_amp_api_response(response)
|
||||
artist = response.json()["data"][0]
|
||||
if fetch_all:
|
||||
for _include in include.split(","):
|
||||
for additional_data in self._extend_api_data(
|
||||
artist["relationships"][_include],
|
||||
limit,
|
||||
):
|
||||
artist["relationships"][_include]["data"].extend(additional_data)
|
||||
return artist
|
||||
|
||||
def get_song(
|
||||
self,
|
||||
song_id: str,
|
||||
@@ -143,13 +169,12 @@ class AppleMusicApi:
|
||||
def get_playlist(
|
||||
self,
|
||||
playlist_id: str,
|
||||
is_library: bool = False,
|
||||
limit_tracks: int = 300,
|
||||
extend: str = "extendedAssetUrls",
|
||||
full_playlist: bool = True,
|
||||
fetch_all: bool = True,
|
||||
) -> dict:
|
||||
response = self.session.get(
|
||||
f"{self.AMP_API_URL}/v1/{'me' if is_library else 'catalog'}/{self.storefront}/playlists/{playlist_id}",
|
||||
f"{self.AMP_API_URL}/v1/catalog/{self.storefront}/playlists/{playlist_id}",
|
||||
params={
|
||||
"extend": extend,
|
||||
"limit[tracks]": limit_tracks,
|
||||
@@ -157,28 +182,31 @@ class AppleMusicApi:
|
||||
)
|
||||
self._check_amp_api_response(response)
|
||||
playlist = response.json()["data"][0]
|
||||
if full_playlist:
|
||||
playlist = self._extend_playlists_tracks(playlist, limit_tracks)
|
||||
if fetch_all:
|
||||
for additional_data in self._extend_api_data(
|
||||
playlist["relationships"]["tracks"],
|
||||
limit_tracks,
|
||||
):
|
||||
playlist["relationships"]["tracks"]["data"].extend(additional_data)
|
||||
return playlist
|
||||
|
||||
def _extend_playlists_tracks(
|
||||
def _extend_api_data(
|
||||
self,
|
||||
playlist: dict,
|
||||
limit_tracks: int,
|
||||
) -> dict:
|
||||
playlist_next_uri = playlist["relationships"]["tracks"].get("next")
|
||||
while playlist_next_uri:
|
||||
playlist_next = self._get_playlist_next(playlist_next_uri, limit_tracks)
|
||||
playlist["relationships"]["tracks"]["data"].extend(playlist_next["data"])
|
||||
playlist_next_uri = playlist_next.get("next")
|
||||
api_response: dict,
|
||||
limit: int,
|
||||
) -> typing.Generator[list[dict], None, None]:
|
||||
next_uri = api_response.get("next")
|
||||
while next_uri:
|
||||
playlist_next = self._get_next_uri_response(next_uri, limit)
|
||||
yield playlist_next["data"]
|
||||
next_uri = playlist_next.get("next")
|
||||
time.sleep(self.WAIT_TIME)
|
||||
return playlist
|
||||
|
||||
def _get_playlist_next(self, playlist_next_uri: str, limit_tracks: int) -> dict:
|
||||
def _get_next_uri_response(self, next_uri: str, limit: int) -> dict:
|
||||
response = self.session.get(
|
||||
self.AMP_API_URL + playlist_next_uri,
|
||||
self.AMP_API_URL + next_uri,
|
||||
params={
|
||||
"limit[tracks]": limit_tracks,
|
||||
"limit": limit,
|
||||
},
|
||||
)
|
||||
self._check_amp_api_response(response)
|
||||
|
||||
+76
-42
@@ -35,7 +35,7 @@ def get_param_string(param: click.Parameter) -> str:
|
||||
return param.default
|
||||
|
||||
|
||||
def write_default_config_file(ctx: click.Context) -> None:
|
||||
def write_default_config_file(ctx: click.Context):
|
||||
ctx.params["config_path"].parent.mkdir(parents=True, exist_ok=True)
|
||||
config_file = {
|
||||
param.name: get_param_string(param)
|
||||
@@ -95,7 +95,7 @@ def load_config_file(
|
||||
"--read-urls-as-txt",
|
||||
"-r",
|
||||
is_flag=True,
|
||||
help="Interpret URLs as paths to text files containing URLs.",
|
||||
help="Interpret URLs as paths to text files containing URLs separated by newlines",
|
||||
)
|
||||
@click.option(
|
||||
"--synced-lyrics-only",
|
||||
@@ -132,6 +132,13 @@ def load_config_file(
|
||||
default=apple_music_api_sig.parameters["cookies_path"].default,
|
||||
help="Path to .txt cookies file.",
|
||||
)
|
||||
@click.option(
|
||||
"--language",
|
||||
"-l",
|
||||
type=str,
|
||||
default=apple_music_api_sig.parameters["language"].default,
|
||||
help="Metadata language as an ISO-2A language code (don't always work for videos).",
|
||||
)
|
||||
# Downloader specific options
|
||||
@click.option(
|
||||
"--output-path",
|
||||
@@ -301,6 +308,7 @@ def main(
|
||||
log_level: str,
|
||||
print_exceptions: bool,
|
||||
cookies_path: Path,
|
||||
language: str,
|
||||
output_path: Path,
|
||||
temp_path: Path,
|
||||
wvd_path: Path,
|
||||
@@ -334,7 +342,13 @@ def main(
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(log_level)
|
||||
logger.debug("Starting downloader")
|
||||
apple_music_api = AppleMusicApi(cookies_path)
|
||||
if not cookies_path.exists():
|
||||
logger.critical(X_NOT_FOUND_STRING.format("Cookies file", cookies_path))
|
||||
return
|
||||
apple_music_api = AppleMusicApi(
|
||||
cookies_path,
|
||||
language=language,
|
||||
)
|
||||
itunes_api = ItunesApi(
|
||||
apple_music_api.storefront,
|
||||
apple_music_api.language,
|
||||
@@ -419,12 +433,22 @@ def main(
|
||||
skip_mv = True
|
||||
else:
|
||||
skip_mv = False
|
||||
if codec_song not in LEGACY_CODECS:
|
||||
logger.warn(
|
||||
"You have chosen a non-legacy codec. Support for non-legacy codecs are not guaranteed, "
|
||||
"as most of the songs cannot be downloaded when using non-legacy codecs."
|
||||
)
|
||||
error_count = 0
|
||||
if read_urls_as_txt:
|
||||
urls = [url.strip() for url in Path(urls[0]).read_text().splitlines()]
|
||||
_urls = []
|
||||
for url in urls:
|
||||
if Path(url).exists():
|
||||
_urls.extend(Path(url).read_text().splitlines())
|
||||
urls = _urls
|
||||
for url_index, url in enumerate(urls, start=1):
|
||||
url_progress = f"URL {url_index}/{len(urls)}"
|
||||
try:
|
||||
logger.info(f'({url_progress}) Checking "{url}"')
|
||||
url_info = downloader.get_url_info(url)
|
||||
download_queue = downloader.get_download_queue(url_info)
|
||||
except Exception as e:
|
||||
@@ -468,8 +492,12 @@ def main(
|
||||
lyrics_synced_path = downloader_song.get_lyrics_synced_path(
|
||||
final_path
|
||||
)
|
||||
cover_path = downloader_song.get_cover_path(final_path)
|
||||
cover_url = downloader.get_cover_url(track)
|
||||
cover_file_extesion = downloader.get_cover_file_extension(cover_url)
|
||||
cover_path = downloader_song.get_cover_path(
|
||||
final_path,
|
||||
cover_file_extesion,
|
||||
)
|
||||
if synced_lyrics_only:
|
||||
pass
|
||||
elif final_path.exists() and not overwrite:
|
||||
@@ -477,11 +505,8 @@ def main(
|
||||
f'({queue_progress}) Song already exists at "{final_path}", skipping'
|
||||
)
|
||||
else:
|
||||
if codec_song in (
|
||||
SongCodec.AAC_LEGACY,
|
||||
SongCodec.AAC_HE_LEGACY,
|
||||
):
|
||||
logger.debug("Getting stream info")
|
||||
logger.debug("Getting stream info")
|
||||
if codec_song in LEGACY_CODECS:
|
||||
stream_info = downloader_song_legacy.get_stream_info(
|
||||
webplayback
|
||||
)
|
||||
@@ -491,14 +516,10 @@ def main(
|
||||
)
|
||||
else:
|
||||
stream_info = downloader_song.get_stream_info(track)
|
||||
if not stream_info.pssh:
|
||||
if not stream_info.stream_url or not stream_info.pssh:
|
||||
logger.warning(
|
||||
f"({queue_progress}) Song does not contain Widevine DRM, skipping"
|
||||
)
|
||||
continue
|
||||
elif not stream_info.stream_url:
|
||||
logger.warning(
|
||||
f"({queue_progress}) Song is not available with the selected codec, skipping"
|
||||
f"({queue_progress}) Song is not downloadable or is not"
|
||||
" available in the chosen codec, skipping"
|
||||
)
|
||||
continue
|
||||
logger.debug("Getting decryption key")
|
||||
@@ -508,13 +529,12 @@ def main(
|
||||
encrypted_path = downloader_song.get_encrypted_path(track["id"])
|
||||
decrypted_path = downloader_song.get_decrypted_path(track["id"])
|
||||
remuxed_path = downloader_song.get_remuxed_path(track["id"])
|
||||
logger.debug(f"Downloading to {encrypted_path}")
|
||||
logger.debug(f'Downloading to "{encrypted_path}"')
|
||||
downloader.download(encrypted_path, stream_info.stream_url)
|
||||
if codec_song in (
|
||||
SongCodec.AAC_LEGACY,
|
||||
SongCodec.AAC_HE_LEGACY,
|
||||
):
|
||||
logger.debug(f"Remuxing/Decrypting to {remuxed_path}")
|
||||
if codec_song in LEGACY_CODECS:
|
||||
logger.debug(
|
||||
f'Decrypting/Remuxing to "{decrypted_path}"/"{remuxed_path}"'
|
||||
)
|
||||
downloader_song_legacy.remux(
|
||||
encrypted_path,
|
||||
decrypted_path,
|
||||
@@ -522,15 +542,19 @@ def main(
|
||||
decryption_key,
|
||||
)
|
||||
else:
|
||||
logger.debug(f"Decrypting to {decrypted_path}")
|
||||
logger.debug(f'Decrypting to "{decrypted_path}"')
|
||||
downloader_song.decrypt(
|
||||
encrypted_path, decrypted_path, decryption_key
|
||||
)
|
||||
logger.debug(f"Remuxing to {final_path}")
|
||||
downloader_song.remux(decrypted_path, remuxed_path)
|
||||
logger.debug(f'Remuxing to "{final_path}"')
|
||||
downloader_song.remux(
|
||||
decrypted_path,
|
||||
remuxed_path,
|
||||
stream_info.codec,
|
||||
)
|
||||
logger.debug("Applying tags")
|
||||
downloader.apply_tags(remuxed_path, tags, cover_url)
|
||||
logger.debug(f"Moving to {final_path}")
|
||||
logger.debug(f'Moving to "{final_path}"')
|
||||
downloader.move_to_output_path(remuxed_path, final_path)
|
||||
if no_synced_lyrics or not lyrics.synced:
|
||||
pass
|
||||
@@ -568,13 +592,17 @@ def main(
|
||||
stream_url_master
|
||||
)
|
||||
tags = downloader_music_video.get_tags(
|
||||
music_video_id_alt,
|
||||
itunes_page,
|
||||
m3u8_master_data,
|
||||
track,
|
||||
)
|
||||
final_path = downloader.get_final_path(tags, ".m4v")
|
||||
cover_path = downloader_music_video.get_cover_path(final_path)
|
||||
cover_url = downloader.get_cover_url(track)
|
||||
cover_file_extesion = downloader.get_cover_file_extension(cover_url)
|
||||
cover_path = downloader_music_video.get_cover_path(
|
||||
final_path,
|
||||
cover_file_extesion,
|
||||
)
|
||||
if final_path.exists() and not overwrite:
|
||||
logger.warning(
|
||||
f'({queue_progress}) Music video already exists at "{final_path}", skipping'
|
||||
@@ -610,35 +638,37 @@ def main(
|
||||
remuxed_path = downloader_music_video.get_remuxed_path(
|
||||
track["id"]
|
||||
)
|
||||
logger.debug(f"Downloading video to {encrypted_path_video}")
|
||||
logger.debug(f'Downloading video to "{encrypted_path_video}"')
|
||||
downloader.download(
|
||||
encrypted_path_video, stream_info_video.stream_url
|
||||
)
|
||||
logger.debug(f"Downloading audio to {encrypted_path_audio}")
|
||||
logger.debug(f'Downloading audio to "{encrypted_path_audio}"')
|
||||
downloader.download(
|
||||
encrypted_path_audio, stream_info_audio.stream_url
|
||||
)
|
||||
logger.debug(f"Decrypting video to {decrypted_path_video}")
|
||||
logger.debug(f'Decrypting video to "{decrypted_path_video}"')
|
||||
downloader_music_video.decrypt(
|
||||
encrypted_path_video,
|
||||
decryption_key_video,
|
||||
decrypted_path_video,
|
||||
)
|
||||
logger.debug(f"Decrypting audio to {decrypted_path_audio}")
|
||||
logger.debug(f'Decrypting audio to "{decrypted_path_audio}"')
|
||||
downloader_music_video.decrypt(
|
||||
encrypted_path_audio,
|
||||
decryption_key_audio,
|
||||
decrypted_path_audio,
|
||||
)
|
||||
logger.debug(f"Remuxing to {remuxed_path}")
|
||||
logger.debug(f'Remuxing to "{remuxed_path}"')
|
||||
downloader_music_video.remux(
|
||||
decrypted_path_video,
|
||||
decrypted_path_audio,
|
||||
remuxed_path,
|
||||
stream_info_video.codec,
|
||||
stream_info_audio.codec,
|
||||
)
|
||||
logger.debug("Applying tags")
|
||||
downloader.apply_tags(remuxed_path, tags, cover_url)
|
||||
logger.debug(f"Moving to {final_path}")
|
||||
logger.debug(f'Moving to "{final_path}"')
|
||||
downloader.move_to_output_path(remuxed_path, final_path)
|
||||
if not save_cover:
|
||||
pass
|
||||
@@ -652,21 +682,25 @@ def main(
|
||||
elif track["type"] == "uploaded-videos":
|
||||
stream_url = downloader_post.get_stream_url(track)
|
||||
tags = downloader_post.get_tags(track)
|
||||
temp_path = downloader_post.get_temp_path(track["id"])
|
||||
post_temp_path = downloader_post.get_post_temp_path(track["id"])
|
||||
final_path = downloader.get_final_path(tags, ".m4v")
|
||||
cover_path = downloader_music_video.get_cover_path(final_path)
|
||||
cover_url = downloader.get_cover_url(track)
|
||||
cover_file_extesion = downloader.get_cover_file_extension(cover_url)
|
||||
cover_path = downloader_music_video.get_cover_path(
|
||||
final_path,
|
||||
cover_file_extesion,
|
||||
)
|
||||
if final_path.exists() and not overwrite:
|
||||
logger.warning(
|
||||
f'({queue_progress}) Post video already exists at "{final_path}", skipping'
|
||||
)
|
||||
else:
|
||||
logger.debug(f"Downloading to {final_path}")
|
||||
downloader.download_ytdlp(temp_path, stream_url)
|
||||
logger.debug(f'Downloading to "{post_temp_path}"')
|
||||
downloader.download_ytdlp(post_temp_path, stream_url)
|
||||
logger.debug("Applying tags")
|
||||
downloader.apply_tags(temp_path, tags, cover_url)
|
||||
logger.debug(f"Moving to {final_path}")
|
||||
downloader.move_to_output_path(temp_path, final_path)
|
||||
downloader.apply_tags(post_temp_path, tags, cover_url)
|
||||
logger.debug(f'Moving to "{final_path}"')
|
||||
downloader.move_to_output_path(post_temp_path, final_path)
|
||||
if not save_cover:
|
||||
pass
|
||||
elif cover_path.exists() and not overwrite:
|
||||
|
||||
+14
-4
@@ -191,13 +191,14 @@ SONG_CODEC_REGEX_MAP = {
|
||||
SongCodec.AAC_DOWNMIX: r"audio-stereo-\d+-downmix",
|
||||
SongCodec.AAC_HE_BINAURAL: r"audio-HE-stereo-\d+-binaural",
|
||||
SongCodec.AAC_HE_DOWNMIX: r"audio-HE-stereo-\d+-downmix",
|
||||
SongCodec.ALAC: r"audio-alac-.*",
|
||||
SongCodec.ATMOS: r"audio-atmos-.*",
|
||||
SongCodec.AC3: r"audio-ac3-.*",
|
||||
SongCodec.ALAC: r"audio-alac-.*",
|
||||
}
|
||||
|
||||
MUSIC_VIDEO_CODEC_MAP = {
|
||||
MusicVideoCodec.H264_BEST: "avc1",
|
||||
MusicVideoCodec.H265_BEST: "hvc1",
|
||||
MusicVideoCodec.H264: "avc1",
|
||||
MusicVideoCodec.H265: "hvc1",
|
||||
}
|
||||
|
||||
SYNCED_LYRICS_FILE_EXTENSION_MAP = {
|
||||
@@ -207,6 +208,12 @@ SYNCED_LYRICS_FILE_EXTENSION_MAP = {
|
||||
}
|
||||
|
||||
|
||||
IMAGE_FILE_EXTENSION_MAP = {
|
||||
"jpeg": ".jpg",
|
||||
"tiff": ".tif",
|
||||
}
|
||||
|
||||
|
||||
EXCLUDED_CONFIG_FILE_PARAMS = (
|
||||
"urls",
|
||||
"config_path",
|
||||
@@ -218,4 +225,7 @@ EXCLUDED_CONFIG_FILE_PARAMS = (
|
||||
|
||||
X_NOT_FOUND_STRING = '{} not found at "{}"'
|
||||
|
||||
AMP_API_HOSTNAME = "https://amp-api.music.apple.com"
|
||||
LEGACY_CODECS = [
|
||||
SongCodec.AAC_LEGACY,
|
||||
SongCodec.AAC_HE_LEGACY,
|
||||
]
|
||||
|
||||
+159
-32
@@ -1,5 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import functools
|
||||
import io
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -7,12 +10,15 @@ from pathlib import Path
|
||||
|
||||
import ciso8601
|
||||
import requests
|
||||
from InquirerPy import inquirer
|
||||
from InquirerPy.base.control import Choice
|
||||
from mutagen.mp4 import MP4, MP4Cover
|
||||
from PIL import Image
|
||||
from pywidevine import PSSH, Cdm, Device
|
||||
from yt_dlp import YoutubeDL
|
||||
|
||||
from .apple_music_api import AppleMusicApi
|
||||
from .constants import MP4_TAGS_MAP
|
||||
from .constants import IMAGE_FILE_EXTENSION_MAP, MP4_TAGS_MAP
|
||||
from .enums import CoverFormat, DownloadMode, RemuxMode
|
||||
from .hardcoded_wvd import HARDCODED_WVD
|
||||
from .itunes_api import ItunesApi
|
||||
@@ -29,7 +35,7 @@ class Downloader:
|
||||
output_path: Path = Path("./Apple Music"),
|
||||
temp_path: Path = Path("./temp"),
|
||||
wvd_path: Path = None,
|
||||
nm3u8dlre_path: str = "N_m3u8dl-RE",
|
||||
nm3u8dlre_path: str = "N_m3u8DL-RE",
|
||||
mp4decrypt_path: str = "mp4decrypt",
|
||||
ffmpeg_path: str = "ffmpeg",
|
||||
mp4box_path: str = "MP4Box",
|
||||
@@ -46,7 +52,7 @@ class Downloader:
|
||||
exclude_tags: str = None,
|
||||
cover_size: int = 1200,
|
||||
truncate: int = 40,
|
||||
no_progress: bool = False,
|
||||
silent: bool = False,
|
||||
):
|
||||
self.apple_music_api = apple_music_api
|
||||
self.itunes_api = itunes_api
|
||||
@@ -70,10 +76,11 @@ class Downloader:
|
||||
self.exclude_tags = exclude_tags
|
||||
self.cover_size = cover_size
|
||||
self.truncate = truncate
|
||||
self.no_progress = no_progress
|
||||
self.silent = silent
|
||||
self._set_binaries_path_full()
|
||||
self._set_exclude_tags_list()
|
||||
self._set_truncate()
|
||||
self._set_subprocess_additional_args()
|
||||
|
||||
def _set_binaries_path_full(self):
|
||||
self.nm3u8dlre_path_full = shutil.which(self.nm3u8dlre_path)
|
||||
@@ -91,6 +98,15 @@ class Downloader:
|
||||
def _set_truncate(self):
|
||||
self.truncate = None if self.truncate < 4 else self.truncate
|
||||
|
||||
def _set_subprocess_additional_args(self):
|
||||
if self.silent:
|
||||
self.subprocess_additional_args = {
|
||||
"stdout": subprocess.DEVNULL,
|
||||
"stderr": subprocess.DEVNULL,
|
||||
}
|
||||
else:
|
||||
self.subprocess_additional_args = {}
|
||||
|
||||
def set_cdm(self):
|
||||
if self.wvd_path:
|
||||
self.cdm = Cdm.from_device(Device.load(self.wvd_path))
|
||||
@@ -100,7 +116,7 @@ class Downloader:
|
||||
def get_url_info(self, url: str) -> UrlInfo:
|
||||
url_info = UrlInfo()
|
||||
url_regex_result = re.search(
|
||||
r"/([a-z]{2})/(album|playlist|song|music-video|post)/([^/]*)(?:/([^/?]*))?(?:\?i=)?([0-9a-z]*)?",
|
||||
r"/([a-z]{2})/(artist|album|playlist|song|music-video|post)/([^/]*)(?:/([^/?]*))?(?:\?i=)?([0-9a-z]*)?",
|
||||
url,
|
||||
)
|
||||
url_info.storefront = url_regex_result.group(1)
|
||||
@@ -119,7 +135,10 @@ class Downloader:
|
||||
|
||||
def _get_download_queue(self, url_type: str, id: str) -> list[DownloadQueueItem]:
|
||||
download_queue = []
|
||||
if url_type == "song":
|
||||
if url_type == "artist":
|
||||
artist = self.apple_music_api.get_artist(id)
|
||||
download_queue.extend(self.get_download_queue_from_artist(artist))
|
||||
elif url_type == "song":
|
||||
download_queue.append(DownloadQueueItem(self.apple_music_api.get_song(id)))
|
||||
elif url_type == "album":
|
||||
album = self.apple_music_api.get_album(id)
|
||||
@@ -144,26 +163,114 @@ class Downloader:
|
||||
raise Exception(f"Invalid url type: {url_type}")
|
||||
return download_queue
|
||||
|
||||
def get_download_queue_from_artist(self, artist: dict) -> list[DownloadQueueItem]:
|
||||
media_type = inquirer.select(
|
||||
message=f'Select which type to download for artist "{artist["attributes"]["name"]}":',
|
||||
choices=[
|
||||
Choice(name="Albums", value="albums"),
|
||||
Choice(
|
||||
name="Music Videos",
|
||||
value="music-videos",
|
||||
),
|
||||
],
|
||||
validate=lambda result: artist["relationships"].get(result, {}).get("data"),
|
||||
invalid_message="The artist doesn't have any items of this type",
|
||||
).execute()
|
||||
if media_type == "albums":
|
||||
return self.select_albums_from_artist(
|
||||
artist["relationships"]["albums"]["data"]
|
||||
)
|
||||
elif media_type == "music-videos":
|
||||
return self.select_music_videos_from_artist(
|
||||
artist["relationships"]["music-videos"]["data"]
|
||||
)
|
||||
|
||||
def select_albums_from_artist(
|
||||
self,
|
||||
albums: list[dict],
|
||||
) -> list[DownloadQueueItem]:
|
||||
choices = [
|
||||
Choice(
|
||||
name=" | ".join(
|
||||
[
|
||||
f'{album["attributes"]["trackCount"]:03d}',
|
||||
f'{album["attributes"]["releaseDate"]:<10}',
|
||||
f'{album["attributes"].get("contentRating", "None").title():<8}',
|
||||
f'{album["attributes"]["name"]}',
|
||||
]
|
||||
),
|
||||
value=album,
|
||||
)
|
||||
for album in albums
|
||||
]
|
||||
selected = inquirer.select(
|
||||
message="Select which albums to download: (Track Count | Release Date | Rating | Title)",
|
||||
choices=choices,
|
||||
multiselect=True,
|
||||
).execute()
|
||||
download_queue = []
|
||||
for album in selected:
|
||||
download_queue.extend(
|
||||
DownloadQueueItem(track)
|
||||
for track in self.apple_music_api.get_album(album["id"])[
|
||||
"relationships"
|
||||
]["tracks"]["data"]
|
||||
)
|
||||
return download_queue
|
||||
|
||||
def select_music_videos_from_artist(
|
||||
self,
|
||||
music_videos: list[dict],
|
||||
) -> list[DownloadQueueItem]:
|
||||
choices = [
|
||||
Choice(
|
||||
name=" | ".join(
|
||||
[
|
||||
self.millis_to_min_sec(
|
||||
music_video["attributes"]["durationInMillis"]
|
||||
),
|
||||
f'{music_video["attributes"].get("contentRating", "None").title():<8}',
|
||||
music_video["attributes"]["name"],
|
||||
],
|
||||
),
|
||||
value=music_video,
|
||||
)
|
||||
for music_video in music_videos
|
||||
]
|
||||
selected = inquirer.select(
|
||||
message="Select which music videos to download: (Duration | Rating | Title)",
|
||||
choices=choices,
|
||||
multiselect=True,
|
||||
).execute()
|
||||
return [DownloadQueueItem(music_video) for music_video in selected]
|
||||
|
||||
@staticmethod
|
||||
def millis_to_min_sec(millis):
|
||||
minutes, seconds = divmod(millis // 1000, 60)
|
||||
return f"{minutes:02d}:{seconds:02d}"
|
||||
|
||||
def sanitize_date(self, date: str):
|
||||
datetime_obj = ciso8601.parse_datetime(date)
|
||||
return datetime_obj.strftime(self.template_date)
|
||||
|
||||
def get_decryption_key(self, pssh: str, track_id: str) -> str:
|
||||
pssh_obj = PSSH(pssh.split(",")[-1])
|
||||
cdm_session = self.cdm.open()
|
||||
challenge = base64.b64encode(
|
||||
self.cdm.get_license_challenge(cdm_session, pssh_obj)
|
||||
).decode()
|
||||
license = self.apple_music_api.get_widevine_license(
|
||||
track_id,
|
||||
pssh,
|
||||
challenge,
|
||||
)
|
||||
self.cdm.parse_license(cdm_session, license)
|
||||
decryption_key = next(
|
||||
i for i in self.cdm.get_keys(cdm_session) if i.type == "CONTENT"
|
||||
).key.hex()
|
||||
self.cdm.close(cdm_session)
|
||||
try:
|
||||
pssh_obj = PSSH(pssh.split(",")[-1])
|
||||
cdm_session = self.cdm.open()
|
||||
challenge = base64.b64encode(
|
||||
self.cdm.get_license_challenge(cdm_session, pssh_obj)
|
||||
).decode()
|
||||
license = self.apple_music_api.get_widevine_license(
|
||||
track_id,
|
||||
pssh,
|
||||
challenge,
|
||||
)
|
||||
self.cdm.parse_license(cdm_session, license)
|
||||
decryption_key = next(
|
||||
i for i in self.cdm.get_keys(cdm_session) if i.type == "CONTENT"
|
||||
).key.hex()
|
||||
finally:
|
||||
self.cdm.close(cdm_session)
|
||||
return decryption_key
|
||||
|
||||
def download(self, path: Path, stream_url: str):
|
||||
@@ -181,19 +288,12 @@ class Downloader:
|
||||
"allow_unplayable_formats": True,
|
||||
"fixup": "never",
|
||||
"allowed_extractors": ["generic"],
|
||||
"noprogress": self.no_progress,
|
||||
"noprogress": self.silent,
|
||||
}
|
||||
) as ydl:
|
||||
ydl.download(stream_url)
|
||||
|
||||
def download_nm3u8dlre(self, path: Path, stream_url: str):
|
||||
if self.no_progress:
|
||||
subprocess_additional_args = {
|
||||
"stdout": subprocess.DEVNULL,
|
||||
"stderr": subprocess.DEVNULL,
|
||||
}
|
||||
else:
|
||||
subprocess_additional_args = {}
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
subprocess.run(
|
||||
[
|
||||
@@ -213,7 +313,7 @@ class Downloader:
|
||||
path.parent,
|
||||
],
|
||||
check=True,
|
||||
**subprocess_additional_args,
|
||||
**self.subprocess_additional_args,
|
||||
)
|
||||
|
||||
def get_sanitized_string(self, dirty_string: str, is_folder: bool) -> str:
|
||||
@@ -254,9 +354,31 @@ class Downloader:
|
||||
]
|
||||
return self.output_path.joinpath(*final_path_folder).joinpath(*final_path_file)
|
||||
|
||||
def get_cover_file_extension(self, cover_url: str) -> str:
|
||||
image_obj = Image.open(io.BytesIO(self.get_url_response_bytes(cover_url)))
|
||||
image_format = image_obj.format.lower()
|
||||
return IMAGE_FILE_EXTENSION_MAP.get(image_format, f".{image_format}")
|
||||
|
||||
def get_cover_url(self, metadata: dict) -> str:
|
||||
if self.cover_format == CoverFormat.RAW:
|
||||
return self._get_raw_cover_url(metadata["attributes"]["artwork"]["url"])
|
||||
return self._get_cover_url(metadata["attributes"]["artwork"]["url"])
|
||||
|
||||
def _get_raw_cover_url(self, cover_url_template: str) -> str:
|
||||
return re.sub(
|
||||
r"image/thumb/",
|
||||
"",
|
||||
re.sub(
|
||||
r"is1-ssl",
|
||||
"a1",
|
||||
re.sub(
|
||||
r"/\{w\}x\{h\}([a-z]{2})\.jpg",
|
||||
"",
|
||||
cover_url_template,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def _get_cover_url(self, cover_url_template: str) -> str:
|
||||
return re.sub(
|
||||
r"\{w\}x\{h\}([a-z]{2})\.jpg",
|
||||
@@ -267,7 +389,9 @@ class Downloader:
|
||||
@staticmethod
|
||||
@functools.lru_cache()
|
||||
def get_url_response_bytes(url: str) -> bytes:
|
||||
return requests.get(url).content
|
||||
response = requests.get(url)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
|
||||
def apply_tags(
|
||||
self,
|
||||
@@ -305,7 +429,10 @@ class Downloader:
|
||||
and tags.get(tag_name) is not None
|
||||
):
|
||||
mp4_tags[MP4_TAGS_MAP[tag_name]] = [tags[tag_name]]
|
||||
if "cover" not in self.exclude_tags_list:
|
||||
if (
|
||||
"cover" not in self.exclude_tags_list
|
||||
and self.cover_format != CoverFormat.RAW
|
||||
):
|
||||
mp4_tags["covr"] = [
|
||||
MP4Cover(
|
||||
self.get_url_response_bytes(cover_url),
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
import m3u8
|
||||
from tabulate import tabulate
|
||||
from InquirerPy import inquirer
|
||||
from InquirerPy.base.control import Choice
|
||||
|
||||
from .constants import MUSIC_VIDEO_CODEC_MAP
|
||||
from .downloader import Downloader
|
||||
@@ -13,10 +15,12 @@ from .models import StreamInfo
|
||||
|
||||
|
||||
class DownloaderMusicVideo:
|
||||
MP4_FORMAT_CODECS = ["hvc1", "audio-atmos", "audio-ec3"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
downloader: Downloader,
|
||||
codec: MusicVideoCodec = MusicVideoCodec.H264_BEST,
|
||||
codec: MusicVideoCodec = MusicVideoCodec.H264,
|
||||
):
|
||||
self.downloader = downloader
|
||||
self.codec = codec
|
||||
@@ -33,10 +37,10 @@ class DownloaderMusicVideo:
|
||||
).geturl()
|
||||
return m3u8.load(stream_url_master_new).data
|
||||
|
||||
def get_stream_url_video(
|
||||
def get_playlist_video(
|
||||
self,
|
||||
playlists: list[dict],
|
||||
):
|
||||
) -> dict:
|
||||
playlists_filtered = [
|
||||
playlist
|
||||
for playlist in playlists
|
||||
@@ -49,39 +53,39 @@ class DownloaderMusicVideo:
|
||||
playlist
|
||||
for playlist in playlists
|
||||
if playlist["stream_info"]["codecs"].startswith(
|
||||
MUSIC_VIDEO_CODEC_MAP[MusicVideoCodec.H264_BEST]
|
||||
MUSIC_VIDEO_CODEC_MAP[MusicVideoCodec.H264]
|
||||
)
|
||||
]
|
||||
playlists_filtered.sort(key=lambda x: x["stream_info"]["bandwidth"])
|
||||
return playlists_filtered[-1]["uri"]
|
||||
return playlists_filtered[-1]
|
||||
|
||||
def get_stream_url_video_from_user(
|
||||
def get_playlist_video_from_user(
|
||||
self,
|
||||
playlists: list[dict],
|
||||
):
|
||||
table = [
|
||||
[
|
||||
i,
|
||||
playlist["stream_info"]["codecs"],
|
||||
playlist["stream_info"]["resolution"],
|
||||
playlist["stream_info"]["bandwidth"],
|
||||
]
|
||||
for i, playlist in enumerate(playlists, 1)
|
||||
]
|
||||
print(tabulate(table))
|
||||
try:
|
||||
choice = (
|
||||
click.prompt("Choose a video codec", type=click.IntRange(1, len(table)))
|
||||
- 1
|
||||
) -> dict:
|
||||
choices = [
|
||||
Choice(
|
||||
name=" | ".join(
|
||||
[
|
||||
playlist["stream_info"]["codecs"][:4],
|
||||
playlist["stream_info"]["resolution"],
|
||||
str(playlist["stream_info"]["bandwidth"]),
|
||||
]
|
||||
),
|
||||
value=playlist,
|
||||
)
|
||||
except click.exceptions.Abort:
|
||||
raise KeyboardInterrupt()
|
||||
return playlists[choice]["uri"]
|
||||
for playlist in playlists
|
||||
]
|
||||
selected = inquirer.select(
|
||||
message="Select which video codec to download: (Codec | Resolution | Bitrate)",
|
||||
choices=choices,
|
||||
).execute()
|
||||
return selected
|
||||
|
||||
def get_stream_url_audio(
|
||||
def get_playlist_audio(
|
||||
self,
|
||||
playlists: list[dict],
|
||||
) -> str:
|
||||
) -> dict:
|
||||
stream_url = next(
|
||||
(
|
||||
playlist
|
||||
@@ -89,31 +93,26 @@ class DownloaderMusicVideo:
|
||||
if playlist["group_id"] == "audio-stereo-256"
|
||||
),
|
||||
None,
|
||||
)["uri"]
|
||||
)
|
||||
return stream_url
|
||||
|
||||
def get_stream_url_audio_from_user(
|
||||
def get_playlist_audio_from_user(
|
||||
self,
|
||||
playlists: list[dict],
|
||||
):
|
||||
table = [
|
||||
[
|
||||
i,
|
||||
playlist["group_id"],
|
||||
]
|
||||
for i, playlist in enumerate(playlists, 1)
|
||||
]
|
||||
print(tabulate(table))
|
||||
try:
|
||||
choice = (
|
||||
click.prompt(
|
||||
"Choose an audio codec", type=click.IntRange(1, len(table))
|
||||
)
|
||||
- 1
|
||||
) -> dict:
|
||||
choices = [
|
||||
Choice(
|
||||
name=playlist["group_id"],
|
||||
value=playlist,
|
||||
)
|
||||
except click.exceptions.Abort:
|
||||
raise KeyboardInterrupt()
|
||||
return playlists[choice]["uri"]
|
||||
for playlist in playlists
|
||||
if playlist.get("uri")
|
||||
]
|
||||
selected = inquirer.select(
|
||||
message="Select which audio codec to download:",
|
||||
choices=choices,
|
||||
).execute()
|
||||
return selected
|
||||
|
||||
def get_pssh(self, m3u8_data: dict):
|
||||
return next(
|
||||
@@ -128,13 +127,11 @@ class DownloaderMusicVideo:
|
||||
def get_stream_info_video(self, m3u8_master_data: dict) -> StreamInfo:
|
||||
stream_info = StreamInfo()
|
||||
if self.codec != MusicVideoCodec.ASK:
|
||||
stream_info.stream_url = self.get_stream_url_video(
|
||||
m3u8_master_data["playlists"]
|
||||
)
|
||||
playlist = self.get_playlist_video(m3u8_master_data["playlists"])
|
||||
else:
|
||||
stream_info.stream_url = self.get_stream_url_video_from_user(
|
||||
m3u8_master_data["playlists"]
|
||||
)
|
||||
playlist = self.get_playlist_video_from_user(m3u8_master_data["playlists"])
|
||||
stream_info.stream_url = playlist["uri"]
|
||||
stream_info.codec = playlist["stream_info"]["codecs"]
|
||||
m3u8_data = m3u8.load(stream_info.stream_url).data
|
||||
stream_info.pssh = self.get_pssh(m3u8_data)
|
||||
return stream_info
|
||||
@@ -142,13 +139,11 @@ class DownloaderMusicVideo:
|
||||
def get_stream_info_audio(self, m3u8_master_data: dict) -> StreamInfo:
|
||||
stream_info = StreamInfo()
|
||||
if self.codec != MusicVideoCodec.ASK:
|
||||
stream_info.stream_url = self.get_stream_url_audio(
|
||||
m3u8_master_data["media"]
|
||||
)
|
||||
playlist = self.get_playlist_audio(m3u8_master_data["media"])
|
||||
else:
|
||||
stream_info.stream_url = self.get_stream_url_audio_from_user(
|
||||
m3u8_master_data["media"]
|
||||
)
|
||||
playlist = self.get_playlist_audio_from_user(m3u8_master_data["media"])
|
||||
stream_info.stream_url = playlist["uri"]
|
||||
stream_info.codec = playlist["group_id"]
|
||||
m3u8_data = m3u8.load(stream_info.stream_url).data
|
||||
stream_info.pssh = self.get_pssh(m3u8_data)
|
||||
return stream_info
|
||||
@@ -158,41 +153,35 @@ class DownloaderMusicVideo:
|
||||
|
||||
def get_tags(
|
||||
self,
|
||||
id_alt: str,
|
||||
itunes_page: dict,
|
||||
m3u8_master_data: dict,
|
||||
metadata: dict,
|
||||
):
|
||||
metadata_itunes = self.downloader.itunes_api.get_resource(id_alt)
|
||||
tags = {
|
||||
"artist": metadata["attributes"]["artistName"],
|
||||
"artist_id": int(itunes_page["artistId"]),
|
||||
"copyright": itunes_page["copyright"],
|
||||
"date": next(
|
||||
(
|
||||
session_data
|
||||
for session_data in m3u8_master_data["session_data"]
|
||||
if session_data["data_id"] == "com.apple.hls.release-date"
|
||||
),
|
||||
None,
|
||||
)["value"],
|
||||
"genre": metadata["attributes"]["genreNames"][0],
|
||||
"artist": metadata_itunes[0]["artistName"],
|
||||
"artist_id": int(metadata_itunes[0]["artistId"]),
|
||||
"copyright": itunes_page.get("copyright"),
|
||||
"date": self.downloader.sanitize_date(metadata_itunes[0]["releaseDate"]),
|
||||
"genre": metadata_itunes[0]["primaryGenreName"],
|
||||
"genre_id": int(itunes_page["genres"][0]["genreId"]),
|
||||
"media_type": 6,
|
||||
"title": metadata["attributes"]["name"],
|
||||
"storefront": int(self.downloader.itunes_api.storefront_id.split("-")[0]),
|
||||
"title": metadata_itunes[0]["trackCensoredName"],
|
||||
"title_id": int(metadata["id"]),
|
||||
}
|
||||
if metadata["attributes"].get("contentRating") == "clean":
|
||||
tags["rating"] = 2
|
||||
elif metadata["attributes"].get("contentRating") == "explicit":
|
||||
if metadata_itunes[0]["trackExplicitness"] == "notExplicit":
|
||||
tags["rating"] = 0
|
||||
elif metadata_itunes[0]["trackExplicitness"] == "explicit":
|
||||
tags["rating"] = 1
|
||||
else:
|
||||
tags["rating"] = 0
|
||||
if itunes_page.get("collectionId"):
|
||||
metadata_itunes = self.downloader.itunes_api.get_resource(itunes_page["id"])
|
||||
tags["rating"] = 2
|
||||
if len(metadata_itunes) > 1:
|
||||
album = self.downloader.apple_music_api.get_album(
|
||||
itunes_page["collectionId"]
|
||||
)
|
||||
tags["album"] = album["attributes"]["name"]
|
||||
tags["album_artist"] = album["attributes"]["artistName"]
|
||||
tags["album"] = metadata_itunes[1]["collectionCensoredName"]
|
||||
tags["album_artist"] = metadata_itunes[1]["artistName"]
|
||||
tags["album_id"] = int(itunes_page["collectionId"])
|
||||
tags["disc"] = metadata_itunes[0]["discNumber"]
|
||||
tags["disc_total"] = metadata_itunes[0]["discCount"]
|
||||
@@ -226,6 +215,7 @@ class DownloaderMusicVideo:
|
||||
decrypted_path,
|
||||
],
|
||||
check=True,
|
||||
**self.downloader.subprocess_additional_args,
|
||||
)
|
||||
|
||||
def remux_mp4box(
|
||||
@@ -233,7 +223,7 @@ class DownloaderMusicVideo:
|
||||
decrypted_path_audio: Path,
|
||||
decrypted_path_video: Path,
|
||||
fixed_path: Path,
|
||||
) -> None:
|
||||
):
|
||||
subprocess.run(
|
||||
[
|
||||
self.downloader.mp4box_path_full,
|
||||
@@ -244,10 +234,12 @@ class DownloaderMusicVideo:
|
||||
decrypted_path_video,
|
||||
"-itags",
|
||||
"artist=placeholder",
|
||||
"-keep-utc",
|
||||
"-new",
|
||||
fixed_path,
|
||||
],
|
||||
check=True,
|
||||
**self.downloader.subprocess_additional_args,
|
||||
)
|
||||
|
||||
def remux_ffmpeg(
|
||||
@@ -255,7 +247,12 @@ class DownloaderMusicVideo:
|
||||
decrypted_path_video: Path,
|
||||
decrypte_path_audio: Path,
|
||||
fixed_path: Path,
|
||||
codec_video: str,
|
||||
codec_audio: str,
|
||||
):
|
||||
use_mp4_flag = any(
|
||||
codec_video.startswith(codec) for codec in self.MP4_FORMAT_CODECS
|
||||
) or any(codec_audio.startswith(codec) for codec in self.MP4_FORMAT_CODECS)
|
||||
subprocess.run(
|
||||
[
|
||||
self.downloader.ffmpeg_path_full,
|
||||
@@ -269,7 +266,7 @@ class DownloaderMusicVideo:
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
"-f",
|
||||
"mp4",
|
||||
"mp4" if use_mp4_flag else "ipod",
|
||||
"-c",
|
||||
"copy",
|
||||
"-c:s",
|
||||
@@ -277,6 +274,7 @@ class DownloaderMusicVideo:
|
||||
fixed_path,
|
||||
],
|
||||
check=True,
|
||||
**self.downloader.subprocess_additional_args,
|
||||
)
|
||||
|
||||
def remux(
|
||||
@@ -284,6 +282,8 @@ class DownloaderMusicVideo:
|
||||
decrypted_path_video: Path,
|
||||
decrypted_path_audio: Path,
|
||||
remuxed_path: Path,
|
||||
codec_video: str,
|
||||
codec_audio: str,
|
||||
):
|
||||
if self.downloader.remux_mode == RemuxMode.MP4BOX:
|
||||
self.remux_mp4box(
|
||||
@@ -296,7 +296,9 @@ class DownloaderMusicVideo:
|
||||
decrypted_path_video,
|
||||
decrypted_path_audio,
|
||||
remuxed_path,
|
||||
codec_video,
|
||||
codec_audio,
|
||||
)
|
||||
|
||||
def get_cover_path(self, final_path: Path) -> Path:
|
||||
return final_path.with_suffix(f".{self.downloader.cover_format.value}")
|
||||
def get_cover_path(self, final_path: Path, file_extension: str) -> Path:
|
||||
return final_path.with_suffix(file_extension)
|
||||
|
||||
+17
-14
@@ -1,9 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from InquirerPy import inquirer
|
||||
from InquirerPy.base.control import Choice
|
||||
|
||||
from .downloader import Downloader
|
||||
from tabulate import tabulate
|
||||
from .enums import PostQuality
|
||||
|
||||
|
||||
@@ -38,18 +40,18 @@ class DownloaderPost:
|
||||
|
||||
def get_stream_url_from_user(self, metadata: dict) -> str:
|
||||
qualities = list(metadata["attributes"]["assetTokens"].keys())
|
||||
table = [
|
||||
[index, quality]
|
||||
for index, quality in enumerate(
|
||||
qualities,
|
||||
start=1,
|
||||
choices = [
|
||||
Choice(
|
||||
name=quality,
|
||||
value=quality,
|
||||
)
|
||||
for quality in qualities
|
||||
]
|
||||
print(tabulate(table))
|
||||
choice = (
|
||||
click.prompt("Choose a quality", type=click.IntRange(1, len(table))) - 1
|
||||
)
|
||||
return metadata["attributes"]["assetTokens"][qualities[choice]]
|
||||
selected = inquirer.select(
|
||||
message="Select which quality to download:",
|
||||
choices=choices,
|
||||
).execute()
|
||||
return metadata["attributes"]["assetTokens"][selected]
|
||||
|
||||
def get_stream_url(self, metadata: dict) -> str:
|
||||
if self.quality == PostQuality.BEST:
|
||||
@@ -62,10 +64,11 @@ class DownloaderPost:
|
||||
attributes = metadata["attributes"]
|
||||
return {
|
||||
"artist": attributes["artistName"],
|
||||
"date": attributes["uploadDate"],
|
||||
"date": self.downloader.sanitize_date(attributes["uploadDate"]),
|
||||
"title": attributes["name"],
|
||||
"title_id": int(metadata["id"]),
|
||||
"storefront": int(self.downloader.itunes_api.storefront_id.split("-")[0]),
|
||||
}
|
||||
|
||||
def get_temp_path(self, track_id: str) -> Path:
|
||||
def get_post_temp_path(self, track_id: str) -> Path:
|
||||
return self.downloader.temp_path / f"{track_id}_temp.m4v"
|
||||
|
||||
+55
-28
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import json
|
||||
@@ -7,9 +9,9 @@ from pathlib import Path
|
||||
from xml.dom import minidom
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import click
|
||||
import m3u8
|
||||
from tabulate import tabulate
|
||||
from InquirerPy import inquirer
|
||||
from InquirerPy.base.control import Choice
|
||||
|
||||
from .constants import SONG_CODEC_REGEX_MAP, SYNCED_LYRICS_FILE_EXTENSION_MAP
|
||||
from .downloader import Downloader
|
||||
@@ -19,6 +21,7 @@ from .models import Lyrics, StreamInfo
|
||||
|
||||
class DownloaderSong:
|
||||
DEFAULT_DECRYPTION_KEY = "32b8ade1769e26b1ffb8986352793fc6"
|
||||
MP4_FORMAT_CODECS = ["ec-3"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -40,7 +43,7 @@ class DownloaderSong:
|
||||
None,
|
||||
)
|
||||
if not drm_info_raw:
|
||||
raise Exception("DRM info not found")
|
||||
return None
|
||||
return json.loads(base64.b64decode(drm_info_raw["value"]).decode("utf-8"))
|
||||
|
||||
def get_asset_infos(self, m3u8_data: dict) -> dict:
|
||||
@@ -69,18 +72,18 @@ class DownloaderSong:
|
||||
|
||||
def get_playlist_from_user(self, m3u8_data: dict) -> dict | None:
|
||||
m3u8_master_playlists = [playlist for playlist in m3u8_data["playlists"]]
|
||||
table = [
|
||||
[i, playlist["stream_info"]["audio"]]
|
||||
for i, playlist in enumerate(m3u8_master_playlists, 1)
|
||||
]
|
||||
print(tabulate(table))
|
||||
try:
|
||||
choice = (
|
||||
click.prompt("Choose a codec", type=click.IntRange(1, len(table))) - 1
|
||||
choices = [
|
||||
Choice(
|
||||
name=playlist["stream_info"]["audio"],
|
||||
value=playlist,
|
||||
)
|
||||
except click.exceptions.Abort:
|
||||
raise KeyboardInterrupt()
|
||||
return m3u8_master_playlists[choice]
|
||||
for playlist in m3u8_master_playlists
|
||||
]
|
||||
selected = inquirer.select(
|
||||
message="Select which codec to download:",
|
||||
choices=choices,
|
||||
).execute()
|
||||
return selected
|
||||
|
||||
def get_pssh(
|
||||
self,
|
||||
@@ -103,7 +106,9 @@ class DownloaderSong:
|
||||
return drm_info["urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"]["URI"]
|
||||
|
||||
def get_stream_info(self, track_metadata: dict) -> StreamInfo:
|
||||
m3u8_url = track_metadata["attributes"]["extendedAssetUrls"]["enhancedHls"]
|
||||
m3u8_url = track_metadata["attributes"]["extendedAssetUrls"].get("enhancedHls")
|
||||
if not m3u8_url:
|
||||
return StreamInfo()
|
||||
return self._get_stream_info(m3u8_url)
|
||||
|
||||
def _get_stream_info(self, m3u8_url: str) -> StreamInfo:
|
||||
@@ -111,6 +116,8 @@ class DownloaderSong:
|
||||
m3u8_obj = m3u8.load(m3u8_url)
|
||||
m3u8_data = m3u8_obj.data
|
||||
drm_infos = self.get_drm_infos(m3u8_data)
|
||||
if not drm_infos:
|
||||
return stream_info
|
||||
asset_infos = self.get_asset_infos(m3u8_data)
|
||||
if self.codec == SongCodec.ASK:
|
||||
playlist = self.get_playlist_from_user(m3u8_data)
|
||||
@@ -123,6 +130,7 @@ class DownloaderSong:
|
||||
drm_ids = asset_infos[variant_id]["AUDIO-SESSION-KEY-IDS"]
|
||||
pssh = self.get_pssh(drm_infos, drm_ids)
|
||||
stream_info.pssh = pssh
|
||||
stream_info.codec = playlist["stream_info"]["codecs"]
|
||||
return stream_info
|
||||
|
||||
@staticmethod
|
||||
@@ -170,20 +178,24 @@ class DownloaderSong:
|
||||
return f"{index}\n{timestamp_srt_start} --> {timestamp_srt_end}\n{text}\n"
|
||||
|
||||
def get_lyrics(self, track_metadata: dict) -> Lyrics:
|
||||
lyrics = Lyrics()
|
||||
if not track_metadata["attributes"]["hasLyrics"]:
|
||||
return Lyrics()
|
||||
return lyrics
|
||||
elif track_metadata.get("relationships") is None:
|
||||
track_metadata = self.downloader.apple_music_api.get_song(
|
||||
track_metadata["id"]
|
||||
)
|
||||
if track_metadata["relationships"]["lyrics"]["data"]:
|
||||
return self._get_lyrics(
|
||||
if (
|
||||
track_metadata["relationships"].get("lyrics")
|
||||
and track_metadata["relationships"]["lyrics"].get("data")
|
||||
and track_metadata["relationships"]["lyrics"]["data"][0].get("attributes")
|
||||
):
|
||||
lyrics = self._get_lyrics(
|
||||
track_metadata["relationships"]["lyrics"]["data"][0]["attributes"][
|
||||
"ttml"
|
||||
]
|
||||
)
|
||||
else:
|
||||
return Lyrics()
|
||||
return lyrics
|
||||
|
||||
def _get_lyrics(self, lyrics_ttml: str) -> Lyrics:
|
||||
lyrics = Lyrics("", "")
|
||||
@@ -195,9 +207,9 @@ class DownloaderSong:
|
||||
lyrics.unsynced += p.text + "\n"
|
||||
if p.attrib.get("begin"):
|
||||
if self.synced_lyrics_format == SyncedLyricsFormat.LRC:
|
||||
lyrics.synced += f"{self.get_lyrics_synced_line_lrc(p.attrib.get('begin'), p.text)}\n"
|
||||
lyrics.synced += f"{self.get_lyrics_synced_line_lrc(p.attrib.get('begin'), p.text)}"
|
||||
elif self.synced_lyrics_format == SyncedLyricsFormat.SRT:
|
||||
lyrics.synced += f"{self.get_lyrics_synced_line_srt(index, p.attrib.get('begin'), p.attrib.get('end'), p.text)}\n"
|
||||
lyrics.synced += f"{self.get_lyrics_synced_line_srt(index, p.attrib.get('begin'), p.attrib.get('end'), p.text)}"
|
||||
elif self.synced_lyrics_format == SyncedLyricsFormat.TTML:
|
||||
if not lyrics.synced:
|
||||
lyrics.synced = minidom.parseString(
|
||||
@@ -292,15 +304,16 @@ class DownloaderSong:
|
||||
decrypted_path,
|
||||
],
|
||||
check=True,
|
||||
**self.downloader.subprocess_additional_args,
|
||||
)
|
||||
|
||||
def remux(self, decrypted_path: Path, remuxed_path: Path) -> None:
|
||||
def remux(self, decrypted_path: Path, remuxed_path: Path, codec: str):
|
||||
if self.downloader.remux_mode == RemuxMode.MP4BOX:
|
||||
self.remux_mp4box(decrypted_path, remuxed_path)
|
||||
elif self.downloader.remux_mode == RemuxMode.FFMPEG:
|
||||
self.remux_ffmpeg(decrypted_path, remuxed_path)
|
||||
self.remux_ffmpeg(decrypted_path, remuxed_path, codec)
|
||||
|
||||
def remux_mp4box(self, decrypted_path: Path, remuxed_path: Path) -> None:
|
||||
def remux_mp4box(self, decrypted_path: Path, remuxed_path: Path):
|
||||
subprocess.run(
|
||||
[
|
||||
self.downloader.mp4box_path_full,
|
||||
@@ -309,13 +322,24 @@ class DownloaderSong:
|
||||
decrypted_path,
|
||||
"-itags",
|
||||
"artist=placeholder",
|
||||
"-keep-utc",
|
||||
"-new",
|
||||
remuxed_path,
|
||||
],
|
||||
check=True,
|
||||
**self.downloader.subprocess_additional_args,
|
||||
)
|
||||
|
||||
def remux_ffmpeg(self, decrypted_path: Path, remuxed_path: Path) -> None:
|
||||
def remux_ffmpeg(
|
||||
self,
|
||||
decrypted_path: Path,
|
||||
remuxed_path: Path,
|
||||
codec: str,
|
||||
):
|
||||
use_mp4_format = any(
|
||||
codec.startswith(possible_codec)
|
||||
for possible_codec in self.MP4_FORMAT_CODECS
|
||||
)
|
||||
subprocess.run(
|
||||
[
|
||||
self.downloader.ffmpeg_path_full,
|
||||
@@ -326,11 +350,14 @@ class DownloaderSong:
|
||||
decrypted_path,
|
||||
"-c",
|
||||
"copy",
|
||||
"-f",
|
||||
"mp4" if use_mp4_format else "ipod",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
remuxed_path,
|
||||
],
|
||||
check=True,
|
||||
**self.downloader.subprocess_additional_args,
|
||||
)
|
||||
|
||||
def get_lyrics_synced_path(self, final_path: Path) -> Path:
|
||||
@@ -338,8 +365,8 @@ class DownloaderSong:
|
||||
SYNCED_LYRICS_FILE_EXTENSION_MAP[self.synced_lyrics_format]
|
||||
)
|
||||
|
||||
def get_cover_path(self, final_path: Path) -> Path:
|
||||
return final_path.parent / f"Cover.{self.downloader.cover_format.value}"
|
||||
def get_cover_path(self, final_path: Path, file_extension: str) -> Path:
|
||||
return final_path.parent / ("Cover" + file_extension)
|
||||
|
||||
def save_lyrics_synced(self, lyrics_synced_path: Path, lyrics_synced: str):
|
||||
lyrics_synced_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
@@ -26,24 +28,28 @@ class DownloaderSongLegacy(DownloaderSong):
|
||||
return stream_info
|
||||
|
||||
def get_decryption_key(self, pssh: str, track_id: str) -> str:
|
||||
widevine_pssh_data = WidevinePsshData()
|
||||
widevine_pssh_data.algorithm = 1
|
||||
widevine_pssh_data.key_ids.append(base64.b64decode(pssh.split(",")[1]))
|
||||
pssh_obj = PSSH(widevine_pssh_data.SerializeToString())
|
||||
cdm_session = self.downloader.cdm.open()
|
||||
challenge = base64.b64encode(
|
||||
self.downloader.cdm.get_license_challenge(cdm_session, pssh_obj)
|
||||
).decode()
|
||||
license = self.downloader.apple_music_api.get_widevine_license(
|
||||
track_id,
|
||||
pssh,
|
||||
challenge,
|
||||
)
|
||||
self.downloader.cdm.parse_license(cdm_session, license)
|
||||
decryption_key = next(
|
||||
i for i in self.downloader.cdm.get_keys(cdm_session) if i.type == "CONTENT"
|
||||
).key.hex()
|
||||
self.downloader.cdm.close(cdm_session)
|
||||
try:
|
||||
widevine_pssh_data = WidevinePsshData()
|
||||
widevine_pssh_data.algorithm = 1
|
||||
widevine_pssh_data.key_ids.append(base64.b64decode(pssh.split(",")[1]))
|
||||
pssh_obj = PSSH(widevine_pssh_data.SerializeToString())
|
||||
cdm_session = self.downloader.cdm.open()
|
||||
challenge = base64.b64encode(
|
||||
self.downloader.cdm.get_license_challenge(cdm_session, pssh_obj)
|
||||
).decode()
|
||||
license = self.downloader.apple_music_api.get_widevine_license(
|
||||
track_id,
|
||||
pssh,
|
||||
challenge,
|
||||
)
|
||||
self.downloader.cdm.parse_license(cdm_session, license)
|
||||
decryption_key = next(
|
||||
i
|
||||
for i in self.downloader.cdm.get_keys(cdm_session)
|
||||
if i.type == "CONTENT"
|
||||
).key.hex()
|
||||
finally:
|
||||
self.downloader.cdm.close(cdm_session)
|
||||
return decryption_key
|
||||
|
||||
def decrypt(
|
||||
@@ -52,7 +58,6 @@ class DownloaderSongLegacy(DownloaderSong):
|
||||
decrypted_path: Path,
|
||||
decryption_key: str,
|
||||
):
|
||||
self.fix_key_id(encrypted_path)
|
||||
subprocess.run(
|
||||
[
|
||||
self.downloader.mp4decrypt_path_full,
|
||||
@@ -62,9 +67,10 @@ class DownloaderSongLegacy(DownloaderSong):
|
||||
decrypted_path,
|
||||
],
|
||||
check=True,
|
||||
**self.downloader.subprocess_additional_args,
|
||||
)
|
||||
|
||||
def remux_mp4box(self, decrypted_path: Path, remuxed_path: Path) -> None:
|
||||
def remux_mp4box(self, decrypted_path: Path, remuxed_path: Path):
|
||||
subprocess.run(
|
||||
[
|
||||
self.downloader.mp4box_path_full,
|
||||
@@ -73,10 +79,12 @@ class DownloaderSongLegacy(DownloaderSong):
|
||||
decrypted_path,
|
||||
"-itags",
|
||||
"artist=placeholder",
|
||||
"-keep-utc",
|
||||
"-new",
|
||||
remuxed_path,
|
||||
],
|
||||
check=True,
|
||||
**self.downloader.subprocess_additional_args,
|
||||
)
|
||||
|
||||
def remux_ffmpeg(
|
||||
@@ -102,6 +110,7 @@ class DownloaderSongLegacy(DownloaderSong):
|
||||
remuxed_path,
|
||||
],
|
||||
check=True,
|
||||
**self.downloader.subprocess_additional_args,
|
||||
)
|
||||
|
||||
def remux(
|
||||
|
||||
+5
-3
@@ -20,8 +20,9 @@ class SongCodec(Enum):
|
||||
AAC_DOWNMIX = "aac-downmix"
|
||||
AAC_HE_BINAURAL = "aac-he-binaural"
|
||||
AAC_HE_DOWNMIX = "aac-he-downmix"
|
||||
ALAC = "alac"
|
||||
ATMOS = "atmos"
|
||||
AC3 = "ac3"
|
||||
ALAC = "alac"
|
||||
ASK = "ask"
|
||||
|
||||
|
||||
@@ -32,8 +33,8 @@ class SyncedLyricsFormat(Enum):
|
||||
|
||||
|
||||
class MusicVideoCodec(Enum):
|
||||
H264_BEST = "h264-best"
|
||||
H265_BEST = "h265-best"
|
||||
H264 = "h264"
|
||||
H265 = "h265"
|
||||
ASK = "ask"
|
||||
|
||||
|
||||
@@ -45,3 +46,4 @@ class PostQuality(Enum):
|
||||
class CoverFormat(Enum):
|
||||
JPG = "jpg"
|
||||
PNG = "png"
|
||||
RAW = "raw"
|
||||
|
||||
@@ -23,3 +23,4 @@ class Lyrics:
|
||||
class StreamInfo:
|
||||
stream_url: str = None
|
||||
pssh: str = None
|
||||
codec: str = None
|
||||
|
||||
+3
-2
@@ -1,13 +1,14 @@
|
||||
[project]
|
||||
name = "gamdl"
|
||||
description = "Download Apple Music songs/music videos/albums/playlists"
|
||||
description = "A Python CLI app for downloading Apple Music songs/music videos/posts."
|
||||
requires-python = ">=3.8"
|
||||
authors = [{ name = "glomatico" }]
|
||||
dependencies = [
|
||||
"ciso8601",
|
||||
"click",
|
||||
"inquirerpy",
|
||||
"m3u8",
|
||||
"tabulate",
|
||||
"pillow",
|
||||
"pywidevine",
|
||||
"pyyaml",
|
||||
"yt-dlp",
|
||||
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
ciso8601
|
||||
click
|
||||
inquirerpy
|
||||
m3u8
|
||||
tabulate
|
||||
pillow
|
||||
pywidevine
|
||||
pyyaml
|
||||
yt-dlp
|
||||
|
||||
Reference in New Issue
Block a user