├── gytmdl ├── __init__.py ├── __main__.py ├── enums.py ├── constants.py ├── custom_logger_formatter.py ├── utils.py ├── cli.py └── downloader.py ├── requirements.txt ├── .gitignore ├── pyproject.toml ├── .github └── workflows │ └── main.yml └── README.md /gytmdl/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "2.1.6" 2 | -------------------------------------------------------------------------------- /gytmdl/__main__.py: -------------------------------------------------------------------------------- 1 | from .cli import main 2 | 3 | main() 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | click 2 | colorama 3 | inquirerpy 4 | mutagen 5 | pillow 6 | yt-dlp 7 | ytmusicapi 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /* 2 | __pycache__ 3 | !gytmdl 4 | !.gitignore 5 | !pyproject.toml 6 | !README.md 7 | !requirements.txt 8 | -------------------------------------------------------------------------------- /gytmdl/enums.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class CoverFormat(Enum): 5 | JPG = "jpg" 6 | PNG = "png" 7 | RAW = "raw" 8 | 9 | 10 | class DownloadMode(Enum): 11 | YTDLP = "ytdlp" 12 | ARIA2C = "aria2c" 13 | -------------------------------------------------------------------------------- /gytmdl/constants.py: -------------------------------------------------------------------------------- 1 | EXCLUDED_CONFIG_FILE_PARAMS = ( 2 | "urls", 3 | "config_path", 4 | "read_urls_as_txt", 5 | "no_config_file", 6 | "version", 7 | "help", 8 | ) 9 | 10 | MP4_TAGS_MAP = { 11 | "album": "\xa9alb", 12 | "album_artist": "aART", 13 | "artist": "\xa9ART", 14 | "date": "\xa9day", 15 | "lyrics": "\xa9lyr", 16 | "media_type": "stik", 17 | "rating": "rtng", 18 | "title": "\xa9nam", 19 | "url": "\xa9url", 20 | } 21 | 22 | X_NOT_FOUND_STRING = '{} not found at "{}"' 23 | 24 | IMAGE_FILE_EXTENSION_MAP = { 25 | "jpeg": ".jpg", 26 | "tiff": ".tif", 27 | } 28 | 29 | PREMIUM_FORMATS = ["141", "774"] 30 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "gytmdl" 3 | description = "A command-line app for downloading YouTube Music songs with tags from YouTube Music." 4 | requires-python = ">=3.10" 5 | authors = [{ name = "glomatico" }] 6 | dependencies = [ 7 | "click", 8 | "colorama", 9 | "inquirerpy", 10 | "mutagen", 11 | "pillow", 12 | "yt-dlp", 13 | "ytmusicapi", 14 | ] 15 | readme = "README.md" 16 | dynamic = ["version"] 17 | 18 | [project.urls] 19 | repository = "https://github.com/glomatico/gytmdl" 20 | 21 | [build-system] 22 | requires = ["flit_core"] 23 | build-backend = "flit_core.buildapi" 24 | 25 | [project.scripts] 26 | gytmdl = "gytmdl.cli:main" 27 | -------------------------------------------------------------------------------- /gytmdl/custom_logger_formatter.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import colorama 4 | 5 | from .utils import color_text 6 | 7 | 8 | class CustomLoggerFormatter(logging.Formatter): 9 | base_format = "[%(levelname)-8s %(asctime)s]" 10 | format_colors = { 11 | logging.DEBUG: colorama.Style.DIM, 12 | logging.INFO: colorama.Fore.GREEN, 13 | logging.WARNING: colorama.Fore.YELLOW, 14 | logging.ERROR: colorama.Fore.RED, 15 | logging.CRITICAL: colorama.Fore.RED, 16 | } 17 | date_format = "%H:%M:%S" 18 | 19 | def format(self, record: logging.LogRecord) -> str: 20 | return logging.Formatter( 21 | color_text(self.base_format, self.format_colors.get(record.levelno)) 22 | + " %(message)s", 23 | datefmt=self.date_format, 24 | ).format(record) 25 | -------------------------------------------------------------------------------- /gytmdl/utils.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import click 4 | import colorama 5 | 6 | 7 | def color_text(text: str, color) -> str: 8 | return color + text + colorama.Style.RESET_ALL 9 | 10 | 11 | def prompt_path(is_file: bool, initial_path: Path) -> Path: 12 | path_validator = click.Path( 13 | exists=True, 14 | file_okay=is_file, 15 | dir_okay=not is_file, 16 | path_type=Path, 17 | ) 18 | while True: 19 | try: 20 | path_obj = path_validator.convert(initial_path.absolute(), None, None) 21 | break 22 | except click.BadParameter as e: 23 | path_str = click.prompt( 24 | str(e) 25 | + " Move it to that location, type the path or drag and drop it here. Then, press enter to continue", 26 | default=str(initial_path), 27 | show_default=False, 28 | ) 29 | path_str = path_str.strip('"') 30 | initial_path = Path(path_str) 31 | return path_obj 32 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | # Controls when the workflow will run 4 | on: 5 | 6 | # Workflow will run when a release has been published for the package 7 | release: 8 | branches: 9 | - master 10 | types: 11 | - published 12 | 13 | # Allows you to run this workflow manually from the Actions tab 14 | workflow_dispatch: 15 | 16 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 17 | jobs: 18 | 19 | # This workflow contains a single job called "publish" 20 | publish: 21 | 22 | # The type of runner that the job will run on 23 | runs-on: ubuntu-latest 24 | 25 | # Steps represent a sequence of tasks that will be executed as part of the job 26 | steps: 27 | 28 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 29 | - uses: actions/checkout@v3 30 | 31 | - name: Set up Python 3.10 32 | uses: actions/setup-python@v5 33 | with: 34 | python-version: "3.10" 35 | cache: pip 36 | 37 | - name: To PyPI using Flit 38 | uses: AsifArmanRahman/to-pypi-using-flit@v1 39 | with: 40 | password: ${{ secrets.PYPI_API_TOKEN }} 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Glomatico's YouTube Music Downloader 2 | 3 | A command-line app for downloading YouTube Music songs with tags from YouTube Music. 4 | 5 | **Discord Server:** 6 | 7 | ## Features 8 | 9 | * **Precise metadata**: [YouTube Music API](https://github.com/sigma67/ytmusicapi) is used to get accurate metadata that yt-dlp alone can't provide, like high-resolution square covers, lyrics, track numbers, and total track counts. 10 | * **Synced Lyrics**: Download synced lyrics in LRC. 11 | * **Artist Support**: Download all albums of an artist using their link. 12 | * **Highly Customizable**: Extensive configuration options for advanced users. 13 | 14 | ## Prerequisites 15 | 16 | * **Python 3.10 or higher** installed on your system. 17 | * **FFmpeg** on your system PATH. 18 | * **Windows**: Download from [AnimMouse's FFmpeg Builds](https://github.com/AnimMouse/ffmpeg-stable-autobuild/releases). 19 | * **Linux**: Download from [John Van Sickle's FFmpeg Builds](https://johnvansickle.com/ffmpeg/). 20 | * (Optional) The **cookies file** of your YouTube Music browser session in Netscape format (requires an active subscription). 21 | * **Firefox**: Use the [Export Cookies](https://addons.mozilla.org/addon/export-cookies-txt) extension. 22 | * **Chromium-based Browsers**: Use the [Open Cookies.txt](https://chromewebstore.google.com/detail/open-cookiestxt/gdocmgbfkjnnpapoeobnolbbkoibbcif) extension. 23 | * With cookies, you can download **age-restricted content**, **private playlists**, and songs in **premium formats** if you have an active Premium subscription. You'll have to set the cookies file path using the command line arguments or the config file (see [Configuration](#configuration)). 24 | * **YouTube cookies can expire very quickly**. As a workaround, export your cookies in an incognito/anonymous window so they don't expire as quickly. 25 | * **You may need to provide a PO token** by using the command line arguments or the config file if you encounter issues when downloading with cookies. To get a PO token, you can follow yt-dlp's instructions [here](https://github.com/yt-dlp/yt-dlp/wiki/PO-Token-Guide). 26 | 27 | ### Optional dependencies 28 | 29 | The following tools are optional but required for specific features. Add them to your system's PATH or specify their paths using command-line arguments or the config file. 30 | 31 | * [aria2](https://aria2.github.io/): Required for `aria2c` download mode. 32 | 33 | ## Installation 34 | 35 | Install the package `gytmdl` using pip: 36 | 37 | ```bash 38 | pip install gytmdl 39 | ``` 40 | 41 | ## Usage 42 | 43 | Run Gytmdl with the following command: 44 | 45 | ```bash 46 | gytmdl [OPTIONS] URLS... 47 | ``` 48 | 49 | ### Supported URL types 50 | 51 | * Song 52 | * Album 53 | * Playlist 54 | * Artist 55 | 56 | **Songs that are not part of an album (standard YouTube videos) are not supported**. To make sure you get valid links, use YouTube Music for searching and enable filtering by songs, albums or artists. 57 | 58 | ### Examples 59 | 60 | * Download a song: 61 | 62 | ```bash 63 | gytmdl "https://music.youtube.com/watch?v=3BFTio5296w" 64 | ``` 65 | 66 | * Download an album: 67 | 68 | ```bash 69 | gytmdl "https://music.youtube.com/playlist?list=OLAK5uy_lvpL_Gr_aVEq-LaivwJaSK5EbFd4HeamM" 70 | ``` 71 | 72 | * Choose which albums or singles to download from an artist: 73 | 74 | ```bash 75 | gytmdl "https://music.youtube.com/channel/UCwZEU0wAwIyZb4x5G_KJp2w" 76 | ``` 77 | 78 | ### Interactive prompt controls 79 | 80 | * **Arrow keys**: Move selection 81 | * **Space**: Toggle selection 82 | * **Ctrl + A**: Select all 83 | * **Enter**: Confirm selection 84 | 85 | ## Configuration 86 | 87 | Gytmdl can be configured by using the command line arguments or the config file. 88 | 89 | The config file is created automatically when you run Gytmdl for the first time at `~/.gytmdl/config.json` on Linux/macOS and `%USERPROFILE%\.gytmdl\config.json` on Windows. 90 | 91 | Config file values can be overridden using command line arguments. 92 | 93 | | Command line argument / Config file key | Description | Default value | 94 | | --------------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------- | 95 | | `--save-cover`, `-s` / `save_cover` | Save cover as a separate file. | `false` | 96 | | `--overwrite` / `overwrite` | Overwrite existing files. | `false` | 97 | | `--read-urls-as-txt`, `-r` / - | Interpret URLs as paths to text files containing URLs separated by newlines. | `false` | 98 | | `--config-path` / - | Path to config file. | `/.gytmdl/config.json` | 99 | | `--log-level` / `log_level` | Log level. | `INFO` | 100 | | `--no-exceptions` / `no_exceptions` | Don't print exceptions. | `false` | 101 | | `--output-path`, `-o` / `output_path` | Path to output directory. | `./YouTube Music` | 102 | | `--temp-path` / `temp_path` | Path to temporary directory. | `./temp` | 103 | | `--cookies-path`, `-c` / `cookies_path` | Path to .txt cookies file. | `null` | 104 | | `--ffmpeg-path` / `ffmpeg_path` | Path to FFmpeg binary. | `ffmpeg` | 105 | | `--aria2c-path` / `aria2c_path` | Path to aria2c binary. | `aria2c` | 106 | | `--download-mode` / `download_mode` | Download mode. | `ytdlp` | 107 | | `--po-token` / `po_token` | Proof of Origin (PO) Token. | `null` | 108 | | `--itag`, `-i` / `itag` | Itag (audio codec/quality). | `140` | 109 | | `--cover-size` / `cover_size` | Cover size. | `1200` | 110 | | `--cover-format` / `cover_format` | Cover format. | `jpg` | 111 | | `--cover-quality` / `cover_quality` | Cover JPEG quality. | `94` | 112 | | `--template-folder` / `template_folder` | Template of the album folders as a format string. | `{album_artist}/{album}` | 113 | | `--template-file` / `template_file` | Template of the song files as a format string. | `{track:02d} {title}` | 114 | | `--template-date` / `template_date` | Date tag template. | `%Y-%m-%dT%H:%M:%SZ` | 115 | | `--no-synced-lyrics` / `no_synced_lyrics` | Don't save synced lyrics. | `false` | 116 | | `--synced-lyrics-only` / `synced_lyrics_only` | Skip track download and only save synced lyrics. | `false` | 117 | | `--exclude-tags`, `-e` / `exclude_tags` | Comma-separated tags to exclude. | `null` | 118 | | `--truncate` / `truncate` | Maximum length of the file/folder names. | `null` | 119 | | `--no-config-file`, `-n` / - | Don't load the config file. | `false` | 120 | 121 | ### Tag variables 122 | 123 | The following variables can be used in the template folder/file and/or in the `exclude_tags` list: 124 | 125 | * `album` 126 | * `album_artist` 127 | * `artist` 128 | * `cover` 129 | * `date` 130 | * `lyrics` 131 | * `media_type` 132 | * `rating` 133 | * `title` 134 | * `track` 135 | * `track_total` 136 | * `url` 137 | 138 | ### Itags (audio codec/quality) 139 | 140 | * Free itags: 141 | * `140`: (AAC 128kbps) 142 | * `139`: (AAC 48kbps) 143 | * `251`: (Opus 128kbps) 144 | * `250`: (Opus 64kbps) 145 | * `249`: (Opus 48kbps) 146 | * Premium itags (requires cookies and an active Premium subscription): 147 | * `141`: (AAC 256kbps) 148 | * `774`: (Opus 256kbps) 149 | 150 | ### Download modes 151 | 152 | * `ytdlp`: Default download mode. 153 | * `aria2c`: Faster than `ytdlp`. 154 | 155 | ### Cover formats 156 | 157 | * `jpg`: Default format. 158 | * `png`: Lossless format. 159 | * `raw`: Raw cover without processing (requires `save_cover` to save separately). 160 | -------------------------------------------------------------------------------- /gytmdl/cli.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import inspect 4 | import json 5 | import logging 6 | import shutil 7 | from enum import Enum 8 | from pathlib import Path 9 | 10 | import click 11 | import colorama 12 | 13 | from . import __version__ 14 | from .constants import EXCLUDED_CONFIG_FILE_PARAMS, PREMIUM_FORMATS, X_NOT_FOUND_STRING 15 | from .custom_logger_formatter import CustomLoggerFormatter 16 | from .downloader import Downloader 17 | from .enums import CoverFormat, DownloadMode 18 | from .utils import color_text, prompt_path 19 | 20 | logger = logging.getLogger("gytmdl") 21 | 22 | downloader_sig = inspect.signature(Downloader.__init__) 23 | 24 | 25 | def get_param_string(param: click.Parameter) -> str: 26 | if isinstance(param.default, Enum): 27 | return param.default.value 28 | elif isinstance(param.default, Path): 29 | return str(param.default) 30 | else: 31 | return param.default 32 | 33 | 34 | def write_default_config_file(ctx: click.Context): 35 | ctx.params["config_path"].parent.mkdir(parents=True, exist_ok=True) 36 | config_file = { 37 | param.name: get_param_string(param) 38 | for param in ctx.command.params 39 | if param.name not in EXCLUDED_CONFIG_FILE_PARAMS 40 | } 41 | ctx.params["config_path"].write_text(json.dumps(config_file, indent=4)) 42 | 43 | 44 | def load_config_file( 45 | ctx: click.Context, 46 | param: click.Parameter, 47 | no_config_file: bool, 48 | ) -> click.Context: 49 | if no_config_file: 50 | return ctx 51 | if not ctx.params["config_path"].exists(): 52 | write_default_config_file(ctx) 53 | config_file = dict(json.loads(ctx.params["config_path"].read_text())) 54 | for param in ctx.command.params: 55 | if ( 56 | config_file.get(param.name) is not None 57 | and not ctx.get_parameter_source(param.name) 58 | == click.core.ParameterSource.COMMANDLINE 59 | ): 60 | ctx.params[param.name] = param.type_cast_value(ctx, config_file[param.name]) 61 | return ctx 62 | 63 | 64 | @click.command() 65 | @click.help_option("-h", "--help") 66 | @click.version_option(__version__, "-v", "--version") 67 | # CLI specific options 68 | @click.argument( 69 | "urls", 70 | nargs=-1, 71 | type=str, 72 | required=True, 73 | ) 74 | @click.option( 75 | "--save-cover", 76 | "-s", 77 | is_flag=True, 78 | help="Save cover as a separate file.", 79 | ) 80 | @click.option( 81 | "--overwrite", 82 | is_flag=True, 83 | help="Overwrite existing files.", 84 | ) 85 | @click.option( 86 | "--read-urls-as-txt", 87 | "-r", 88 | is_flag=True, 89 | help="Interpret URLs as paths to text files containing URLs separated by newlines.", 90 | ) 91 | @click.option( 92 | "--config-path", 93 | type=Path, 94 | default=Path.home() / ".gytmdl" / "config.json", 95 | help="Path to config file.", 96 | ) 97 | @click.option( 98 | "--log-level", 99 | type=str, 100 | default="INFO", 101 | help="Log level.", 102 | ) 103 | @click.option( 104 | "--no-exceptions", 105 | is_flag=True, 106 | help="Don't print exceptions.", 107 | ) 108 | # Downloader specific options 109 | @click.option( 110 | "--output-path", 111 | "-o", 112 | type=Path, 113 | default=downloader_sig.parameters["output_path"].default, 114 | help="Path to output directory.", 115 | ) 116 | @click.option( 117 | "--temp-path", 118 | type=Path, 119 | default=downloader_sig.parameters["temp_path"].default, 120 | help="Path to temporary directory.", 121 | ) 122 | @click.option( 123 | "--cookies-path", 124 | "-c", 125 | type=Path, 126 | default=downloader_sig.parameters["cookies_path"].default, 127 | help="Path to .txt cookies file.", 128 | ) 129 | @click.option( 130 | "--ffmpeg-path", 131 | type=str, 132 | default=downloader_sig.parameters["ffmpeg_path"].default, 133 | help="Path to FFmpeg binary.", 134 | ) 135 | @click.option( 136 | "--aria2c-path", 137 | type=str, 138 | default=downloader_sig.parameters["aria2c_path"].default, 139 | help="Path to aria2c binary.", 140 | ) 141 | @click.option( 142 | "--download-mode", 143 | type=DownloadMode, 144 | default=downloader_sig.parameters["download_mode"].default, 145 | help="Download mode.", 146 | ) 147 | @click.option( 148 | "--po-token", 149 | type=str, 150 | default=downloader_sig.parameters["po_token"].default, 151 | help="Proof of Origin (PO) Token.", 152 | ) 153 | @click.option( 154 | "--itag", 155 | "-i", 156 | type=str, 157 | default=downloader_sig.parameters["itag"].default, 158 | help="Itag (audio codec/quality).", 159 | ) 160 | @click.option( 161 | "--cover-size", 162 | type=int, 163 | default=downloader_sig.parameters["cover_size"].default, 164 | help="Cover size.", 165 | ) 166 | @click.option( 167 | "--cover-format", 168 | type=CoverFormat, 169 | default=downloader_sig.parameters["cover_format"].default, 170 | help="Cover format.", 171 | ) 172 | @click.option( 173 | "--cover-quality", 174 | type=int, 175 | default=downloader_sig.parameters["cover_quality"].default, 176 | help="Cover JPEG quality.", 177 | ) 178 | @click.option( 179 | "--template-folder", 180 | type=str, 181 | default=downloader_sig.parameters["template_folder"].default, 182 | help="Template of the album folders as a format string.", 183 | ) 184 | @click.option( 185 | "--template-file", 186 | type=str, 187 | default=downloader_sig.parameters["template_file"].default, 188 | help="Template of the song files as a format string.", 189 | ) 190 | @click.option( 191 | "--template-date", 192 | type=str, 193 | default=downloader_sig.parameters["template_date"].default, 194 | help="Date tag template.", 195 | ) 196 | @click.option( 197 | "--exclude-tags", 198 | "-e", 199 | type=str, 200 | default=downloader_sig.parameters["exclude_tags"].default, 201 | help="Comma-separated tags to exclude.", 202 | ) 203 | @click.option( 204 | "--no-synced-lyrics", 205 | is_flag=True, 206 | help="Don't save synced lyrics.", 207 | ) 208 | @click.option( 209 | "--synced-lyrics-only", 210 | is_flag=True, 211 | help="Skip track download and only save synced lyrics.", 212 | ) 213 | @click.option( 214 | "--truncate", 215 | type=int, 216 | default=downloader_sig.parameters["truncate"].default, 217 | help="Maximum length of the file/folder names.", 218 | ) 219 | # This option should always be last 220 | @click.option( 221 | "--no-config-file", 222 | "-n", 223 | is_flag=True, 224 | callback=load_config_file, 225 | help="Don't load the config file.", 226 | ) 227 | def main( 228 | urls: tuple[str], 229 | save_cover: bool, 230 | overwrite: bool, 231 | read_urls_as_txt: bool, 232 | config_path: Path, 233 | log_level: str, 234 | no_exceptions: bool, 235 | output_path: Path, 236 | temp_path: Path, 237 | cookies_path: Path, 238 | ffmpeg_path: str, 239 | aria2c_path: str, 240 | download_mode: DownloadMode, 241 | po_token: str, 242 | itag: str, 243 | cover_size: int, 244 | cover_format: CoverFormat, 245 | cover_quality: int, 246 | template_folder: str, 247 | template_file: str, 248 | template_date: str, 249 | exclude_tags: str, 250 | no_synced_lyrics: bool, 251 | synced_lyrics_only: bool, 252 | truncate: int, 253 | no_config_file: bool, 254 | ): 255 | colorama.just_fix_windows_console() 256 | logger.setLevel(log_level) 257 | stream_handler = logging.StreamHandler() 258 | stream_handler.setFormatter(CustomLoggerFormatter()) 259 | logger.addHandler(stream_handler) 260 | if itag in PREMIUM_FORMATS and cookies_path is None: 261 | logger.critical("Cookies file is required for premium formats") 262 | return 263 | if po_token is None and cookies_path is not None: 264 | logger.warning("PO Token not provided, downloading may fail") 265 | if not shutil.which(ffmpeg_path): 266 | logger.critical(X_NOT_FOUND_STRING.format("FFmpeg", ffmpeg_path)) 267 | return 268 | if download_mode == DownloadMode.ARIA2C and not shutil.which(aria2c_path): 269 | logger.critical(X_NOT_FOUND_STRING.format("aria2c", aria2c_path)) 270 | return 271 | if cookies_path is not None: 272 | cookies_path = prompt_path( 273 | True, 274 | cookies_path, 275 | ) 276 | if read_urls_as_txt: 277 | _urls = [] 278 | for url in urls: 279 | if Path(url).exists(): 280 | _urls.extend(Path(url).read_text().splitlines()) 281 | urls = _urls 282 | logger.info("Starting Gytmdl") 283 | downloader = Downloader( 284 | output_path, 285 | temp_path, 286 | cookies_path, 287 | ffmpeg_path, 288 | aria2c_path, 289 | itag, 290 | download_mode, 291 | po_token, 292 | cover_size, 293 | cover_format, 294 | cover_quality, 295 | template_folder, 296 | template_file, 297 | template_date, 298 | exclude_tags, 299 | truncate, 300 | ) 301 | error_count = 0 302 | download_queue = [] 303 | for url_index, url in enumerate(urls, start=1): 304 | url_progress = color_text(f"URL {url_index}/{len(urls)}", colorama.Style.DIM) 305 | try: 306 | logger.info(f'({url_progress}) Checking "{url}"') 307 | download_queue = list(downloader.get_download_queue(url)) 308 | except Exception as e: 309 | error_count += 1 310 | logger.error( 311 | f'({url_progress}) Failed to check "{url}"', 312 | exc_info=not no_exceptions, 313 | ) 314 | continue 315 | for queue_index, queue_item in enumerate(download_queue, start=1): 316 | queue_progress = color_text( 317 | f"Track {queue_index}/{len(download_queue)} from URL {url_index}/{len(urls)}", 318 | colorama.Style.DIM, 319 | ) 320 | try: 321 | logger.info(f'({queue_progress}) Downloading "{queue_item["title"]}"') 322 | logger.debug("Getting tags") 323 | ytmusic_watch_playlist = downloader.get_ytmusic_watch_playlist( 324 | queue_item["id"] 325 | ) 326 | if not ytmusic_watch_playlist: 327 | logger.warning( 328 | f"({queue_progress}) Track doesn't have an album or is not available, skipping" 329 | ) 330 | continue 331 | tags = downloader.get_tags(ytmusic_watch_playlist) 332 | final_path = downloader.get_final_path(tags) 333 | synced_lyrics_path = downloader.get_synced_lyrics_path(final_path) 334 | if synced_lyrics_only: 335 | pass 336 | elif final_path.exists() and not overwrite: 337 | logger.warning( 338 | f'({queue_progress}) Track already exists at "{final_path}", skipping' 339 | ) 340 | else: 341 | video_id = ytmusic_watch_playlist["tracks"][0]["videoId"] 342 | track_temp_path = downloader.get_track_temp_path(video_id) 343 | remuxed_path = downloader.get_remuxed_path(video_id) 344 | cover_url = downloader.get_cover_url(ytmusic_watch_playlist) 345 | cover_file_extension = downloader.get_cover_file_extension( 346 | cover_url 347 | ) 348 | cover_path = downloader.get_cover_path( 349 | final_path, cover_file_extension 350 | ) 351 | logger.debug(f'Downloading to "{track_temp_path}"') 352 | downloader.download(video_id, track_temp_path) 353 | logger.debug(f'Remuxing to "{remuxed_path}"') 354 | downloader.remux(track_temp_path, remuxed_path) 355 | logger.debug("Applying tags") 356 | downloader.apply_tags(remuxed_path, tags, cover_url) 357 | logger.debug(f'Moving to "{final_path}"') 358 | downloader.move_to_output_path(remuxed_path, final_path) 359 | if no_synced_lyrics or not tags.get("lyrics"): 360 | pass 361 | elif synced_lyrics_path.exists() and not overwrite: 362 | logger.debug( 363 | f'Synced lyrics already exists at "{synced_lyrics_path}", skipping' 364 | ) 365 | else: 366 | logger.debug("Getting synced lyrics") 367 | synced_lyrics = downloader.get_synced_lyrics(ytmusic_watch_playlist) 368 | if synced_lyrics: 369 | logger.debug(f'Saving synced lyrics to "{synced_lyrics_path}"') 370 | downloader.save_synced_lyrics(synced_lyrics_path, synced_lyrics) 371 | if not save_cover: 372 | pass 373 | elif cover_path.exists() and not overwrite: 374 | logger.debug(f'Cover already exists at "{cover_path}", skipping') 375 | else: 376 | logger.debug(f'Saving cover to "{cover_path}"') 377 | downloader.save_cover(cover_path, cover_url) 378 | except Exception as e: 379 | error_count += 1 380 | logger.error( 381 | f'({queue_progress}) Failed to download "{queue_item["title"]}"', 382 | exc_info=not no_exceptions, 383 | ) 384 | finally: 385 | if temp_path.exists(): 386 | logger.debug(f'Cleaning up "{temp_path}"') 387 | downloader.cleanup_temp_path() 388 | logger.info(f"Done ({error_count} error(s))") 389 | -------------------------------------------------------------------------------- /gytmdl/downloader.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import functools 5 | import io 6 | import re 7 | import shutil 8 | import subprocess 9 | import typing 10 | from pathlib import Path 11 | 12 | import requests 13 | from InquirerPy import inquirer 14 | from InquirerPy.base.control import Choice 15 | from mutagen.mp4 import MP4, MP4Cover 16 | from PIL import Image 17 | from yt_dlp import YoutubeDL 18 | from yt_dlp.extractor.youtube import YoutubeTabIE 19 | from ytmusicapi import YTMusic 20 | from .constants import PREMIUM_FORMATS 21 | 22 | from .constants import IMAGE_FILE_EXTENSION_MAP, MP4_TAGS_MAP 23 | from .enums import CoverFormat, DownloadMode 24 | 25 | 26 | class Downloader: 27 | def __init__( 28 | self, 29 | output_path: Path = Path("./YouTube Music"), 30 | temp_path: Path = Path("./temp"), 31 | cookies_path: Path = None, 32 | ffmpeg_path: str = "ffmpeg", 33 | aria2c_path: str = "aria2c", 34 | itag: str = "140", 35 | download_mode: DownloadMode = DownloadMode.YTDLP, 36 | po_token: str = None, 37 | cover_size: int = "1200", 38 | cover_format: CoverFormat = CoverFormat.JPG, 39 | cover_quality: int = 94, 40 | template_folder: str = "{album_artist}/{album}", 41 | template_file: str = "{track:02d} {title}", 42 | template_date: str = "%Y-%m-%dT%H:%M:%SZ", 43 | exclude_tags: str = None, 44 | truncate: int = None, 45 | silent: bool = False, 46 | ): 47 | self.output_path = output_path 48 | self.temp_path = temp_path 49 | self.cookies_path = cookies_path 50 | self.ffmpeg_path = ffmpeg_path 51 | self.aria2c_path = aria2c_path 52 | self.itag = itag 53 | self.download_mode = download_mode 54 | self.po_token = po_token 55 | self.cover_size = cover_size 56 | self.cover_format = cover_format 57 | self.cover_quality = cover_quality 58 | self.template_folder = template_folder 59 | self.template_file = template_file 60 | self.template_date = template_date 61 | self.exclude_tags = exclude_tags 62 | self.truncate = truncate 63 | self.silent = silent 64 | self._set_ytmusic_instance() 65 | self._set_ytdlp_options() 66 | self._set_exclude_tags() 67 | self._set_truncate() 68 | 69 | def _set_ytmusic_instance(self): 70 | self.ytmusic = YTMusic() 71 | 72 | def _set_ytdlp_options(self): 73 | extractor_args = {} 74 | if self.itag in PREMIUM_FORMATS or self.cookies_path is not None: 75 | extractor_args["player_client"] = ["web_music"] 76 | if self.po_token is None: 77 | extractor_args["formats"] = ["missing_pot"] 78 | else: 79 | extractor_args["po_token"] = [self.po_token] 80 | else: 81 | extractor_args["player_client"] = ["tv"] 82 | self.ytdlp_options = { 83 | "quiet": True, 84 | "no_warnings": True, 85 | "noprogress": self.silent, 86 | "allowed_extractors": ["youtube", "youtube:tab"], 87 | "extractor_args": {"youtube": extractor_args}, 88 | } 89 | if self.cookies_path is not None: 90 | self.ytdlp_options["cookiefile"] = str(self.cookies_path) 91 | 92 | def _set_exclude_tags(self): 93 | self.exclude_tags = ( 94 | [i.lower() for i in self.exclude_tags.split(",")] 95 | if self.exclude_tags is not None 96 | else [] 97 | ) 98 | 99 | def _set_truncate(self): 100 | if self.truncate is not None: 101 | self.truncate = None if self.truncate < 4 else self.truncate 102 | 103 | @functools.lru_cache() 104 | def _get_ytdlp_info(self, url: str) -> dict: 105 | with YoutubeDL( 106 | { 107 | **self.ytdlp_options, 108 | "extract_flat": True, 109 | } 110 | ) as ydl: 111 | return ydl.extract_info(url, download=False) 112 | 113 | def get_download_queue( 114 | self, 115 | url: str, 116 | ) -> typing.Generator[dict, None, None]: 117 | artist_match = re.match(YoutubeTabIE._VALID_URL, url) 118 | if artist_match and artist_match.group("channel_type") == "channel": 119 | yield from self._get_download_queue_artist(artist_match.group("id")) 120 | else: 121 | yield from self._get_download_queue_url(url) 122 | 123 | def _get_download_queue_url( 124 | self, 125 | url: str, 126 | ) -> typing.Generator[dict, None, None]: 127 | ytdlp_info = self._get_ytdlp_info(url.split("&")[0]) 128 | if "MPREb_" in ytdlp_info["webpage_url_basename"]: 129 | ytdlp_info = self._get_ytdlp_info(ytdlp_info["url"]) 130 | if "playlist" in ytdlp_info["webpage_url_basename"]: 131 | for entry in ytdlp_info["entries"]: 132 | yield entry 133 | if "watch" in ytdlp_info["webpage_url_basename"]: 134 | yield ytdlp_info 135 | 136 | def _get_download_queue_artist( 137 | self, 138 | channel_id: str, 139 | ) -> typing.Generator[dict, None, None]: 140 | artist = self.ytmusic.get_artist(channel_id) 141 | media_type = inquirer.select( 142 | message=f'Select which type to download for artist "{artist["name"]}":', 143 | choices=[ 144 | Choice( 145 | name="Albums", 146 | value="albums", 147 | ), 148 | Choice( 149 | name="Singles", 150 | value="singles", 151 | ), 152 | ], 153 | validate=lambda result: artist.get(result, {}).get("results"), 154 | invalid_message="The artist doesn't have any items of this type", 155 | ).execute() 156 | artist_albums = ( 157 | self.ytmusic.get_artist_albums( 158 | artist[media_type]["browseId"], artist[media_type]["params"] 159 | ) 160 | if artist[media_type].get("browseId") and artist[media_type].get("params") 161 | else artist[media_type]["results"] 162 | ) 163 | choices = [ 164 | Choice( 165 | name=" | ".join( 166 | [ 167 | album.get("year", "Unknown"), 168 | album["title"], 169 | ] 170 | ), 171 | value=album, 172 | ) 173 | for album in artist_albums 174 | ] 175 | selected = inquirer.select( 176 | message="Select which items to download: (Year | Title)", 177 | choices=choices, 178 | multiselect=True, 179 | ).execute() 180 | for album in selected: 181 | yield from self._get_download_queue_url( 182 | "https://music.youtube.com/browse/" + album["browseId"] 183 | ) 184 | 185 | @staticmethod 186 | def _get_artist(artist_list: dict) -> str: 187 | if len(artist_list) == 1: 188 | return artist_list[0]["name"] 189 | return ( 190 | ", ".join([i["name"] for i in artist_list][:-1]) 191 | + f' & {artist_list[-1]["name"]}' 192 | ) 193 | 194 | def get_ytmusic_watch_playlist(self, video_id: str) -> dict | None: 195 | ytmusic_watch_playlist = self.ytmusic.get_watch_playlist(video_id) 196 | if not ytmusic_watch_playlist["tracks"][0].get("album"): 197 | return None 198 | return ytmusic_watch_playlist 199 | 200 | @functools.lru_cache() 201 | def get_ytmusic_album(self, browse_id: str) -> dict: 202 | return self.ytmusic.get_album(browse_id) 203 | 204 | @staticmethod 205 | def _get_datetime_obj(date: str) -> datetime.datetime: 206 | return datetime.datetime.strptime(date, "%Y") 207 | 208 | def get_tags(self, ytmusic_watch_playlist: dict) -> dict: 209 | video_id = ytmusic_watch_playlist["tracks"][0]["videoId"] 210 | ytmusic_album = self.get_ytmusic_album( 211 | ytmusic_watch_playlist["tracks"][0]["album"]["id"] 212 | ) 213 | tags = { 214 | "album": ytmusic_album["title"], 215 | "album_artist": self._get_artist(ytmusic_album["artists"]), 216 | "artist": self._get_artist(ytmusic_watch_playlist["tracks"][0]["artists"]), 217 | "url": f"https://music.youtube.com/watch?v={video_id}", 218 | "media_type": 1, 219 | "title": ytmusic_watch_playlist["tracks"][0]["title"], 220 | "track_total": ytmusic_album["trackCount"], 221 | "video_id": video_id, 222 | } 223 | for index, entry in enumerate( 224 | self._get_ytdlp_info( 225 | f'https://www.youtube.com/playlist?list={ytmusic_album["audioPlaylistId"]}' 226 | )["entries"] 227 | ): 228 | if entry["id"] == video_id: 229 | if ytmusic_album["tracks"][index]["isExplicit"]: 230 | tags["rating"] = 1 231 | else: 232 | tags["rating"] = 0 233 | tags["track"] = index + 1 234 | break 235 | if ytmusic_watch_playlist.get("lyrics"): 236 | lyrics_ytmusic = self.ytmusic.get_lyrics(ytmusic_watch_playlist["lyrics"]) 237 | if lyrics_ytmusic is not None and lyrics_ytmusic.get("lyrics"): 238 | tags["lyrics"] = lyrics_ytmusic["lyrics"] 239 | datetime_obj = ( 240 | self._get_datetime_obj(ytmusic_album["year"]) 241 | if ytmusic_album.get("year") 242 | else None 243 | ) 244 | if datetime_obj: 245 | tags["date"] = datetime_obj.strftime(self.template_date) 246 | return tags 247 | 248 | def get_lyrics_synced_timestamp_lrc(self, time: int) -> str: 249 | lrc_timestamp = datetime.datetime.fromtimestamp( 250 | time / 1000.0, 251 | tz=datetime.timezone.utc, 252 | ) 253 | return lrc_timestamp.strftime("%M:%S.%f")[:-4] 254 | 255 | def get_synced_lyrics(self, ytmusic_watch_playlist: dict) -> str: 256 | lyrics_ytmusic = self.ytmusic.get_lyrics( 257 | ytmusic_watch_playlist["lyrics"], 258 | True, 259 | ) 260 | if ( 261 | lyrics_ytmusic is not None 262 | and lyrics_ytmusic.get("lyrics") 263 | and lyrics_ytmusic.get("hasTimestamps") 264 | ): 265 | return ( 266 | "\n".join( 267 | [ 268 | f"[{self.get_lyrics_synced_timestamp_lrc(i.start_time)}]{i.text}" 269 | for i in lyrics_ytmusic["lyrics"] 270 | ] 271 | ) 272 | + "\n" 273 | ) 274 | return None 275 | 276 | def get_synced_lyrics_path(self, final_path: Path) -> Path: 277 | return final_path.with_suffix(".lrc") 278 | 279 | def save_synced_lyrics(self, synced_lyrics_path: Path, synced_lyrics: str): 280 | if synced_lyrics: 281 | synced_lyrics_path.parent.mkdir(parents=True, exist_ok=True) 282 | synced_lyrics_path.write_text(synced_lyrics, encoding="utf8") 283 | 284 | def get_sanitized_string(self, dirty_string: str, is_folder: bool) -> str: 285 | dirty_string = re.sub(r'[\\/:*?"<>|;]', "_", dirty_string) 286 | if is_folder: 287 | dirty_string = dirty_string[: self.truncate] 288 | if dirty_string.endswith("."): 289 | dirty_string = dirty_string[:-1] + "_" 290 | else: 291 | if self.truncate is not None: 292 | dirty_string = dirty_string[: self.truncate - 4] 293 | return dirty_string.strip() 294 | 295 | def get_track_temp_path(self, video_id: str) -> Path: 296 | return self.temp_path / f"{video_id}_temp.m4a" 297 | 298 | def get_remuxed_path(self, video_id: str) -> Path: 299 | return self.temp_path / f"{video_id}_remuxed.m4a" 300 | 301 | def get_cover_path(self, final_path: Path, file_extension: str) -> Path: 302 | return final_path.parent / ("Cover" + file_extension) 303 | 304 | def get_final_path(self, tags: dict) -> Path: 305 | final_path_folder = self.template_folder.split("/") 306 | final_path_file = self.template_file.split("/") 307 | final_path_folder = [ 308 | self.get_sanitized_string(i.format(**tags), True) for i in final_path_folder 309 | ] 310 | final_path_file = [ 311 | self.get_sanitized_string(i.format(**tags), True) 312 | for i in final_path_file[:-1] 313 | ] + [ 314 | self.get_sanitized_string(final_path_file[-1].format(**tags), False) 315 | + ".m4a" 316 | ] 317 | return self.output_path.joinpath(*final_path_folder).joinpath(*final_path_file) 318 | 319 | def download(self, video_id: str, temp_path: Path): 320 | with YoutubeDL( 321 | { 322 | **self.ytdlp_options, 323 | "external_downloader": ( 324 | { 325 | "default": self.aria2c_path, 326 | } 327 | if self.download_mode == DownloadMode.ARIA2C 328 | else None 329 | ), 330 | "fixup": "never", 331 | "format": self.itag, 332 | "outtmpl": str(temp_path), 333 | } 334 | ) as ydl: 335 | ydl.download("https://music.youtube.com/watch?v=" + video_id) 336 | 337 | def remux(self, temp_path: Path, remuxed_path: Path): 338 | command = [ 339 | self.ffmpeg_path, 340 | "-loglevel", 341 | "error", 342 | "-i", 343 | temp_path, 344 | ] 345 | if self.itag not in ("141", "140", "139"): 346 | command.extend( 347 | [ 348 | "-f", 349 | "mp4", 350 | ] 351 | ) 352 | subprocess.run( 353 | [ 354 | *command, 355 | "-movflags", 356 | "+faststart", 357 | "-c", 358 | "copy", 359 | remuxed_path, 360 | ], 361 | check=True, 362 | ) 363 | 364 | @staticmethod 365 | @functools.lru_cache() 366 | def get_url_response_bytes(url: str) -> bytes: 367 | return requests.get(url).content 368 | 369 | def get_cover_url(self, ytmusic_watch_playlist: dict) -> str: 370 | return ( 371 | f'{ytmusic_watch_playlist["tracks"][0]["thumbnail"][0]["url"].split("=")[0]}' 372 | + ( 373 | "=d" 374 | if self.cover_format == CoverFormat.RAW 375 | else f'=w{self.cover_size}-l{self.cover_quality}-{"rj" if self.cover_format == CoverFormat.JPG else "rp"}' 376 | ) 377 | ) 378 | 379 | def get_cover_file_extension(self, cover_url: str) -> str: 380 | image_obj = Image.open(io.BytesIO(self.get_url_response_bytes(cover_url))) 381 | image_format = image_obj.format.lower() 382 | return IMAGE_FILE_EXTENSION_MAP.get(image_format, f".{image_format}") 383 | 384 | def apply_tags( 385 | self, 386 | path: Path, 387 | tags: dict, 388 | cover_url: str, 389 | ): 390 | to_apply_tags = [ 391 | tag_name for tag_name in tags.keys() if tag_name not in self.exclude_tags 392 | ] 393 | mp4_tags = {} 394 | for tag_name in to_apply_tags: 395 | if tag_name in ("disc", "disc_total"): 396 | if mp4_tags.get("disk") is None: 397 | mp4_tags["disk"] = [[0, 0]] 398 | if tag_name == "disc": 399 | mp4_tags["disk"][0][0] = tags[tag_name] 400 | elif tag_name == "disc_total": 401 | mp4_tags["disk"][0][1] = tags[tag_name] 402 | elif tag_name in ("track", "track_total"): 403 | if mp4_tags.get("trkn") is None: 404 | mp4_tags["trkn"] = [[0, 0]] 405 | if tag_name == "track": 406 | mp4_tags["trkn"][0][0] = tags[tag_name] 407 | elif tag_name == "track_total": 408 | mp4_tags["trkn"][0][1] = tags[tag_name] 409 | if ( 410 | MP4_TAGS_MAP.get(tag_name) is not None 411 | and tags.get(tag_name) is not None 412 | ): 413 | mp4_tags[MP4_TAGS_MAP[tag_name]] = [tags[tag_name]] 414 | if "cover" not in self.exclude_tags and self.cover_format != CoverFormat.RAW: 415 | mp4_tags["covr"] = [ 416 | MP4Cover( 417 | self.get_url_response_bytes(cover_url), 418 | imageformat=( 419 | MP4Cover.FORMAT_JPEG 420 | if self.cover_format == CoverFormat.JPG 421 | else MP4Cover.FORMAT_PNG 422 | ), 423 | ) 424 | ] 425 | mp4 = MP4(path) 426 | mp4.clear() 427 | mp4.update(mp4_tags) 428 | mp4.save() 429 | 430 | def move_to_output_path( 431 | self, 432 | remuxed_path: Path, 433 | final_path: Path, 434 | ): 435 | final_path.parent.mkdir(parents=True, exist_ok=True) 436 | shutil.move(remuxed_path, final_path) 437 | 438 | @functools.lru_cache() 439 | def save_cover(self, cover_path: Path, cover_url: str): 440 | cover_path.write_bytes(self.get_url_response_bytes(cover_url)) 441 | 442 | def cleanup_temp_path(self): 443 | shutil.rmtree(self.temp_path) 444 | --------------------------------------------------------------------------------