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.py331
1 files changed, 331 insertions, 0 deletions
diff --git a/tidal_dl_ng/download.py b/tidal_dl_ng/download.py
new file mode 100644
index 0000000..ba9fde0
--- /dev/null
+++ b/tidal_dl_ng/download.py
@@ -0,0 +1,331 @@
+import base64
+import json
+import os
+import random
+import shutil
+import tempfile
+import time
+from logging import Logger
+from requests.exceptions import HTTPError
+from uuid import uuid4
+
+import ffmpeg
+import m3u8
+import requests
+from tidal_dl_ng.config import Settings
+from tidal_dl_ng.constants import MediaType
+from tidal_dl_ng.helper.decryption import decrypt_file, decrypt_security_token
+from tidal_dl_ng.helper.exceptions import MediaUnknown
+from tidal_dl_ng.helper.path import check_file_exists, format_path_media, path_validate
+from tidal_dl_ng.helper.wrapper import WrapperLogger
+from tidal_dl_ng.metadata import Metadata
+from tidal_dl_ng.model.gui_data import ProgressBars
+from pathvalidate import sanitize_filepath
+from rich.progress import Progress
+from tidalapi import Album, Playlist, Session, Track, UserPlaylist, Video, Mix
+
+
+# TODO: Set appropriate client string and use it for video download.
+# https://github.com/globocom/m3u8#using-different-http-clients
+class RequestsClient:
+ def download(self, uri, timeout=None, headers={}, verify_ssl=True):
+ o = requests.get(uri, timeout=timeout, headers=headers)
+
+ return o.text, o.url
+
+
+class Download:
+ # TODO: Implement download cover 1280.
+ session: Session = None
+ skip_existing: bool = False
+
+ def __init__(self, session: Session, skip_existing: bool = False):
+ self.session = session
+ self.skip_existing = skip_existing
+
+ def _video(self, video: Video, path_file: str) -> str | None:
+ result: str | None = None
+ m3u8_variant: m3u8.M3U8 = m3u8.load(video.get_url())
+ m3u8_playlist: m3u8.M3U8 | bool = False
+ settings: Settings = Settings()
+ resolution_best: int = 0
+
+ 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)
+
+ if settings.data.quality_video.value == playlist.stream_info.resolution[1]:
+ break
+
+ if m3u8_playlist:
+ with open(path_file, "wb") as f:
+ for segment in m3u8_playlist.data["segments"]:
+ url = segment["uri"]
+ r = requests.get(url)
+
+ f.write(r.content)
+
+ result = path_file
+
+ return result
+
+ def item(
+ self,
+ path_base: str,
+ fn_logger: Logger | WrapperLogger,
+ id: str = None,
+ file_template: str = None,
+ media: Track | Video = None,
+ media_type: MediaType = None,
+ video_download: bool = True,
+ progress_gui: ProgressBars = None,
+ progress: Progress = None
+ ) -> (bool, str):
+ if id:
+ if media_type == MediaType.Track:
+ media = Track(self.session, id)
+ elif media_type == MediaType.Video:
+ media = Video(self.session, id)
+
+ # If video download is not allowed
+ if not video_download:
+ return False, ""
+ else:
+ raise MediaUnknown
+ else:
+ media = media
+
+ if file_template:
+ file_name_relative = format_path_media(file_template, media)
+ path_file = os.path.abspath(os.path.normpath(os.path.join(path_base, file_name_relative)))
+ else:
+ path_file = os.path.abspath(os.path.normpath(format_path_media(path_base, media)))
+
+ if isinstance(media, Track):
+ stream = media.stream()
+ # TODO: Check for `manifest_mime_type'. It could be also xml.
+ stream_manifest = json.loads(base64.b64decode(stream.manifest).decode("utf-8"))
+ # TODO: Handle more than one dowload URL
+ stream_url = stream_manifest["urls"][0]
+ file_extension = self.get_file_extension(stream_url, stream_manifest["codecs"])
+ elif isinstance(media, Video):
+ file_extension = ".ts"
+
+ path_file = sanitize_filepath(path_file + file_extension)
+ # Check if path & filename longer than the OS allows. Shorten if necessary.
+ validation_result, path_file = path_validate(path_file, True)
+
+ download_skip = check_file_exists(path_file) if self.skip_existing else False
+
+ if not download_skip:
+ with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp_path_dir:
+ tmp_path_file = os.path.join(tmp_path_dir, str(uuid4()) + file_extension)
+
+ if isinstance(media, Track):
+ # TODO: Refactor to separate method.
+ if progress_gui is None:
+ progress_stdout: bool = True
+ else:
+ progress_stdout: bool = False
+ progress_gui.item_name.emit(media.name)
+
+ try:
+ # Streaming, so we can iterate over the response.
+ r = requests.get(stream_url, stream=True)
+ r.raise_for_status()
+ total_size_in_bytes = int(r.headers.get("content-length", 0))
+ block_size = 4096
+ p_task = progress.add_task(
+ f"[blue]Item '{media.name[:30]}'",
+ total=total_size_in_bytes / block_size,
+ visible=progress_stdout,
+ )
+
+ while not progress.tasks[p_task].finished:
+ with open(tmp_path_file, "wb") as f:
+ for data in r.iter_content(chunk_size=block_size):
+ f.write(data)
+ progress.advance(p_task, advance=1)
+
+ if not progress_stdout:
+ progress_gui.item.emit(progress.tasks[p_task].percentage)
+ except HTTPError:
+ # TODO: Handle Exception...
+ pass
+
+ needs_decryption = self.is_encrypted(stream_manifest)
+
+ if needs_decryption:
+ key, nonce = decrypt_security_token(stream_manifest["encryptionKey"])
+ tmp_path_file_decrypted = tmp_path_file + "_decrypted"
+ decrypt_file(tmp_path_file, tmp_path_file_decrypted, key, nonce)
+ else:
+ tmp_path_file_decrypted = tmp_path_file
+
+ self.metadata_write(media, tmp_path_file_decrypted)
+ elif isinstance(media, Video):
+ tmp_path_file_decrypted = self._video(media, tmp_path_file)
+
+ # TODO: Check if is possible to write metadata to MPEG Transport Stream files.
+ # TODO: Make optional.
+ if True:
+ tmp_path_file_decrypted = self._video_convert(tmp_path_file_decrypted)
+ path_file = os.path.splitext(path_file)[0] + ".mp4"
+
+ os.makedirs(os.path.dirname(path_file), exist_ok=True)
+ shutil.move(tmp_path_file_decrypted, path_file)
+ else:
+ fn_logger.debug(f"Download skipped, since file exists: '{path_file}'")
+
+ return not download_skip, path_file
+
+ def cover_url(self, sid: str, width: int = 320, height: int = 320):
+ if sid is None:
+ return ""
+
+ return f"https://resources.tidal.com/images/{sid.replace('-', '/')}/{int(width)}x{int(height)}.jpg"
+
+ def cover_data(self, sid, width: int = 320, height: int = 320):
+ url = self.cover_url(sid, width, height)
+
+ try:
+ result: str = requests.get(url).content
+ except:
+ result: str = ""
+
+ return result
+
+ def metadata_write(self, track: Track, path_file: str):
+ result: bool = False
+ release_date: str = track.album.release_date.strftime("%Y-%m-%d") if track.album.release_date else ""
+ copy_right: str = track.copyright if track.copyright else ""
+
+ try:
+ lyrics: str = track.lyrics().text if hasattr(track, "lyrics") else ""
+ except HTTPError:
+ lyrics: str = ""
+
+ m: Metadata = Metadata(
+ path_file=path_file,
+ lyrics=lyrics,
+ copy_right=copy_right,
+ title=track.name,
+ artists=[artist.name for artist in track.artists],
+ album=track.album.name,
+ tracknumber=track.track_num,
+ date=release_date,
+ isrc=track.isrc,
+ albumartist=track.artist.name,
+ totaltrack=track.album.num_tracks if track.album.num_tracks else 1,
+ totaldisc=track.album.num_volumes if track.album.num_volumes else 1,
+ discnumber=track.volume_num,
+ url_cover=self.cover_url(track.album.cover),
+ )
+
+ m.save()
+
+ result = True
+
+ return result
+
+ def list(
+ self,
+ path_base: str,
+ fn_logger: Logger | WrapperLogger,
+ id_media: str = None,
+ media_type: MediaType = None,
+ file_template: str = None,
+ list_media: Album | Playlist | UserPlaylist | Mix = None,
+ video_download: bool = False,
+ progress_gui: ProgressBars = None,
+ progress: Progress = None,
+ download_delay: bool = True,
+ ):
+ if not list_media:
+ if media_type == MediaType.Album:
+ list_media = Album(self.session, id_media)
+ elif media_type == MediaType.Playlist:
+ list_media = Playlist(self.session, id_media)
+ elif media_type == MediaType.Mix:
+ list_media = Mix(self.session, id_media)
+ else:
+ raise MediaUnknown
+
+ if file_template:
+ file_name_relative = format_path_media(file_template, list_media)
+ path_file = path_base
+ else:
+ file_name_relative = file_template
+ path_file = format_path_media(path_base, list_media)
+
+ # TODO: Extend with pagination support: Iterate through `items` and `tracks`until len(returned list) == 0
+ if isinstance(list_media, Mix):
+ items = list_media.items()
+ list_media_name = list_media.title[:30]
+ elif video_download:
+ items = list_media.items(limit=100)
+ list_media_name = list_media.name[:30]
+ else:
+ items = list_media.tracks(limit=999)
+ list_media_name = list_media.name[:30]
+
+ if progress_gui is None:
+ progress_stdout: bool = True
+ else:
+ progress_stdout: bool = False
+
+ p_task1 = progress.add_task(f"[green]List '{list_media_name}'", total=len(items), visible=progress_stdout)
+
+ while not progress.finished:
+ for media in items:
+ Progress()
+ # TODO: Handle return value of `track` method.
+ self.item(
+ path_base=path_file,
+ file_template=file_name_relative,
+ media=media,
+ progress_gui=progress_gui,
+ progress=progress,
+ fn_logger=fn_logger
+ )
+ progress.advance(p_task1)
+
+ if not progress_stdout:
+ progress_gui.list_item.emit(progress.tasks[p_task1].percentage)
+
+ if download_delay:
+ time_sleep: float = round(random.SystemRandom().uniform(2, 5), 1)
+
+ # TODO: Fix logging. Is not displayed in debug window.
+ fn_logger.debug(f"Next download will start in {time_sleep} seconds.")
+ time.sleep(time_sleep)
+
+ def is_encrypted(self, manifest: dict) -> bool:
+ result = manifest["encryptionType"] != "NONE"
+
+ return result
+
+ def get_file_extension(self, stream_url: str, stream_codec: str) -> str:
+ result = None
+
+ if ".flac" in stream_url:
+ result = ".flac"
+ elif ".mp4" in stream_url:
+ if "ac4" in stream_codec or "mha1" in stream_codec:
+ result = ".mp4"
+ elif "flac" in stream_codec:
+ result = ".flac"
+ else:
+ result = ".m4a"
+ else:
+ result = ".m4a"
+
+ return result
+
+ def _video_convert(self, path_file: str) -> str:
+ path_file_out = os.path.splitext(path_file)[0] + ".mp4"
+ result, _ = ffmpeg.input(path_file).output(path_file_out, map=0, c="copy").run()
+
+ return path_file_out