diff --git a/README.md b/README.md index ebda126..f36c0eb 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,8 @@ If you like to have the GUI version only, have a look at the - Adjustable audio and video download quality. - FLAC extraction from MP4 containers - 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 diff --git a/tidal_dl_ng/constants.py b/tidal_dl_ng/constants.py index 10fb93d..7da2711 100644 --- a/tidal_dl_ng/constants.py +++ b/tidal_dl_ng/constants.py @@ -9,6 +9,8 @@ COVER_NAME: str = "cover.jpg" BLOCK_SIZE: int = 4096 BLOCKS: int = 1024 CHUNK_SIZE: int = BLOCK_SIZE * BLOCKS +PLAYLIST_EXTENSION: str = ".m3u" +PLAYLIST_PREFIX: str = "_" class QualityVideo(StrEnum): diff --git a/tidal_dl_ng/dialog.py b/tidal_dl_ng/dialog.py index 4e36d8a..b17c151 100644 --- a/tidal_dl_ng/dialog.py +++ b/tidal_dl_ng/dialog.py @@ -182,6 +182,8 @@ class DialogPreferences(QtWidgets.QDialog): "metadata_cover_embed", "cover_album_file", "skip_existing", + "symlink_to_track", + "playlist_create", ] def gui_populate(self): diff --git a/tidal_dl_ng/download.py b/tidal_dl_ng/download.py index af00497..6679371 100644 --- a/tidal_dl_ng/download.py +++ b/tidal_dl_ng/download.py @@ -23,6 +23,8 @@ from tidal_dl_ng.constants import ( CHUNK_SIZE, COVER_NAME, EXTENSION_LYRICS, + PLAYLIST_EXTENSION, + PLAYLIST_PREFIX, REQUESTS_TIMEOUT_SEC, MediaType, QualityVideo, @@ -299,7 +301,7 @@ class Download: # Create file name and path 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 = ( (pathlib.Path(self.path_base).expanduser() / (file_name_relative + file_extension_dummy)) .resolve() @@ -310,8 +312,21 @@ class Download: path_media_dst = pathlib.Path(path_file_sanitize(str(path_media_dst), adapt=True)) # Compute if and how downloads need to be skipped. + skip_download: bool = False + if self.skip_existing: 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: skip_file: bool = False @@ -353,56 +368,64 @@ class Download: elif isinstance(media, Video): 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 = pathlib.Path(path_file_sanitize(str(path_media_dst), adapt=True)) os.makedirs(path_media_dst.parent, exist_ok=True) - # Create a temp directory and file. - with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp_path_dir: - tmp_path_file: pathlib.Path = pathlib.Path(tmp_path_dir) / str(uuid4()) + if not skip_download: + # Create a temp directory and file. + 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 - tmp_path_file.touch() + # Create empty file + tmp_path_file.touch() - # Download media. - result_download, tmp_path_file = self._download( - media=media, stream_manifest=stream_manifest, path_file=tmp_path_file + # Download media. + result_download, tmp_path_file = self._download( + 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: # Set quality back to the global user value self.adjust_quality_audio(quality_audio_old) @@ -430,6 +453,32 @@ class Download: 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: # Save original quality settings quality_old: Quality = self.session.audio_quality @@ -596,7 +645,7 @@ class Download: raise MediaMissing # 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. list_media_name: str = name_builder_title(media) @@ -618,6 +667,7 @@ class Download: ) is_album: bool = isinstance(media, Album) + result_dirs: [pathlib.Path] = [] # Iterate through list items while not self.progress.finished: @@ -639,6 +689,7 @@ class Download: for future in futures.as_completed(l_futures): # Retrieve result status, result_path_file = future.result() + result_dirs.append(result_path_file.parent) # Advance progress bar. self.progress.advance(p_task1) @@ -646,8 +697,44 @@ class Download: if not progress_stdout: 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}'.") + 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: path_file_out: pathlib.Path = path_file.with_suffix(AudioExtensions.MP4) result, _ = ( diff --git a/tidal_dl_ng/model/cfg.py b/tidal_dl_ng/model/cfg.py index 88636f1..557371a 100644 --- a/tidal_dl_ng/model/cfg.py +++ b/tidal_dl_ng/model/cfg.py @@ -42,6 +42,8 @@ class Settings: download_delay_sec_max: float = 5.0 album_track_num_pad_min: int = 1 downloads_concurrent_max: int = 3 + symlink_to_track: bool = False + playlist_create: bool = False @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." ) 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 diff --git a/tidal_dl_ng/ui/dialog_settings.py b/tidal_dl_ng/ui/dialog_settings.py index 945af92..3098817 100644 --- a/tidal_dl_ng/ui/dialog_settings.py +++ b/tidal_dl_ng/ui/dialog_settings.py @@ -27,7 +27,7 @@ class Ui_DialogSettings: def setupUi(self, DialogSettings): if not DialogSettings.objectName(): DialogSettings.setObjectName("DialogSettings") - DialogSettings.resize(640, 767) + DialogSettings.resize(640, 800) sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) sizePolicy.setHorizontalStretch(100) sizePolicy.setVerticalStretch(100) @@ -165,13 +165,35 @@ class Ui_DialogSettings: self.horizontalLayout.addLayout(self.lv_flag_skip_existing) - self.verticalLayout_3 = QVBoxLayout() - self.verticalLayout_3.setObjectName("verticalLayout_3") + self.lv_symlink_to_track = QVBoxLayout() + 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.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.gb_choices = QGroupBox(DialogSettings) @@ -574,6 +596,8 @@ class Ui_DialogSettings: self.cb_metadata_cover_embed.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_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.l_icon_quality_audio.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) self.l_quality_audio.setText(QCoreApplication.translate("DialogSettings", "TextLabel", None)) diff --git a/tidal_dl_ng/ui/dialog_settings.ui b/tidal_dl_ng/ui/dialog_settings.ui index d81701a..5287e38 100644 --- a/tidal_dl_ng/ui/dialog_settings.ui +++ b/tidal_dl_ng/ui/dialog_settings.ui @@ -7,7 +7,7 @@ 0 0 640 - 767 + 800 @@ -224,7 +224,33 @@ - + + + + + CheckBox + + + + + + + + + + + + + + + CheckBox + + + + + + +