2025-07-02 08:55:56 +02:00
"""
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.
"""
2023-12-19 23:58:57 +01:00
import os
2024-04-25 22:53:51 +02:00
import pathlib
2023-12-19 23:58:57 +01:00
import random
import shutil
import tempfile
import time
2024-01-13 11:57:11 +01:00
from collections.abc import Callable
2024-11-12 22:15:11 +01:00
from concurrent import futures
2025-03-03 09:31:37 +01:00
from threading import Event
2023-12-19 23:58:57 +01:00
from uuid import uuid4
2024-04-20 18:11:36 +02:00
import m3u8
2023-12-19 23:58:57 +01:00
import requests
2025-01-04 13:42:46 +01:00
from ffmpeg import FFmpeg
2025-06-30 16:33:32 +02:00
from pathvalidate import sanitize_filename
2024-11-13 12:36:01 +01:00
from requests.adapters import HTTPAdapter , Retry
2024-01-14 17:29:39 +01:00
from requests.exceptions import HTTPError
from rich.progress import Progress , TaskID
from tidalapi import Album , Mix , Playlist , Session , Track , UserPlaylist , Video
2024-11-13 09:09:00 +01:00
from tidalapi.exceptions import TooManyRequests
2024-11-29 21:56:42 +01:00
from tidalapi.media import AudioExtensions , Codec , Quality , Stream , StreamManifest , VideoExtensions
2023-12-20 07:42:36 +01:00
2024-11-09 17:33:21 +01:00
from tidal_dl_ng.config import Settings
2024-11-13 13:45:38 +01:00
from tidal_dl_ng.constants import (
CHUNK_SIZE ,
COVER_NAME ,
EXTENSION_LYRICS ,
2025-10-17 15:40:28 +02:00
METADATA_EXPLICIT ,
2025-10-17 13:46:47 +02:00
METADATA_LOOKUP_UPC ,
2024-12-16 14:42:53 +01:00
PLAYLIST_EXTENSION ,
PLAYLIST_PREFIX ,
2024-11-13 13:45:38 +01:00
REQUESTS_TIMEOUT_SEC ,
2025-08-13 08:17:11 +02:00
AudioExtensionsValid ,
2025-08-12 18:22:59 +02:00
CoverDimensions ,
2024-11-13 13:45:38 +01:00
MediaType ,
2025-10-17 13:46:47 +02:00
MetadataTargetUPC ,
2024-11-13 13:45:38 +01:00
QualityVideo ,
)
2023-12-19 23:58:57 +01:00
from tidal_dl_ng.helper.decryption import decrypt_file , decrypt_security_token
2024-04-20 16:32:52 +02:00
from tidal_dl_ng.helper.exceptions import MediaMissing
2025-02-28 11:47:38 +01:00
from tidal_dl_ng.helper.path import (
check_file_exists ,
format_path_media ,
path_file_sanitize ,
url_to_filename ,
)
2024-03-19 07:23:35 +01:00
from tidal_dl_ng.helper.tidal import (
instantiate_media ,
items_results_all ,
name_builder_album_artist ,
name_builder_artist ,
name_builder_item ,
name_builder_title ,
)
2023-12-19 23:58:57 +01:00
from tidal_dl_ng.metadata import Metadata
2024-11-12 22:15:11 +01:00
from tidal_dl_ng.model.downloader import DownloadSegmentResult
2023-12-19 23:58:57 +01:00
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 :
2025-07-02 08:55:56 +02:00
"""HTTP client for downloading text content from a URI."""
2024-01-12 11:20:10 +01:00
def download (
self , uri : str , timeout : int = REQUESTS_TIMEOUT_SEC , headers : dict | None = None , verify_ssl : bool = True
2025-07-02 08:47:59 +02:00
) -> tuple [ str , str ]:
2025-07-02 08:55:56 +02:00
"""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).
"""
2024-01-12 10:21:34 +01:00
if not headers :
headers = {}
2023-12-19 23:58:57 +01:00
o = requests . get ( uri , timeout = timeout , headers = headers )
return o . text , o . url
2024-04-25 22:53:51 +02:00
# TODO: Use pathlib.Path everywhere
2023-12-19 23:58:57 +01:00
class Download :
2025-07-02 08:55:56 +02:00
"""Main class for managing downloads, segment merging, file operations, and metadata for TIDAL media."""
2024-01-23 17:22:10 +01:00
settings : Settings
session : Session
2024-11-13 09:09:00 +01:00
skip_existing : bool = False
2024-01-23 17:22:10 +01:00
fn_logger : Callable
progress_gui : ProgressBars
progress : Progress
2025-03-01 21:53:46 +01:00
progress_overall : Progress
2025-03-03 09:31:37 +01:00
event_abort : Event
event_run : Event
2023-12-19 23:58:57 +01:00
2024-01-19 08:17:47 +01:00
def __init__ (
self ,
session : Session ,
path_base : str ,
fn_logger : Callable ,
2024-11-13 09:09:00 +01:00
skip_existing : bool = False ,
2025-07-02 08:47:59 +02:00
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 :
2025-07-02 08:55:56 +02:00
"""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.
"""
2024-01-14 18:10:58 +01:00
self . settings = Settings ()
2023-12-19 23:58:57 +01:00
self . session = session
self . skip_existing = skip_existing
2024-01-19 08:17:47 +01:00
self . fn_logger = fn_logger
self . progress_gui = progress_gui
self . progress = progress
2025-03-01 21:53:46 +01:00
self . progress_overall = progress_overall
2024-01-19 08:17:47 +01:00
self . path_base = path_base
2025-03-03 09:31:37 +01:00
self . event_abort = event_abort
self . event_run = event_run
2023-12-19 23:58:57 +01:00
2024-04-21 19:18:05 +02:00
if not self . settings . data . path_binary_ffmpeg and (
self . settings . data . video_convert_mp4 or self . settings . data . extract_flac
):
2024-11-06 09:41:39 +01:00
self . settings . data . video_convert_mp4 = False
self . settings . data . extract_flac = False
2024-04-21 19:18:05 +02:00
2024-11-06 09:41:39 +01:00
self . fn_logger . error (
"FFmpeg path is not set. Videos can be downloaded but will not be processed. FLAC cannot be "
"extracted from MP4 containers. Make sure FFmpeg is installed. The path to the FFmpeg binary must "
"be set in (`path_binary_ffmpeg`)."
)
2024-03-22 15:50:06 +01:00
2025-07-02 10:29:54 +02:00
def _get_media_urls (
2024-01-13 16:26:33 +01:00
self ,
2024-01-14 17:29:39 +01:00
media : Track | Video ,
2024-12-13 10:14:41 +01:00
stream_manifest : StreamManifest | None = None ,
2025-07-02 10:29:54 +02:00
) -> list [ str ]:
"""Extract URLs for the given media item.
2025-07-02 08:55:56 +02:00
Args:
media (Track | Video): The media item to download.
stream_manifest (StreamManifest | None, optional): Stream manifest for tracks. Defaults to None.
Returns:
2025-07-02 10:29:54 +02:00
list[str]: List of URLs for the media segments.
2025-07-02 08:55:56 +02:00
"""
2024-04-20 18:11:36 +02:00
# Get urls for media.
2025-07-02 10:29:54 +02:00
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 ))
2024-01-13 16:26:33 +01:00
2025-07-02 10:29:54 +02:00
return m3u8_playlist . files
2024-01-13 16:26:33 +01:00
else :
2025-07-02 10:29:54 +02:00
return []
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
2024-01-13 16:26:33 +01:00
2024-11-10 12:24:17 +01:00
# Compute total iterations for progress
if urls_count > 1 :
progress_total : int = urls_count
block_size : int | None = None
elif urls_count == 1 :
2025-02-28 17:27:12 +01:00
try :
# Get file size and compute progress steps
r = requests . head ( urls [ 0 ], timeout = REQUESTS_TIMEOUT_SEC )
2025-07-02 10:29:54 +02:00
2025-02-28 17:27:12 +01:00
total_size_in_bytes : int = int ( r . headers . get ( "content-length" , 0 ))
2025-07-02 10:29:54 +02:00
block_size = 1048576
progress_total = total_size_in_bytes / block_size
2025-02-28 17:27:12 +01:00
finally :
r . close ()
2024-11-10 12:24:17 +01:00
else :
raise ValueError
2024-01-13 16:26:33 +01:00
2024-11-10 12:24:17 +01:00
# Create progress Task
p_task : TaskID = self . progress . add_task (
f "[blue]Item ' { media_name [: 30 ] } '" ,
total = progress_total ,
2024-11-12 22:15:11 +01:00
visible = progress_to_stdout ,
2024-11-10 12:24:17 +01:00
)
2025-07-02 10:29:54 +02:00
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 ] = []
2024-01-14 17:29:39 +01:00
2024-11-12 22:15:11 +01:00
# Download segments until progress is finished.
2024-11-15 09:14:00 +01:00
# TODO: Compute download speed (https://github.com/Textualize/rich/blob/master/examples/downloader.py)
2024-11-10 12:24:17 +01:00
while not self . progress . tasks [ p_task ] . finished :
2024-11-12 22:15:11 +01:00
with futures . ThreadPoolExecutor (
max_workers = self . settings . data . downloads_simultaneous_per_track_max
) as executor :
# Dispatch all download tasks to worker threads
2025-07-02 08:47:59 +02:00
l_futures : list [ futures . Future ] = [
2024-11-12 22:15:11 +01:00
executor . submit ( self . _download_segment , url , path_base , block_size , p_task , progress_to_stdout )
for url in urls
]
2025-03-03 09:31:37 +01:00
2024-11-12 22:15:11 +01:00
# Report results as they become available
for future in futures . as_completed ( l_futures ):
# Retrieve result
result_dl_segment : DownloadSegmentResult = future . result ()
2024-01-13 16:26:33 +01:00
2024-11-12 22:15:11 +01:00
dl_segment_results . append ( result_dl_segment )
2024-01-14 17:29:39 +01:00
2025-07-02 10:29:54 +02:00
# Check for a link that was skipped
2024-11-12 22:15:11 +01:00
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
2025-07-02 10:29:54 +02:00
self . fn_logger . error ( "Something went wrong while downloading. File is corrupt!" )
2024-11-10 12:24:17 +01:00
2025-03-03 09:31:37 +01:00
# 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 ()
2025-07-02 10:29:54 +02:00
return False , dl_segment_results
2025-03-03 09:31:37 +01:00
2025-07-02 10:29:54 +02:00
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)
"""
2024-11-13 09:49:33 +01:00
tmp_path_file_decrypted : pathlib . Path = path_file
2025-07-02 10:29:54 +02:00
result_merge : bool = False
2024-11-10 12:24:17 +01:00
2024-11-13 09:49:33 +01:00
# 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 )
2025-07-02 10:29:54 +02:00
result_merge = self . _segments_merge ( path_file , dl_segment_results )
2024-11-13 09:49:33 +01:00
2024-11-14 08:07:54 +01:00
if not result_merge :
2025-07-02 10:29:54 +02:00
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 :
2024-11-12 22:15:11 +01:00
key , nonce = decrypt_security_token ( stream_manifest . encryption_key )
2024-11-20 12:30:14 +01:00
tmp_path_file_decrypted = path_file . with_suffix ( ".decrypted" )
2025-07-02 10:29:54 +02:00
2024-11-12 22:15:11 +01:00
decrypt_file ( path_file , tmp_path_file_decrypted , key , nonce )
2024-01-13 16:26:33 +01:00
2024-11-14 08:07:54 +01:00
return result_merge , tmp_path_file_decrypted
2024-01-13 16:26:33 +01:00
2025-07-02 10:29:54 +02:00
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
2025-07-02 08:47:59 +02:00
def _segments_merge ( self , path_file : pathlib . Path , dl_segment_results : list [ DownloadSegmentResult ]) -> bool :
2025-07-02 08:55:56 +02:00
"""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.
"""
2025-01-05 21:26:11 +01:00
result : bool = True
2024-11-12 22:15:11 +01:00
# Copy the content of all segments into one file.
try :
with path_file . open ( "wb" ) as f_target :
for dl_segment_result in dl_segment_results :
2024-11-14 08:07:54 +01:00
with dl_segment_result . path_segment . open ( "rb" ) as f_segment :
# Read and write junks, which gives better HDD write performance
while segment := f_segment . read ( CHUNK_SIZE ):
f_target . write ( segment )
2024-11-12 22:15:11 +01:00
2024-11-14 08:07:54 +01:00
# Delete segment from HDD
dl_segment_result . path_segment . unlink ()
2024-11-12 22:15:11 +01:00
2024-11-14 08:07:54 +01:00
except Exception :
2025-01-05 21:26:11 +01:00
if dl_segment_result is not dl_segment_results [ - 1 ]:
result = False
2024-11-12 22:15:11 +01:00
return result
def _download_segment (
self , url : str , path_base : pathlib . Path , block_size : int | None , p_task : TaskID , progress_to_stdout : bool
) -> DownloadSegmentResult :
2025-07-02 08:55:56 +02:00
"""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.
"""
2024-11-12 22:15:11 +01:00
result : bool = False
path_segment : pathlib . Path = path_base / url_to_filename ( url )
2024-11-12 22:36:16 +01:00
# Calculate the segment ID based on the file name within the URL.
2024-11-14 15:10:14 +01:00
filename_stem : str = str ( path_segment . stem ) . split ( "_" )[ - 1 ]
# CAUTION: This is a workaround, so BTS (LOW quality) track will work. They usually have only ONE link.
id_segment : int = int ( filename_stem ) if filename_stem . isdecimal () else 0
2024-11-13 09:09:00 +01:00
error : HTTPError | None = None
2024-11-12 22:15:11 +01:00
2025-03-03 09:31:37 +01:00
# If app is terminated (CTRL+C)
if self . event_abort . is_set ():
return DownloadSegmentResult (
result = False , url = url , path_segment = path_segment , id_segment = id_segment , error = error
)
if not self . event_run . is_set ():
self . event_run . wait ()
2024-11-13 12:36:01 +01:00
# Retry download on failed segments, with an exponential delay between retries
2025-02-28 17:27:12 +01:00
with requests . Session () as s :
retries = Retry ( total = 5 , backoff_factor = 1 ) # , status_forcelist=[ 502, 503, 504 ])
2024-11-13 12:36:01 +01:00
2025-02-28 17:27:12 +01:00
s . mount ( "https://" , HTTPAdapter ( max_retries = retries ))
2024-11-13 12:36:01 +01:00
2025-02-28 17:27:12 +01:00
try :
# Create the request object with stream=True, so the content won't be loaded into memory at once.
r = s . get ( url , stream = True , timeout = REQUESTS_TIMEOUT_SEC )
2024-11-12 22:15:11 +01:00
2025-02-28 17:27:12 +01:00
r . raise_for_status ()
2024-11-12 22:15:11 +01:00
2025-02-28 17:27:12 +01:00
# Write the content to disk. If `chunk_size` is set to `None` the whole file will be written at once.
with path_segment . open ( "wb" ) as f :
for data in r . iter_content ( chunk_size = block_size ):
f . write ( data )
# Advance progress bar.
self . progress . advance ( p_task )
2024-11-12 22:15:11 +01:00
2025-02-28 17:27:12 +01:00
result = True
except Exception :
self . progress . advance ( p_task )
2024-11-12 22:15:11 +01:00
2024-11-13 09:09:00 +01:00
# To send the progress to the GUI, we need to emit the percentage.
if not progress_to_stdout :
self . progress_gui . item . emit ( self . progress . tasks [ p_task ] . percentage )
return DownloadSegmentResult (
result = result , url = url , path_segment = path_segment , id_segment = id_segment , error = error
)
2024-11-12 22:15:11 +01:00
2025-01-12 17:42:09 +01:00
def extension_guess (
2025-07-02 08:47:59 +02:00
self , quality_audio : Quality , metadata_tags : list [ str ], is_video : bool
2025-01-12 17:42:09 +01:00
) -> AudioExtensions | VideoExtensions :
2025-07-02 08:55:56 +02:00
"""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.
"""
2024-12-16 23:10:35 +01:00
result : AudioExtensions | VideoExtensions
if is_video :
result = AudioExtensions . MP4 if self . settings . data . video_convert_mp4 else VideoExtensions . TS
else :
result = (
AudioExtensions . FLAC
2025-01-12 17:42:09 +01:00
if (
self . settings . data . extract_flac
and quality_audio in ( Quality . hi_res_lossless , Quality . high_lossless )
)
or ( "HIRES_LOSSLESS" not in metadata_tags and quality_audio not in ( Quality . low_96k , Quality . low_320k ))
or quality_audio == Quality . high_lossless
2024-12-16 23:10:35 +01:00
else AudioExtensions . M4A
)
return result
2023-12-19 23:58:57 +01:00
def item (
self ,
2024-01-13 11:57:11 +01:00
file_template : str ,
2025-07-02 08:47:59 +02:00
media_id : str | None = None ,
media_type : MediaType | None = None ,
2025-07-02 14:52:31 +02:00
media : Track | Video | None = None ,
2023-12-19 23:58:57 +01:00
video_download : bool = True ,
2024-02-01 20:04:55 +01:00
download_delay : bool = False ,
2024-04-21 19:18:05 +02:00
quality_audio : Quality | None = None ,
2024-04-21 22:42:19 +02:00
quality_video : QualityVideo | None = None ,
2024-11-11 10:05:32 +01:00
is_parent_album : bool = False ,
2025-04-02 07:00:28 +02:00
list_position : int = 0 ,
list_total : int = 0 ,
2025-07-02 08:47:59 +02:00
) -> tuple [ bool , pathlib . Path | str ]:
2025-07-02 08:55:56 +02:00
"""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.
2025-07-02 14:52:31 +02:00
media (Track | Video | None, optional): Media item. Defaults to None.
2025-07-02 08:55:56 +02:00
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)
"""
2025-07-02 14:52:31 +02:00
# Step 1: Validate and prepare media
2025-07-03 08:53:44 +02:00
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 ):
2025-07-02 14:52:31 +02:00
return False , ""
2025-07-03 08:53:44 +02:00
media = validated_media
2025-07-02 14:52:31 +02:00
# 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
2025-07-03 08:53:44 +02:00
def _validate_and_prepare_media (
2025-07-02 14:52:31 +02:00
self ,
2025-07-03 08:53:44 +02:00
media : Track | Video | Album | Playlist | UserPlaylist | Mix | None ,
2025-07-02 14:52:31 +02:00
media_id : str | None ,
media_type : MediaType | None ,
2025-07-03 08:53:44 +02:00
video_download : bool = True ,
) -> Track | Video | Album | Playlist | UserPlaylist | Mix | None :
2025-07-02 14:52:31 +02:00
"""Validate and prepare media instance for download.
Args:
2025-07-03 08:53:44 +02:00
media (Track | Video | Album | Playlist | UserPlaylist | Mix | None): Media instance.
2025-07-02 14:52:31 +02:00
media_id (str | None): Media ID if creating new instance.
media_type (MediaType | None): Media type if creating new instance.
2025-07-03 08:53:44 +02:00
video_download (bool, optional): Whether video downloads are allowed. Defaults to True.
2025-07-02 14:52:31 +02:00
Returns:
2025-07-03 08:53:44 +02:00
Track | Video | Album | Playlist | UserPlaylist | Mix | None: Prepared media instance or None if invalid.
2025-07-02 14:52:31 +02:00
"""
2024-03-25 14:12:28 +01:00
try :
if media_id and media_type :
# If no media instance is provided, we need to create the media instance.
2025-07-03 08:53:44 +02:00
# Throws `tidalapi.exceptions.ObjectNotFound` if item is not available anymore.
media = instantiate_media ( self . session , media_type , media_id )
2025-07-02 14:52:31 +02:00
elif isinstance ( media , Track | Video ):
# Check if media is available not deactivated / removed from TIDAL.
2024-03-25 14:12:28 +01:00
if not media . available :
self . fn_logger . info (
2025-03-24 08:57:47 +01:00
f "This item is not available for listening anymore on TIDAL. Skipping: { name_builder_item ( media ) } "
2024-03-25 14:12:28 +01:00
)
2025-07-02 14:52:31 +02:00
return None
2025-03-25 21:22:35 +01:00
elif isinstance ( media , Track ):
2024-03-25 14:12:28 +01:00
# Re-create media instance with full album information
2025-07-02 14:52:31 +02:00
media = self . session . track ( str ( media . id ), with_album = True )
2025-07-03 08:53:44 +02:00
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
2024-03-25 14:12:28 +01:00
elif not media :
raise MediaMissing
except :
2025-07-02 14:52:31 +02:00
return None
2024-02-26 06:53:53 +01:00
2025-07-03 08:53:44 +02:00
# If video download is not allowed and this is a video, return None
2024-02-26 06:48:11 +01:00
if not video_download and isinstance ( media , Video ):
2024-01-19 08:17:47 +01:00
self . fn_logger . info (
2024-01-13 11:57:11 +01:00
f "Video downloads are deactivated (see settings). Skipping video: { name_builder_item ( media ) } "
)
2025-07-02 14:52:31 +02:00
return None
2023-12-19 23:58:57 +01:00
2025-07-02 14:52:31 +02:00
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)
"""
2024-03-19 07:02:00 +01:00
# Create file name and path
2025-07-02 14:52:31 +02:00
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
2025-01-12 17:42:09 +01:00
file_extension_dummy : str = self . extension_guess (
2025-07-02 14:52:31 +02:00
quality_for_extension ,
metadata_tags = metadata_tags ,
2025-01-17 09:43:41 +01:00
is_video = isinstance ( media , Video ),
2025-01-12 17:42:09 +01:00
)
2025-07-02 14:52:31 +02:00
2025-04-02 07:00:28 +02:00
file_name_relative : str = format_path_media (
2025-10-17 13:13:19 +02:00
file_template ,
media ,
self . settings . data . album_track_num_pad_min ,
list_position ,
list_total ,
delimiter_artist = self . settings . data . filename_delimiter_artist ,
delimiter_album_artist = self . settings . data . filename_delimiter_album_artist ,
2025-04-02 07:00:28 +02:00
)
2025-07-02 14:52:31 +02:00
2024-11-13 09:49:33 +01:00
path_media_dst : pathlib . Path = (
2024-12-16 23:10:35 +01:00
pathlib . Path ( self . path_base ) . expanduser () / ( file_name_relative + file_extension_dummy )
) . absolute ()
2024-03-19 07:02:00 +01:00
2024-01-13 11:57:11 +01:00
# Sanitize final path_file to fit into OS boundaries.
2025-02-28 16:57:18 +01:00
path_media_dst = pathlib . Path ( path_file_sanitize ( path_media_dst , adapt = True ))
2023-12-19 23:58:57 +01:00
2024-01-13 11:57:11 +01:00
# Compute if and how downloads need to be skipped.
2024-12-16 10:55:09 +01:00
skip_download : bool = False
2024-11-13 09:09:00 +01:00
if self . skip_existing :
2024-12-16 23:10:35 +01:00
skip_file : bool = check_file_exists ( path_media_dst , extension_ignore = False )
2024-12-16 10:55:09 +01:00
if self . settings . data . symlink_to_track and not isinstance ( media , Video ):
# Compute symlink tracks path, sanitize and check if file exists
2025-10-17 13:13:19 +02:00
file_name_track_dir_relative : str = format_path_media (
self . settings . data . format_track ,
media ,
delimiter_artist = self . settings . data . filename_delimiter_artist ,
delimiter_album_artist = self . settings . data . filename_delimiter_album_artist ,
)
2024-12-16 23:10:35 +01:00
path_media_track_dir : pathlib . Path = (
pathlib . Path ( self . path_base ) . expanduser () / ( file_name_track_dir_relative + file_extension_dummy )
) . absolute ()
2025-02-28 16:57:18 +01:00
path_media_track_dir = pathlib . Path ( path_file_sanitize ( path_media_track_dir , adapt = True ))
2024-12-16 23:10:35 +01:00
file_exists_track_dir : bool = check_file_exists ( path_media_track_dir , extension_ignore = False )
file_exists_playlist_dir : bool = (
not file_exists_track_dir and skip_file and not path_media_dst . is_symlink ()
2024-12-16 10:55:09 +01:00
)
2024-12-16 23:10:35 +01:00
skip_download = file_exists_playlist_dir or file_exists_track_dir
2025-07-02 14:52:31 +02:00
# If file exists in playlist dir but not in track dir, we don't skip the file itself
2024-12-16 23:10:35 +01:00
if skip_file and file_exists_playlist_dir :
skip_file = False
2024-01-13 11:57:11 +01:00
else :
2024-11-13 09:09:00 +01:00
skip_file : bool = False
2025-07-02 14:52:31 +02:00
return path_media_dst , file_extension_dummy , skip_file , skip_download
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.
Args:
quality_audio (Quality | None): Audio quality setting.
quality_video (QualityVideo | None): Video quality setting.
Returns:
tuple[Quality | None, QualityVideo | None]: Previous quality settings.
"""
quality_audio_old : Quality | None = None
quality_video_old : QualityVideo | None = None
if quality_audio :
quality_audio_old = self . adjust_quality_audio ( quality_audio )
if quality_video :
quality_video_old = self . adjust_quality_video ( quality_video )
return quality_audio_old , quality_video_old
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.
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.
Returns:
bool: Whether download was successful.
"""
if skip_download :
return True
# Get stream information and final file extension
stream_manifest , file_extension , do_flac_extract , media_stream = self . _get_stream_info ( media )
if stream_manifest is None and isinstance ( media , Track ):
return False
# 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 ))
os . makedirs ( path_media_dst . parent , exist_ok = True )
# Perform actual download
return self . _perform_actual_download (
media , path_media_dst , stream_manifest , do_flac_extract , is_parent_album , media_stream
)
def _get_stream_info ( self , media : Track | Video ) -> tuple [ StreamManifest | None , str , bool , Stream | None ]:
"""Get stream information for media.
Args:
media (Track | Video): Media item.
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
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."
)
return None , "" , False , None
except Exception :
self . fn_logger . exception ( f "Something went wrong. Skipping ' { name_builder_item ( media ) } '." )
return None , "" , False , None
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
2024-11-13 12:04:24 +01:00
)
2024-11-20 12:30:14 +01:00
2025-07-02 14:52:31 +02:00
if not result_download :
return False
2024-12-13 10:14:41 +01:00
2025-07-02 14:52:31 +02:00
# 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 )
2024-12-13 10:14:41 +01:00
2025-07-02 14:52:31 +02:00
# 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 )
2024-12-13 10:14:41 +01:00
2025-07-02 14:52:31 +02:00
# Handle metadata, lyrics, and cover
self . _handle_metadata_and_extras ( media , tmp_path_file , path_media_dst , is_parent_album , media_stream )
2024-11-13 09:09:00 +01:00
2025-07-02 14:52:31 +02:00
self . fn_logger . info ( f "Downloaded item ' { name_builder_item ( media ) } '." )
2024-11-13 09:09:00 +01:00
2025-07-02 14:52:31 +02:00
# Move final file to the configured destination directory.
shutil . move ( tmp_path_file , path_media_dst )
2025-06-30 16:33:32 +02:00
2025-07-02 14:52:31 +02:00
return True
2023-12-19 23:58:57 +01:00
2025-07-02 14:52:31 +02:00
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.
2024-11-12 22:15:11 +01:00
2025-07-02 14:52:31 +02:00
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
2024-11-12 22:15:11 +01:00
2025-07-02 14:52:31 +02:00
tmp_path_lyrics : pathlib . Path | None = None
tmp_path_cover : pathlib . Path | None = None
2024-12-16 10:55:09 +01:00
2025-07-02 14:52:31 +02:00
# 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
)
2024-12-16 10:55:09 +01:00
2025-07-02 14:52:31 +02:00
# Move lyrics file
if self . settings . data . lyrics_file and tmp_path_lyrics :
self . _move_lyrics ( tmp_path_lyrics , path_media_dst )
2024-12-16 10:55:09 +01:00
2025-07-02 14:52:31 +02:00
# Move cover file
if self . settings . data . cover_album_file and tmp_path_cover :
self . _move_cover ( tmp_path_cover , path_media_dst )
2024-12-16 10:55:09 +01:00
2025-07-02 14:52:31 +02:00
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.
2024-12-16 10:55:09 +01:00
2025-07-02 14:52:31 +02:00
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 )
2024-12-16 10:55:09 +01:00
2025-07-02 14:52:31 +02:00
# Reset quality settings
if quality_audio_old is not None :
self . adjust_quality_audio ( quality_audio_old )
2024-12-16 10:55:09 +01:00
2025-07-02 14:52:31 +02:00
if quality_video_old is not None :
self . adjust_quality_video ( quality_video_old )
2024-12-16 10:55:09 +01:00
2025-07-02 14:52:31 +02:00
# Apply download delay if needed
2025-03-03 09:31:37 +01:00
if ( download_delay and not skip_file ) and not self . event_abort . is_set ():
2024-11-20 12:30:14 +01:00
time_sleep : float = round (
random . SystemRandom () . uniform (
self . settings . data . download_delay_sec_min , self . settings . data . download_delay_sec_max
),
1 ,
)
2024-02-01 20:04:55 +01:00
self . fn_logger . debug ( f "Next download will start in { time_sleep } seconds." )
time . sleep ( time_sleep )
2024-12-16 10:40:06 +01:00
def media_move_and_symlink (
self , media : Track | Video , path_media_src : pathlib . Path , file_extension : str
) -> pathlib . Path :
2025-07-02 08:55:56 +02:00
"""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.
"""
2024-12-16 10:40:06 +01:00
# Compute tracks path, sanitize and ensure path exists
2025-10-17 13:13:19 +02:00
file_name_relative : str = format_path_media (
self . settings . data . format_track ,
media ,
delimiter_artist = self . settings . data . filename_delimiter_artist ,
delimiter_album_artist = self . settings . data . filename_delimiter_album_artist ,
)
2024-12-16 10:40:06 +01:00
path_media_dst : pathlib . Path = (
2024-12-16 23:10:35 +01:00
pathlib . Path ( self . path_base ) . expanduser () / ( file_name_relative + file_extension )
) . absolute ()
2025-02-28 16:57:18 +01:00
path_media_dst = pathlib . Path ( path_file_sanitize ( path_media_dst , adapt = True ))
2024-12-16 10:40:06 +01:00
os . makedirs ( path_media_dst . parent , exist_ok = True )
# Move item and symlink it
2024-12-16 14:42:53 +01:00
if path_media_dst != path_media_src :
if self . skip_existing :
2024-12-16 16:40:21 +01:00
skip_file : bool = check_file_exists ( path_media_dst , extension_ignore = False )
skip_symlink : bool = path_media_src . is_symlink ()
2024-12-16 14:42:53 +01:00
else :
skip_file : bool = False
2024-12-16 16:40:21 +01:00
skip_symlink : bool = False
2024-12-16 10:40:06 +01:00
2024-12-16 14:42:53 +01:00
if not skip_file :
2024-12-16 16:40:21 +01:00
self . fn_logger . debug ( f "Move: { path_media_src } -> { path_media_dst } " )
2024-12-16 14:42:53 +01:00
shutil . move ( path_media_src , path_media_dst )
2024-12-16 10:40:06 +01:00
2024-12-16 16:40:21 +01:00
if not skip_symlink :
self . fn_logger . debug ( f "Symlink: { path_media_src } -> { path_media_dst } " )
2025-01-04 12:51:17 +01:00
path_media_dst_relative : pathlib . Path = path_media_dst . relative_to ( path_media_src . parent , walk_up = True )
2024-12-16 16:40:21 +01:00
path_media_src . unlink ( missing_ok = True )
2025-01-04 12:51:17 +01:00
path_media_src . symlink_to ( path_media_dst_relative )
2024-12-16 10:40:06 +01:00
return path_media_dst
2025-07-02 08:47:59 +02:00
def adjust_quality_audio ( self , quality : Quality ) -> Quality :
2025-07-02 08:55:56 +02:00
"""Temporarily set audio quality and return the previous value.
Args:
quality (Quality): New audio quality.
Returns:
Quality: Previous audio quality.
"""
2024-04-21 15:58:31 +02:00
# Save original quality settings
quality_old : Quality = self . session . audio_quality
self . session . audio_quality = quality
return quality_old
2025-07-02 08:47:59 +02:00
def adjust_quality_video ( self , quality : QualityVideo ) -> QualityVideo :
2025-07-02 08:55:56 +02:00
"""Temporarily set video quality and return the previous value.
Args:
quality (QualityVideo): New video quality.
Returns:
QualityVideo: Previous video quality.
"""
2024-04-21 22:42:19 +02:00
quality_old : QualityVideo = self . settings . data . quality_video
2024-04-21 19:18:05 +02:00
2024-04-21 22:42:19 +02:00
self . settings . data . quality_video = quality
2024-04-21 19:18:05 +02:00
return quality_old
2024-11-11 10:05:32 +01:00
def _move_file ( self , path_file_source : pathlib . Path , path_file_destination : str | pathlib . Path ) -> bool :
2025-07-02 08:55:56 +02:00
"""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.
"""
2024-04-25 22:53:51 +02:00
result : bool
2024-11-11 10:05:32 +01:00
2024-02-01 20:53:45 +01:00
# Check if the file was downloaded
2024-11-13 10:37:29 +01:00
if path_file_source and path_file_source . is_file ():
2024-02-01 20:53:45 +01:00
# Move it.
2024-11-11 10:05:32 +01:00
shutil . move ( path_file_source , path_file_destination )
2024-02-01 20:53:45 +01:00
2024-04-25 22:53:51 +02:00
result = True
else :
result = False
return result
2024-11-13 09:49:33 +01:00
def _move_lyrics ( self , path_lyrics : pathlib . Path , file_media_dst : pathlib . Path ) -> bool :
2025-07-02 08:55:56 +02:00
"""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.
"""
2024-11-11 10:05:32 +01:00
# Build tmp lyrics filename
2024-11-13 09:49:33 +01:00
path_file_lyrics : pathlib . Path = file_media_dst . with_suffix ( EXTENSION_LYRICS )
2024-11-11 10:05:32 +01:00
result : bool = self . _move_file ( path_lyrics , path_file_lyrics )
return result
2024-11-13 09:49:33 +01:00
def _move_cover ( self , path_cover : pathlib . Path , file_media_dst : pathlib . Path ) -> bool :
2025-07-02 08:55:56 +02:00
"""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.
"""
2024-11-11 10:05:32 +01:00
# Build tmp lyrics filename
2024-11-13 09:49:33 +01:00
path_file_cover : pathlib . Path = file_media_dst . parent / COVER_NAME
2024-11-11 10:05:32 +01:00
result : bool = self . _move_file ( path_cover , path_file_cover )
return result
def lyrics_to_file ( self , dir_destination : pathlib . Path , lyrics : str ) -> str :
2025-07-02 08:55:56 +02:00
"""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.
"""
2024-11-11 10:05:32 +01:00
return self . write_to_tmp_file ( dir_destination , mode = "x" , content = lyrics )
def cover_to_file ( self , dir_destination : pathlib . Path , image : bytes ) -> str :
2025-07-02 08:55:56 +02:00
"""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.
"""
2024-11-11 10:05:32 +01:00
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 :
2025-07-02 08:55:56 +02:00
"""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.
"""
2025-02-28 10:55:41 +01:00
result : pathlib . Path = dir_destination / str ( uuid4 ())
2024-11-11 10:05:32 +01:00
encoding : str | None = "utf-8" if isinstance ( content , str ) else None
2024-02-01 20:53:45 +01:00
try :
2024-11-11 10:05:32 +01:00
with open ( result , mode = mode , encoding = encoding ) as f :
f . write ( content )
2024-02-01 20:53:45 +01:00
except :
result = ""
return result
2024-11-11 10:05:32 +01:00
@staticmethod
2025-07-02 08:47:59 +02:00
def cover_data ( url : str | None = None , path_file : str | None = None ) -> str | bytes :
2025-07-02 08:55:56 +02:00
"""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.
"""
2024-11-11 10:05:32 +01:00
result : str | bytes = ""
if url :
try :
2025-02-28 17:27:12 +01:00
response : requests . Response = requests . get ( url , timeout = REQUESTS_TIMEOUT_SEC )
result = response . content
2024-11-11 10:05:32 +01:00
except Exception as e :
# TODO: Implement propper logging.
print ( e )
2025-02-28 17:27:12 +01:00
finally :
response . close ()
2024-11-11 10:05:32 +01:00
elif path_file :
try :
with open ( path_file , "rb" ) as f :
result = f . read ()
except OSError as e :
# TODO: Implement propper logging.
print ( e )
return result
def metadata_write (
2024-11-29 21:56:42 +01:00
self , track : Track , path_media : pathlib . Path , is_parent_album : bool , media_stream : Stream
2025-07-02 08:47:59 +02:00
) -> tuple [ bool , pathlib . Path | None , pathlib . Path | None ]:
2025-07-02 08:55:56 +02:00
"""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)
"""
2023-12-19 23:58:57 +01:00
result : bool = False
2024-04-25 22:53:51 +02:00
path_lyrics : pathlib . Path | None = None
2024-11-11 10:05:32 +01:00
path_cover : pathlib . Path | None = None
2024-01-14 17:29:39 +01:00
release_date : str = (
2024-03-22 15:02:26 +01:00
track . album . available_release_date . strftime ( "%Y-%m- %d " )
if track . album . available_release_date
else track . album . release_date . strftime ( "%Y-%m- %d " ) if track . album . release_date else ""
2024-01-14 17:29:39 +01:00
)
2024-01-15 21:55:51 +01:00
copy_right : str = track . copyright if hasattr ( track , "copyright" ) and track . copyright else ""
isrc : str = track . isrc if hasattr ( track , "isrc" ) and track . isrc else ""
2024-01-14 18:04:31 +01:00
lyrics : str = ""
2025-10-17 14:41:23 +02:00
lyrics_synced : str = ""
lyrics_unsynced : str = ""
2024-11-11 10:05:32 +01:00
cover_data : bytes = None
2023-12-19 23:58:57 +01:00
2024-02-01 20:53:45 +01:00
if self . settings . data . lyrics_embed or self . settings . data . lyrics_file :
2024-01-14 18:04:31 +01:00
# Try to retrieve lyrics.
try :
2024-03-04 08:24:13 +01:00
lyrics_obj = track . lyrics ()
2025-10-17 14:41:23 +02:00
if lyrics_obj . text :
lyrics_unsynced = lyrics_obj . text
lyrics = lyrics_unsynced
2024-03-04 08:24:13 +01:00
if lyrics_obj . subtitles :
2025-10-17 14:41:23 +02:00
lyrics_synced = lyrics_obj . subtitles
lyrics = lyrics_synced
2024-04-20 16:19:08 +02:00
except :
2024-03-04 08:24:13 +01:00
lyrics = ""
2024-01-14 18:04:31 +01:00
# TODO: Implement proper logging.
print ( f "Could not retrieve lyrics for ` { name_builder_item ( track ) } `." )
2023-12-19 23:58:57 +01:00
2024-02-09 11:01:25 +01:00
if lyrics and self . settings . data . lyrics_file :
2024-11-12 22:15:11 +01:00
path_lyrics = self . lyrics_to_file ( path_media . parent , lyrics )
2024-11-11 10:05:32 +01:00
2025-09-07 19:37:39 +02:00
cover_dimension = self . settings . data . metadata_cover_dimension
2024-11-11 10:05:32 +01:00
if self . settings . data . metadata_cover_embed or ( self . settings . data . cover_album_file and is_parent_album ):
2025-09-07 19:37:39 +02:00
# Do not write CoverDimensions.PxORIGIN to metadata, since it can exceed max metadata file size (>16Mb)
2025-08-12 18:22:59 +02:00
url_cover = track . album . image (
2025-09-07 19:37:39 +02:00
int ( cover_dimension ) if cover_dimension != CoverDimensions . PxORIGIN else int ( CoverDimensions . Px1280 )
2025-08-12 18:22:59 +02:00
)
2024-11-11 10:05:32 +01:00
cover_data = self . cover_data ( url = url_cover )
if cover_data and self . settings . data . cover_album_file and is_parent_album :
2025-09-07 19:37:39 +02:00
if cover_dimension == CoverDimensions . PxORIGIN :
url_cover_album_file = track . album . image ( CoverDimensions . PxORIGIN )
cover_data_album_file = self . cover_data ( url = url_cover_album_file )
else :
cover_data_album_file = cover_data
path_cover = self . cover_to_file ( path_media . parent , cover_data_album_file )
2024-02-01 20:53:45 +01:00
2025-10-17 13:46:47 +02:00
metadata_target_upc = MetadataTargetUPC ( self . settings . data . metadata_target_upc )
target_upc : dict [ str , str ] = METADATA_LOOKUP_UPC [ metadata_target_upc ]
2025-10-17 15:40:28 +02:00
explicit : bool = track . explicit if hasattr ( track , "explicit" ) else False
title = name_builder_title ( track )
2025-10-19 18:16:19 -05:00
title += METADATA_EXPLICIT if explicit and self . settings . data . mark_explicit else ""
2025-10-17 13:46:47 +02:00
2024-01-17 22:18:48 +01:00
# `None` values are not allowed.
2023-12-19 23:58:57 +01:00
m : Metadata = Metadata (
2024-04-25 22:53:51 +02:00
path_file = path_media ,
2025-10-17 13:46:47 +02:00
target_upc = target_upc ,
2025-10-17 14:41:23 +02:00
lyrics = lyrics_synced ,
lyrics_unsynced = lyrics_unsynced ,
2023-12-19 23:58:57 +01:00
copy_right = copy_right ,
2025-10-17 15:40:28 +02:00
title = title ,
2025-10-17 12:06:25 +02:00
artists = name_builder_artist ( track , delimiter = self . settings . data . metadata_delimiter_artist ),
2024-01-14 17:29:39 +01:00
album = track . album . name if track . album else "" ,
2023-12-19 23:58:57 +01:00
tracknumber = track . track_num ,
date = release_date ,
2023-12-20 07:42:36 +01:00
isrc = isrc ,
2025-10-17 12:06:25 +02:00
albumartist = name_builder_album_artist ( track , delimiter = self . settings . data . metadata_delimiter_album_artist ),
2024-01-14 17:29:39 +01:00
totaltrack = track . album . num_tracks if track . album and track . album . num_tracks else 1 ,
totaldisc = track . album . num_volumes if track . album and track . album . num_volumes else 1 ,
discnumber = track . volume_num if track . volume_num else 1 ,
2024-11-11 10:05:32 +01:00
cover_data = cover_data if self . settings . data . metadata_cover_embed else None ,
2024-11-29 21:56:42 +01:00
album_replay_gain = media_stream . album_replay_gain ,
album_peak_amplitude = media_stream . album_peak_amplitude ,
track_replay_gain = media_stream . track_replay_gain ,
track_peak_amplitude = media_stream . track_peak_amplitude ,
2025-08-12 19:36:41 +02:00
url_share = track . share_url if track . share_url and self . settings . data . metadata_write_url else "" ,
2025-02-28 11:47:38 +01:00
replay_gain_write = self . settings . data . metadata_replay_gain ,
2025-08-12 09:55:34 +02:00
upc = track . album . upc if track . album and track . album . upc else "" ,
2025-10-17 15:40:28 +02:00
explicit = explicit ,
2023-12-19 23:58:57 +01:00
)
m . save ()
result = True
2024-11-11 10:05:32 +01:00
return result , path_lyrics , path_cover
2023-12-19 23:58:57 +01:00
2024-01-12 10:21:34 +01:00
def items (
2023-12-19 23:58:57 +01:00
self ,
2024-01-19 08:17:47 +01:00
file_template : str ,
2025-07-02 08:47:59 +02:00
media : Album | Playlist | UserPlaylist | Mix | None = None ,
media_id : str | None = None ,
media_type : MediaType | None = None ,
2023-12-19 23:58:57 +01:00
video_download : bool = False ,
download_delay : bool = True ,
2024-04-21 22:42:19 +02:00
quality_audio : Quality | None = None ,
quality_video : QualityVideo | None = None ,
2025-07-02 08:47:59 +02:00
) -> None :
2025-07-02 08:55:56 +02:00
"""Download all items in an album, playlist, or mix.
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.
"""
2025-07-02 15:11:19 +02:00
# Validate and prepare media collection
2025-07-03 08:53:44 +02:00
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 ):
2025-07-02 15:11:19 +02:00
return
2025-07-03 08:53:44 +02:00
media = validated_media
2025-07-02 15:11:19 +02:00
# 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)
"""
2024-01-14 17:58:54 +01:00
# Create file name and path
2025-10-17 13:13:19 +02:00
file_name_relative : str = format_path_media (
file_template ,
media ,
delimiter_artist = self . settings . data . filename_delimiter_artist ,
delimiter_album_artist = self . settings . data . filename_delimiter_album_artist ,
)
2023-12-19 23:58:57 +01:00
2024-01-18 22:14:46 +01:00
# Get the name of the list and check, if videos should be included.
2024-11-15 09:53:21 +01:00
list_media_name : str = name_builder_title ( media )
list_media_name_short : str = list_media_name [: 30 ]
2023-12-19 23:58:57 +01:00
2024-01-18 22:14:46 +01:00
# Get all items of the list.
2024-11-15 09:53:21 +01:00
items = items_results_all ( media , videos_include = video_download )
2024-01-18 22:14:46 +01:00
2024-01-14 17:58:54 +01:00
# Determine where to redirect the progress information.
2024-01-19 08:17:47 +01:00
if self . progress_gui is None :
2023-12-19 23:58:57 +01:00
progress_stdout : bool = True
else :
progress_stdout : bool = False
2025-07-02 15:11:19 +02:00
2025-03-01 21:53:46 +01:00
self . progress_gui . list_name . emit ( list_media_name_short )
2025-07-02 15:11:19 +02:00
return file_name_relative , list_media_name , list_media_name_short , items , progress_stdout
2023-12-19 23:58:57 +01:00
2025-07-02 15:11:19 +02:00
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.
2023-12-19 23:58:57 +01:00
2025-07-02 15:11:19 +02:00
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.
"""
2025-07-02 08:47:59 +02:00
result_dirs : list [ pathlib . Path ] = []
2024-11-11 10:05:32 +01:00
2025-10-06 13:36:29 +02:00
# Check if items list is empty
if not items :
# Mark progress as complete for empty lists
progress . update ( progress_task , completed = progress . tasks [ progress_task ] . total )
if not progress_stdout and self . progress_gui :
self . progress_gui . list_item . emit ( 100.0 )
return result_dirs
2024-01-14 17:58:54 +01:00
# Iterate through list items
2025-03-01 21:53:46 +01:00
while not progress . finished :
2024-12-15 23:35:10 +01:00
with futures . ThreadPoolExecutor ( max_workers = self . settings . data . downloads_concurrent_max ) as executor :
# Dispatch all download tasks to worker threads
2025-07-02 15:11:19 +02:00
download_futures : list [ futures . Future ] = [
2024-12-15 23:35:10 +01:00
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 ,
2025-04-02 07:00:28 +02:00
list_position = count + 1 ,
list_total = list_total ,
2024-12-15 23:35:10 +01:00
)
2025-04-02 07:00:28 +02:00
for count , item_media in enumerate ( items )
2024-12-15 23:35:10 +01:00
]
2025-03-03 09:31:37 +01:00
2025-07-02 15:11:19 +02:00
# Process download results
result_dirs = self . _process_download_futures ( download_futures , progress , progress_task , progress_stdout )
2024-12-17 09:41:06 +01:00
2025-07-02 15:11:19 +02:00
# Check for abort signal
if self . event_abort . is_set ():
return result_dirs
2024-01-14 17:58:54 +01:00
2025-07-02 15:11:19 +02:00
return result_dirs
2023-12-19 23:58:57 +01:00
2025-07-02 15:11:19 +02:00
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.
2023-12-19 23:58:57 +01:00
2025-07-02 15:11:19 +02:00
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.
2025-03-03 09:31:37 +01:00
2025-07-02 15:11:19 +02:00
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 )
]
2025-03-03 09:31:37 +01:00
2025-07-02 15:11:19 +02:00
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.
2024-12-16 14:42:53 +01:00
2025-07-02 15:11:19 +02:00
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
2024-11-15 09:53:21 +01:00
2025-04-02 07:00:28 +02:00
def playlist_populate (
2025-07-02 08:47:59 +02:00
self , dirs_scoped : set [ pathlib . Path ], name_list : str , is_album : bool , sort_alphabetically : bool
) -> list [ pathlib . Path ]:
2025-07-02 08:55:56 +02:00
"""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.
"""
2025-07-02 08:47:59 +02:00
result : list [ pathlib . Path ] = []
2024-12-16 14:42:53 +01:00
# For each dir, which contains tracks
for dir_scoped in dirs_scoped :
# Sanitize final playlist name to fit into OS boundaries.
2025-02-28 11:47:38 +01:00
path_playlist = dir_scoped / sanitize_filename ( PLAYLIST_PREFIX + name_list + PLAYLIST_EXTENSION )
2024-12-16 14:42:53 +01:00
path_playlist = pathlib . Path ( path_file_sanitize ( path_playlist , adapt = True ))
2024-12-16 16:40:21 +01:00
self . fn_logger . debug ( f "Playlist: Creating { path_playlist } " )
2024-12-16 14:42:53 +01:00
# Get all tracks in the directory
2025-07-02 08:47:59 +02:00
path_tracks : list [ pathlib . Path ] = []
2024-12-16 14:42:53 +01:00
2025-08-13 08:17:11 +02:00
for extension_audio in AudioExtensionsValid :
2024-12-16 14:42:53 +01:00
path_tracks = path_tracks + list ( dir_scoped . glob ( f "* { extension_audio !s} " ))
2025-04-02 07:00:28 +02:00
# Sort alphabetically, e.g. if items are prefixed with numbers
if sort_alphabetically :
path_tracks . sort ()
elif not is_album :
2025-10-17 13:13:19 +02:00
# If it is not an album sort by creation time
path_tracks . sort ( key = lambda x : os . path . getctime ( x ))
2024-12-16 14:42:53 +01:00
# Write data to m3u file
with path_playlist . open ( mode = "w" , encoding = "utf-8" ) as f :
for path_track in path_tracks :
2024-12-16 23:10:35 +01:00
# If it's a symlink write the relative file path to the actual track into the playlist file
2024-12-16 14:42:53 +01:00
if path_track . is_symlink ():
media_file_target = path_track . resolve () . relative_to ( path_track . parent , walk_up = True )
2025-01-11 16:07:05 +01:00
else :
media_file_target = path_track . name
2024-12-16 14:42:53 +01:00
f . write ( str ( media_file_target ) + os . linesep )
result . append ( path_playlist )
return result
2024-11-12 22:15:11 +01:00
def _video_convert ( self , path_file : pathlib . Path ) -> pathlib . Path :
2025-07-02 08:55:56 +02:00
"""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.
"""
2024-11-12 22:15:11 +01:00
path_file_out : pathlib . Path = path_file . with_suffix ( AudioExtensions . MP4 )
2025-01-03 16:06:13 +01:00
ffmpeg = (
FFmpeg ( executable = self . settings . data . path_binary_ffmpeg )
. option ( "y" )
. input ( url = path_file )
. output ( url = path_file_out , codec = "copy" , map = 0 , loglevel = "quiet" )
2024-06-06 21:57:08 +02:00
)
2024-04-20 18:11:36 +02:00
2025-01-03 16:06:13 +01:00
ffmpeg . execute ()
2024-04-20 18:11:36 +02:00
return path_file_out
2024-11-12 22:15:11 +01:00
def _extract_flac ( self , path_media_src : pathlib . Path ) -> pathlib . Path :
2025-07-02 08:55:56 +02:00
"""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.
"""
2024-11-12 22:15:11 +01:00
path_media_out = path_media_src . with_suffix ( AudioExtensions . FLAC )
2025-01-03 16:06:13 +01:00
ffmpeg = (
FFmpeg ( executable = self . settings . data . path_binary_ffmpeg )
. input ( url = path_media_src )
2024-05-21 15:38:59 +02:00
. output (
2025-01-04 13:42:46 +01:00
url = path_media_out ,
map = 0 ,
2024-11-13 10:58:52 +01:00
movflags = "use_metadata_tags" ,
acodec = "copy" ,
map_metadata = "0:g" ,
2025-01-04 13:42:46 +01:00
loglevel = "quiet" ,
2024-05-21 15:38:59 +02:00
)
2024-04-21 19:18:05 +02:00
)
2025-01-03 16:06:13 +01:00
ffmpeg . execute ()
2024-04-25 22:53:51 +02:00
return path_media_out
2024-04-21 19:18:05 +02:00
2025-07-02 08:47:59 +02:00
def _extract_video_stream ( self , m3u8_variant : m3u8 . M3U8 , quality : int ) -> tuple [ m3u8 . M3U8 | bool , str ]:
2025-07-02 08:55:56 +02:00
"""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)
"""
2024-04-20 18:11:36 +02:00
m3u8_playlist : m3u8 . M3U8 | bool = False
resolution_best : int = 0
mime_type : str = ""
if m3u8_variant . is_variant :
for playlist in m3u8_variant . playlists :
if resolution_best < playlist . stream_info . resolution [ 1 ]:
resolution_best = playlist . stream_info . resolution [ 1 ]
m3u8_playlist = m3u8 . load ( playlist . uri )
mime_type = playlist . stream_info . codecs
if quality == playlist . stream_info . resolution [ 1 ]:
break
return m3u8_playlist , mime_type