♻️ Some refactoring started within download.py.

✨ Track extension can be ignored while skipping files. Closes #11.
✨ Started to implement MPEG DASH download support.
This commit is contained in:
Robert Honz
2024-01-13 11:57:11 +01:00
parent 0cba5d83b1
commit 14483a1629
12 changed files with 268 additions and 139 deletions
+1 -1
View File
@@ -170,7 +170,7 @@ profile = "black"
multi_line_output = 3 multi_line_output = 3
indent = 4 indent = 4
color_output = true color_output = true
known_third_party = ["Crypto", "PySide6", "coloredlogs", "constants", "dataclasses_json", "ffmpeg", "m3u8", "mutagen", "pathvalidate", "requests", "rich", "tidalapi", "typer"] known_third_party = ["Crypto", "PySide6", "coloredlogs", "constants", "dataclasses_json", "ffmpeg", "helper", "m3u8", "model", "mutagen", "pathvalidate", "requests", "rich", "tidalapi", "typer"]
[tool.mypy] [tool.mypy]
# https://mypy.readthedocs.io/en/latest/config_file.html#using-a-pyproject-toml-file # https://mypy.readthedocs.io/en/latest/config_file.html#using-a-pyproject-toml-file
+1 -1
View File
@@ -169,7 +169,7 @@ def download(
media_type = MediaType.Video media_type = MediaType.Video
dl.item( dl.item(
id_media=id_item, media_id=id_item,
media_type=media_type, media_type=media_type,
path_base=settings.data.download_base_path, path_base=settings.data.download_base_path,
file_template=file_template, file_template=file_template,
+2
View File
@@ -62,6 +62,8 @@ class Tidal(BaseConfig, metaclass=SingletonMeta):
def __init__(self, settings: Settings = None): def __init__(self, settings: Settings = None):
self.session = tidalapi.Session() self.session = tidalapi.Session()
self.session.config.client_id = "6BDSRdpK9hqEBTgU"
self.session.config.client_secret = "xeuPmY7nbpZ9IIbLAcQ93shka1VNheUAqN6IcszjTG8="
self.session.video_quality = tidalapi.VideoQuality.high self.session.video_quality = tidalapi.VideoQuality.high
self.file_path = path_file_token() self.file_path = path_file_token()
self.token_from_storage = self.read(self.file_path) self.token_from_storage = self.read(self.file_path)
+6
View File
@@ -17,3 +17,9 @@ class MediaType(Enum):
Playlist = "playlist" Playlist = "playlist"
Album = "album" Album = "album"
Mix = "mix" Mix = "mix"
class SkipExisting(Enum):
Disabled = False
Filename = "exact"
ExtensionIgnore = "extension_ignore"
+171 -84
View File
@@ -5,22 +5,26 @@ import random
import shutil import shutil
import tempfile import tempfile
import time import time
import xml.etree.ElementTree as ET
from collections.abc import Callable
from logging import Logger from logging import Logger
from uuid import uuid4 from uuid import uuid4
import ffmpeg import ffmpeg
import m3u8 import m3u8
import requests import requests
from pathvalidate import sanitize_filepath from helper.format import is_json, is_xml
from helper.tidal import name_builder_item
from model.tidal import StreamManifest
from requests.exceptions import HTTPError from requests.exceptions import HTTPError
from rich.progress import Progress from rich.progress import Progress
from tidalapi import Album, Mix, Playlist, Session, Track, UserPlaylist, Video from tidalapi import Album, Mix, Playlist, Session, Track, UserPlaylist, Video
from tidal_dl_ng.config import Settings from tidal_dl_ng.config import Settings
from tidal_dl_ng.constants import REQUESTS_TIMEOUT_SEC, MediaType from tidal_dl_ng.constants import REQUESTS_TIMEOUT_SEC, MediaType, SkipExisting
from tidal_dl_ng.helper.decryption import decrypt_file, decrypt_security_token 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.exceptions import MediaMissing, MediaUnknown, UnknownManifestFormat
from tidal_dl_ng.helper.path import check_file_exists, format_path_media, path_validate from tidal_dl_ng.helper.path import check_file_exists, format_path_media, path_file_sanitize
from tidal_dl_ng.helper.wrapper import WrapperLogger from tidal_dl_ng.helper.wrapper import WrapperLogger
from tidal_dl_ng.metadata import Metadata from tidal_dl_ng.metadata import Metadata
from tidal_dl_ng.model.gui_data import ProgressBars from tidal_dl_ng.model.gui_data import ProgressBars
@@ -43,12 +47,15 @@ class RequestsClient:
class Download: class Download:
# TODO: Implement download cover 1280. # TODO: Implement download cover 1280.
session: Session = None session: Session = None
skip_existing: bool = False skip_existing: SkipExisting = False
def __init__(self, session: Session, skip_existing: bool = False): def __init__(self, session: Session, skip_existing: SkipExisting = SkipExisting.Disabled):
self.session = session self.session = session
self.skip_existing = skip_existing self.skip_existing = skip_existing
def _audio_mpeg_dash(self, audio: Track, path_file: str) -> str | None:
pass
def _video(self, video: Video, path_file: str) -> str | None: def _video(self, video: Video, path_file: str) -> str | None:
result: str | None = None result: str | None = None
m3u8_variant: m3u8.M3U8 = m3u8.load(video.get_url()) m3u8_variant: m3u8.M3U8 = m3u8.load(video.get_url())
@@ -77,116 +84,157 @@ class Download:
return result return result
def instantiate_media(
self, session: Session, media_type: MediaType.Track | MediaType.Video, id_media: str
) -> Track | Video:
if media_type == MediaType.Track:
media = Track(session, id_media)
elif media_type == MediaType.Video:
media = Video(session, id_media)
else:
raise MediaUnknown
return media
def item( def item(
self, self,
path_base: str, path_base: str,
fn_logger: Logger | WrapperLogger, file_template: str,
id_media: str = None, fn_logger: Callable,
file_template: str = None,
media: Track | Video = None, media: Track | Video = None,
media_id: str = None,
media_type: MediaType = None, media_type: MediaType = None,
video_download: bool = True, video_download: bool = True,
progress_gui: ProgressBars = None, progress_gui: ProgressBars = None,
progress: Progress = None, progress: Progress = None,
) -> (bool, str): ) -> (bool, str):
if id_media: # If only a media_id is provided, we need to create the media instance.
if media_type == MediaType.Track: if media_id and media_type:
media = Track(self.session, id_media) media = self.instantiate_media(self.session, media_type, media_id)
elif media_type == MediaType.Video: elif not media:
media = Video(self.session, id_media) raise MediaMissing
# If video download is not allowed # If video download is not allowed end here
if not video_download: if not video_download:
return False, "" fn_logger.info(
else: f"Video downloads are deactivated (see settings). Skipping video: {name_builder_item(media)}"
raise MediaUnknown )
else:
media = media
if file_template: return False, ""
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)))
# Create file name and path
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)))
# Compute the file extension
# TODO: Move further down?
if isinstance(media, Track): if isinstance(media, Track):
stream = media.stream() stream = media.stream()
# TODO: Check for `manifest_mime_type'. It could be also xml. stream_manifest = self.stream_manifest_parse(stream.manifest)
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) # Sanitize final path_file to fit into OS boundaries.
# Check if path & filename longer than the OS allows. Shorten if necessary. path_file = path_file_sanitize(path_file, adapt=True)
validation_result, path_file = path_validate(path_file, True)
download_skip = check_file_exists(path_file) if self.skip_existing else False # Compute if and how downloads need to be skipped.
if self.skip_existing:
if self.skip_existing == SkipExisting.ExtensionIgnore:
extension_ignore = True
else:
extension_ignore = False
# TODO: Check if extension is already in `path_file` or not.
download_skip = check_file_exists(path_file, extension_ignore=extension_ignore)
else:
download_skip = False
if not download_skip: if not download_skip:
# Create a temp directory and file.
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp_path_dir: with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp_path_dir:
tmp_path_file = os.path.join(tmp_path_dir, str(uuid4()) + file_extension) tmp_path_file = os.path.join(tmp_path_dir, str(uuid4()))
if isinstance(media, Track): if isinstance(media, Track):
# TODO: Refactor to separate method. tmp_path_file = self._audio_stream(
if progress_gui is None: fn_logger, media, progress, progress_gui, stream_manifest, tmp_path_file
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, timeout=REQUESTS_TIMEOUT_SEC)
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): elif isinstance(media, Video):
tmp_path_file_decrypted = self._video(media, tmp_path_file) tmp_path_file = self._video(media, tmp_path_file)
# TODO: Check if is possible to write metadata to MPEG Transport Stream files. # TODO: Check if is possible to write metadata to MPEG Transport Stream files.
# TODO: Make optional. # TODO: Make optional.
# Convert `*.ts` file to `*.mp4` using ffmpeg
if True: if True:
tmp_path_file_decrypted = self._video_convert(tmp_path_file_decrypted) tmp_path_file = self._video_convert(tmp_path_file)
path_file = os.path.splitext(path_file)[0] + ".mp4" path_file = os.path.splitext(path_file)[0] + ".mp4"
# Move final file to the configured destination directory.
os.makedirs(os.path.dirname(path_file), exist_ok=True) os.makedirs(os.path.dirname(path_file), exist_ok=True)
shutil.move(tmp_path_file_decrypted, path_file) shutil.move(tmp_path_file, path_file)
else: else:
fn_logger.debug(f"Download skipped, since file exists: '{path_file}'") fn_logger.debug(f"Download skipped, since file exists: '{path_file}'")
return not download_skip, path_file return not download_skip, path_file
def _audio_stream(
self,
fn_logger: Callable,
media: Track,
progress: Progress,
progress_gui: ProgressBars,
stream_manifest: StreamManifest,
path_file: str,
):
# Set the correct progress output channel.
if progress_gui is None:
progress_stdout: bool = True
else:
progress_stdout: bool = False
progress_gui.item_name.emit(media.name)
try:
# Download the media as stream, so we can iterate over the response.
r = requests.get(stream_manifest.stream_url, stream=True, timeout=REQUESTS_TIMEOUT_SEC)
r.raise_for_status()
# Get file size and compute progress steps
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,
)
# Write content to file until progress is finished.
while not progress.tasks[p_task].finished:
with open(path_file, "wb") as f:
for data in r.iter_content(chunk_size=block_size):
f.write(data)
# Advance progress bar.
progress.advance(p_task)
# To send the progress to the GUI, we need to emit the percentage.
if not progress_stdout:
progress_gui.item.emit(progress.tasks[p_task].percentage)
except HTTPError as e:
# TODO: Handle Exception...
fn_logger(e)
# Check if file is encrypted.
needs_decryption = self.is_encrypted(stream_manifest.encryption_type)
if needs_decryption:
key, nonce = decrypt_security_token(stream_manifest.encryption_key)
tmp_path_file_decrypted = path_file + "_decrypted"
decrypt_file(path_file, tmp_path_file_decrypted, key, nonce)
else:
tmp_path_file_decrypted = path_file
# Write metadata to file.
self.metadata_write(media, tmp_path_file_decrypted)
return tmp_path_file_decrypted
def cover_url(self, sid: str, width: int = 320, height: int = 320): def cover_url(self, sid: str, width: int = 320, height: int = 320):
if sid is None: if sid is None:
return "" return ""
@@ -303,8 +351,8 @@ class Download:
fn_logger.debug(f"Next download will start in {time_sleep} seconds.") fn_logger.debug(f"Next download will start in {time_sleep} seconds.")
time.sleep(time_sleep) time.sleep(time_sleep)
def is_encrypted(self, manifest: dict) -> bool: def is_encrypted(self, encryption_type: str) -> bool:
result = manifest["encryptionType"] != "NONE" result = encryption_type != "NONE"
return result return result
@@ -330,3 +378,42 @@ class Download:
result, _ = ffmpeg.input(path_file).output(path_file_out, map=0, c="copy").run() result, _ = ffmpeg.input(path_file).output(path_file_out, map=0, c="copy").run()
return path_file_out return path_file_out
def stream_manifest_parse(self, manifest: str) -> StreamManifest:
# Stream Manifest is base64 encoded.
manifest_parsed: str = base64.b64decode(manifest).decode("utf-8")
if is_xml(manifest_parsed):
root = ET.fromstring(manifest_parsed)
stream_url: str = root[0][0][0][0].attrib["media"]
codecs: str = root[0][0][0].attrib["codecs"]
mime_type: str = root[0][0].attrib["mimeType"]
file_extension: str = self.get_file_extension(stream_url, codecs)
# TODO: Handle encryption key. But I have never seen an encrypted file so far.
encryption_type: str = "NONE"
encryption_key: str | None = None
elif is_json(manifest_parsed):
# JSON string to object.
stream_manifest = json.loads(manifest_parsed)
# TODO: Handle more than one dowload URL
stream_url: str = stream_manifest["urls"][0]
codecs: str = stream_manifest["codecs"]
mime_type: str = stream_manifest["mimeType"]
file_extension: str = self.get_file_extension(stream_url, codecs)
encryption_type: str = stream_manifest["encryptionType"]
encryption_key: str | None = (
stream_manifest["encryptionKey"] if self.is_encrypted(encryption_type) else None
)
else:
raise UnknownManifestFormat
result: StreamManifest = StreamManifest(
stream_url=stream_url,
codecs=codecs,
file_extension=file_extension,
encryption_type=encryption_type,
encryption_key=encryption_key,
mime_type=mime_type,
)
return result
+8
View File
@@ -4,3 +4,11 @@ class LoginError(Exception):
class MediaUnknown(Exception): class MediaUnknown(Exception):
pass pass
class UnknownManifestFormat(Exception):
pass
class MediaMissing(Exception):
pass
+18
View File
@@ -0,0 +1,18 @@
def is_xml(value: str) -> bool:
result = False
if value:
if value.startswith("<?xml"):
result = True
return result
def is_json(value: str) -> bool:
result = False
if value:
if value.startswith("{"):
result = True
return result
+32 -48
View File
@@ -1,11 +1,11 @@
import glob
import os import os
import pathlib
import platform
import re import re
from collections.abc import Callable from collections.abc import Callable
from pathlib import Path from pathlib import Path
from pathvalidate import sanitize_filename from pathvalidate import sanitize_filename, sanitize_filepath
from pathvalidate.error import ValidationError
from tidalapi import Album, Mix, Playlist, Track, UserPlaylist, Video from tidalapi import Album, Mix, Playlist, Track, UserPlaylist, Video
@@ -121,59 +121,43 @@ def get_fn_format(media: Track | Album | Playlist | UserPlaylist | Video | Mix)
return result return result
def length_max_name_file() -> int: def path_file_sanitize(path_file: str, adapt: bool = False) -> (bool, str):
system = platform.system() # Split into path and filename
result: int = 255 pathname, filename = os.path.split(path_file)
# Sanitize path
try: try:
if system in ["Darwin", "Linux"]: pathname_sanitized = sanitize_filepath(pathname, replacement_text=" ", validate_after_sanitize=True)
result: int = os.pathconf("/", "PC_NAME_MAX") except ValidationError as e:
except Exception as e: # If adaption of path is allowed in case of an error set path to HOME.
# TODO: Implement propper logging. if adapt:
print(e) pathname_sanitized = Path.home()
else:
return result raise e
def length_max_name_path() -> int:
system = platform.system()
result: int = 255
# Sanitize filename
try: try:
if system in ["Darwin", "Linux"]: filename_sanitized = sanitize_filename(path_file, replacement_text=" ", validate_after_sanitize=True)
result: int = os.pathconf("/", "PC_PATH_MAX") filename_sanitized_extension = Path(filename_sanitized).suffix
except Exception as e:
# TODO: Implement propper logging. # Check if the file extension was removed by shortening the filename length
print(e) if filename_sanitized_extension == "":
# Add the original file extension
file_extension = "_" + Path(path_file).suffix
filename_sanitized = filename_sanitized[: -len(file_extension)] + file_extension
except ValidationError as e:
raise e
# Join path and filename
result = os.path.join(pathname_sanitized, filename_sanitized)
return result return result
def path_validate(path_file: str, adapt: bool = False) -> (bool, str): def check_file_exists(path_file: str, extension_ignore: bool = False):
result: bool = False if extension_ignore:
length_max_path: int = length_max_name_path() path_file = Path(path_file).stem + ".*"
length_max_file: int = length_max_name_file()
path, file = os.path.split(path_file)
filename, extension = os.path.splitext(file)
if len(path) >= length_max_path: result = True if glob.glob(path_file) else False
result = False
if adapt:
path = Path.home()
if len(file) >= length_max_file:
result = False
if adapt:
file = f'{filename[:length_max_file - len(extension + "_")]}_{extension}'
path_file = os.path.join(path, file)
return result, path_file
def check_file_exists(path_file: str):
result = pathlib.Path(path_file).is_file()
return result return result
+13
View File
@@ -0,0 +1,13 @@
from tidalapi import Track
def name_builder_artist(media: Track) -> str:
return ", ".join(artist.name for artist in media.artists)
def name_builder_title(media: Track) -> str:
return media.name
def name_builder_item(media: Track) -> str:
return f"{name_builder_artist(media)} - {name_builder_title(media)}"
+2 -2
View File
@@ -34,13 +34,13 @@ class Metadata:
type_audio: str = "", type_audio: str = "",
album: str = "", album: str = "",
title: str = "", title: str = "",
artists: [str] | None = None, artists: list[str] | None = None,
copy_right: str = "", copy_right: str = "",
tracknumber: int = 0, tracknumber: int = 0,
discnumber: int = 0, discnumber: int = 0,
totaltrack: int = 0, totaltrack: int = 0,
totaldisc: int = 0, totaldisc: int = 0,
composer: [str] | None = None, composer: list[str] | None = None,
isrc: str = "", isrc: str = "",
albumartist: str = "", albumartist: str = "",
date: str = "", date: str = "",
+3 -3
View File
@@ -3,13 +3,14 @@ from dataclasses import dataclass
from dataclasses_json import dataclass_json from dataclasses_json import dataclass_json
from tidalapi import Quality from tidalapi import Quality
from tidal_dl_ng.constants import QualityVideo from tidal_dl_ng.constants import QualityVideo, SkipExisting
@dataclass_json @dataclass_json
@dataclass @dataclass
class Settings: class Settings:
skip_existing: bool = False skip_existing: SkipExisting = SkipExisting.Disabled
skip_existing_ignore_extension: bool = False
album_cover_save: bool = True album_cover_save: bool = True
lyrics_save: bool = False lyrics_save: bool = False
# TODO: Implement API KEY selection. # TODO: Implement API KEY selection.
@@ -32,7 +33,6 @@ class Settings:
metadata_cover_height: int = 320 metadata_cover_height: int = 320
@dataclass_json
@dataclass @dataclass
class HelpSettings: class HelpSettings:
skip_existing: str = "Do not download, if file already exists." skip_existing: str = "Do not download, if file already exists."
+11
View File
@@ -0,0 +1,11 @@
from dataclasses import dataclass
@dataclass
class StreamManifest:
stream_url: str
codecs: str
mime_type: str
file_extension: str
encryption_type: str | None = None
encryption_key: str | None = None