Merge pull request #282 from exislow/108-feature-adding-option-for-downloading-playlists-as-m3u

108 feature adding option for downloading playlists as m3u
This commit is contained in:
exislow
2024-12-16 14:43:30 +01:00
committed by GitHub
7 changed files with 201 additions and 51 deletions
+2
View File
@@ -75,6 +75,8 @@ If you like to have the GUI version only, have a look at the
- Adjustable audio and video download quality. - Adjustable audio and video download quality.
- FLAC extraction from MP4 containers - FLAC extraction from MP4 containers
- Lyrics and album art / cover download - Lyrics and album art / cover download
- Creates playlist files
- Can symlink tracks instead of having several copies, if added to different playlist
## ▶️ Getting started with development ## ▶️ Getting started with development
+2
View File
@@ -9,6 +9,8 @@ COVER_NAME: str = "cover.jpg"
BLOCK_SIZE: int = 4096 BLOCK_SIZE: int = 4096
BLOCKS: int = 1024 BLOCKS: int = 1024
CHUNK_SIZE: int = BLOCK_SIZE * BLOCKS CHUNK_SIZE: int = BLOCK_SIZE * BLOCKS
PLAYLIST_EXTENSION: str = ".m3u"
PLAYLIST_PREFIX: str = "_"
class QualityVideo(StrEnum): class QualityVideo(StrEnum):
+2
View File
@@ -182,6 +182,8 @@ class DialogPreferences(QtWidgets.QDialog):
"metadata_cover_embed", "metadata_cover_embed",
"cover_album_file", "cover_album_file",
"skip_existing", "skip_existing",
"symlink_to_track",
"playlist_create",
] ]
def gui_populate(self): def gui_populate(self):
+132 -45
View File
@@ -23,6 +23,8 @@ from tidal_dl_ng.constants import (
CHUNK_SIZE, CHUNK_SIZE,
COVER_NAME, COVER_NAME,
EXTENSION_LYRICS, EXTENSION_LYRICS,
PLAYLIST_EXTENSION,
PLAYLIST_PREFIX,
REQUESTS_TIMEOUT_SEC, REQUESTS_TIMEOUT_SEC,
MediaType, MediaType,
QualityVideo, QualityVideo,
@@ -299,7 +301,7 @@ class Download:
# Create file name and path # Create file name and path
file_extension_dummy: str = AudioExtensions.FLAC file_extension_dummy: str = AudioExtensions.FLAC
file_name_relative = format_path_media(file_template, media, self.settings.data.album_track_num_pad_min) file_name_relative: str = format_path_media(file_template, media, self.settings.data.album_track_num_pad_min)
path_media_dst: pathlib.Path = ( path_media_dst: pathlib.Path = (
(pathlib.Path(self.path_base).expanduser() / (file_name_relative + file_extension_dummy)) (pathlib.Path(self.path_base).expanduser() / (file_name_relative + file_extension_dummy))
.resolve() .resolve()
@@ -310,8 +312,21 @@ class Download:
path_media_dst = pathlib.Path(path_file_sanitize(str(path_media_dst), adapt=True)) path_media_dst = pathlib.Path(path_file_sanitize(str(path_media_dst), adapt=True))
# Compute if and how downloads need to be skipped. # Compute if and how downloads need to be skipped.
skip_download: bool = False
if self.skip_existing: if self.skip_existing:
skip_file: bool = check_file_exists(path_media_dst, extension_ignore=True) skip_file: bool = check_file_exists(path_media_dst, extension_ignore=True)
if self.settings.data.symlink_to_track and not isinstance(media, Video):
# Compute symlink tracks path, sanitize and check if file exists
file_name_symlink_relative: str = format_path_media(self.settings.data.format_track, media)
path_media_symlink_dst: pathlib.Path = (
(pathlib.Path(self.path_base).expanduser() / (file_name_symlink_relative + file_extension_dummy))
.resolve()
.absolute()
)
path_media_symlink_dst = pathlib.Path(path_file_sanitize(str(path_media_symlink_dst), adapt=True))
skip_download = check_file_exists(path_media_symlink_dst, extension_ignore=True)
else: else:
skip_file: bool = False skip_file: bool = False
@@ -353,56 +368,64 @@ class Download:
elif isinstance(media, Video): elif isinstance(media, Video):
file_extension = AudioExtensions.MP4 if self.settings.data.video_convert_mp4 else VideoExtensions.TS file_extension = AudioExtensions.MP4 if self.settings.data.video_convert_mp4 else VideoExtensions.TS
# Compute file name and create destination directory # Compute file name, sanitize once again and create destination directory
path_media_dst = path_media_dst.with_suffix(file_extension) path_media_dst = path_media_dst.with_suffix(file_extension)
path_media_dst = pathlib.Path(path_file_sanitize(str(path_media_dst), adapt=True))
os.makedirs(path_media_dst.parent, exist_ok=True) os.makedirs(path_media_dst.parent, exist_ok=True)
# Create a temp directory and file. if not skip_download:
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp_path_dir: # Create a temp directory and file.
tmp_path_file: pathlib.Path = pathlib.Path(tmp_path_dir) / str(uuid4()) with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp_path_dir:
tmp_path_file: pathlib.Path = pathlib.Path(tmp_path_dir) / str(uuid4())
# Create empty file # Create empty file
tmp_path_file.touch() tmp_path_file.touch()
# Download media. # Download media.
result_download, tmp_path_file = self._download( result_download, tmp_path_file = self._download(
media=media, stream_manifest=stream_manifest, path_file=tmp_path_file media=media, stream_manifest=stream_manifest, path_file=tmp_path_file
)
if result_download:
# Convert video from TS to MP4
if isinstance(media, Video) and self.settings.data.video_convert_mp4:
# Convert `*.ts` file to `*.mp4` using ffmpeg
tmp_path_file = self._video_convert(tmp_path_file)
# Extract FLAC from MP4 container using ffmpeg
if isinstance(media, Track) and self.settings.data.extract_flac and do_flac_extract:
tmp_path_file = self._extract_flac(tmp_path_file)
tmp_path_lyrics: pathlib.Path | None = None
tmp_path_cover: pathlib.Path | None = None
# Write metadata to file.
if not isinstance(media, Video):
result_metadata, tmp_path_lyrics, tmp_path_cover = self.metadata_write(
media, tmp_path_file, is_parent_album, media_stream
)
# Move lyrics file
if self.settings.data.lyrics_file and not isinstance(media, Video) and tmp_path_lyrics:
self._move_lyrics(tmp_path_lyrics, path_media_dst)
# Move cover file
# TODO: Cover is downloaded with every track of the album. Needs refactoring, so cover is only
# downloaded for an album once.
if self.settings.data.cover_album_file and tmp_path_cover:
self._move_cover(tmp_path_cover, path_media_dst)
self.fn_logger.info(f"Downloaded item '{name_builder_item(media)}'.")
# Move final file to the configured destination directory.
shutil.move(tmp_path_file, path_media_dst)
# If files needs to be symlinked, do postprocessing here.
if self.settings.data.symlink_to_track and not isinstance(media, Video):
path_media_symlink_dst: pathlib.Path = self.media_move_and_symlink(
media, path_media_dst, file_extension
) )
if result_download:
# Convert video from TS to MP4
if isinstance(media, Video) and self.settings.data.video_convert_mp4:
# Convert `*.ts` file to `*.mp4` using ffmpeg
tmp_path_file = self._video_convert(tmp_path_file)
# Extract FLAC from MP4 container using ffmpeg
if isinstance(media, Track) and self.settings.data.extract_flac and do_flac_extract:
tmp_path_file = self._extract_flac(tmp_path_file)
tmp_path_lyrics: pathlib.Path | None = None
tmp_path_cover: pathlib.Path | None = None
# Write metadata to file.
if not isinstance(media, Video):
result_metadata, tmp_path_lyrics, tmp_path_cover = self.metadata_write(
media, tmp_path_file, is_parent_album, media_stream
)
# Move lyrics file
if self.settings.data.lyrics_file and not isinstance(media, Video) and tmp_path_lyrics:
self._move_lyrics(tmp_path_lyrics, path_media_dst)
# Move cover file
# TODO: Cover is downloaded with every track of the album. Needs refactoring, so cover is only
# downloaded for an album once.
if self.settings.data.cover_album_file and tmp_path_cover:
self._move_cover(tmp_path_cover, path_media_dst)
self.fn_logger.info(f"Downloaded item '{name_builder_item(media)}'.")
# Move final file to the configured destination directory.
shutil.move(tmp_path_file, path_media_dst)
if quality_audio: if quality_audio:
# Set quality back to the global user value # Set quality back to the global user value
self.adjust_quality_audio(quality_audio_old) self.adjust_quality_audio(quality_audio_old)
@@ -430,6 +453,32 @@ class Download:
return status_download, path_media_dst return status_download, path_media_dst
def media_move_and_symlink(
self, media: Track | Video, path_media_src: pathlib.Path, file_extension: str
) -> pathlib.Path:
# Compute tracks path, sanitize and ensure path exists
file_name_relative: str = format_path_media(self.settings.data.format_track, media)
path_media_dst: pathlib.Path = (
(pathlib.Path(self.path_base).expanduser() / (file_name_relative + file_extension)).resolve().absolute()
)
path_media_dst = pathlib.Path(path_file_sanitize(str(path_media_dst), adapt=True))
os.makedirs(path_media_dst.parent, exist_ok=True)
# Move item and symlink it
if path_media_dst != path_media_src:
if self.skip_existing:
skip_file: bool = check_file_exists(path_media_dst, extension_ignore=True)
else:
skip_file: bool = False
if not skip_file:
shutil.move(path_media_src, path_media_dst)
os.symlink(path_media_dst, path_media_src)
return path_media_dst
def adjust_quality_audio(self, quality) -> Quality: def adjust_quality_audio(self, quality) -> Quality:
# Save original quality settings # Save original quality settings
quality_old: Quality = self.session.audio_quality quality_old: Quality = self.session.audio_quality
@@ -596,7 +645,7 @@ class Download:
raise MediaMissing raise MediaMissing
# Create file name and path # Create file name and path
file_name_relative = format_path_media(file_template, media, self.settings.data.album_track_num_pad_min) file_name_relative: str = format_path_media(file_template, media, self.settings.data.album_track_num_pad_min)
# Get the name of the list and check, if videos should be included. # Get the name of the list and check, if videos should be included.
list_media_name: str = name_builder_title(media) list_media_name: str = name_builder_title(media)
@@ -618,6 +667,7 @@ class Download:
) )
is_album: bool = isinstance(media, Album) is_album: bool = isinstance(media, Album)
result_dirs: [pathlib.Path] = []
# Iterate through list items # Iterate through list items
while not self.progress.finished: while not self.progress.finished:
@@ -639,6 +689,7 @@ class Download:
for future in futures.as_completed(l_futures): for future in futures.as_completed(l_futures):
# Retrieve result # Retrieve result
status, result_path_file = future.result() status, result_path_file = future.result()
result_dirs.append(result_path_file.parent)
# Advance progress bar. # Advance progress bar.
self.progress.advance(p_task1) self.progress.advance(p_task1)
@@ -646,8 +697,44 @@ class Download:
if not progress_stdout: if not progress_stdout:
self.progress_gui.list_item.emit(self.progress.tasks[p_task1].percentage) self.progress_gui.list_item.emit(self.progress.tasks[p_task1].percentage)
# Create playlist file
if self.settings.data.playlist_create:
self.playlist_populate(set(result_dirs), list_media_name, is_album)
self.fn_logger.info(f"Finished list '{list_media_name}'.") self.fn_logger.info(f"Finished list '{list_media_name}'.")
def playlist_populate(self, dirs_scoped: [pathlib.Path], name_list: str, is_album: bool) -> [pathlib.Path]:
result: [pathlib.Path] = []
# For each dir, which contains tracks
for dir_scoped in dirs_scoped:
# Sanitize final playlist name to fit into OS boundaries.
path_playlist = dir_scoped / (PLAYLIST_PREFIX + name_list + PLAYLIST_EXTENSION)
path_playlist = pathlib.Path(path_file_sanitize(path_playlist, adapt=True))
# Get all tracks in the directory
path_tracks: [pathlib.Path] = []
for extension_audio in AudioExtensions:
path_tracks = path_tracks + list(dir_scoped.glob(f"*{extension_audio!s}"))
# If it is not an album sort by modification time
if not is_album:
path_tracks.sort(key=lambda x: os.path.getmtime(x))
# Write data to m3u file
with path_playlist.open(mode="w", encoding="utf-8") as f:
for path_track in path_tracks:
# If its a symlink write the relative file path to the actual track into the playlist file
if path_track.is_symlink():
media_file_target = path_track.resolve().relative_to(path_track.parent, walk_up=True)
f.write(str(media_file_target) + os.linesep)
result.append(path_playlist)
return result
def _video_convert(self, path_file: pathlib.Path) -> pathlib.Path: def _video_convert(self, path_file: pathlib.Path) -> pathlib.Path:
path_file_out: pathlib.Path = path_file.with_suffix(AudioExtensions.MP4) path_file_out: pathlib.Path = path_file.with_suffix(AudioExtensions.MP4)
result, _ = ( result, _ = (
+7
View File
@@ -42,6 +42,8 @@ class Settings:
download_delay_sec_max: float = 5.0 download_delay_sec_max: float = 5.0
album_track_num_pad_min: int = 1 album_track_num_pad_min: int = 1
downloads_concurrent_max: int = 3 downloads_concurrent_max: int = 3
symlink_to_track: bool = False
playlist_create: bool = False
@dataclass_json @dataclass_json
@@ -90,6 +92,11 @@ class HelpSettings:
"Minimum length of the album track count, will be padded with zeroes (0). To disable padding set this to 1." "Minimum length of the album track count, will be padded with zeroes (0). To disable padding set this to 1."
) )
downloads_concurrent_max: str = "Maximum concurrent number of downloads (threads)." downloads_concurrent_max: str = "Maximum concurrent number of downloads (threads)."
symlink_to_track: str = (
"If enabled the tracks of albums, playlists and mixes will be downloaded to the track directory but symlinked "
"accordingly."
)
playlist_create: str = "Creates a '_playlist.m3u8' file for downloaded albums, playlists and mixes."
@dataclass_json @dataclass_json
+28 -4
View File
@@ -27,7 +27,7 @@ class Ui_DialogSettings:
def setupUi(self, DialogSettings): def setupUi(self, DialogSettings):
if not DialogSettings.objectName(): if not DialogSettings.objectName():
DialogSettings.setObjectName("DialogSettings") DialogSettings.setObjectName("DialogSettings")
DialogSettings.resize(640, 767) DialogSettings.resize(640, 800)
sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
sizePolicy.setHorizontalStretch(100) sizePolicy.setHorizontalStretch(100)
sizePolicy.setVerticalStretch(100) sizePolicy.setVerticalStretch(100)
@@ -165,13 +165,35 @@ class Ui_DialogSettings:
self.horizontalLayout.addLayout(self.lv_flag_skip_existing) self.horizontalLayout.addLayout(self.lv_flag_skip_existing)
self.verticalLayout_3 = QVBoxLayout() self.lv_symlink_to_track = QVBoxLayout()
self.verticalLayout_3.setObjectName("verticalLayout_3") self.lv_symlink_to_track.setObjectName("lv_symlink_to_track")
self.cb_symlink_to_track = QCheckBox(self.gb_flags)
self.cb_symlink_to_track.setObjectName("cb_symlink_to_track")
self.horizontalLayout.addLayout(self.verticalLayout_3) self.lv_symlink_to_track.addWidget(self.cb_symlink_to_track)
self.horizontalLayout.addLayout(self.lv_symlink_to_track)
self.lv_flags.addLayout(self.horizontalLayout) self.lv_flags.addLayout(self.horizontalLayout)
self.horizontalLayout_12 = QHBoxLayout()
self.horizontalLayout_12.setObjectName("horizontalLayout_12")
self.lv_playlist_create = QVBoxLayout()
self.lv_playlist_create.setObjectName("lv_playlist_create")
self.cb_playlist_create = QCheckBox(self.gb_flags)
self.cb_playlist_create.setObjectName("cb_playlist_create")
self.lv_playlist_create.addWidget(self.cb_playlist_create)
self.horizontalLayout_12.addLayout(self.lv_playlist_create)
self.verticalLayout_4 = QVBoxLayout()
self.verticalLayout_4.setObjectName("verticalLayout_4")
self.horizontalLayout_12.addLayout(self.verticalLayout_4)
self.lv_flags.addLayout(self.horizontalLayout_12)
self.lv_main.addWidget(self.gb_flags) self.lv_main.addWidget(self.gb_flags)
self.gb_choices = QGroupBox(DialogSettings) self.gb_choices = QGroupBox(DialogSettings)
@@ -574,6 +596,8 @@ class Ui_DialogSettings:
self.cb_metadata_cover_embed.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None)) self.cb_metadata_cover_embed.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None))
self.cb_cover_album_file.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None)) self.cb_cover_album_file.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None))
self.cb_skip_existing.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None)) self.cb_skip_existing.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None))
self.cb_symlink_to_track.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None))
self.cb_playlist_create.setText(QCoreApplication.translate("DialogSettings", "CheckBox", None))
self.gb_choices.setTitle(QCoreApplication.translate("DialogSettings", "Choices", None)) self.gb_choices.setTitle(QCoreApplication.translate("DialogSettings", "Choices", None))
self.l_icon_quality_audio.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) self.l_icon_quality_audio.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None))
self.l_quality_audio.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) self.l_quality_audio.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None))
+28 -2
View File
@@ -7,7 +7,7 @@
<x>0</x> <x>0</x>
<y>0</y> <y>0</y>
<width>640</width> <width>640</width>
<height>767</height> <height>800</height>
</rect> </rect>
</property> </property>
<property name="sizePolicy"> <property name="sizePolicy">
@@ -224,7 +224,33 @@
</layout> </layout>
</item> </item>
<item> <item>
<layout class="QVBoxLayout" name="verticalLayout_3"/> <layout class="QVBoxLayout" name="lv_symlink_to_track">
<item>
<widget class="QCheckBox" name="cb_symlink_to_track">
<property name="text">
<string>CheckBox</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_12">
<item>
<layout class="QVBoxLayout" name="lv_playlist_create">
<item>
<widget class="QCheckBox" name="cb_playlist_create">
<property name="text">
<string>CheckBox</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QVBoxLayout" name="verticalLayout_4"/>
</item> </item>
</layout> </layout>
</item> </item>