Merge pull request #42 from glomatico/dev

Dev
This commit is contained in:
Rafael Moraes
2023-09-09 01:47:07 -03:00
committed by GitHub
6 changed files with 535 additions and 482 deletions
+6 -6
View File
@@ -54,14 +54,14 @@ gamdl can be configured using the command line arguments or the config file. The
| `--template-file-music-video` / `template_file_music_video` | Template of the music video files as a format string. | `{title}` |
| `--cover-size` / `cover_size` | Size of the cover. | `1200` |
| `--cover-format` / `cover_format` | Format of the cover. | `jpg` |
| `--remux-mode` / `remux_mode` | Remuxing mode. | `ffmpeg` |
| `--download-mode` / `download_mode` | Download mode. | `yt-dlp` |
| `--remux-mode` / `remux_mode` | Remux mode. | `ffmpeg` |
| `--download-mode` / `download_mode` | Download mode. | `ytdlp` |
| `-e`, `--exclude-tags` / `exclude_tags` | List of tags to exclude from file tagging separated by commas. | `null` |
| `--truncate` / `truncate` | Maximum length of the file/folder names. | `40` |
| `-l`, `--log-level` / `log_level` | Log level. | `INFO` |
| `--prefer-hevc` / `prefer_hevc` | Prefer HEVC over AVC when downloading music videos. | `false` |
| `--ask-video-format` / `ask_video_format` | Ask for the video format when downloading music videos. | `false` |
| `--disable-music-video-album-skip` / `disable_music_video_album_skip` | Don't skip downloading music videos in albums. | `false` |
| `--disable-music-video-skip` / `disable_music_video_skip` | Don't skip downloading music videos in albums/playlists. | `false` |
| `-l`, `--lrc-only` / `lrc_only` | Download only the synced lyrics. | `false` |
| `-n`, `--no-lrc` / `no_lrc` | Don't download the synced lyrics. | `false` |
| `-s`, `--save-cover` / `save_cover` | Save cover as a separate file. | `false` |
@@ -87,6 +87,7 @@ The following variables can be used in the template folders/files and/or in the
* `composer_sort`
* `copyright`
* `cover`
* `date`
* `disc`
* `disc_total`
* `gapless`
@@ -95,7 +96,6 @@ The following variables can be used in the template folders/files and/or in the
* `lyrics`
* `media_type`
* `rating`
* `release_date`
* `storefront`
* `title`
* `title_id`
@@ -105,10 +105,10 @@ The following variables can be used in the template folders/files and/or in the
* `xid`
### Remux mode
Can be either `ffmpeg` or `mp4box`. `mp4decrypt` is required for music videos and remuxing with `mp4box`. `mp4box` is slower but will keep the closed captions track in music videos that have one. `mp4box` can be obtained from [here](https://gpac.wp.imt.fr/downloads).
Can be either `ffmpeg` or `mp4box`. `mp4decrypt` is required for music videos and remuxing with `mp4box`. `mp4box` is slower but will not convert the closed captions track in music videos that have one. `mp4box` can be obtained from [here](https://gpac.wp.imt.fr/downloads).
### Download mode
Can be either `yt-dlp` or `nm3u8dlre`. `nm3u8dlre` is faster but requires `ffmpeg`. `nm3u8dlre` can be obtained from [here](https://github.com/nilaoda/N_m3u8DL-RE/releases).
Can be either `ytdlp` or `nm3u8dlre`. `nm3u8dlre` is faster but requires `ffmpeg`. `nm3u8dlre` can be obtained from [here](https://github.com/nilaoda/N_m3u8DL-RE/releases).
## Songs quality
Songs will be downloaded in AAC 256kbps by default or in HE-AAC 64kbps if the `songs_heaac` option is enabled.
+1 -1
View File
@@ -1 +1 @@
__version__ = "1.9.5"
__version__ = "1.9.6"
+207 -168
View File
@@ -5,26 +5,16 @@ from pathlib import Path
import click
from .dl import Dl
EXCLUDED_PARAMS = (
"urls",
"config_location",
"url_txt",
"no_config_file",
"version",
"help",
)
X_NOT_FOUND_STRING = '{} not found at "{}"'
from .constants import *
from .downloader import Downloader
def write_default_config_file(ctx: click.Context):
def write_default_config_file(ctx: click.Context) -> None:
ctx.params["config_location"].parent.mkdir(parents=True, exist_ok=True)
config_file = {
param.name: param.default
for param in ctx.command.params
if param.name not in EXCLUDED_PARAMS
if param.name not in EXCLUDED_CONFIG_FILE_PARAMS
}
with open(ctx.params["config_location"], "w") as f:
f.write(json.dumps(config_file, indent=4))
@@ -32,7 +22,7 @@ def write_default_config_file(ctx: click.Context):
def no_config_callback(
ctx: click.Context, param: click.Parameter, no_config_file: bool
):
) -> click.Context:
if no_config_file:
return ctx
if not ctx.params["config_location"].exists():
@@ -166,12 +156,12 @@ def no_config_callback(
"--remux-mode",
type=click.Choice(["ffmpeg", "mp4box"]),
default="ffmpeg",
help="Remuxing mode.",
help="Remux mode.",
)
@click.option(
"--download-mode",
type=click.Choice(["nm3u8dlre", "yt-dlp"]),
default="yt-dlp",
type=click.Choice(["ytdlp", "nm3u8dlre"]),
default="ytdlp",
help="Download mode.",
)
@click.option(
@@ -205,9 +195,9 @@ def no_config_callback(
help="Ask for the video format when downloading music videos.",
)
@click.option(
"--disable-music-video-album-skip",
"--disable-music-video-skip",
is_flag=True,
help="Don't skip downloading music videos in albums.",
help="Don't skip downloading music videos in albums/playlists.",
)
@click.option(
"--lrc-only",
@@ -284,7 +274,7 @@ def main(
log_level: str,
prefer_hevc: bool,
ask_video_format: bool,
disable_music_video_album_skip: bool,
disable_music_video_skip: bool,
lrc_only: bool,
no_lrc: bool,
save_cover: bool,
@@ -300,12 +290,43 @@ def main(
)
logger = logging.getLogger(__name__)
logger.setLevel(log_level)
if not wvd_location.exists() and not lrc_only:
logger.critical(X_NOT_FOUND_STRING.format(".wvd file", wvd_location))
return
logger.debug("Starting downloader")
downloader = Downloader(**locals())
if not cookies_location.exists():
logger.critical(X_NOT_FOUND_STRING.format("Cookies file", cookies_location))
logger.critical(X_NOT_FOUND_STR.format("Cookies file", cookies_location))
return
if not wvd_location.exists() and not lrc_only:
logger.critical(X_NOT_FOUND_STR.format(".wvd file", wvd_location))
return
if remux_mode == "ffmpeg" and not lrc_only:
if not downloader.ffmpeg_location:
logger.critical(X_NOT_FOUND_STR.format("FFmpeg", ffmpeg_location))
return
if not downloader.mp4decrypt_location:
logger.warning(
X_NOT_FOUND_STR.format("mp4decrypt", mp4decrypt_location)
+ ", music videos videos will not be downloaded"
)
if remux_mode == "mp4box" and not lrc_only:
if not downloader.mp4box_location:
logger.critical(X_NOT_FOUND_STR.format("MP4Box", mp4box_location))
return
if not downloader.mp4decrypt_location:
logger.critical(X_NOT_FOUND_STR.format("mp4decrypt", mp4decrypt_location))
return
if download_mode == "nm3u8dlre" and not lrc_only:
if not downloader.nm3u8dlre_location:
logger.critical(X_NOT_FOUND_STR.format("N_m3u8DL-RE", nm3u8dlre_location))
return
if not downloader.ffmpeg_location:
logger.critical(X_NOT_FOUND_STR.format("FFmpeg", ffmpeg_location))
return
logger.debug("Setting up session")
downloader.setup_session()
logger.debug("Setting up CDM")
downloader.setup_cdm()
error_count = 0
download_queue = []
if url_txt:
logger.debug("Reading URLs from text files")
_urls = []
@@ -313,163 +334,185 @@ def main(
with open(url, "r") as f:
_urls.extend(f.read().splitlines())
urls = tuple(_urls)
logger.debug("Starting downloader")
dl = Dl(**locals())
if remux_mode == "ffmpeg" and not lrc_only:
if not dl.ffmpeg_location:
logger.critical(X_NOT_FOUND_STRING.format("FFmpeg", ffmpeg_location))
return
if not dl.mp4decrypt_location:
logger.warning(
X_NOT_FOUND_STRING.format("mp4decrypt", mp4decrypt_location)
+ ", music videos videos will not be downloaded"
)
if remux_mode == "mp4box" and not lrc_only:
if not dl.mp4box_location:
logger.critical(X_NOT_FOUND_STRING.format("MP4Box", mp4box_location))
return
if not dl.mp4decrypt_location:
logger.critical(
X_NOT_FOUND_STRING.format("mp4decrypt", mp4decrypt_location)
)
return
if download_mode == "nm3u8dlre" and not lrc_only:
if not dl.nm3u8dlre_location:
logger.critical(
X_NOT_FOUND_STRING.format("N_m3u8DL-RE", nm3u8dlre_location)
)
return
if not dl.ffmpeg_location:
logger.critical(X_NOT_FOUND_STRING.format("FFmpeg", ffmpeg_location))
return
if not dl.session.cookies.get_dict().get("media-user-token"):
logger.critical("Invalid cookies file")
return
download_queue = []
for i, url in enumerate(urls, start=1):
for url_index, url in enumerate(urls, start=1):
try:
logger.debug(f'Checking "{url}" (URL {i}/{len(urls)})')
download_queue.append(dl.get_download_queue(url))
logger.debug(f'Checking "{url}" (URL {url_index}/{len(urls)})')
download_queue.append(downloader.get_download_queue(url))
except Exception:
logger.error(
f"Failed to check URL {i}/{len(urls)}", exc_info=print_exceptions
f'Failed to check "{url}" (URL {url_index}/{len(urls)})',
exc_info=print_exceptions,
)
error_count = 0
for i, url in enumerate(download_queue, start=1):
for j, track in enumerate(url, start=1):
if track["type"] == "music-videos" and (not dl.mp4decrypt_location or lrc_only):
continue
error_count += 1
for queue_item_index, queue_item in enumerate(download_queue, start=1):
download_type, tracks = queue_item
for track_index, track in enumerate(tracks, start=1):
logger.info(
f'Downloading "{track["attributes"]["name"]}" (track {j}/{len(url)} from URL {i}/{len(download_queue)})'
f'Downloading "{track["attributes"]["name"]}" (track {track_index}/{len(tracks)} '
f"from URL {queue_item_index}/{len(download_queue)})"
)
try:
if not track["attributes"].get("playParams"):
logger.warning("Track is not streamable, skipping")
continue
track_id = track["id"]
webplayback = dl.get_webplayback(track_id)
logger.debug("Getting webplayback")
webplayback = downloader.get_webplayback(track_id)
cover_url = downloader.get_cover_url(webplayback)
if track["type"] == "songs":
unsynced_lyrics, synced_lyrics = dl.get_lyrics(track_id)
tags = dl.get_tags_song(webplayback, unsynced_lyrics)
final_location = dl.get_final_location(tags)
logger.debug("Getting lyrics")
lyrics_unsynced, lyrics_synced = downloader.get_lyrics(track_id)
logger.debug("Getting tags")
tags = downloader.get_tags_song(webplayback, lyrics_unsynced)
final_location = downloader.get_final_location(tags)
cover_location = downloader.get_cover_location_song(final_location)
lrc_location = downloader.get_lrc_location(final_location)
logger.debug(f'Final location is "{final_location}"')
if not lrc_only:
if not final_location.exists() or overwrite:
logger.debug("Getting stream URL")
stream_url = dl.get_stream_url_song(webplayback)
logger.debug("Getting decryption key")
decryption_key = dl.get_decryption_key_song(
stream_url, track_id
if lrc_only:
pass
elif final_location.exists() and not overwrite:
logger.warning(
f"File already exists at {final_location}, skipping"
)
else:
logger.debug("Getting stream URL")
stream_url = downloader.get_stream_url_song(webplayback)
logger.debug("Getting decryption key")
decryption_key = downloader.get_decryption_key_song(
stream_url, track_id
)
encrypted_location = downloader.get_encrypted_location_audio(
track_id
)
logger.debug(f'Downloading to "{encrypted_location}"')
if download_mode == "ytdlp":
downloader.download_ytdlp(encrypted_location, stream_url)
if download_mode == "nm3u8dlre":
downloader.download_nm3u8dlre(
encrypted_location, stream_url
)
encrypted_location = dl.get_encrypted_location_audio(
track_id
decrypted_location = downloader.get_decrypted_location_audio(
track_id
)
fixed_location = downloader.get_fixed_location(track_id, ".m4a")
if remux_mode == "ffmpeg":
logger.debug(
f'Decrypting and remuxing to "{fixed_location}"'
)
logger.debug(f'Downloading to "{encrypted_location}"')
dl.download(encrypted_location, stream_url)
decrypted_location = dl.get_decrypted_location_audio(
track_id
downloader.fixup_song_ffmpeg(
encrypted_location, decryption_key, fixed_location
)
fixed_location = dl.get_fixed_location(track_id, ".m4a")
if remux_mode == "ffmpeg":
logger.debug(
f'Decrypting and remuxing to "{fixed_location}"'
)
dl.fixup_song_ffmpeg(
encrypted_location, decryption_key, fixed_location
)
if remux_mode == "mp4box":
logger.debug(f'Decrypting to "{decrypted_location}"')
dl.decrypt(
encrypted_location,
decrypted_location,
decryption_key,
)
logger.debug(f'Remuxing to "{fixed_location}"')
dl.fixup_song_mp4box(decrypted_location, fixed_location)
logger.debug("Applying tags")
dl.apply_tags(fixed_location, tags)
logger.debug("Moving to final location")
dl.move_to_final_location(fixed_location, final_location)
else:
logger.warning(
f"File already exists at {final_location}, skipping"
if remux_mode == "mp4box":
logger.debug(f'Decrypting to "{decrypted_location}"')
downloader.decrypt(
encrypted_location,
decrypted_location,
decryption_key,
)
if save_cover:
cover_location = dl.get_cover_location_song(final_location)
if not cover_location.exists() or overwrite:
logger.debug(f'Saving cover to "{cover_location}"')
dl.save_cover(tags, cover_location)
else:
logger.debug(
f'File already exists at "{cover_location}", skipping'
)
if not no_lrc and synced_lyrics:
lrc_location = dl.get_lrc_location(final_location)
if overwrite or not lrc_location.exists():
logger.debug(f'Saving synced lyrics to "{lrc_location}"')
dl.make_lrc(lrc_location, synced_lyrics)
else:
logger.warning(f'"{lrc_location}" already exists, skipping')
logger.debug(f'Remuxing to "{fixed_location}"')
downloader.fixup_song_mp4box(
decrypted_location, fixed_location
)
logger.debug("Applying tags")
downloader.apply_tags(fixed_location, tags, cover_url)
logger.debug("Moving to final location")
downloader.move_to_final_location(
fixed_location, final_location
)
if no_lrc or not lyrics_synced:
pass
elif lrc_location.exists() and not overwrite:
logger.warning(f'"{lrc_location}" already exists, skipping')
else:
logger.debug(f'Saving synced lyrics to "{lrc_location}"')
downloader.make_lrc(lrc_location, lyrics_synced)
if not save_cover or lrc_only:
pass
elif cover_location.exists() and not overwrite:
logger.debug(
f'File already exists at "{cover_location}", skipping'
)
else:
logger.debug(f'Saving cover to "{cover_location}"')
downloader.save_cover(cover_location, cover_url)
if track["type"] == "music-videos":
tags = dl.get_tags_music_video(
if (
not disable_music_video_skip
and download_type in ("albums", "playlists")
or lrc_only
or not downloader.mp4decrypt_location
):
logger.warning(
"Music video is not downloadable with current settings, skipping"
)
continue
tags = downloader.get_tags_music_video(
track["attributes"]["url"].split("/")[-1].split("?")[0]
)
final_location = dl.get_final_location(tags)
final_location = downloader.get_final_location(tags)
cover_location = downloader.get_cover_location_music_video(
final_location
)
logger.debug(f'Final location is "{final_location}"')
if not final_location.exists() or overwrite:
if final_location.exists() and not overwrite:
logger.warning(
f'File already exists at "{final_location}", skipping'
)
else:
logger.debug("Getting stream URLs")
(
stream_url_video,
stream_url_audio,
) = dl.get_stream_url_music_video(webplayback)
) = downloader.get_stream_url_music_video(webplayback)
logger.debug("Getting decryption keys")
decryption_key_video = dl.get_decryption_key_music_video(
stream_url_video, track_id
decryption_key_video = (
downloader.get_decryption_key_music_video(
stream_url_video, track_id
)
)
decryption_key_audio = dl.get_decryption_key_music_video(
stream_url_audio, track_id
decryption_key_audio = (
downloader.get_decryption_key_music_video(
stream_url_audio, track_id
)
)
encrypted_location_video = dl.get_encrypted_location_video(
track_id
encrypted_location_video = (
downloader.get_encrypted_location_video(track_id)
)
decrypted_location_video = dl.get_decrypted_location_video(
track_id
encrypted_location_audio = (
downloader.get_encrypted_location_audio(track_id)
)
decrypted_location_video = (
downloader.get_decrypted_location_video(track_id)
)
decrypted_location_audio = (
downloader.get_decrypted_location_audio(track_id)
)
logger.debug(
f'Downloading video to "{encrypted_location_video}"'
)
dl.download(encrypted_location_video, stream_url_video)
encrypted_location_audio = dl.get_encrypted_location_audio(
track_id
)
decrypted_location_audio = dl.get_decrypted_location_audio(
track_id
)
if download_mode == "ytdlp":
downloader.download_ytdlp(
encrypted_location_video, stream_url_video
)
if download_mode == "nm3u8dlre":
downloader.download_nm3u8dlre(
encrypted_location_video, stream_url_video
)
logger.debug(
f'Downloading audio to "{encrypted_location_audio}"'
)
dl.download(encrypted_location_audio, stream_url_audio)
if download_mode == "ytdlp":
downloader.download_ytdlp(
encrypted_location_audio, stream_url_audio
)
if download_mode == "nm3u8dlre":
downloader.download_nm3u8dlre(
encrypted_location_audio, stream_url_audio
)
logger.debug(
f'Decrypting video to "{decrypted_location_video}"'
)
dl.decrypt(
downloader.decrypt(
encrypted_location_audio,
decrypted_location_audio,
decryption_key_audio,
@@ -477,49 +520,45 @@ def main(
logger.debug(
f'Decrypting audio to "{decrypted_location_audio}"'
)
dl.decrypt(
downloader.decrypt(
encrypted_location_video,
decrypted_location_video,
decryption_key_video,
)
fixed_location = dl.get_fixed_location(track_id, ".m4v")
fixed_location = downloader.get_fixed_location(track_id, ".m4v")
logger.debug(f'Remuxing to "{fixed_location}"')
if remux_mode == "ffmpeg":
dl.fixup_music_video_ffmpeg(
downloader.fixup_music_video_ffmpeg(
decrypted_location_video,
decrypted_location_audio,
fixed_location,
)
if remux_mode == "mp4box":
dl.fixup_music_video_mp4box(
downloader.fixup_music_video_mp4box(
decrypted_location_audio,
decrypted_location_video,
fixed_location,
)
logger.debug("Applying tags")
dl.apply_tags(fixed_location, tags)
downloader.apply_tags(fixed_location, tags, cover_url)
logger.debug("Moving to final location")
dl.move_to_final_location(fixed_location, final_location)
downloader.move_to_final_location(
fixed_location, final_location
)
if not save_cover:
pass
elif cover_location.exists() and not overwrite:
logger.debug(
f'File already exists at "{cover_location}", skipping'
)
else:
logger.warning(
f'File already exists at "{final_location}", skipping'
)
if save_cover:
cover_location = dl.get_cover_location_music_video(
final_location
)
if not cover_location.exists() or overwrite:
logger.debug(f'Saving cover to "{cover_location}"')
dl.save_cover(tags, cover_location)
else:
logger.debug(
f'File already exists at "{cover_location}", skipping'
)
logger.debug(f'Saving cover to "{cover_location}"')
downloader.save_cover(cover_location, cover_url)
except Exception:
error_count += 1
logger.error(
f'Failed to download "{track["attributes"]["name"]}" (track {j}/{len(url)} from URL '
+ f"{i}/{len(download_queue)})",
f'Failed to download "{track["attributes"]["name"]}" (track {track_index}/{len(tracks)} '
f"from URL {queue_item_index}/{len(download_queue)})",
exc_info=print_exceptions,
)
finally:
+194
View File
@@ -0,0 +1,194 @@
STOREFRONT_IDS = {
"AE": "143481-2,32",
"AG": "143540-2,32",
"AI": "143538-2,32",
"AL": "143575-2,32",
"AM": "143524-2,32",
"AO": "143564-2,32",
"AR": "143505-28,32",
"AT": "143445-4,32",
"AU": "143460-27,32",
"AZ": "143568-2,32",
"BB": "143541-2,32",
"BE": "143446-2,32",
"BF": "143578-2,32",
"BG": "143526-2,32",
"BH": "143559-2,32",
"BJ": "143576-2,32",
"BM": "143542-2,32",
"BN": "143560-2,32",
"BO": "143556-28,32",
"BR": "143503-15,32",
"BS": "143539-2,32",
"BT": "143577-2,32",
"BW": "143525-2,32",
"BY": "143565-2,32",
"BZ": "143555-2,32",
"CA": "143455-6,32",
"CG": "143582-2,32",
"CH": "143459-57,32",
"CL": "143483-28,32",
"CN": "143465-19,32",
"CO": "143501-28,32",
"CR": "143495-28,32",
"CV": "143580-2,32",
"CY": "143557-2,32",
"CZ": "143489-2,32",
"DE": "143443-4,32",
"DK": "143458-2,32",
"DM": "143545-2,32",
"DO": "143508-28,32",
"DZ": "143563-2,32",
"EC": "143509-28,32",
"EE": "143518-2,32",
"EG": "143516-2,32",
"ES": "143454-8,32",
"FI": "143447-2,32",
"FJ": "143583-2,32",
"FM": "143591-2,32",
"FR": "143442-3,32",
"GB": "143444-2,32",
"GD": "143546-2,32",
"GH": "143573-2,32",
"GM": "143584-2,32",
"GR": "143448-2,32",
"GT": "143504-28,32",
"GW": "143585-2,32",
"GY": "143553-2,32",
"HK": "143463-45,32",
"HN": "143510-28,32",
"HR": "143494-2,32",
"HU": "143482-2,32",
"ID": "143476-2,32",
"IE": "143449-2,32",
"IL": "143491-2,32",
"IN": "143467-2,32",
"IS": "143558-2,32",
"IT": "143450-7,32",
"JM": "143511-2,32",
"JO": "143528-2,32",
"JP": "143462-9,32",
"KE": "143529-2,32",
"KG": "143586-2,32",
"KH": "143579-2,32",
"KN": "143548-2,32",
"KR": "143466-13,32",
"KW": "143493-2,32",
"KY": "143544-2,32",
"KZ": "143517-2,32",
"LA": "143587-2,32",
"LB": "143497-2,32",
"LC": "143549-2,32",
"LK": "143486-2,32",
"LR": "143588-2,32",
"LT": "143520-2,32",
"LU": "143451-2,32",
"LV": "143519-2,32",
"MD": "143523-2,32",
"MG": "143531-2,32",
"MK": "143530-2,32",
"ML": "143532-2,32",
"MN": "143592-2,32",
"MO": "143515-45,32",
"MR": "143590-2,32",
"MS": "143547-2,32",
"MT": "143521-2,32",
"MU": "143533-2,32",
"MW": "143589-2,32",
"MX": "143468-28,32",
"MY": "143473-2,32",
"MZ": "143593-2,32",
"NA": "143594-2,32",
"NE": "143534-2,32",
"NG": "143561-2,32",
"NI": "143512-28,32",
"NL": "143452-10,32",
"NO": "143457-2,32",
"NP": "143484-2,32",
"NZ": "143461-27,32",
"OM": "143562-2,32",
"PA": "143485-28,32",
"PE": "143507-28,32",
"PG": "143597-2,32",
"PH": "143474-2,32",
"PK": "143477-2,32",
"PL": "143478-2,32",
"PT": "143453-24,32",
"PW": "143595-2,32",
"PY": "143513-28,32",
"QA": "143498-2,32",
"RO": "143487-2,32",
"RU": "143469-16,32",
"SA": "143479-2,32",
"SB": "143601-2,32",
"SC": "143599-2,32",
"SE": "143456-17,32",
"SG": "143464-19,32",
"SI": "143499-2,32",
"SK": "143496-2,32",
"SL": "143600-2,32",
"SN": "143535-2,32",
"SR": "143554-2,32",
"ST": "143598-2,32",
"SV": "143506-28,32",
"SZ": "143602-2,32",
"TC": "143552-2,32",
"TD": "143581-2,32",
"TH": "143475-2,32",
"TJ": "143603-2,32",
"TM": "143604-2,32",
"TN": "143536-2,32",
"TR": "143480-2,32",
"TT": "143551-2,32",
"TW": "143470-18,32",
"TZ": "143572-2,32",
"UA": "143492-2,32",
"UG": "143537-2,32",
"US": "143441-1,32",
"UY": "143514-2,32",
"UZ": "143566-2,32",
"VC": "143550-2,32",
"VE": "143502-28,32",
"VG": "143543-2,32",
"VN": "143471-2,32",
"YE": "143571-2,32",
"ZA": "143472-2,32",
"ZW": "143605-2,32",
}
MP4_TAGS_MAP = {
"album": "\xa9alb",
"album_artist": "aART",
"album_id": "plID",
"album_sort": "soal",
"artist": "\xa9ART",
"artist_id": "atID",
"artist_sort": "soar",
"comment": "\xa9cmt",
"composer": "\xa9wrt",
"composer_id": "cmID",
"composer_sort": "soco",
"copyright": "cprt",
"date": "\xa9day",
"genre": "\xa9gen",
"genre_id": "geID",
"lyrics": "\xa9lyr",
"media_type": "stik",
"rating": "rtng",
"storefront": "sfID",
"title": "\xa9nam",
"title_id": "cnID",
"title_sort": "sonm",
"xid": "xid ",
}
EXCLUDED_CONFIG_FILE_PARAMS = (
"urls",
"config_location",
"url_txt",
"no_config_file",
"version",
"help",
)
X_NOT_FOUND_STR = '{} not found at "{}"'
+127 -152
View File
@@ -14,36 +14,10 @@ from mutagen.mp4 import MP4, MP4Cover
from pywidevine import PSSH, Cdm, Device, WidevinePsshData
from yt_dlp import YoutubeDL
import gamdl.storefronts
MP4_TAGS_MAP = {
"album": "\xa9alb",
"album_artist": "aART",
"album_id": "plID",
"album_sort": "soal",
"artist": "\xa9ART",
"artist_id": "atID",
"artist_sort": "soar",
"comment": "\xa9cmt",
"composer": "\xa9wrt",
"composer_id": "cmID",
"composer_sort": "soco",
"copyright": "cprt",
"genre": "\xa9gen",
"genre_id": "geID",
"lyrics": "\xa9lyr",
"media_type": "stik",
"rating": "rtng",
"release_date": "\xa9day",
"storefront": "sfID",
"title": "\xa9nam",
"title_id": "cnID",
"title_sort": "sonm",
"xid": "xid ",
}
from gamdl.constants import MP4_TAGS_MAP, STOREFRONT_IDS
class Dl:
class Downloader:
def __init__(
self,
final_path: Path = None,
@@ -62,23 +36,29 @@ class Dl:
template_file_music_video: str = None,
cover_size: int = None,
cover_format: str = None,
remux_mode: str = None,
download_mode: str = None,
exclude_tags: str = None,
truncate: int = None,
prefer_hevc: bool = None,
ask_video_format: bool = None,
disable_music_video_album_skip: bool = None,
lrc_only: bool = None,
songs_heaac: bool = None,
**kwargs,
):
self.final_path = final_path
self.temp_path = temp_path
self.ffmpeg_location = shutil.which(ffmpeg_location)
self.mp4box_location = shutil.which(mp4box_location)
self.mp4decrypt_location = shutil.which(mp4decrypt_location)
self.nm3u8dlre_location = shutil.which(nm3u8dlre_location)
self.cookies_location = cookies_location
self.wvd_location = wvd_location
self.ffmpeg_location = (
shutil.which(ffmpeg_location) if ffmpeg_location else None
)
self.mp4box_location = (
shutil.which(mp4box_location) if mp4box_location else None
)
self.mp4decrypt_location = (
shutil.which(mp4decrypt_location) if mp4decrypt_location else None
)
self.nm3u8dlre_location = (
shutil.which(nm3u8dlre_location) if nm3u8dlre_location else None
)
self.template_folder_album = template_folder_album
self.template_folder_compilation = template_folder_compilation
self.template_file_single_disc = template_file_single_disc
@@ -87,8 +67,6 @@ class Dl:
self.template_file_music_video = template_file_music_video
self.cover_size = cover_size
self.cover_format = cover_format
self.remux_mode = remux_mode
self.download_mode = download_mode
self.exclude_tags = (
[i.lower() for i in exclude_tags.split(",")]
if exclude_tags is not None
@@ -97,12 +75,10 @@ class Dl:
self.truncate = None if truncate is not None and truncate < 4 else truncate
self.prefer_hevc = prefer_hevc
self.ask_video_format = ask_video_format
self.disable_music_video_album_skip = disable_music_video_album_skip
self.songs_flavor = "32:ctrp64" if songs_heaac else "28:ctrp256"
if not lrc_only:
self.cdm = Cdm.from_device(Device.load(wvd_location))
self.cdm_session = self.cdm.open()
cookies = MozillaCookieJar(cookies_location)
def setup_session(self):
cookies = MozillaCookieJar(self.cookies_location)
cookies.load(ignore_discard=True, ignore_expires=True)
self.session = requests.Session()
self.session.cookies.update(cookies)
@@ -133,9 +109,13 @@ class Dl:
token = re.search('(?=eyJh)(.*?)(?=")', index_js_page).group(1)
self.session.headers.update({"authorization": f"Bearer {token}"})
self.country = self.session.cookies.get_dict()["itua"]
self.storefront = getattr(gamdl.storefronts, self.country.upper())
self.storefront = STOREFRONT_IDS[self.country.upper()]
def get_download_queue(self, url):
def setup_cdm(self):
self.cdm = Cdm.from_device(Device.load(self.wvd_location))
self.cdm_session = self.cdm.open()
def get_download_queue(self, url: str) -> tuple[str, list[dict]]:
download_queue = []
track_id = url.split("/")[-1].split("i=")[-1].split("&")[0].split("?")[0]
response = self.session.get(
@@ -147,28 +127,15 @@ class Dl:
"ids[music-videos]": track_id,
},
).json()["data"][0]
if response["type"] == "songs" and "playParams" in response["attributes"]:
download_queue.append(response)
if (
response["type"] == "music-videos"
and "playParams" in response["attributes"]
):
if response["type"] in ("songs", "music-videos"):
download_queue.append(response)
if response["type"] in ("albums", "playlists"):
for track in response["relationships"]["tracks"]["data"]:
if "playParams" in track["attributes"]:
if (
track["type"] == "music-videos"
and self.disable_music_video_album_skip
):
download_queue.append(track)
if track["type"] == "songs":
download_queue.append(track)
download_queue.extend(response["relationships"]["tracks"]["data"])
if not download_queue:
raise Exception("Criteria not met")
return download_queue
return response["type"], download_queue
def get_webplayback(self, track_id):
def get_webplayback(self, track_id: str) -> dict:
response = self.session.post(
"https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/webPlayback",
json={
@@ -178,12 +145,12 @@ class Dl:
).json()["songList"][0]
return response
def get_stream_url_song(self, webplayback):
def get_stream_url_song(self, webplayback: dict) -> str:
return next(
i for i in webplayback["assets"] if i["flavor"] == self.songs_flavor
)["URL"]
def get_stream_url_music_video(self, webplayback):
def get_stream_url_music_video(self, webplayback: dict) -> tuple[str, str]:
ydl = YoutubeDL(
{
"allow_unplayable_formats": True,
@@ -233,63 +200,64 @@ class Dl:
)
return stream_url_video, stream_url_audio
def get_encrypted_location_video(self, track_id):
def get_encrypted_location_video(self, track_id: str) -> Path:
return self.temp_path / f"{track_id}_encrypted_video.mp4"
def get_encrypted_location_audio(self, track_id):
def get_encrypted_location_audio(self, track_id: str) -> Path:
return self.temp_path / f"{track_id}_encrypted_audio.m4a"
def get_decrypted_location_video(self, track_id):
def get_decrypted_location_video(self, track_id: str) -> Path:
return self.temp_path / f"{track_id}_decrypted_video.mp4"
def get_decrypted_location_audio(self, track_id):
def get_decrypted_location_audio(self, track_id: str) -> Path:
return self.temp_path / f"{track_id}_decrypted_audio.m4a"
def get_fixed_location(self, track_id, file_extension):
def get_fixed_location(self, track_id: str, file_extension: str) -> Path:
return self.temp_path / f"{track_id}_fixed{file_extension}"
def get_cover_location_song(self, final_location):
def get_cover_location_song(self, final_location: Path) -> Path:
return final_location.parent / f"Cover.{self.cover_format}"
def get_cover_location_music_video(self, final_location):
def get_cover_location_music_video(self, final_location: Path) -> Path:
return final_location.with_suffix(f".{self.cover_format}")
def get_lrc_location(self, final_location):
def get_lrc_location(self, final_location: Path) -> Path:
return final_location.with_suffix(".lrc")
def download(self, encrypted_location, stream_url):
if self.download_mode == "yt-dlp":
params = {
def download_ytdlp(self, encrypted_location: Path, stream_url: str) -> None:
with YoutubeDL(
{
"quiet": True,
"no_warnings": True,
"outtmpl": str(encrypted_location),
"allow_unplayable_formats": True,
"fixup": "never",
}
with YoutubeDL(params) as ydl:
ydl.download(stream_url)
else:
subprocess.run(
[
self.nm3u8dlre_location,
stream_url,
"--binary-merge",
"--no-log",
"--log-level",
"off",
"--ffmpeg-binary-path",
self.ffmpeg_location,
"--save-name",
encrypted_location.stem,
"--save-dir",
encrypted_location.parent,
"--tmp-dir",
encrypted_location.parent,
],
check=True,
)
) as ydl:
ydl.download(stream_url)
def get_license_b64(self, challenge, track_uri, track_id):
def download_nm3u8dlre(self, encrypted_location: Path, stream_url: str) -> None:
subprocess.run(
[
self.nm3u8dlre_location,
stream_url,
"--binary-merge",
"--no-log",
"--log-level",
"off",
"--ffmpeg-binary-path",
self.ffmpeg_location,
"--save-name",
encrypted_location.stem,
"--save-dir",
encrypted_location.parent,
"--tmp-dir",
encrypted_location.parent,
],
check=True,
)
def get_license_b64(self, challenge: str, track_uri: str, track_id: str) -> str:
return self.session.post(
"https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/acquireWebPlaybackLicense",
json={
@@ -302,7 +270,7 @@ class Dl:
},
).json()["license"]
def get_decryption_key_music_video(self, stream_url, track_id):
def get_decryption_key_music_video(self, stream_url: str, track_id: str) -> str:
playlist = m3u8.load(stream_url)
track_uri = next(
i
@@ -319,7 +287,7 @@ class Dl:
i for i in self.cdm.get_keys(self.cdm_session) if i.type == "CONTENT"
).key.hex()
def get_decryption_key_song(self, stream_url, track_id):
def get_decryption_key_song(self, stream_url: str, track_id: str) -> str:
track_uri = m3u8.load(stream_url).keys[0].uri
widevine_pssh_data = WidevinePsshData()
widevine_pssh_data.algorithm = 1
@@ -334,7 +302,7 @@ class Dl:
i for i in self.cdm.get_keys(self.cdm_session) if i.type == "CONTENT"
).key.hex()
def get_synced_lyrics_lrc_timestamp(self, ttml_timestamp):
def get_lyrics_synced_lrc_timestamp(self, ttml_timestamp: str) -> str:
mins = int(ttml_timestamp.split(":")[-2]) if ":" in ttml_timestamp else 0
secs, ms = str(
float(ttml_timestamp.split(":")[-1])
@@ -354,7 +322,7 @@ class Dl:
)
return lrc_timestamp.strftime("%M:%S.%f")[:-4]
def get_lyrics(self, track_id):
def get_lyrics(self, track_id: str) -> tuple[str, str]:
try:
lyrics_ttml = ElementTree.fromstring(
self.session.get(
@@ -363,30 +331,32 @@ class Dl:
)
except:
return None, None
unsynced_lyrics = ""
synced_lyrics = ""
lyrics_unsynced = ""
lyrics_synced = ""
for div in lyrics_ttml.iter("{http://www.w3.org/ns/ttml}div"):
for p in div.iter("{http://www.w3.org/ns/ttml}p"):
if p.attrib.get("begin"):
synced_lyrics += f'[{self.get_synced_lyrics_lrc_timestamp(p.attrib.get("begin"))}]{p.text}\n'
lyrics_synced += f'[{self.get_lyrics_synced_lrc_timestamp(p.attrib.get("begin"))}]{p.text}\n'
if p.text is not None:
unsynced_lyrics += p.text + "\n"
unsynced_lyrics += "\n"
return unsynced_lyrics[:-2], synced_lyrics
lyrics_unsynced += p.text + "\n"
lyrics_unsynced += "\n"
return lyrics_unsynced[:-2], lyrics_synced
def get_cover_url(self, webplayback: dict) -> str:
return (
webplayback["artwork-urls"]["default"]["url"].rsplit("/", 1)[0]
+ f"/{self.cover_size}x{self.cover_size}bb.{self.cover_format}"
)
@functools.lru_cache()
def get_cover(self, cover_url):
def get_cover(self, cover_url: str) -> bytes:
return requests.get(cover_url).content
def get_tags_song(self, webplayback, unsynced_lyrics):
def get_tags_song(self, webplayback: dict, lyrics_unsynced: str) -> dict:
flavor = next(
i for i in webplayback["assets"] if i["flavor"] == self.songs_flavor
)
metadata = flavor["metadata"]
cover_url = flavor["artworkURL"].replace(
"600x600bb.jpg",
f"{self.cover_size}x{self.cover_size}bb.{self.cover_format}",
)
tags = {
"album": metadata["playlistName"],
"album_artist": metadata["playlistArtistName"],
@@ -395,13 +365,21 @@ class Dl:
"artist": metadata["artistName"],
"artist_id": int(metadata["artistId"]),
"artist_sort": metadata["sort-artist"],
"comments": metadata.get("comments"),
"compilation": metadata["compilation"],
"cover_url": cover_url,
"composer": metadata.get("composerName"),
"composer_id": int(metadata.get("composerId"))
if metadata.get("composerId")
else None,
"composer_sort": metadata.get("sort-composer"),
"copyright": metadata.get("copyright"),
"date": metadata.get("releaseDate"),
"disc": metadata["discNumber"],
"disc_total": metadata["discCount"],
"gapless": metadata["gapless"],
"genre": metadata["genre"],
"genre_id": metadata["genreId"],
"lyrics": lyrics_unsynced if lyrics_unsynced else None,
"media_type": 1,
"rating": metadata["explicit"],
"storefront": metadata["s"],
@@ -410,24 +388,11 @@ class Dl:
"title_sort": metadata["sort-name"],
"track": metadata["trackNumber"],
"track_total": metadata["trackCount"],
"xid": metadata.get("xid"),
}
if "comments" in metadata:
tags["comment"] = metadata["comments"]
if "composerId" in metadata:
tags["composer"] = metadata["composerName"]
tags["composer_id"] = int(metadata["composerId"])
tags["composer_sort"] = metadata["sort-composer"]
if "copyright" in metadata:
tags["copyright"] = metadata["copyright"]
if "releaseDate" in metadata:
tags["release_date"] = metadata["releaseDate"]
if "xid" in metadata:
tags["xid"] = metadata["xid"]
if unsynced_lyrics:
tags["lyrics"] = unsynced_lyrics
return tags
def get_tags_music_video(self, track_id):
def get_tags_music_video(self, track_id: str) -> dict:
metadata = requests.get(
f"https://itunes.apple.com/lookup",
params={
@@ -446,20 +411,15 @@ class Dl:
tags = {
"artist": metadata[0]["artistName"],
"artist_id": metadata[0]["artistId"],
"cover_url": metadata[0]["artworkUrl30"].replace(
"30x30bb.jpg",
f"{self.cover_size}x{self.cover_size}bb.{self.cover_format}",
),
"copyright": extra_metadata.get("copyright"),
"date": metadata[0]["releaseDate"],
"genre": metadata[0]["primaryGenreName"],
"genre_id": int(extra_metadata["genres"][0]["genreId"]),
"media_type": 6,
"release_date": metadata[0]["releaseDate"],
"storefront": int(self.storefront.split("-")[0]),
"title": metadata[0]["trackCensoredName"],
"title_id": metadata[0]["trackId"],
}
if "copyright" in extra_metadata:
tags["copyright"] = extra_metadata["copyright"]
if metadata[0]["trackExplicitness"] == "notExplicit":
tags["rating"] = 0
elif metadata[0]["trackExplicitness"] == "explicit":
@@ -476,7 +436,7 @@ class Dl:
tags["track_total"] = metadata[0]["trackCount"]
return tags
def get_sanitized_string(self, dirty_string, is_folder):
def get_sanitized_string(self, dirty_string: str, is_folder: bool) -> str:
dirty_string = re.sub(r'[\\/:*?"<>|;]', "_", dirty_string)
if is_folder:
dirty_string = dirty_string[: self.truncate]
@@ -487,7 +447,7 @@ class Dl:
dirty_string = dirty_string[: self.truncate - 4]
return dirty_string.strip()
def get_final_location(self, tags):
def get_final_location(self, tags: dict) -> Path:
if "album" in tags:
final_location_folder = (
self.template_folder_compilation.split("/")
@@ -518,7 +478,9 @@ class Dl:
*final_location_file
)
def decrypt(self, encrypted_location, decrypted_location, decryption_key):
def decrypt(
self, encrypted_location: Path, decrypted_location: Path, decryption_key: str
) -> None:
subprocess.run(
[
self.mp4decrypt_location,
@@ -529,7 +491,7 @@ class Dl:
],
)
def fixup_song_mp4box(self, decrypted_location, fixed_location):
def fixup_song_mp4box(self, decrypted_location: Path, fixed_location: Path) -> None:
subprocess.run(
[
self.mp4box_location,
@@ -544,7 +506,10 @@ class Dl:
)
def fixup_music_video_mp4box(
self, decrypted_location_audio, decrypted_location_video, fixed_location
self,
decrypted_location_audio: Path,
decrypted_location_video: Path,
fixed_location: Path,
):
subprocess.run(
[
@@ -562,7 +527,9 @@ class Dl:
check=True,
)
def fixup_song_ffmpeg(self, encrypted_location, decryption_key, fixed_location):
def fixup_song_ffmpeg(
self, encrypted_location: Path, decryption_key: str, fixed_location: Path
) -> None:
subprocess.run(
[
self.ffmpeg_location,
@@ -583,8 +550,11 @@ class Dl:
)
def fixup_music_video_ffmpeg(
self, decrypted_location_video, decrypted_location_audio, fixed_location
):
self,
decrypted_location_video: Path,
decrypted_location_audio: Path,
fixed_location: Path,
) -> None:
subprocess.run(
[
self.ffmpeg_location,
@@ -601,12 +571,14 @@ class Dl:
"mp4",
"-c",
"copy",
"-c:s",
"mov_text",
fixed_location,
],
check=True,
)
def apply_tags(self, fixed_location, tags):
def apply_tags(self, fixed_location: Path, tags: dict, cover_url: str) -> None:
mp4_tags = {
v: [tags[k]]
for k, v in MP4_TAGS_MAP.items()
@@ -621,7 +593,7 @@ class Dl:
if "cover" not in self.exclude_tags:
mp4_tags["covr"] = [
MP4Cover(
self.get_cover(tags["cover_url"]),
self.get_cover(cover_url),
imageformat=MP4Cover.FORMAT_JPEG
if self.cover_format == "jpg"
else MP4Cover.FORMAT_PNG,
@@ -642,15 +614,18 @@ class Dl:
mp4.update(mp4_tags)
mp4.save()
def move_to_final_location(self, fixed_location, final_location):
def move_to_final_location(
self, fixed_location: Path, final_location: Path
) -> None:
final_location.parent.mkdir(parents=True, exist_ok=True)
shutil.move(fixed_location, final_location)
def save_cover(self, tags, cover_location):
@functools.lru_cache()
def save_cover(self, cover_location: Path, cover_url: str) -> None:
with open(cover_location, "wb") as f:
f.write(self.get_cover(tags["cover_url"]))
f.write(self.get_cover(cover_url))
def make_lrc(self, lrc_location, synced_lyrics):
def make_lrc(self, lrc_location: Path, lyrics_synced: str) -> None:
lrc_location.parent.mkdir(parents=True, exist_ok=True)
with open(lrc_location, "w", encoding="utf8") as f:
f.write(synced_lyrics)
f.write(lyrics_synced)
-155
View File
@@ -1,155 +0,0 @@
AE = "143481-2,32"
AG = "143540-2,32"
AI = "143538-2,32"
AL = "143575-2,32"
AM = "143524-2,32"
AO = "143564-2,32"
AR = "143505-28,32"
AT = "143445-4,32"
AU = "143460-27,32"
AZ = "143568-2,32"
BB = "143541-2,32"
BE = "143446-2,32"
BF = "143578-2,32"
BG = "143526-2,32"
BH = "143559-2,32"
BJ = "143576-2,32"
BM = "143542-2,32"
BN = "143560-2,32"
BO = "143556-28,32"
BR = "143503-15,32"
BS = "143539-2,32"
BT = "143577-2,32"
BW = "143525-2,32"
BY = "143565-2,32"
BZ = "143555-2,32"
CA = "143455-6,32"
CG = "143582-2,32"
CH = "143459-57,32"
CL = "143483-28,32"
CN = "143465-19,32"
CO = "143501-28,32"
CR = "143495-28,32"
CV = "143580-2,32"
CY = "143557-2,32"
CZ = "143489-2,32"
DE = "143443-4,32"
DK = "143458-2,32"
DM = "143545-2,32"
DO = "143508-28,32"
DZ = "143563-2,32"
EC = "143509-28,32"
EE = "143518-2,32"
EG = "143516-2,32"
ES = "143454-8,32"
FI = "143447-2,32"
FJ = "143583-2,32"
FM = "143591-2,32"
FR = "143442-3,32"
GB = "143444-2,32"
GD = "143546-2,32"
GH = "143573-2,32"
GM = "143584-2,32"
GR = "143448-2,32"
GT = "143504-28,32"
GW = "143585-2,32"
GY = "143553-2,32"
HK = "143463-45,32"
HN = "143510-28,32"
HR = "143494-2,32"
HU = "143482-2,32"
ID = "143476-2,32"
IE = "143449-2,32"
IL = "143491-2,32"
IN = "143467-2,32"
IS = "143558-2,32"
IT = "143450-7,32"
JM = "143511-2,32"
JO = "143528-2,32"
JP = "143462-9,32"
KE = "143529-2,32"
KG = "143586-2,32"
KH = "143579-2,32"
KN = "143548-2,32"
KR = "143466-13,32"
KW = "143493-2,32"
KY = "143544-2,32"
KZ = "143517-2,32"
LA = "143587-2,32"
LB = "143497-2,32"
LC = "143549-2,32"
LK = "143486-2,32"
LR = "143588-2,32"
LT = "143520-2,32"
LU = "143451-2,32"
LV = "143519-2,32"
MD = "143523-2,32"
MG = "143531-2,32"
MK = "143530-2,32"
ML = "143532-2,32"
MN = "143592-2,32"
MO = "143515-45,32"
MR = "143590-2,32"
MS = "143547-2,32"
MT = "143521-2,32"
MU = "143533-2,32"
MW = "143589-2,32"
MX = "143468-28,32"
MY = "143473-2,32"
MZ = "143593-2,32"
NA = "143594-2,32"
NE = "143534-2,32"
NG = "143561-2,32"
NI = "143512-28,32"
NL = "143452-10,32"
NO = "143457-2,32"
NP = "143484-2,32"
NZ = "143461-27,32"
OM = "143562-2,32"
PA = "143485-28,32"
PE = "143507-28,32"
PG = "143597-2,32"
PH = "143474-2,32"
PK = "143477-2,32"
PL = "143478-2,32"
PT = "143453-24,32"
PW = "143595-2,32"
PY = "143513-28,32"
QA = "143498-2,32"
RO = "143487-2,32"
RU = "143469-16,32"
SA = "143479-2,32"
SB = "143601-2,32"
SC = "143599-2,32"
SE = "143456-17,32"
SG = "143464-19,32"
SI = "143499-2,32"
SK = "143496-2,32"
SL = "143600-2,32"
SN = "143535-2,32"
SR = "143554-2,32"
ST = "143598-2,32"
SV = "143506-28,32"
SZ = "143602-2,32"
TC = "143552-2,32"
TD = "143581-2,32"
TH = "143475-2,32"
TJ = "143603-2,32"
TM = "143604-2,32"
TN = "143536-2,32"
TR = "143480-2,32"
TT = "143551-2,32"
TW = "143470-18,32"
TZ = "143572-2,32"
UA = "143492-2,32"
UG = "143537-2,32"
US = "143441-1,32"
UY = "143514-2,32"
UZ = "143566-2,32"
VC = "143550-2,32"
VE = "143502-28,32"
VG = "143543-2,32"
VN = "143471-2,32"
YE = "143571-2,32"
ZA = "143472-2,32"
ZW = "143605-2,32"