summaryrefslogtreecommitdiff
path: root/tidal_dl_ng/download.py
diff options
context:
space:
mode:
Diffstat (limited to 'tidal_dl_ng/download.py')
-rw-r--r--tidal_dl_ng/download.py1098
1 files changed, 891 insertions, 207 deletions
diff --git a/tidal_dl_ng/download.py b/tidal_dl_ng/download.py
index 9b13053..ba604b6 100644
--- a/tidal_dl_ng/download.py
+++ b/tidal_dl_ng/download.py
@@ -1,3 +1,13 @@
+"""
+download.py
+
+Implements the Download class and helpers for downloading media from TIDAL, including segment merging, file moving, metadata writing, and playlist creation.
+
+Classes:
+ RequestsClient: Simple HTTP client for downloading text content.
+ Download: Main class for managing downloads, segment merging, file operations, and metadata.
+"""
+
import os
import pathlib
import random
@@ -55,9 +65,22 @@ from tidal_dl_ng.model.gui_data import ProgressBars
# TODO: Set appropriate client string and use it for video download.
# https://github.com/globocom/m3u8#using-different-http-clients
class RequestsClient:
+ """HTTP client for downloading text content from a URI."""
+
def download(
self, uri: str, timeout: int = REQUESTS_TIMEOUT_SEC, headers: dict | None = None, verify_ssl: bool = True
- ):
+ ) -> tuple[str, str]:
+ """Download the content of a URI as text.
+
+ Args:
+ uri (str): The URI to download.
+ timeout (int, optional): Timeout in seconds. Defaults to REQUESTS_TIMEOUT_SEC.
+ headers (dict | None, optional): HTTP headers. Defaults to None.
+ verify_ssl (bool, optional): Whether to verify SSL. Defaults to True.
+
+ Returns:
+ tuple[str, str]: Tuple of (text content, final URL).
+ """
if not headers:
headers = {}
@@ -68,6 +91,8 @@ class RequestsClient:
# TODO: Use pathlib.Path everywhere
class Download:
+ """Main class for managing downloads, segment merging, file operations, and metadata for TIDAL media."""
+
settings: Settings
session: Session
skip_existing: bool = False
@@ -84,12 +109,25 @@ class Download:
path_base: str,
fn_logger: Callable,
skip_existing: bool = False,
- progress_gui: ProgressBars = None,
- progress: Progress = None,
- progress_overall: Progress = None,
- event_abort: Event = None,
- event_run: Event = None,
- ):
+ progress_gui: ProgressBars | None = None,
+ progress: Progress | None = None,
+ progress_overall: Progress | None = None,
+ event_abort: Event | None = None,
+ event_run: Event | None = None,
+ ) -> None:
+ """Initialize the Download object and its dependencies.
+
+ Args:
+ session (Session): TIDAL session object.
+ path_base (str): Base path for downloads.
+ fn_logger (Callable): Logger function or object.
+ skip_existing (bool, optional): Whether to skip existing files. Defaults to False.
+ progress_gui (ProgressBars | None, optional): GUI progress bars. Defaults to None.
+ progress (Progress | None, optional): Rich progress bar. Defaults to None.
+ progress_overall (Progress | None, optional): Overall progress bar. Defaults to None.
+ event_abort (Event | None, optional): Abort event. Defaults to None.
+ event_run (Event | None, optional): Run event. Defaults to None.
+ """
self.settings = Settings()
self.session = session
self.skip_existing = skip_existing
@@ -113,43 +151,54 @@ class Download:
"be set in (`path_binary_ffmpeg`)."
)
- def _download(
+ def _get_media_urls(
self,
media: Track | Video,
- path_file: pathlib.Path,
stream_manifest: StreamManifest | None = None,
- ) -> (bool, pathlib.Path):
- media_name: str = name_builder_item(media)
- urls: [str]
- path_base: pathlib.Path = path_file.parent
- result_segments: bool = True
- dl_segment_results: [DownloadSegmentResult] = []
- result_merge: bool = False
+ ) -> list[str]:
+ """Extract URLs for the given media item.
+ Args:
+ media (Track | Video): The media item to download.
+ stream_manifest (StreamManifest | None, optional): Stream manifest for tracks. Defaults to None.
+
+ Returns:
+ list[str]: List of URLs for the media segments.
+ """
# Get urls for media.
- try:
- if isinstance(media, Track):
- urls = stream_manifest.get_urls()
- elif isinstance(media, Video):
- m3u8_variant: m3u8.M3U8 = m3u8.load(media.get_url())
- # Find the desired video resolution or the next best one.
- m3u8_playlist, codecs = self._extract_video_stream(m3u8_variant, int(self.settings.data.quality_video))
- # Populate urls.
- urls = m3u8_playlist.files
- except Exception:
- return False, path_file
+ if isinstance(media, Track):
+ return stream_manifest.get_urls()
+ elif isinstance(media, Video):
+ quality_video = self.settings.data.quality_video
+ m3u8_variant: m3u8.M3U8 = m3u8.load(media.get_url())
+ # Find the desired video resolution or the next best one.
+ m3u8_playlist, _ = self._extract_video_stream(m3u8_variant, int(quality_video))
- # Set the correct progress output channel.
- if self.progress_gui is None:
- progress_to_stdout: bool = True
+ return m3u8_playlist.files
else:
- progress_to_stdout: bool = False
- # Send signal to GUI with media name
- self.progress_gui.item_name.emit(media_name[:30])
+ return []
- # Compute total iterations for progress
+ def _setup_progress(
+ self,
+ media_name: str,
+ urls: list[str],
+ progress_to_stdout: bool,
+ ) -> tuple[TaskID, int | float | None, int | None]:
+ """Set up the progress bar/task and compute progress total and block size.
+
+ Args:
+ media_name (str): Name of the media item.
+ urls (list[str]): List of segment URLs.
+ progress_to_stdout (bool): Whether to show progress in stdout.
+
+ Returns:
+ tuple[TaskID, int | float | None, int | None]: (TaskID, progress_total, block_size)
+ """
urls_count: int = len(urls)
+ progress_total: int | float | None = None
+ block_size: int | None = None
+ # Compute total iterations for progress
if urls_count > 1:
progress_total: int = urls_count
block_size: int | None = None
@@ -157,9 +206,10 @@ class Download:
try:
# Get file size and compute progress steps
r = requests.head(urls[0], timeout=REQUESTS_TIMEOUT_SEC)
+
total_size_in_bytes: int = int(r.headers.get("content-length", 0))
- block_size: int | None = 1048576
- progress_total: float = total_size_in_bytes / block_size
+ block_size = 1048576
+ progress_total = total_size_in_bytes / block_size
finally:
r.close()
else:
@@ -171,6 +221,30 @@ class Download:
total=progress_total,
visible=progress_to_stdout,
)
+ return p_task, progress_total, block_size
+
+ def _download_segments(
+ self,
+ urls: list[str],
+ path_base: pathlib.Path,
+ block_size: int | None,
+ p_task: TaskID,
+ progress_to_stdout: bool,
+ ) -> tuple[bool, list[DownloadSegmentResult]]:
+ """Download all segments with progress reporting and abort handling.
+
+ Args:
+ urls (list[str]): List of segment URLs.
+ path_base (pathlib.Path): Base path for segment files.
+ block_size (int | None): Block size for streaming.
+ p_task (TaskID): Progress bar task ID.
+ progress_to_stdout (bool): Whether to show progress in stdout.
+
+ Returns:
+ tuple[bool, list[DownloadSegmentResult]]: (result_segments, list of segment results)
+ """
+ result_segments: bool = True
+ dl_segment_results: list[DownloadSegmentResult] = []
# Download segments until progress is finished.
# TODO: Compute download speed (https://github.com/Textualize/rich/blob/master/examples/downloader.py)
@@ -179,7 +253,7 @@ class Download:
max_workers=self.settings.data.downloads_simultaneous_per_track_max
) as executor:
# Dispatch all download tasks to worker threads
- l_futures: [futures.Future] = [
+ l_futures: list[futures.Future] = [
executor.submit(self._download_segment, url, path_base, block_size, p_task, progress_to_stdout)
for url in urls
]
@@ -191,14 +265,15 @@ class Download:
dl_segment_results.append(result_dl_segment)
- # check for a link that was skipped
+ # Check for a link that was skipped
if not result_dl_segment.result and (result_dl_segment.url is not urls[-1]):
# Sometimes it happens, if a track is very short (< 8 seconds or so), that the last URL in `urls` is
# invalid (HTTP Error 500) and not necessary. File won't be corrupt.
# If this is NOT the case, but any other URL has resulted in an error,
# mark the whole thing as corrupt.
result_segments = False
- self.fn_logger.error(f"Something went wrong while downloading {media_name}. File is corrupt!")
+
+ self.fn_logger.error("Something went wrong while downloading. File is corrupt!")
# If app is terminated (CTRL+C)
if self.event_abort.is_set():
@@ -206,26 +281,106 @@ class Download:
for f in l_futures:
f.cancel()
- return False, path_file
+ return False, dl_segment_results
+
+ return result_segments, dl_segment_results
+
+ def _download_postprocess(
+ self,
+ result_segments: bool,
+ path_file: pathlib.Path,
+ dl_segment_results: list[DownloadSegmentResult],
+ media: Track | Video,
+ stream_manifest: StreamManifest | None = None,
+ ) -> tuple[bool, pathlib.Path]:
+ """Merge segments, decrypt if needed, and return the final file path.
+
+ Args:
+ result_segments (bool): Whether all segments downloaded successfully.
+ path_file (pathlib.Path): Path to the output file.
+ dl_segment_results (list[DownloadSegmentResult]): List of segment download results.
+ media (Track | Video): The media item.
+ stream_manifest (StreamManifest | None, optional): Stream manifest for tracks. Defaults to None.
+ Returns:
+ tuple[bool, pathlib.Path]: (Success, path to downloaded or decrypted file)
+ """
tmp_path_file_decrypted: pathlib.Path = path_file
+ result_merge: bool = False
# Only if no error happened while downloading.
if result_segments:
# Bring list into right order, so segments can be easily merged.
dl_segment_results.sort(key=lambda x: x.id_segment)
- result_merge: bool = self._segments_merge(path_file, dl_segment_results)
+
+ result_merge = self._segments_merge(path_file, dl_segment_results)
if not result_merge:
- self.fn_logger.error(f"Something went wrong while writing to {media_name}. File is corrupt!")
- elif result_merge and isinstance(media, Track) and stream_manifest.is_encrypted:
+ self.fn_logger.error(f"Something went wrong while writing to {media.name}. File is corrupt!")
+ elif isinstance(media, Track) and stream_manifest.is_encrypted:
key, nonce = decrypt_security_token(stream_manifest.encryption_key)
tmp_path_file_decrypted = path_file.with_suffix(".decrypted")
+
decrypt_file(path_file, tmp_path_file_decrypted, key, nonce)
return result_merge, tmp_path_file_decrypted
- def _segments_merge(self, path_file, dl_segment_results) -> bool:
+ def _download(
+ self,
+ media: Track | Video,
+ path_file: pathlib.Path,
+ stream_manifest: StreamManifest | None = None,
+ ) -> tuple[bool, pathlib.Path]:
+ """Download a media item (track or video), handling segments and merging.
+
+ Args:
+ media (Track | Video): The media item to download.
+ path_file (pathlib.Path): Path to the output file.
+ stream_manifest (StreamManifest | None, optional): Stream manifest for tracks. Defaults to None.
+
+ Returns:
+ tuple[bool, pathlib.Path]: (Success, path to downloaded or decrypted file)
+ """
+ media_name: str = name_builder_item(media)
+
+ try:
+ urls: list[str] = self._get_media_urls(media, stream_manifest)
+ except Exception:
+ return False, path_file
+
+ # Set the correct progress output channel.
+ if self.progress_gui is None:
+ progress_to_stdout: bool = True
+ else:
+ progress_to_stdout: bool = False
+ # Send signal to GUI with media name
+ self.progress_gui.item_name.emit(media_name[:30])
+
+ try:
+ p_task, progress_total, block_size = self._setup_progress(media_name, urls, progress_to_stdout)
+ except Exception:
+ return False, path_file
+
+ result_segments, dl_segment_results = self._download_segments(
+ urls, path_file.parent, block_size, p_task, progress_to_stdout
+ )
+
+ result_merge, tmp_path_file_decrypted = self._download_postprocess(
+ result_segments, path_file, dl_segment_results, media, stream_manifest
+ )
+
+ return result_merge, tmp_path_file_decrypted
+
+ def _segments_merge(self, path_file: pathlib.Path, dl_segment_results: list[DownloadSegmentResult]) -> bool:
+ """Merge downloaded segments into a single file and clean up segment files.
+
+ Args:
+ path_file (pathlib.Path): Path to the output file.
+ dl_segment_results (list[DownloadSegmentResult]): List of segment download results.
+
+ Returns:
+ bool: True if merge succeeded, False otherwise.
+ """
result: bool = True
# Copy the content of all segments into one file.
@@ -249,6 +404,18 @@ class Download:
def _download_segment(
self, url: str, path_base: pathlib.Path, block_size: int | None, p_task: TaskID, progress_to_stdout: bool
) -> DownloadSegmentResult:
+ """Download a single segment of a media file.
+
+ Args:
+ url (str): URL of the segment.
+ path_base (pathlib.Path): Base path for segment file.
+ block_size (int | None): Block size for streaming.
+ p_task (TaskID): Progress bar task ID.
+ progress_to_stdout (bool): Whether to show progress in stdout.
+
+ Returns:
+ DownloadSegmentResult: Result of the segment download.
+ """
result: bool = False
path_segment: pathlib.Path = path_base / url_to_filename(url)
# Calculate the segment ID based on the file name within the URL.
@@ -298,8 +465,18 @@ class Download:
)
def extension_guess(
- self, quality_audio: Quality, metadata_tags: [str], is_video: bool
+ self, quality_audio: Quality, metadata_tags: list[str], is_video: bool
) -> AudioExtensions | VideoExtensions:
+ """Guess the file extension for a media item based on quality and type.
+
+ Args:
+ quality_audio (Quality): Audio quality.
+ metadata_tags (list[str]): Metadata tags for the media.
+ is_video (bool): Whether the media is a video.
+
+ Returns:
+ AudioExtensions | VideoExtensions: Guessed file extension.
+ """
result: AudioExtensions | VideoExtensions
if is_video:
@@ -321,9 +498,9 @@ class Download:
def item(
self,
file_template: str,
- media: Track | Video = None,
- media_id: str = None,
- media_type: MediaType = None,
+ media_id: str | None = None,
+ media_type: MediaType | None = None,
+ media: Track | Video | None = None,
video_download: bool = True,
download_delay: bool = False,
quality_audio: Quality | None = None,
@@ -331,43 +508,152 @@ class Download:
is_parent_album: bool = False,
list_position: int = 0,
list_total: int = 0,
- ) -> (bool, pathlib.Path):
+ ) -> tuple[bool, pathlib.Path | str]:
+ """Download a single media item, handling file naming, skipping, and post-processing.
+
+ Args:
+ file_template (str): Template for file naming.
+ media_id (str | None, optional): Media ID. Defaults to None.
+ media_type (MediaType | None, optional): Media type. Defaults to None.
+ media (Track | Video | None, optional): Media item. Defaults to None.
+ video_download (bool, optional): Whether to allow video downloads. Defaults to True.
+ download_delay (bool, optional): Whether to delay between downloads. Defaults to False.
+ quality_audio (Quality | None, optional): Audio quality. Defaults to None.
+ quality_video (QualityVideo | None, optional): Video quality. Defaults to None.
+ is_parent_album (bool, optional): Whether this is a parent album. Defaults to False.
+ list_position (int, optional): Position in list. Defaults to 0.
+ list_total (int, optional): Total items in list. Defaults to 0.
+
+ Returns:
+ tuple[bool, pathlib.Path | str]: (Downloaded, path to file)
+ """
+ # Step 1: Validate and prepare media
+ validated_media = self._validate_and_prepare_media(media, media_id, media_type, video_download)
+ if validated_media is None or not isinstance(validated_media, Track | Video):
+ return False, ""
+
+ media = validated_media
+
+ # Step 2: Create file paths and determine skip logic
+ path_media_dst, file_extension_dummy, skip_file, skip_download = self._prepare_file_paths_and_skip_logic(
+ media, file_template, quality_audio, list_position, list_total
+ )
+
+ if skip_file:
+ self.fn_logger.debug(f"Download skipped, since file exists: '{path_media_dst}'")
+
+ return True, path_media_dst
+
+ # Step 3: Handle quality settings
+ quality_audio_old, quality_video_old = self._adjust_quality_settings(quality_audio, quality_video)
+
+ # Step 4: Download and process media
+ download_success = self._download_and_process_media(
+ media, path_media_dst, skip_download, is_parent_album, file_extension_dummy
+ )
+
+ # Step 5: Post-processing
+ self._perform_post_processing(
+ media,
+ path_media_dst,
+ quality_audio,
+ quality_video,
+ quality_audio_old,
+ quality_video_old,
+ download_delay,
+ skip_file,
+ )
+
+ return download_success, path_media_dst
+
+ def _validate_and_prepare_media(
+ self,
+ media: Track | Video | Album | Playlist | UserPlaylist | Mix | None,
+ media_id: str | None,
+ media_type: MediaType | None,
+ video_download: bool = True,
+ ) -> Track | Video | Album | Playlist | UserPlaylist | Mix | None:
+ """Validate and prepare media instance for download.
+
+ Args:
+ media (Track | Video | Album | Playlist | UserPlaylist | Mix | None): Media instance.
+ media_id (str | None): Media ID if creating new instance.
+ media_type (MediaType | None): Media type if creating new instance.
+ video_download (bool, optional): Whether video downloads are allowed. Defaults to True.
+
+ Returns:
+ Track | Video | Album | Playlist | UserPlaylist | Mix | None: Prepared media instance or None if invalid.
+ """
try:
if media_id and media_type:
# If no media instance is provided, we need to create the media instance.
+ # Throws `tidalapi.exceptions.ObjectNotFound` if item is not available anymore.
media = instantiate_media(self.session, media_type, media_id)
- elif isinstance(media, Track | Video): # Check if media is available not deactivated / removed from TIDAL.
+ elif isinstance(media, Track | Video):
+ # Check if media is available not deactivated / removed from TIDAL.
if not media.available:
self.fn_logger.info(
f"This item is not available for listening anymore on TIDAL. Skipping: {name_builder_item(media)}"
)
-
- return False, ""
+ return None
elif isinstance(media, Track):
# Re-create media instance with full album information
- media = self.session.track(media.id, with_album=True)
+ media = self.session.track(str(media.id), with_album=True)
+ elif isinstance(media, Album):
+ # Check if media is available not deactivated / removed from TIDAL.
+ if not media.available:
+ self.fn_logger.info(
+ f"This item is not available for listening anymore on TIDAL. Skipping: {name_builder_title(media)}"
+ )
+ return None
elif not media:
raise MediaMissing
except:
- return False, ""
+ return None
- # If video download is not allowed end here
+ # If video download is not allowed and this is a video, return None
if not video_download and isinstance(media, Video):
self.fn_logger.info(
f"Video downloads are deactivated (see settings). Skipping video: {name_builder_item(media)}"
)
+ return None
- return False, ""
+ return media
+
+ def _prepare_file_paths_and_skip_logic(
+ self,
+ media: Track | Video,
+ file_template: str,
+ quality_audio: Quality | None,
+ list_position: int,
+ list_total: int,
+ ) -> tuple[pathlib.Path, str, bool, bool]:
+ """Prepare file paths and determine skip logic.
+
+ Args:
+ media (Track | Video): Media item.
+ file_template (str): Template for file naming.
+ quality_audio (Quality | None): Audio quality setting.
+ list_position (int): Position in list.
+ list_total (int): Total items in list.
+ Returns:
+ tuple[pathlib.Path, str, bool, bool]: (path_media_dst, file_extension_dummy, skip_file, skip_download)
+ """
# Create file name and path
+ metadata_tags = [] if isinstance(media, Video) else (media.media_metadata_tags or [])
+ quality_for_extension = quality_audio if quality_audio is not None else Quality.high_lossless
+
file_extension_dummy: str = self.extension_guess(
- quality_audio,
- metadata_tags=[] if isinstance(media, Video) else media.media_metadata_tags,
+ quality_for_extension,
+ metadata_tags=metadata_tags,
is_video=isinstance(media, Video),
)
+
file_name_relative: str = format_path_media(
file_template, media, self.settings.data.album_track_num_pad_min, list_position, list_total
)
+
path_media_dst: pathlib.Path = (
pathlib.Path(self.path_base).expanduser() / (file_name_relative + file_extension_dummy)
).absolute()
@@ -394,122 +680,246 @@ class Download:
)
skip_download = file_exists_playlist_dir or file_exists_track_dir
- # If
+ # If file exists in playlist dir but not in track dir, we don't skip the file itself
if skip_file and file_exists_playlist_dir:
skip_file = False
else:
skip_file: bool = False
- if not skip_file:
- # If a quality is explicitly set, change it and remember the previously set quality.
- quality_audio_old: Quality = self.adjust_quality_audio(quality_audio) if quality_audio else quality_audio
- quality_video_old: QualityVideo = (
- self.adjust_quality_video(quality_video) if quality_video else quality_video
- )
- do_flac_extract = False
- # Get extension.
- file_extension: str
- stream_manifest: StreamManifest | None = None
+ return path_media_dst, file_extension_dummy, skip_file, skip_download
- if isinstance(media, Track):
- try:
- media_stream: Stream = media.get_stream()
- stream_manifest = media_stream.get_stream_manifest()
- except TooManyRequests:
- self.fn_logger.exception(
- f"Too many requests against TIDAL backend. Skipping '{name_builder_item(media)}'. "
- f"Consider to activate delay between downloads."
- )
+ def _adjust_quality_settings(
+ self, quality_audio: Quality | None, quality_video: QualityVideo | None
+ ) -> tuple[Quality | None, QualityVideo | None]:
+ """Adjust quality settings and return previous values.
- return False, ""
- except Exception:
- self.fn_logger.exception(f"Something went wrong. Skipping '{name_builder_item(media)}'.")
+ Args:
+ quality_audio (Quality | None): Audio quality setting.
+ quality_video (QualityVideo | None): Video quality setting.
- return False, ""
+ Returns:
+ tuple[Quality | None, QualityVideo | None]: Previous quality settings.
+ """
+ quality_audio_old: Quality | None = None
+ quality_video_old: QualityVideo | None = None
- file_extension = stream_manifest.file_extension
+ if quality_audio:
+ quality_audio_old = self.adjust_quality_audio(quality_audio)
- if self.settings.data.extract_flac and (
- stream_manifest.codecs.upper() == Codec.FLAC and file_extension != AudioExtensions.FLAC
- ):
- file_extension = AudioExtensions.FLAC
- do_flac_extract = True
- elif isinstance(media, Video):
- file_extension = AudioExtensions.MP4 if self.settings.data.video_convert_mp4 else VideoExtensions.TS
+ if quality_video:
+ quality_video_old = self.adjust_quality_video(quality_video)
- # If file extension was guessed wrong in the beginning
- if path_media_dst.suffix != file_extension:
- # Compute file name, sanitize once again, because file extension could have been replaced after guessing it first and create destination directory
- path_media_dst = path_media_dst.with_suffix(file_extension)
- path_media_dst = pathlib.Path(path_file_sanitize(path_media_dst, adapt=True))
+ return quality_audio_old, quality_video_old
- os.makedirs(path_media_dst.parent, exist_ok=True)
+ def _download_and_process_media(
+ self,
+ media: Track | Video,
+ path_media_dst: pathlib.Path,
+ skip_download: bool,
+ is_parent_album: bool,
+ file_extension_dummy: str,
+ ) -> bool:
+ """Download and process media file.
- 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())
+ Args:
+ media (Track | Video): Media item.
+ path_media_dst (pathlib.Path): Destination file path.
+ skip_download (bool): Whether to skip download.
+ is_parent_album (bool): Whether this is a parent album.
+ file_extension_dummy (str): Dummy file extension.
- # Create empty file
- tmp_path_file.touch()
+ Returns:
+ bool: Whether download was successful.
+ """
+ if skip_download:
+ return True
- # Download media.
- result_download, tmp_path_file = self._download(
- media=media, stream_manifest=stream_manifest, path_file=tmp_path_file
- )
+ # Get stream information and final file extension
+ stream_manifest, file_extension, do_flac_extract, media_stream = self._get_stream_info(media)
- 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)
+ if stream_manifest is None and isinstance(media, Track):
+ return False
- # 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)
+ # Update path if extension changed
+ if path_media_dst.suffix != file_extension:
+ path_media_dst = path_media_dst.with_suffix(file_extension)
+ path_media_dst = pathlib.Path(path_file_sanitize(path_media_dst, adapt=True))
- tmp_path_lyrics: pathlib.Path | None = None
- tmp_path_cover: pathlib.Path | None = None
+ os.makedirs(path_media_dst.parent, exist_ok=True)
- # 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
- )
+ # Perform actual download
+ return self._perform_actual_download(
+ media, path_media_dst, stream_manifest, do_flac_extract, 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)
+ def _get_stream_info(self, media: Track | Video) -> tuple[StreamManifest | None, str, bool, Stream | None]:
+ """Get stream information for media.
- # 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)
+ Args:
+ media (Track | Video): Media item.
- self.fn_logger.info(f"Downloaded item '{name_builder_item(media)}'.")
+ Returns:
+ tuple[StreamManifest | None, str, bool, Stream | None]: Stream info.
+ """
+ stream_manifest: StreamManifest | None = None
+ media_stream: Stream | None = None
+ do_flac_extract: bool = False
- # Move final file to the configured destination directory.
- shutil.move(tmp_path_file, path_media_dst)
+ if isinstance(media, Track):
+ try:
+ media_stream = media.get_stream()
+ stream_manifest = media_stream.get_stream_manifest()
+ except TooManyRequests:
+ self.fn_logger.exception(
+ f"Too many requests against TIDAL backend. Skipping '{name_builder_item(media)}'. "
+ f"Consider to activate delay between downloads."
+ )
- # If files needs to be symlinked, do postprocessing here.
- if self.settings.data.symlink_to_track and not isinstance(media, Video):
- path_media_track_dir: pathlib.Path = self.media_move_and_symlink(media, path_media_dst, file_extension)
+ return None, "", False, None
+ except Exception:
+ self.fn_logger.exception(f"Something went wrong. Skipping '{name_builder_item(media)}'.")
- if quality_audio:
- # Set quality back to the global user value
- self.adjust_quality_audio(quality_audio_old)
+ return None, "", False, None
- if quality_video:
- # Set quality back to the global user value
- self.adjust_quality_video(quality_video_old)
- else:
- self.fn_logger.debug(f"Download skipped, since file exists: '{path_media_dst}'")
+ file_extension = stream_manifest.file_extension
+
+ if self.settings.data.extract_flac and (
+ stream_manifest.codecs.upper() == Codec.FLAC and file_extension != AudioExtensions.FLAC
+ ):
+ file_extension = AudioExtensions.FLAC
+ do_flac_extract = True
+ elif isinstance(media, Video):
+ file_extension = AudioExtensions.MP4 if self.settings.data.video_convert_mp4 else VideoExtensions.TS
+
+ return stream_manifest, file_extension, do_flac_extract, media_stream
+
+ def _perform_actual_download(
+ self,
+ media: Track | Video,
+ path_media_dst: pathlib.Path,
+ stream_manifest: StreamManifest | None,
+ do_flac_extract: bool,
+ is_parent_album: bool,
+ media_stream: Stream | None,
+ ) -> bool:
+ """Perform the actual download and processing.
+
+ Args:
+ media (Track | Video): Media item.
+ path_media_dst (pathlib.Path): Destination file path.
+ stream_manifest (StreamManifest | None): Stream manifest.
+ do_flac_extract (bool): Whether to extract FLAC.
+ is_parent_album (bool): Whether this is a parent album.
+ media_stream (Stream | None): Media stream.
+
+ Returns:
+ bool: Whether download was successful.
+ """
+ # 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())
+ tmp_path_file.touch()
+
+ # Download media.
+ result_download, tmp_path_file = self._download(
+ media=media, stream_manifest=stream_manifest, path_file=tmp_path_file
+ )
+
+ if not result_download:
+ return False
+
+ # Convert video from TS to MP4
+ if isinstance(media, Video) and self.settings.data.video_convert_mp4:
+ 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)
+
+ # Handle metadata, lyrics, and cover
+ self._handle_metadata_and_extras(media, tmp_path_file, path_media_dst, is_parent_album, media_stream)
+
+ 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)
+
+ return True
+
+ def _handle_metadata_and_extras(
+ self,
+ media: Track | Video,
+ tmp_path_file: pathlib.Path,
+ path_media_dst: pathlib.Path,
+ is_parent_album: bool,
+ media_stream: Stream | None,
+ ) -> None:
+ """Handle metadata, lyrics, and cover processing.
+
+ Args:
+ media (Track | Video): Media item.
+ tmp_path_file (pathlib.Path): Temporary file path.
+ path_media_dst (pathlib.Path): Destination file path.
+ is_parent_album (bool): Whether this is a parent album.
+ media_stream (Stream | None): Media stream.
+ """
+ if isinstance(media, Video):
+ return
+
+ tmp_path_lyrics: pathlib.Path | None = None
+ tmp_path_cover: pathlib.Path | None = None
+
+ # Write metadata to file.
+ if media_stream:
+ 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 tmp_path_lyrics:
+ self._move_lyrics(tmp_path_lyrics, path_media_dst)
+
+ # Move cover file
+ if self.settings.data.cover_album_file and tmp_path_cover:
+ self._move_cover(tmp_path_cover, path_media_dst)
+
+ def _perform_post_processing(
+ self,
+ media: Track | Video,
+ path_media_dst: pathlib.Path,
+ quality_audio: Quality | None,
+ quality_video: QualityVideo | None,
+ quality_audio_old: Quality | None,
+ quality_video_old: QualityVideo | None,
+ download_delay: bool,
+ skip_file: bool,
+ ) -> None:
+ """Perform post-processing tasks.
+
+ Args:
+ media (Track | Video): Media item.
+ path_media_dst (pathlib.Path): Destination file path.
+ quality_audio (Quality | None): Audio quality setting.
+ quality_video (QualityVideo | None): Video quality setting.
+ quality_audio_old (Quality | None): Previous audio quality.
+ quality_video_old (QualityVideo | None): Previous video quality.
+ download_delay (bool): Whether to apply download delay.
+ skip_file (bool): Whether file was skipped.
+ """
+ # If files needs to be symlinked, do postprocessing here.
+ if self.settings.data.symlink_to_track and not isinstance(media, Video):
+ # Determine file extension for symlink
+ file_extension = path_media_dst.suffix
+ self.media_move_and_symlink(media, path_media_dst, file_extension)
- status_download: bool = not skip_file
+ # Reset quality settings
+ if quality_audio_old is not None:
+ self.adjust_quality_audio(quality_audio_old)
- # Whether a file was downloaded or skipped and the download delay is enabled, wait until the next download.
- # Only use this, if you have a list of several Track items.
+ if quality_video_old is not None:
+ self.adjust_quality_video(quality_video_old)
+
+ # Apply download delay if needed
if (download_delay and not skip_file) and not self.event_abort.is_set():
time_sleep: float = round(
random.SystemRandom().uniform(
@@ -521,11 +931,19 @@ class Download:
self.fn_logger.debug(f"Next download will start in {time_sleep} seconds.")
time.sleep(time_sleep)
- 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:
+ """Move a media file and create a symlink if required.
+
+ Args:
+ media (Track | Video): Media item.
+ path_media_src (pathlib.Path): Source file path.
+ file_extension (str): File extension.
+
+ Returns:
+ pathlib.Path: Destination 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 = (
@@ -557,14 +975,30 @@ class Download:
return path_media_dst
- def adjust_quality_audio(self, quality) -> Quality:
+ def adjust_quality_audio(self, quality: Quality) -> Quality:
+ """Temporarily set audio quality and return the previous value.
+
+ Args:
+ quality (Quality): New audio quality.
+
+ Returns:
+ Quality: Previous audio quality.
+ """
# Save original quality settings
quality_old: Quality = self.session.audio_quality
self.session.audio_quality = quality
return quality_old
- def adjust_quality_video(self, quality) -> QualityVideo:
+ def adjust_quality_video(self, quality: QualityVideo) -> QualityVideo:
+ """Temporarily set video quality and return the previous value.
+
+ Args:
+ quality (QualityVideo): New video quality.
+
+ Returns:
+ QualityVideo: Previous video quality.
+ """
quality_old: QualityVideo = self.settings.data.quality_video
self.settings.data.quality_video = quality
@@ -572,6 +1006,15 @@ class Download:
return quality_old
def _move_file(self, path_file_source: pathlib.Path, path_file_destination: str | pathlib.Path) -> bool:
+ """Move a file from source to destination.
+
+ Args:
+ path_file_source (pathlib.Path): Source file path.
+ path_file_destination (str | pathlib.Path): Destination file path.
+
+ Returns:
+ bool: True if moved, False otherwise.
+ """
result: bool
# Check if the file was downloaded
@@ -586,6 +1029,15 @@ class Download:
return result
def _move_lyrics(self, path_lyrics: pathlib.Path, file_media_dst: pathlib.Path) -> bool:
+ """Move a lyrics file to the destination.
+
+ Args:
+ path_lyrics (pathlib.Path): Source lyrics file.
+ file_media_dst (pathlib.Path): Destination media file path.
+
+ Returns:
+ bool: True if moved, False otherwise.
+ """
# Build tmp lyrics filename
path_file_lyrics: pathlib.Path = file_media_dst.with_suffix(EXTENSION_LYRICS)
result: bool = self._move_file(path_lyrics, path_file_lyrics)
@@ -593,6 +1045,15 @@ class Download:
return result
def _move_cover(self, path_cover: pathlib.Path, file_media_dst: pathlib.Path) -> bool:
+ """Move a cover file to the destination.
+
+ Args:
+ path_cover (pathlib.Path): Source cover file.
+ file_media_dst (pathlib.Path): Destination media file path.
+
+ Returns:
+ bool: True if moved, False otherwise.
+ """
# Build tmp lyrics filename
path_file_cover: pathlib.Path = file_media_dst.parent / COVER_NAME
result: bool = self._move_file(path_cover, path_file_cover)
@@ -600,12 +1061,40 @@ class Download:
return result
def lyrics_to_file(self, dir_destination: pathlib.Path, lyrics: str) -> str:
+ """Write lyrics to a temporary file.
+
+ Args:
+ dir_destination (pathlib.Path): Directory for the temp file.
+ lyrics (str): Lyrics content.
+
+ Returns:
+ str: Path to the temp file.
+ """
return self.write_to_tmp_file(dir_destination, mode="x", content=lyrics)
def cover_to_file(self, dir_destination: pathlib.Path, image: bytes) -> str:
+ """Write cover image to a temporary file.
+
+ Args:
+ dir_destination (pathlib.Path): Directory for the temp file.
+ image (bytes): Image data.
+
+ Returns:
+ str: Path to the temp file.
+ """
return self.write_to_tmp_file(dir_destination, mode="xb", content=image)
def write_to_tmp_file(self, dir_destination: pathlib.Path, mode: str, content: str | bytes) -> str:
+ """Write content to a temporary file.
+
+ Args:
+ dir_destination (pathlib.Path): Directory for the temp file.
+ mode (str): File open mode.
+ content (str | bytes): Content to write.
+
+ Returns:
+ str: Path to the temp file.
+ """
result: pathlib.Path = dir_destination / str(uuid4())
encoding: str | None = "utf-8" if isinstance(content, str) else None
@@ -618,7 +1107,16 @@ class Download:
return result
@staticmethod
- def cover_data(url: str = None, path_file: str = None) -> str | bytes:
+ def cover_data(url: str | None = None, path_file: str | None = None) -> str | bytes:
+ """Retrieve cover image data from a URL or file.
+
+ Args:
+ url (str | None, optional): URL to download image from. Defaults to None.
+ path_file (str | None, optional): Path to image file. Defaults to None.
+
+ Returns:
+ str | bytes: Image data or empty string on failure.
+ """
result: str | bytes = ""
if url:
@@ -642,7 +1140,18 @@ class Download:
def metadata_write(
self, track: Track, path_media: pathlib.Path, is_parent_album: bool, media_stream: Stream
- ) -> (bool, pathlib.Path | None, pathlib.Path | None):
+ ) -> tuple[bool, pathlib.Path | None, pathlib.Path | None]:
+ """Write metadata, lyrics, and cover to a media file.
+
+ Args:
+ track (Track): Track object.
+ path_media (pathlib.Path): Path to media file.
+ is_parent_album (bool): Whether this is a parent album.
+ media_stream (Stream): Stream object.
+
+ Returns:
+ tuple[bool, pathlib.Path | None, pathlib.Path | None]: (Success, path to lyrics, path to cover)
+ """
result: bool = False
path_lyrics: pathlib.Path | None = None
path_cover: pathlib.Path | None = None
@@ -713,31 +1222,84 @@ class Download:
def items(
self,
file_template: str,
- media: Album | Playlist | UserPlaylist | Mix = None,
- media_id: str = None,
- media_type: MediaType = None,
+ media: Album | Playlist | UserPlaylist | Mix | None = None,
+ media_id: str | None = None,
+ media_type: MediaType | None = None,
video_download: bool = False,
download_delay: bool = True,
quality_audio: Quality | None = None,
quality_video: QualityVideo | None = None,
- ):
- try:
- if media_id and media_type:
- # If no media instance is provided, we need to create the media instance.
- # Throws `tidalapi.exceptions.ObjectNotFound` if item is not available anymore.
- media = instantiate_media(self.session, media_type, media_id)
- elif isinstance(media, Album): # Check if media is available not deactivated / removed from TIDAL.
- if not media.available:
- self.fn_logger.info(
- f"This item is not available for listening anymore on TIDAL. Skipping: {name_builder_title(media)}"
- )
+ ) -> None:
+ """Download all items in an album, playlist, or mix.
- return
- elif not media:
- raise MediaMissing
- except:
+ Args:
+ file_template (str): Template for file naming.
+ media (Album | Playlist | UserPlaylist | Mix | None, optional): Media item. Defaults to None.
+ media_id (str | None, optional): Media ID. Defaults to None.
+ media_type (MediaType | None, optional): Media type. Defaults to None.
+ video_download (bool, optional): Whether to allow video downloads. Defaults to False.
+ download_delay (bool, optional): Whether to delay between downloads. Defaults to True.
+ quality_audio (Quality | None, optional): Audio quality. Defaults to None.
+ quality_video (QualityVideo | None, optional): Video quality. Defaults to None.
+ """
+ # Validate and prepare media collection
+ validated_media = self._validate_and_prepare_media(media, media_id, media_type, video_download)
+ if validated_media is None or not isinstance(validated_media, Album | Playlist | UserPlaylist | Mix):
return
+ media = validated_media
+
+ # Set up download context
+ download_context = self._setup_collection_download_context(media, file_template, video_download)
+ file_name_relative, list_media_name, list_media_name_short, items, progress_stdout = download_context
+
+ # Set up progress tracking
+ progress: Progress = self.progress_overall if self.progress_overall else self.progress
+ progress_task: TaskID = progress.add_task(
+ f"[green]List '{list_media_name_short}'", total=len(items), visible=progress_stdout
+ )
+
+ # Download configuration
+ is_album: bool = isinstance(media, Album)
+ sort_by_track_num: bool = bool("album_track_num" in file_name_relative or "list_pos" in file_name_relative)
+ list_total: int = len(items)
+
+ # Execute downloads
+ result_dirs: list[pathlib.Path] = self._execute_collection_downloads(
+ items,
+ file_name_relative,
+ quality_audio,
+ quality_video,
+ download_delay,
+ is_album,
+ list_total,
+ progress,
+ progress_task,
+ progress_stdout,
+ )
+
+ # Create playlist file if requested
+ if self.settings.data.playlist_create:
+ self.playlist_populate(set(result_dirs), list_media_name, is_album, sort_by_track_num)
+
+ self.fn_logger.info(f"Finished list '{list_media_name}'.")
+
+ def _setup_collection_download_context(
+ self,
+ media: Album | Playlist | UserPlaylist | Mix,
+ file_template: str,
+ video_download: bool,
+ ) -> tuple[str, str, str, list, bool]:
+ """Set up download context for media collection.
+
+ Args:
+ media (Album | Playlist | UserPlaylist | Mix): Media collection.
+ file_template (str): Template for file naming.
+ video_download (bool): Whether to allow video downloads.
+
+ Returns:
+ tuple[str, str, str, list, bool]: (file_name_relative, list_media_name, list_media_name_short, items, progress_stdout)
+ """
# Create file name and path
file_name_relative: str = format_path_media(file_template, media)
@@ -753,26 +1315,48 @@ class Download:
progress_stdout: bool = True
else:
progress_stdout: bool = False
+
self.progress_gui.list_name.emit(list_media_name_short)
- progress: Progress = self.progress_overall if self.progress_overall else self.progress
+ return file_name_relative, list_media_name, list_media_name_short, items, progress_stdout
- # Create the list progress task.
- p_task1: TaskID = progress.add_task(
- f"[green]List '{list_media_name_short}'", total=len(items), visible=progress_stdout
- )
+ def _execute_collection_downloads(
+ self,
+ items: list,
+ file_name_relative: str,
+ quality_audio: Quality | None,
+ quality_video: QualityVideo | None,
+ download_delay: bool,
+ is_album: bool,
+ list_total: int,
+ progress: Progress,
+ progress_task: TaskID,
+ progress_stdout: bool,
+ ) -> list[pathlib.Path]:
+ """Execute downloads for all items in the collection.
- is_album: bool = isinstance(media, Album)
- # TODO: Refactor strings to constants (also in cfg.py)
- sort_by_track_num: bool = bool("album_track_num" in file_name_relative or "list_pos" in file_name_relative)
- result_dirs: [pathlib.Path] = []
- list_total: int = len(items)
+ Args:
+ items (list): List of media items to download.
+ file_name_relative (str): Relative file name template.
+ quality_audio (Quality | None): Audio quality setting.
+ quality_video (QualityVideo | None): Video quality setting.
+ download_delay (bool): Whether to apply download delay.
+ is_album (bool): Whether this is an album.
+ list_total (int): Total number of items.
+ progress (Progress): Progress bar instance.
+ progress_task (TaskID): Progress task ID.
+ progress_stdout (bool): Whether to show progress in stdout.
+
+ Returns:
+ list[pathlib.Path]: List of result directories.
+ """
+ result_dirs: list[pathlib.Path] = []
# Iterate through list items
while not progress.finished:
with futures.ThreadPoolExecutor(max_workers=self.settings.data.downloads_concurrent_max) as executor:
# Dispatch all download tasks to worker threads
- l_futures: [futures.Future] = [
+ download_futures: list[futures.Future] = [
executor.submit(
self.item,
media=item_media,
@@ -787,39 +1371,114 @@ class Download:
for count, item_media in enumerate(items)
]
- # Report results as they become available
- for future in futures.as_completed(l_futures):
- # Retrieve result
- status, result_path_file = future.result()
+ # Process download results
+ result_dirs = self._process_download_futures(download_futures, progress, progress_task, progress_stdout)
- if result_path_file:
- result_dirs.append(result_path_file.parent)
+ # Check for abort signal
+ if self.event_abort.is_set():
+ return result_dirs
- # Advance progress bar.
- progress.advance(p_task1)
+ return result_dirs
- if not progress_stdout:
- self.progress_gui.list_item.emit(progress.tasks[p_task1].percentage)
+ def _create_download_futures(
+ self,
+ items: list,
+ file_name_relative: str,
+ quality_audio: Quality | None,
+ quality_video: QualityVideo | None,
+ download_delay: bool,
+ is_album: bool,
+ list_total: int,
+ ) -> list[futures.Future]:
+ """Create download futures for all items in the collection.
- # If app is terminated (CTRL+C)
- if self.event_abort.is_set():
- # Cancel all not yet started tasks
- for f in l_futures:
- f.cancel()
+ Args:
+ items (list): List of media items to download.
+ file_name_relative (str): Relative file name template.
+ quality_audio (Quality | None): Audio quality setting.
+ quality_video (QualityVideo | None): Video quality setting.
+ download_delay (bool): Whether to apply download delay.
+ is_album (bool): Whether this is an album.
+ list_total (int): Total number of items.
- # End method here.
- return
+ Returns:
+ list[futures.Future]: List of download futures.
+ """
+ with futures.ThreadPoolExecutor(max_workers=self.settings.data.downloads_concurrent_max) as executor:
+ return [
+ executor.submit(
+ self.item,
+ media=item_media,
+ file_template=file_name_relative,
+ quality_audio=quality_audio,
+ quality_video=quality_video,
+ download_delay=download_delay,
+ is_parent_album=is_album,
+ list_position=count + 1,
+ list_total=list_total,
+ )
+ for count, item_media in enumerate(items)
+ ]
- # Create playlist file
- if self.settings.data.playlist_create:
- self.playlist_populate(set(result_dirs), list_media_name, is_album, sort_by_track_num)
+ def _process_download_futures(
+ self,
+ futures_list: list[futures.Future],
+ progress: Progress,
+ progress_task: TaskID,
+ progress_stdout: bool,
+ ) -> list[pathlib.Path]:
+ """Process download futures and collect results.
- self.fn_logger.info(f"Finished list '{list_media_name}'.")
+ Args:
+ futures_list (list[futures.Future]): List of download futures.
+ progress (Progress): Progress bar instance.
+ progress_task (TaskID): Progress task ID.
+ progress_stdout (bool): Whether to show progress in stdout.
+
+ Returns:
+ list[pathlib.Path]: List of result directories.
+ """
+ result_dirs: list[pathlib.Path] = []
+
+ # Report results as they become available
+ for future in futures.as_completed(futures_list):
+ # Retrieve result
+ status, result_path_file = future.result()
+
+ if result_path_file:
+ result_dirs.append(result_path_file.parent)
+
+ # Advance progress bar.
+ progress.advance(progress_task)
+
+ if not progress_stdout:
+ self.progress_gui.list_item.emit(progress.tasks[progress_task].percentage)
+
+ # If app is terminated (CTRL+C)
+ if self.event_abort.is_set():
+ # Cancel all not yet started tasks
+ for f in futures_list:
+ f.cancel()
+
+ break
+
+ return result_dirs
def playlist_populate(
- self, dirs_scoped: [pathlib.Path], name_list: str, is_album: bool, sort_alphabetically
- ) -> [pathlib.Path]:
- result: [pathlib.Path] = []
+ self, dirs_scoped: set[pathlib.Path], name_list: str, is_album: bool, sort_alphabetically: bool
+ ) -> list[pathlib.Path]:
+ """Create playlist files (m3u) for downloaded tracks in each directory.
+
+ Args:
+ dirs_scoped (set[pathlib.Path]): Set of directories containing tracks.
+ name_list (str): Name of the playlist.
+ is_album (bool): Whether this is an album.
+ sort_alphabetically (bool): Whether to sort tracks alphabetically.
+
+ Returns:
+ list[pathlib.Path]: List of created playlist file paths.
+ """
+ result: list[pathlib.Path] = []
# For each dir, which contains tracks
for dir_scoped in dirs_scoped:
@@ -830,7 +1489,7 @@ class Download:
self.fn_logger.debug(f"Playlist: Creating {path_playlist}")
# Get all tracks in the directory
- path_tracks: [pathlib.Path] = []
+ path_tracks: list[pathlib.Path] = []
for extension_audio in AudioExtensions:
path_tracks = path_tracks + list(dir_scoped.glob(f"*{extension_audio!s}"))
@@ -858,6 +1517,14 @@ class Download:
return result
def _video_convert(self, path_file: pathlib.Path) -> pathlib.Path:
+ """Convert a TS video file to MP4 using ffmpeg.
+
+ Args:
+ path_file (pathlib.Path): Path to the TS file.
+
+ Returns:
+ pathlib.Path: Path to the converted MP4 file.
+ """
path_file_out: pathlib.Path = path_file.with_suffix(AudioExtensions.MP4)
ffmpeg = (
FFmpeg(executable=self.settings.data.path_binary_ffmpeg)
@@ -871,6 +1538,14 @@ class Download:
return path_file_out
def _extract_flac(self, path_media_src: pathlib.Path) -> pathlib.Path:
+ """Extract FLAC audio from a media file using ffmpeg.
+
+ Args:
+ path_media_src (pathlib.Path): Path to the source media file.
+
+ Returns:
+ pathlib.Path: Path to the extracted FLAC file.
+ """
path_media_out = path_media_src.with_suffix(AudioExtensions.FLAC)
ffmpeg = (
FFmpeg(executable=self.settings.data.path_binary_ffmpeg)
@@ -889,7 +1564,16 @@ class Download:
return path_media_out
- def _extract_video_stream(self, m3u8_variant: m3u8.M3U8, quality: int) -> (m3u8.M3U8 | bool, str):
+ def _extract_video_stream(self, m3u8_variant: m3u8.M3U8, quality: int) -> tuple[m3u8.M3U8 | bool, str]:
+ """Extract the best matching video stream from an m3u8 variant playlist.
+
+ Args:
+ m3u8_variant (m3u8.M3U8): The m3u8 variant playlist.
+ quality (int): Desired video quality (vertical resolution).
+
+ Returns:
+ tuple[m3u8.M3U8 | bool, str]: (Selected m3u8 playlist or False, codecs string)
+ """
m3u8_playlist: m3u8.M3U8 | bool = False
resolution_best: int = 0
mime_type: str = ""