Refactor template options and add playlist file support

This commit is contained in:
Rafael Moraes
2025-10-22 17:48:36 -03:00
parent e104ee72a6
commit 2e6b3dc6c1
6 changed files with 144 additions and 75 deletions
+32 -32
View File
@@ -287,51 +287,51 @@ def make_sync(func):
help="Cover format.",
)
@click.option(
"--template-folder-album",
"--album-folder-template",
type=str,
default=base_downloader_sig.parameters["template_folder_album"].default,
default=base_downloader_sig.parameters["album_folder_template"].default,
help="Template folder for tracks that are part of an album.",
)
@click.option(
"--template-folder-compilation",
"--compilation-folder-template",
type=str,
default=base_downloader_sig.parameters["template_folder_compilation"].default,
default=base_downloader_sig.parameters["compilation_folder_template"].default,
help="Template folder for tracks that are part of a compilation album.",
)
@click.option(
"--template-file-single-disc",
"--single-disc-folder-template",
type=str,
default=base_downloader_sig.parameters["template_file_single_disc"].default,
default=base_downloader_sig.parameters["single_disc_folder_template"].default,
help="Template file for the tracks that are part of a single-disc album.",
)
@click.option(
"--template-file-multi-disc",
"--multi-disc-folder-template",
type=str,
default=base_downloader_sig.parameters["template_file_multi_disc"].default,
default=base_downloader_sig.parameters["multi_disc_folder_template"].default,
help="Template file for the tracks that are part of a multi-disc album.",
)
@click.option(
"--template-folder-no-album",
"--no-album-folder-template",
type=str,
default=base_downloader_sig.parameters["template_folder_no_album"].default,
default=base_downloader_sig.parameters["no_album_folder_template"].default,
help="Template folder for the tracks that are not part of an album.",
)
@click.option(
"--template-file-no-album",
"--no-album-file-template",
type=str,
default=base_downloader_sig.parameters["template_file_no_album"].default,
default=base_downloader_sig.parameters["no_album_file_template"].default,
help="Template file for the tracks that are not part of an album.",
)
@click.option(
"--template-file-playlist",
"--playlist-file-template",
type=str,
default=base_downloader_sig.parameters["template_file_playlist"].default,
default=base_downloader_sig.parameters["playlist_file_template"].default,
help="Template file for the M3U8 playlist.",
)
@click.option(
"--template-date",
"--date-tag-template",
type=str,
default=base_downloader_sig.parameters["template_date"].default,
default=base_downloader_sig.parameters["date_tag_template"].default,
help="Date tag template.",
)
@click.option(
@@ -440,14 +440,14 @@ async def main(
download_mode: DownloadMode,
remux_mode: RemuxMode,
cover_format: CoverFormat,
template_folder_album: str,
template_folder_compilation: str,
template_file_single_disc: str,
template_file_multi_disc: str,
template_folder_no_album: str,
template_file_no_album: str,
template_file_playlist: str,
template_date: str,
album_folder_template: str,
compilation_folder_template: str,
single_disc_folder_template: str,
multi_disc_folder_template: str,
no_album_folder_template: str,
no_album_file_template: str,
playlist_file_template: str,
date_tag_template: str,
exclude_tags: list[str],
cover_size: int,
truncate: int,
@@ -511,14 +511,14 @@ async def main(
download_mode=download_mode,
remux_mode=remux_mode,
cover_format=cover_format,
template_folder_album=template_folder_album,
template_folder_compilation=template_folder_compilation,
template_file_single_disc=template_file_single_disc,
template_file_multi_disc=template_file_multi_disc,
template_folder_no_album=template_folder_no_album,
template_file_no_album=template_file_no_album,
template_file_playlist=template_file_playlist,
template_date=template_date,
album_folder_template=album_folder_template,
compilation_folder_template=compilation_folder_template,
single_disc_folder_template=single_disc_folder_template,
multi_disc_folder_template=multi_disc_folder_template,
no_album_folder_template=no_album_folder_template,
no_album_file_template=no_album_file_template,
playlist_file_template=playlist_file_template,
date_tag_template=date_tag_template,
exclude_tags=exclude_tags,
cover_size=cover_size,
truncate=truncate,
+5 -3
View File
@@ -9,9 +9,11 @@ TEMP_PATH_TEMPLATE = "gamdl_temp_{}"
ILLEGAL_CHARS_RE = r'[\\/:*?"<>|;]'
ILLEGAL_CHAR_REPLACEMENT = "_"
SONG_MEDIA_TYPE = {"songs", "library-songs"}
MUSIC_VIDEO_MEDIA_TYPE = {"music-videos", "library-music-videos"}
UPLOADED_VIDEO_MEDIA_TYPE = {"uploaded-videos"}
SONG_MEDIA_TYPE = {"song", "songs", "library-songs"}
ALBUM_MEDIA_TYPE = {"album", "albums", "library-albums"}
MUSIC_VIDEO_MEDIA_TYPE = {"music-video", "music-videos", "library-music-videos"}
UPLOADED_VIDEO_MEDIA_TYPE = {"post", "uploaded-videos"}
PLAYLIST_MEDIA_TYPE = {"playlist", "playlists", "library-playlists"}
VALID_URL_PATTERN = re.compile(
r"https://music\.apple\.com"
+22 -10
View File
@@ -3,7 +3,9 @@ from pathlib import Path
from ..utils import safe_gather
from .constants import (
ALBUM_MEDIA_TYPE,
MUSIC_VIDEO_MEDIA_TYPE,
PLAYLIST_MEDIA_TYPE,
SONG_MEDIA_TYPE,
UPLOADED_VIDEO_MEDIA_TYPE,
VALID_URL_PATTERN,
@@ -12,10 +14,7 @@ from .downloader_base import AppleMusicBaseDownloader
from .downloader_music_video import AppleMusicMusicVideoDownloader
from .downloader_song import AppleMusicSongDownloader
from .downloader_uploaded_video import AppleMusicUploadedVideoDownloader
from .exceptions import (
MediaFormatNotAvailableError,
MediaNotStreamableError,
)
from .exceptions import MediaFormatNotAvailableError, MediaNotStreamableError
from .types import DownloadItem, UrlInfo
@@ -35,6 +34,7 @@ class AppleMusicDownloader:
async def get_single_download_item(
self,
media_metadata: dict,
playlist_metadata: dict = None,
) -> DownloadItem:
download_item = None
@@ -72,6 +72,11 @@ class AppleMusicDownloader:
asyncio.create_task(
self.song_downloader.get_download_item(
media_metadata,
(
collection_metadata
if collection_metadata["type"] in PLAYLIST_MEDIA_TYPE
else None
),
)
)
for media_metadata in collection_metadata["relationships"]["tracks"]["data"]
@@ -110,7 +115,7 @@ class AppleMusicDownloader:
if url_type == "artist":
pass
if url_type == "song":
if url_type in SONG_MEDIA_TYPE:
song_respose = await self.base_downloader.apple_music_api.get_song(id)
if song_respose is None:
@@ -120,7 +125,7 @@ class AppleMusicDownloader:
await self.get_single_download_item(song_respose["data"][0])
)
if url_type in {"album", "albums"}:
if url_type in ALBUM_MEDIA_TYPE:
if is_library:
album_response = (
await self.base_downloader.apple_music_api.get_library_album(id)
@@ -137,7 +142,7 @@ class AppleMusicDownloader:
album_response["data"][0],
)
if url_type == "playlist":
if url_type in PLAYLIST_MEDIA_TYPE:
if is_library:
playlist_response = (
await self.base_downloader.apple_music_api.get_library_playlist(id)
@@ -154,7 +159,7 @@ class AppleMusicDownloader:
playlist_response["data"][0],
)
if url_type == "music-video":
if url_type in MUSIC_VIDEO_MEDIA_TYPE:
music_video_response = (
await self.base_downloader.apple_music_api.get_music_video(id)
)
@@ -166,7 +171,7 @@ class AppleMusicDownloader:
await self.get_single_download_item(music_video_response["data"][0])
)
if url_type == "post":
if url_type in UPLOADED_VIDEO_MEDIA_TYPE:
uploaded_video = (
await self.base_downloader.apple_music_api.get_uploaded_video(id)
)
@@ -180,7 +185,7 @@ class AppleMusicDownloader:
return download_items
async def download(self, download_item: DownloadItem) -> None:
async def download(self, download_item: DownloadItem | Exception) -> None:
try:
if isinstance(download_item, Exception):
raise download_item
@@ -259,3 +264,10 @@ class AppleMusicDownloader:
download_item.lyrics.synced,
download_item.synced_lyrics_path,
)
if download_item.playlist_tags and self.base_downloader.save_playlist:
self.base_downloader.update_playlist_file(
download_item.playlist_file_path,
download_item.final_path,
download_item.playlist_tags.playlist_track,
)
+80 -29
View File
@@ -37,8 +37,6 @@ class AppleMusicBaseDownloader:
overwrite: bool = False,
save_cover: bool = False,
save_playlist: bool = False,
no_synced_lyrics: bool = False,
synced_lyrics_only: bool = False,
nm3u8dlre_path: str = "N_m3u8DL-RE",
mp4decrypt_path: str = "mp4decrypt",
ffmpeg_path: str = "ffmpeg",
@@ -46,14 +44,14 @@ class AppleMusicBaseDownloader:
download_mode: DownloadMode = DownloadMode.YTDLP,
remux_mode: RemuxMode = RemuxMode.FFMPEG,
cover_format: CoverFormat = CoverFormat.JPG,
template_folder_album: str = "{album_artist}/{album}",
template_folder_compilation: str = "Compilations/{album}",
template_file_single_disc: str = "{track:02d} {title}",
template_file_multi_disc: str = "{disc}-{track:02d} {title}",
template_folder_no_album: str = "{artist}/Unknown Album",
template_file_no_album: str = "{title}",
template_file_playlist: str = "Playlists/{playlist_artist}/{playlist_title}",
template_date: str = "%Y-%m-%dT%H:%M:%SZ",
album_folder_template: str = "{album_artist}/{album}",
compilation_folder_template: str = "Compilations/{album}",
single_disc_folder_template: str = "{track:02d} {title}",
multi_disc_folder_template: str = "{disc}-{track:02d} {title}",
no_album_folder_template: str = "{artist}/Unknown Album",
no_album_file_template: str = "{title}",
playlist_file_template: str = "Playlists/{playlist_artist}/{playlist_title}",
date_tag_template: str = "%Y-%m-%dT%H:%M:%SZ",
exclude_tags: list[str] = None,
cover_size: int = 1200,
truncate: int = None,
@@ -68,8 +66,6 @@ class AppleMusicBaseDownloader:
self.overwrite = overwrite
self.save_cover = save_cover
self.save_playlist = save_playlist
self.no_synced_lyrics = no_synced_lyrics
self.synced_lyrics_only = synced_lyrics_only
self.nm3u8dlre_path = nm3u8dlre_path
self.mp4decrypt_path = mp4decrypt_path
self.ffmpeg_path = ffmpeg_path
@@ -77,14 +73,14 @@ class AppleMusicBaseDownloader:
self.download_mode = download_mode
self.remux_mode = remux_mode
self.cover_format = cover_format
self.template_folder_album = template_folder_album
self.template_folder_compilation = template_folder_compilation
self.template_file_single_disc = template_file_single_disc
self.template_file_multi_disc = template_file_multi_disc
self.template_folder_no_album = template_folder_no_album
self.template_file_no_album = template_file_no_album
self.template_file_playlist = template_file_playlist
self.template_date = template_date
self.album_folder_template = album_folder_template
self.compilation_folder_template = compilation_folder_template
self.single_disc_folder_template = single_disc_folder_template
self.multi_disc_folder_template = multi_disc_folder_template
self.no_album_folder_template = no_album_folder_template
self.no_album_file_template = no_album_file_template
self.playlist_file_template = playlist_file_template
self.date_tag_template = date_tag_template
self.exclude_tags = exclude_tags
self.cover_size = cover_size
self.truncate = truncate
@@ -98,7 +94,7 @@ class AppleMusicBaseDownloader:
self._setup_interface()
def _setup_binary_paths(self):
self.full_n3u8dlre_path = shutil.which(self.nm3u8dlre_path)
self.full_nm3u8dlre_path = shutil.which(self.nm3u8dlre_path)
self.full_mp4decrypt_path = shutil.which(self.mp4decrypt_path)
self.full_ffmpeg_path = shutil.which(self.ffmpeg_path)
self.full_mp4box_path = shutil.which(self.mp4box_path)
@@ -208,18 +204,18 @@ class AppleMusicBaseDownloader:
) -> str:
if tags.album is not None:
template_folder = (
self.template_folder_compilation.split("/")
self.compilation_folder_template.split("/")
if tags.compilation
else self.template_folder_album.split("/")
else self.album_folder_template.split("/")
)
template_file = (
self.template_file_multi_disc.split("/")
self.multi_disc_folder_template.split("/")
if tags.disc_total > 1
else self.template_file_single_disc.split("/")
else self.single_disc_folder_template.split("/")
)
else:
template_folder = self.template_folder_no_album.split("/")
template_file = self.template_file_no_album.split("/")
template_folder = self.no_album_folder_template.split("/")
template_file = self.no_album_file_template.split("/")
template_final = template_folder + template_file
@@ -304,7 +300,7 @@ class AppleMusicBaseDownloader:
download_path_obj.parent.mkdir(parents=True, exist_ok=True)
await async_subprocess(
self.full_n3u8dlre_path,
self.full_nm3u8dlre_path,
stream_url,
"--binary-merge",
"--no-log",
@@ -336,7 +332,7 @@ class AppleMusicBaseDownloader:
if v is not None and k not in exclude_tags
}
)
mp4_tags = filtered_tags.as_mp4_tags(self.template_date)
mp4_tags = filtered_tags.as_mp4_tags(self.date_tag_template)
skip_tagging = "all" in exclude_tags
mp4 = MP4(media_path)
@@ -382,6 +378,61 @@ class AppleMusicBaseDownloader:
Path(cover_path).parent.mkdir(parents=True, exist_ok=True)
Path(cover_path).write_bytes(cover_bytes)
def get_playlist_file_path(
self,
tags: PlaylistTags,
) -> str:
template_file = self.playlist_file_template.split("/")
tags_dict = tags.__dict__.copy()
return str(
Path(
self.output_path,
*[
self.get_sanitized_string(i.format(**tags_dict), True)
for i in template_file[0:-1]
],
*[
self.get_sanitized_string(
template_file[-1].format(**tags_dict), False
)
+ ".m3u8"
],
)
)
def update_playlist_file(
self,
playlist_file_path: str,
final_path: str,
playlist_track: int,
) -> None:
playlist_file_path_obj = Path(playlist_file_path)
final_path_obj = Path(final_path)
output_dir_obj = Path(self.output_path)
playlist_file_path_obj.parent.mkdir(parents=True, exist_ok=True)
playlist_file_path_parent_parts_len = len(playlist_file_path_obj.parent.parts)
output_path_parts_len = len(output_dir_obj.parts)
final_path_relative = Path(
("../" * (playlist_file_path_parent_parts_len - output_path_parts_len)),
*final_path_obj.parts[output_path_parts_len:],
)
playlist_file_lines = (
playlist_file_path_obj.open("r", encoding="utf8").readlines()
if playlist_file_path_obj.exists()
else []
)
if len(playlist_file_lines) < playlist_track:
playlist_file_lines.extend(
"\n" for _ in range(playlist_track - len(playlist_file_lines))
)
playlist_file_lines[playlist_track - 1] = final_path_relative.as_posix() + "\n"
with playlist_file_path_obj.open("w", encoding="utf8") as playlist_file:
playlist_file.writelines(playlist_file_lines)
def cleanup_temp(self, random_uuid: str) -> None:
temp_folder = Path(self.temp_path) / TEMP_PATH_TEMPLATE.format(random_uuid)
if temp_folder.exists():
+4 -1
View File
@@ -60,6 +60,9 @@ class AppleMusicSongDownloader:
playlist_metadata,
song_metadata,
)
download_item.playlist_file_path = self.downloader.get_playlist_file_path(
download_item.playlist_tags,
)
if self.codec.is_legacy():
download_item.stream_info = (
@@ -106,7 +109,7 @@ class AppleMusicSongDownloader:
download_item.final_path = self.downloader.get_final_path(
download_item.media_tags,
".m4a",
playlist_metadata,
download_item.playlist_tags,
)
download_item.synced_lyrics_path = self.get_lyrics_synced_path(
download_item.final_path,
+1
View File
@@ -21,6 +21,7 @@ class DownloadItem:
cover_url_template: str = None
staged_path: str = None
final_path: str = None
playlist_file_path: str = None
synced_lyrics_path: str = None
cover_path: str = None