2023-12-19 23:58:57 +01:00
|
|
|
import base64
|
|
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
import random
|
|
|
|
|
import shutil
|
|
|
|
|
import tempfile
|
|
|
|
|
import time
|
2024-01-13 11:57:11 +01:00
|
|
|
from collections.abc import Callable
|
2023-12-19 23:58:57 +01:00
|
|
|
from logging import Logger
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
|
|
|
|
|
import ffmpeg
|
|
|
|
|
import m3u8
|
|
|
|
|
import requests
|
2024-01-13 11:57:11 +01:00
|
|
|
from helper.tidal import name_builder_item
|
|
|
|
|
from model.tidal import StreamManifest
|
2024-01-13 16:26:33 +01:00
|
|
|
from mpegdash.parser import MPEGDASHParser
|
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
|
2023-12-20 07:42:36 +01:00
|
|
|
|
2023-12-19 23:58:57 +01:00
|
|
|
from tidal_dl_ng.config import Settings
|
2024-01-14 17:29:39 +01:00
|
|
|
from tidal_dl_ng.constants import REQUESTS_TIMEOUT_SEC, CoverDimensions, MediaType, SkipExisting, StreamManifestMimeType
|
2023-12-19 23:58:57 +01:00
|
|
|
from tidal_dl_ng.helper.decryption import decrypt_file, decrypt_security_token
|
2024-01-13 11:57:11 +01:00
|
|
|
from tidal_dl_ng.helper.exceptions import MediaMissing, MediaUnknown, UnknownManifestFormat
|
|
|
|
|
from tidal_dl_ng.helper.path import check_file_exists, format_path_media, path_file_sanitize
|
2023-12-19 23:58:57 +01:00
|
|
|
from tidal_dl_ng.helper.wrapper import WrapperLogger
|
|
|
|
|
from tidal_dl_ng.metadata import Metadata
|
|
|
|
|
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:
|
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
|
|
|
|
|
):
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Download:
|
|
|
|
|
# TODO: Implement download cover 1280.
|
|
|
|
|
session: Session = None
|
2024-01-13 11:57:11 +01:00
|
|
|
skip_existing: SkipExisting = False
|
2023-12-19 23:58:57 +01:00
|
|
|
|
2024-01-13 11:57:11 +01:00
|
|
|
def __init__(self, session: Session, skip_existing: SkipExisting = SkipExisting.Disabled):
|
2023-12-19 23:58:57 +01:00
|
|
|
self.session = session
|
|
|
|
|
self.skip_existing = skip_existing
|
|
|
|
|
|
2024-01-14 17:29:39 +01:00
|
|
|
def _download(
|
2024-01-13 16:26:33 +01:00
|
|
|
self,
|
|
|
|
|
fn_logger: Callable,
|
2024-01-14 17:29:39 +01:00
|
|
|
media: Track | Video,
|
2024-01-13 16:26:33 +01:00
|
|
|
progress: Progress,
|
|
|
|
|
progress_gui: ProgressBars,
|
|
|
|
|
stream_manifest: StreamManifest,
|
|
|
|
|
path_file: str,
|
|
|
|
|
):
|
|
|
|
|
media_name: str = name_builder_item(media)
|
|
|
|
|
|
|
|
|
|
# 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:
|
2024-01-14 17:29:39 +01:00
|
|
|
# Compute total iterations for progress
|
|
|
|
|
urls_count: int = len(stream_manifest.urls)
|
2024-01-13 16:26:33 +01:00
|
|
|
|
2024-01-14 17:29:39 +01:00
|
|
|
if urls_count > 1:
|
|
|
|
|
progress_total: int = urls_count
|
|
|
|
|
block_size: int | None = None
|
|
|
|
|
else:
|
|
|
|
|
# Compute progress iterations based on the file size.
|
|
|
|
|
r = requests.get(stream_manifest.urls[0], stream=True, timeout=REQUESTS_TIMEOUT_SEC)
|
2024-01-13 16:26:33 +01:00
|
|
|
|
2024-01-14 17:29:39 +01:00
|
|
|
r.raise_for_status()
|
|
|
|
|
|
|
|
|
|
# Get file size and compute progress steps
|
|
|
|
|
total_size_in_bytes: int = int(r.headers.get("content-length", 0))
|
|
|
|
|
block_size: int | None = 4096
|
|
|
|
|
progress_total: float = total_size_in_bytes / block_size
|
|
|
|
|
|
|
|
|
|
# Create progress Task
|
|
|
|
|
p_task: TaskID = progress.add_task(
|
|
|
|
|
f"[blue]Item '{media_name[:20]}'",
|
|
|
|
|
total=progress_total,
|
2024-01-13 16:26:33 +01:00
|
|
|
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:
|
2024-01-14 17:29:39 +01:00
|
|
|
for url in stream_manifest.urls:
|
|
|
|
|
# Create the request object with stream=True, so the content won't be loaded into memory at once.
|
|
|
|
|
r = requests.get(url, stream=True, timeout=REQUESTS_TIMEOUT_SEC)
|
2024-01-13 16:26:33 +01:00
|
|
|
|
2024-01-14 17:29:39 +01:00
|
|
|
r.raise_for_status()
|
|
|
|
|
|
|
|
|
|
# Write the content to disk. If `chunk_size` is set to `None` the whole file will be written at once.
|
|
|
|
|
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)
|
|
|
|
|
pass
|
2024-01-13 16:26:33 +01:00
|
|
|
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.
|
2024-01-14 17:29:39 +01:00
|
|
|
if not isinstance(media, Video):
|
|
|
|
|
self.metadata_write(media, tmp_path_file_decrypted)
|
2024-01-13 16:26:33 +01:00
|
|
|
|
|
|
|
|
return tmp_path_file_decrypted
|
|
|
|
|
|
2024-01-13 11:57:11 +01:00
|
|
|
def instantiate_media(
|
2024-01-14 17:58:54 +01:00
|
|
|
self,
|
|
|
|
|
session: Session,
|
|
|
|
|
media_type: type[MediaType.Track, MediaType.Video, MediaType.Album, MediaType.Playlist, MediaType.Mix],
|
|
|
|
|
id_media: str,
|
2024-01-13 11:57:11 +01:00
|
|
|
) -> Track | Video:
|
|
|
|
|
if media_type == MediaType.Track:
|
|
|
|
|
media = Track(session, id_media)
|
|
|
|
|
elif media_type == MediaType.Video:
|
|
|
|
|
media = Video(session, id_media)
|
2024-01-14 17:58:54 +01:00
|
|
|
elif media_type == MediaType.Album:
|
|
|
|
|
media = Album(self.session, id_media)
|
|
|
|
|
elif media_type == MediaType.Playlist:
|
|
|
|
|
media = Playlist(self.session, id_media)
|
|
|
|
|
elif media_type == MediaType.Mix:
|
|
|
|
|
media = Mix(self.session, id_media)
|
2024-01-13 11:57:11 +01:00
|
|
|
else:
|
|
|
|
|
raise MediaUnknown
|
|
|
|
|
|
|
|
|
|
return media
|
|
|
|
|
|
2023-12-19 23:58:57 +01:00
|
|
|
def item(
|
|
|
|
|
self,
|
|
|
|
|
path_base: str,
|
2024-01-13 11:57:11 +01:00
|
|
|
file_template: str,
|
|
|
|
|
fn_logger: Callable,
|
2023-12-19 23:58:57 +01:00
|
|
|
media: Track | Video = None,
|
2024-01-13 11:57:11 +01:00
|
|
|
media_id: str = None,
|
2023-12-19 23:58:57 +01:00
|
|
|
media_type: MediaType = None,
|
|
|
|
|
video_download: bool = True,
|
|
|
|
|
progress_gui: ProgressBars = None,
|
2023-12-20 07:42:36 +01:00
|
|
|
progress: Progress = None,
|
2023-12-19 23:58:57 +01:00
|
|
|
) -> (bool, str):
|
2024-01-14 17:58:54 +01:00
|
|
|
# If no media instance is provided, we need to create the media instance.
|
2024-01-13 11:57:11 +01:00
|
|
|
if media_id and media_type:
|
|
|
|
|
media = self.instantiate_media(self.session, media_type, media_id)
|
|
|
|
|
elif not media:
|
|
|
|
|
raise MediaMissing
|
2023-12-19 23:58:57 +01:00
|
|
|
|
2024-01-13 11:57:11 +01:00
|
|
|
# If video download is not allowed end here
|
|
|
|
|
if not video_download:
|
|
|
|
|
fn_logger.info(
|
|
|
|
|
f"Video downloads are deactivated (see settings). Skipping video: {name_builder_item(media)}"
|
|
|
|
|
)
|
2023-12-19 23:58:57 +01:00
|
|
|
|
2024-01-13 11:57:11 +01:00
|
|
|
return False, ""
|
2023-12-19 23:58:57 +01:00
|
|
|
|
2024-01-13 11:57:11 +01:00
|
|
|
# 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)))
|
|
|
|
|
|
2024-01-13 16:26:33 +01:00
|
|
|
# Populate StreamManifest for further download.
|
2023-12-19 23:58:57 +01:00
|
|
|
if isinstance(media, Track):
|
|
|
|
|
stream = media.stream()
|
2024-01-13 16:26:33 +01:00
|
|
|
manifest: str = stream.manifest
|
|
|
|
|
mime_type: str = stream.manifest_mime_type
|
|
|
|
|
else:
|
|
|
|
|
manifest: str = media.get_url()
|
|
|
|
|
mime_type: str = StreamManifestMimeType.VIDEO.value
|
|
|
|
|
|
|
|
|
|
stream_manifest = self.stream_manifest_parse(manifest, mime_type)
|
2023-12-19 23:58:57 +01:00
|
|
|
|
2024-01-13 11:57:11 +01:00
|
|
|
# Sanitize final path_file to fit into OS boundaries.
|
2024-01-13 16:26:33 +01:00
|
|
|
path_file = path_file_sanitize(path_file + stream_manifest.file_extension, 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-01-13 16:26:33 +01:00
|
|
|
if self.skip_existing.value:
|
2024-01-13 12:04:35 +01:00
|
|
|
extension_ignore = self.skip_existing == SkipExisting.ExtensionIgnore
|
2024-01-13 11:57:11 +01:00
|
|
|
# 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
|
2023-12-19 23:58:57 +01:00
|
|
|
|
|
|
|
|
if not download_skip:
|
2024-01-13 11:57:11 +01:00
|
|
|
# Create a temp directory and file.
|
2023-12-19 23:58:57 +01:00
|
|
|
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp_path_dir:
|
2024-01-14 17:29:39 +01:00
|
|
|
tmp_path_file = os.path.join(tmp_path_dir, str(uuid4()) + stream_manifest.file_extension)
|
|
|
|
|
# Download media.
|
|
|
|
|
tmp_path_file = self._download(fn_logger, media, progress, progress_gui, stream_manifest, tmp_path_file)
|
2023-12-19 23:58:57 +01:00
|
|
|
|
2024-01-14 17:29:39 +01:00
|
|
|
if isinstance(media, Video):
|
2023-12-19 23:58:57 +01:00
|
|
|
# TODO: Make optional.
|
2024-01-13 11:57:11 +01:00
|
|
|
# Convert `*.ts` file to `*.mp4` using ffmpeg
|
2024-01-14 17:29:39 +01:00
|
|
|
tmp_path_file = self._video_convert(tmp_path_file)
|
|
|
|
|
path_file = os.path.splitext(path_file)[0] + ".mp4"
|
2023-12-19 23:58:57 +01:00
|
|
|
|
2024-01-13 11:57:11 +01:00
|
|
|
# Move final file to the configured destination directory.
|
2023-12-19 23:58:57 +01:00
|
|
|
os.makedirs(os.path.dirname(path_file), exist_ok=True)
|
2024-01-13 11:57:11 +01:00
|
|
|
shutil.move(tmp_path_file, path_file)
|
2023-12-19 23:58:57 +01:00
|
|
|
else:
|
|
|
|
|
fn_logger.debug(f"Download skipped, since file exists: '{path_file}'")
|
|
|
|
|
|
|
|
|
|
return not download_skip, path_file
|
|
|
|
|
|
2024-01-14 17:29:39 +01:00
|
|
|
def cover_url(self, sid: str, dimension: CoverDimensions = CoverDimensions.Px320):
|
2023-12-19 23:58:57 +01:00
|
|
|
if sid is None:
|
|
|
|
|
return ""
|
|
|
|
|
|
2024-01-14 17:29:39 +01:00
|
|
|
return f"https://resources.tidal.com/images/{sid.replace('-', '/')}/{dimension.value}.jpg"
|
2023-12-19 23:58:57 +01:00
|
|
|
|
|
|
|
|
def metadata_write(self, track: Track, path_file: str):
|
2024-01-12 08:10:31 +01:00
|
|
|
settings: Settings = Settings()
|
2023-12-19 23:58:57 +01:00
|
|
|
result: bool = False
|
2024-01-14 17:29:39 +01:00
|
|
|
release_date: str = (
|
|
|
|
|
track.album.release_date.strftime("%Y-%m-%d") if track.album and track.album.release_date else ""
|
|
|
|
|
)
|
|
|
|
|
copy_right: str = track.copyright if hasattr(track, "copyright") else ""
|
|
|
|
|
isrc: str = track.isrc if hasattr(track, "isrc") else ""
|
2024-01-14 18:04:31 +01:00
|
|
|
lyrics: str = ""
|
2023-12-19 23:58:57 +01:00
|
|
|
|
2024-01-14 18:04:31 +01:00
|
|
|
if settings.data.lyrics_save:
|
|
|
|
|
# Try to retrieve lyrics.
|
|
|
|
|
try:
|
|
|
|
|
lyrics: str = track.lyrics().subtitles if hasattr(track, "lyrics") else ""
|
|
|
|
|
except HTTPError:
|
|
|
|
|
# TODO: Implement proper logging.
|
|
|
|
|
print(f"Could not retrieve lyrics for `{name_builder_item(track)}`.")
|
2023-12-19 23:58:57 +01:00
|
|
|
|
2023-12-20 07:42:36 +01:00
|
|
|
# TODO: Check if it is possible to pass "None" values.
|
2023-12-19 23:58:57 +01:00
|
|
|
m: Metadata = Metadata(
|
|
|
|
|
path_file=path_file,
|
|
|
|
|
lyrics=lyrics,
|
|
|
|
|
copy_right=copy_right,
|
|
|
|
|
title=track.name,
|
|
|
|
|
artists=[artist.name for artist in track.artists],
|
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,
|
2024-01-14 17:29:39 +01:00
|
|
|
albumartist=name_builder_item(track),
|
|
|
|
|
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,
|
|
|
|
|
url_cover=self.cover_url(track.album.cover, settings.data.metadata_cover_dimension) if track.album else "",
|
2023-12-19 23:58:57 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
m.save()
|
|
|
|
|
|
|
|
|
|
result = True
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
|
2024-01-12 10:21:34 +01:00
|
|
|
def items(
|
2023-12-19 23:58:57 +01:00
|
|
|
self,
|
|
|
|
|
path_base: str,
|
|
|
|
|
fn_logger: Logger | WrapperLogger,
|
2024-01-14 17:58:54 +01:00
|
|
|
media_id: str = None,
|
2023-12-19 23:58:57 +01:00
|
|
|
media_type: MediaType = None,
|
|
|
|
|
file_template: str = None,
|
2024-01-14 17:58:54 +01:00
|
|
|
media: Album | Playlist | UserPlaylist | Mix = None,
|
2023-12-19 23:58:57 +01:00
|
|
|
video_download: bool = False,
|
|
|
|
|
progress_gui: ProgressBars = None,
|
|
|
|
|
progress: Progress = None,
|
|
|
|
|
download_delay: bool = True,
|
|
|
|
|
):
|
2024-01-14 17:58:54 +01:00
|
|
|
# If no media instance is provided, we need to create the media instance.
|
|
|
|
|
if media_id and media_type:
|
|
|
|
|
media = self.instantiate_media(self.session, media_type, media_id)
|
|
|
|
|
elif not media:
|
|
|
|
|
raise MediaMissing
|
2023-12-19 23:58:57 +01:00
|
|
|
|
2024-01-14 17:58:54 +01:00
|
|
|
# Create file name and path
|
|
|
|
|
file_name_relative = format_path_media(file_template, media)
|
2023-12-19 23:58:57 +01:00
|
|
|
|
|
|
|
|
# TODO: Extend with pagination support: Iterate through `items` and `tracks`until len(returned list) == 0
|
2024-01-14 17:58:54 +01:00
|
|
|
# Get the items and name of the list.
|
|
|
|
|
if isinstance(media, Mix):
|
|
|
|
|
items = media.items()
|
|
|
|
|
list_media_name = media.title[:20]
|
2023-12-19 23:58:57 +01:00
|
|
|
elif video_download:
|
2024-01-14 17:58:54 +01:00
|
|
|
items = media.items(limit=100)
|
|
|
|
|
list_media_name = media.name[:20]
|
2023-12-19 23:58:57 +01:00
|
|
|
else:
|
2024-01-14 17:58:54 +01:00
|
|
|
items = media.tracks(limit=999)
|
|
|
|
|
list_media_name = media.name[:20]
|
2023-12-19 23:58:57 +01:00
|
|
|
|
2024-01-14 17:58:54 +01:00
|
|
|
# Determine where to redirect the progress information.
|
2023-12-19 23:58:57 +01:00
|
|
|
if progress_gui is None:
|
|
|
|
|
progress_stdout: bool = True
|
|
|
|
|
else:
|
|
|
|
|
progress_stdout: bool = False
|
|
|
|
|
|
2024-01-14 17:58:54 +01:00
|
|
|
# Create the list progress task.
|
|
|
|
|
p_task1: TaskID = progress.add_task(
|
|
|
|
|
f"[green]List '{list_media_name}'", total=len(items), visible=progress_stdout
|
|
|
|
|
)
|
2023-12-19 23:58:57 +01:00
|
|
|
|
2024-01-14 17:58:54 +01:00
|
|
|
# Iterate through list items
|
2023-12-19 23:58:57 +01:00
|
|
|
while not progress.finished:
|
|
|
|
|
for media in items:
|
|
|
|
|
# TODO: Handle return value of `track` method.
|
2024-01-14 17:58:54 +01:00
|
|
|
# Download the item.
|
2024-01-10 20:26:34 +01:00
|
|
|
status_download, result_path_file = self.item(
|
2024-01-14 17:58:54 +01:00
|
|
|
path_base=path_base,
|
2023-12-19 23:58:57 +01:00
|
|
|
file_template=file_name_relative,
|
|
|
|
|
media=media,
|
|
|
|
|
progress_gui=progress_gui,
|
|
|
|
|
progress=progress,
|
2023-12-20 07:42:36 +01:00
|
|
|
fn_logger=fn_logger,
|
2023-12-19 23:58:57 +01:00
|
|
|
)
|
2024-01-14 17:58:54 +01:00
|
|
|
|
|
|
|
|
# Advance progress bar.
|
2023-12-19 23:58:57 +01:00
|
|
|
progress.advance(p_task1)
|
|
|
|
|
|
|
|
|
|
if not progress_stdout:
|
2024-01-14 17:29:39 +01:00
|
|
|
# progress_gui.list_item.emit(progress.tasks[p_task1].percentage)
|
|
|
|
|
pass
|
2023-12-19 23:58:57 +01:00
|
|
|
|
2024-01-14 17:58:54 +01:00
|
|
|
# If a file was downloaded and the download delay is enabled, wait until the next download.
|
2024-01-10 20:26:34 +01:00
|
|
|
if download_delay and status_download:
|
2023-12-19 23:58:57 +01:00
|
|
|
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)
|
|
|
|
|
|
2024-01-13 11:57:11 +01:00
|
|
|
def is_encrypted(self, encryption_type: str) -> bool:
|
|
|
|
|
result = encryption_type != "NONE"
|
2023-12-19 23:58:57 +01:00
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
def get_file_extension(self, stream_url: str, stream_codec: str) -> str:
|
|
|
|
|
if ".flac" in stream_url:
|
2024-01-13 16:26:33 +01:00
|
|
|
result: str = ".flac"
|
2023-12-19 23:58:57 +01:00
|
|
|
elif ".mp4" in stream_url:
|
2024-01-13 16:26:33 +01:00
|
|
|
# TODO: Need to investigate, what the correct extension is.
|
|
|
|
|
# if "ac4" in stream_codec or "mha1" in stream_codec:
|
|
|
|
|
# result = ".mp4"
|
|
|
|
|
# elif "flac" in stream_codec:
|
|
|
|
|
# result = ".flac"
|
|
|
|
|
# else:
|
|
|
|
|
# result = ".m4a"
|
|
|
|
|
result: str = ".mp4"
|
2024-01-14 17:29:39 +01:00
|
|
|
elif ".ts" in stream_url:
|
2024-01-13 16:26:33 +01:00
|
|
|
result: str = ".ts"
|
2023-12-19 23:58:57 +01:00
|
|
|
else:
|
2024-01-13 16:26:33 +01:00
|
|
|
result: str = ".m4a"
|
2023-12-19 23:58:57 +01:00
|
|
|
|
|
|
|
|
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
|
2024-01-13 11:57:11 +01:00
|
|
|
|
2024-01-13 16:26:33 +01:00
|
|
|
def stream_manifest_parse(self, manifest: str, mime_type: str) -> StreamManifest:
|
|
|
|
|
if mime_type == StreamManifestMimeType.MPD.value:
|
|
|
|
|
# Stream Manifest is base64 encoded.
|
|
|
|
|
manifest_parsed: str = base64.b64decode(manifest).decode("utf-8")
|
|
|
|
|
mpd = MPEGDASHParser.parse(manifest_parsed)
|
|
|
|
|
codecs: str = mpd.periods[0].adaptation_sets[0].representations[0].codecs
|
|
|
|
|
mime_type: str = mpd.periods[0].adaptation_sets[0].mime_type
|
2024-01-13 11:57:11 +01:00
|
|
|
# TODO: Handle encryption key. But I have never seen an encrypted file so far.
|
|
|
|
|
encryption_type: str = "NONE"
|
|
|
|
|
encryption_key: str | None = None
|
2024-01-13 16:26:33 +01:00
|
|
|
# .initialization + the very first of .media; See https://developers.broadpeak.io/docs/foundations-dash
|
|
|
|
|
segments_count = 1 + 1
|
|
|
|
|
|
|
|
|
|
for s in mpd.periods[0].adaptation_sets[0].representations[0].segment_templates[0].segment_timelines[0].Ss:
|
|
|
|
|
segments_count += s.r if s.r else 1
|
|
|
|
|
|
|
|
|
|
# Populate segment urls.
|
|
|
|
|
segment_template = mpd.periods[0].adaptation_sets[0].representations[0].segment_templates[0]
|
|
|
|
|
stream_urls: list[str] = []
|
|
|
|
|
|
|
|
|
|
for index in range(segments_count):
|
2024-01-14 17:29:39 +01:00
|
|
|
stream_urls.append(segment_template.media.replace("$Number$", str(index)))
|
2024-01-13 16:26:33 +01:00
|
|
|
|
2024-01-14 17:29:39 +01:00
|
|
|
elif mime_type == StreamManifestMimeType.BTS.value:
|
2024-01-13 16:26:33 +01:00
|
|
|
# Stream Manifest is base64 encoded.
|
|
|
|
|
manifest_parsed: str = base64.b64decode(manifest).decode("utf-8")
|
2024-01-13 11:57:11 +01:00
|
|
|
# JSON string to object.
|
|
|
|
|
stream_manifest = json.loads(manifest_parsed)
|
2024-01-14 17:29:39 +01:00
|
|
|
# TODO: Handle more than one download URL
|
2024-01-13 16:26:33 +01:00
|
|
|
stream_urls: str = stream_manifest["urls"]
|
2024-01-13 11:57:11 +01:00
|
|
|
codecs: str = stream_manifest["codecs"]
|
|
|
|
|
mime_type: str = stream_manifest["mimeType"]
|
|
|
|
|
encryption_type: str = stream_manifest["encryptionType"]
|
|
|
|
|
encryption_key: str | None = (
|
|
|
|
|
stream_manifest["encryptionKey"] if self.is_encrypted(encryption_type) else None
|
|
|
|
|
)
|
2024-01-13 16:26:33 +01:00
|
|
|
elif mime_type == StreamManifestMimeType.VIDEO.value:
|
|
|
|
|
# Parse M3U8 video playlist
|
|
|
|
|
m3u8_variant: m3u8.M3U8 = m3u8.load(manifest)
|
|
|
|
|
settings: Settings = Settings()
|
|
|
|
|
# Find the desired video resolution or the next best one.
|
|
|
|
|
m3u8_playlist, codecs = self._extract_video_stream(m3u8_variant, settings.data.quality_video.value)
|
|
|
|
|
# Populate urls.
|
|
|
|
|
stream_urls: list[str] = m3u8_playlist.files
|
|
|
|
|
|
|
|
|
|
# TODO: Handle encryption key. But I have never seen an encrypted file so far.
|
|
|
|
|
encryption_type: str = "NONE"
|
|
|
|
|
encryption_key: str | None = None
|
2024-01-13 11:57:11 +01:00
|
|
|
else:
|
|
|
|
|
raise UnknownManifestFormat
|
|
|
|
|
|
2024-01-13 16:26:33 +01:00
|
|
|
file_extension: str = self.get_file_extension(stream_urls[0], codecs)
|
|
|
|
|
|
2024-01-13 11:57:11 +01:00
|
|
|
result: StreamManifest = StreamManifest(
|
2024-01-14 17:29:39 +01:00
|
|
|
urls=stream_urls,
|
2024-01-13 11:57:11 +01:00
|
|
|
codecs=codecs,
|
|
|
|
|
file_extension=file_extension,
|
|
|
|
|
encryption_type=encryption_type,
|
|
|
|
|
encryption_key=encryption_key,
|
2024-01-14 17:29:39 +01:00
|
|
|
mime_type=mime_type,
|
2024-01-13 11:57:11 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return result
|
2024-01-13 16:26:33 +01:00
|
|
|
|
|
|
|
|
def _extract_video_stream(self, m3u8_variant: m3u8.M3U8, quality: str) -> (m3u8.M3U8 | bool, str):
|
|
|
|
|
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
|