diff --git a/.vscode/launch.json b/.vscode/launch.json index d6dcc102..929cc427 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -30,6 +30,7 @@ "console": "internalConsole", "justMyCode": true, "env": { + "PYDEVD_DISABLE_FILE_VALIDATION": "1", "YTP_CONFIG_PATH": "${workspaceFolder}/var/config", "YTP_DOWNLOAD_PATH": "${workspaceFolder}/var/downloads", "YTP_TEMP_PATH": "${workspaceFolder}/var/tmp", @@ -39,6 +40,7 @@ "YTP_YTDL_DEBUG": "true", "YTP_MAX_WORKERS": "2", "YTP_IGNORE_UI": "true", + "YTP_PIP_IGNORE_UPDATES": "true", } }, { diff --git a/app/library/M3u8.py b/app/library/M3u8.py index 3b6c95db..215c82d4 100644 --- a/app/library/M3u8.py +++ b/app/library/M3u8.py @@ -2,7 +2,7 @@ import math import os from urllib.parse import quote from .Utils import calcDownloadPath, StreamingError -from .ffprobe import FFProbe +from .ffprobe import ffprobe class M3u8: @@ -30,15 +30,14 @@ class M3u8: raise StreamingError(f"File '{realFile}' does not exist.") try: - ffprobe = FFProbe(realFile) - await ffprobe.run() + ff = await ffprobe(realFile) except UnicodeDecodeError: pass - if "duration" not in ffprobe.metadata: + if "duration" not in ff.metadata: raise StreamingError(f"Unable to get '{realFile}' play duration.") - duration: float = float(ffprobe.metadata.get("duration")) + duration: float = float(ff.metadata.get("duration")) m3u8 = [] @@ -53,7 +52,7 @@ class M3u8: segmentParams: dict = {} - for stream in ffprobe.streams: + for stream in ff.streams(): if stream.is_video(): if stream.codec_name not in self.ok_vcodecs: segmentParams["vc"] = 1 diff --git a/app/library/Playlist.py b/app/library/Playlist.py index 27aa1c47..5ea5ae3b 100644 --- a/app/library/Playlist.py +++ b/app/library/Playlist.py @@ -2,10 +2,8 @@ import glob import re from pathlib import Path from urllib.parse import quote - from aiohttp.web import Response - -from .ffprobe import FFProbe +from .ffprobe import ffprobe from .Subtitle import Subtitle from .Utils import calcDownloadPath, checkId, StreamingError @@ -32,15 +30,14 @@ class Playlist: ) try: - ffprobe = FFProbe(rFile) - await ffprobe.run() + ff = await ffprobe(rFile) except UnicodeDecodeError: pass - if "duration" not in ffprobe.metadata: + if "duration" not in ff.metadata: raise StreamingError(f"Unable to get '{rFile}' duration.") - duration: float = float(ffprobe.metadata.get("duration")) + duration: float = float(ff.metadata.get("duration")) playlist = [] playlist.append("#EXTM3U") diff --git a/app/library/Segments.py b/app/library/Segments.py index e4e7b3d4..8c96f3e4 100644 --- a/app/library/Segments.py +++ b/app/library/Segments.py @@ -4,6 +4,7 @@ import logging import os import tempfile +from .ffprobe import ffprobe from .config import Config from .Utils import calcDownloadPath, StreamingError @@ -21,6 +22,7 @@ class Segments: self.acodec = config.streamer_acodec # sadly due to unforeseen circumstances, we have to convert the video for now. self.vconvert = True + self.aconvert = True async def stream(self, path: str, file: str) -> bytes: realFile: str = calcDownloadPath(basePath=path, folder=file, createPath=False) @@ -28,6 +30,11 @@ class Segments: if not os.path.exists(realFile): raise StreamingError(f"File {realFile} does not exist.") + try: + ff = await ffprobe(realFile) + except UnicodeDecodeError: + pass + tmpDir: str = tempfile.gettempdir() tmpFile = os.path.join(tmpDir, f'ytptube_stream.{hashlib.md5(realFile.encode("utf-8")).hexdigest()}') @@ -57,24 +64,27 @@ class Segments: fargs.append("-map_metadata") fargs.append("-1") - fargs.append("-pix_fmt") - fargs.append("yuv420p") - fargs.append("-g") - fargs.append("52") + # video section. + if ff.has_video(): + fargs.append("-pix_fmt") + fargs.append("yuv420p") + fargs.append("-g") + fargs.append("52") - fargs.append("-map") - fargs.append("0:v:0") - fargs.append("-strict") - fargs.append("-2") + fargs.append("-map") + fargs.append("0:v:0") + fargs.append("-strict") + fargs.append("-2") - fargs.append("-codec:v") - fargs.append(self.vcodec if self.vconvert else "copy") + fargs.append("-codec:v") + fargs.append(self.vcodec if self.vconvert else "copy") # audio section. - fargs.append("-map") - fargs.append("0:a:0") - fargs.append("-codec:a") - fargs.append(self.acodec if self.aconvert else "copy") + if ff.has_audio(): + fargs.append("-map") + fargs.append("0:a:0") + fargs.append("-codec:a") + fargs.append(self.acodec if self.aconvert else "copy") fargs.append("-sn") diff --git a/app/library/ffprobe.py b/app/library/ffprobe.py index 72b6e548..25924646 100644 --- a/app/library/ffprobe.py +++ b/app/library/ffprobe.py @@ -7,6 +7,31 @@ import functools import json import operator import os +from functools import lru_cache +from typing import TypedDict + + +# parameter-less decorator +def async_lru_cache_decorator(async_function): + @functools.lru_cache + def cached_async_function(*args, **kwargs): + coroutine = async_function(*args, **kwargs) + return asyncio.ensure_future(coroutine) + + return cached_async_function + + +# decorator with options +def async_lru_cache(*lru_cache_args, **lru_cache_kwargs): + def async_lru_cache_decorator(async_function): + @functools.lru_cache(*lru_cache_args, **lru_cache_kwargs) + def cached_async_function(*args, **kwargs): + coroutine = async_function(*args, **kwargs) + return asyncio.ensure_future(coroutine) + + return cached_async_function + + return async_lru_cache_decorator class FFProbeError(Exception): @@ -163,78 +188,86 @@ class FFStream: raise FFProbeError("None integer bit_rate") -class FFProbe: - """ - FFProbe wraps the ffprobe command and pulls the data into an object form:: - metadata=FFProbe('multimedia-file.mov') - """ +class FFProbeResult: + def __init__(self): + self.metadata: dict = {} + self.video: list[FFStream] = [] + self.audio: list[FFStream] = [] + self.subtitle: list[FFStream] = [] + self.attachment: list[FFStream] = [] - audio: list[FFStream] = [] - attachment: list[FFStream] = [] - streams: list[FFStream] = [] - subtitle: list[FFStream] = [] - video: list[FFStream] = [] - metadata: dict = {} - path_to_video: str = "" + def get(self, key: str, default=None): + return getattr(self, key) if hasattr(self, key) else default - def __init__(self, path_to_video): - self.path_to_video = path_to_video + def streams(self) -> list[FFStream]: + "List of all streams." + return self.video + self.audio + self.subtitle + self.attachment - async def run(self): - try: - with open(os.devnull, "w") as tempf: - await asyncio.create_subprocess_exec("ffprobe", "-h", stdout=tempf, stderr=tempf) - except FileNotFoundError: - raise IOError("ffprobe not found.") + def has_video(self): + "Is there a video stream?" + return len(self.video) > 0 - if not os.path.isfile(self.path_to_video): - raise IOError(f"No such media file '{self.path_to_video}'.") + def has_audio(self): + "Is there an audio stream?" + return len(self.audio) > 0 - args = [ - "-v", - "quiet", - "-of", - "json", - "-show_streams", - "-show_format", - self.path_to_video, - ] - p = await asyncio.create_subprocess_exec( - "ffprobe", - *args, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - - exitCode = await p.wait() - - data, err = await p.communicate() - if 0 == exitCode: - parsed: dict = json.loads(data.decode("utf-8")) - else: - raise FFProbeError(f"FFProbe return with non-0 exit code. '{err.decode('utf-8')}'") - - stream = False - self.streams = [] - self.video = [] - self.audio = [] - self.subtitle = [] - self.attachment = [] - - for stream in parsed.get("streams", []): - self.streams.append(FFStream(stream)) - - self.metadata = parsed.get("format", {}) - - for stream in self.streams: - if stream.is_audio(): - self.audio.append(stream) - elif stream.is_video(): - self.video.append(stream) - elif stream.is_subtitle(): - self.subtitle.append(stream) - elif stream.is_attachment(): - self.attachment.append(stream) + def has_subtitle(self): + "Is there a subtitle stream?" + return len(self.subtitle) > 0 def __repr__(self): return "".format(**vars(self)) + + +@async_lru_cache(maxsize=512) +async def ffprobe(file: str) -> FFProbeResult: + """ + Run ffprobe on a file and return the parsed data as a dictionary. + + Args: + file (str): The path to the media file. + + Returns: + dict: A dictionary containing the parsed data. + """ + try: + with open(os.devnull, "w") as tempf: + await asyncio.create_subprocess_exec("ffprobe", "-h", stdout=tempf, stderr=tempf) + except FileNotFoundError: + raise IOError("ffprobe not found.") + + if not os.path.isfile(file): + raise IOError(f"No such media file '{file}'.") + + args = ["-v", "quiet", "-of", "json", "-show_streams", "-show_format", file] + + p = await asyncio.create_subprocess_exec( + "ffprobe", + *args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + exitCode = await p.wait() + + data, err = await p.communicate() + if 0 == exitCode: + parsed: dict = json.loads(data.decode("utf-8")) + else: + raise FFProbeError(f"FFProbe return with non-0 exit code. '{err.decode('utf-8')}'") + + result = FFProbeResult() + result.metadata = parsed.get("format", {}) + + for stream in parsed.get("streams", []): + stream = FFStream(stream) + if stream.is_audio(): + result.audio.append(stream) + elif stream.is_video(): + result.video.append(stream) + elif stream.is_subtitle(): + result.subtitle.append(stream) + elif stream.is_attachment(): + result.attachment.append(stream) + + return result