diff --git a/README.md b/README.md index 57842429..42daa75b 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,13 @@ This shortcut is powerful, as it's allow you to select your preset on the fly pu Combined with the new and powerful presets system, you could add presets for specific websites that need cookies, and use that preset to download directly from your iOS device. +#### Advanced iOS Shortcut + +This shortcut [YTPTube To Media](https://www.icloud.com/shortcuts/6e3db0bd532843e3aec70e6ce211be08) is more advanced, as it's parses +the `yt-dlp` output and attempt to download the media directly to your iOS device. It doesn't always work, but it's a good +starting point for those who want to download media directly to their iOS device. We provide no support for this use case +other than the shortcut itself. this shortcut missing support for parsing the http_headers, it's only parse the cookies. + ## Running behind a reverse proxy It's advisable to run YTPTube behind a reverse proxy, if authentication and/or HTTPS support are required. diff --git a/app/library/HttpAPI.py b/app/library/HttpAPI.py index 5cf0e176..8d4e6521 100644 --- a/app/library/HttpAPI.py +++ b/app/library/HttpAPI.py @@ -18,6 +18,7 @@ import httpx import magic from aiohttp import web from aiohttp.web import Request, RequestHandler, Response +from yt_dlp.cookies import LenientSimpleCookie from .cache import Cache from .common import Common @@ -40,13 +41,14 @@ from .Utils import ( arg_converter, decrypt_data, encrypt_data, + extract_info, get_file, get_mime_type, get_sidecar_subtitles, - get_video_info, validate_url, validate_uuid, ) +from .YTDLPOpts import YTDLPOpts LOG = logging.getLogger("http_api") MIME = magic.Magic(mime=True) @@ -471,33 +473,65 @@ class HttpAPI(Common): except ValueError as e: return web.json_response(data={"error": str(e)}, status=web.HTTPBadRequest.status_code) - try: - key = self.cache.hash(url) + preset = request.query.get("preset") + if preset: + exists = Presets.get_instance().get(name=preset) + if not exists: + return web.json_response( + data={"status": False, "message": f"Preset '{preset}' does not exist."}, + status=web.HTTPBadRequest.status_code, + ) + else: + preset = self.config.default_preset - if self.cache.has(key): + try: + key = self.cache.hash(url + str(preset)) + + if self.cache.has(key) and not request.query.get("force", False): data = self.cache.get(key) data["_cached"] = { + "status": "hit", "key": key, "ttl": data.get("_cached", {}).get("ttl", 300), "ttl_left": data.get("_cached", {}).get("expires", time.time() + 300) - time.time(), "expires": data.get("_cached", {}).get("expires", time.time() + 300), } - return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=self.encoder.encode) + return web.Response(body=json.dumps(data, indent=4), status=web.HTTPOk.status_code) - opts = { - "proxy": self.config.ytdl_options.get("proxy", None), - } + opts = {} + + if self.config.ytdl_options.get("proxy", None): + opts["proxy"] = self.config.ytdl_options.get("proxy", None) + + ytdlp_opts = YTDLPOpts.get_instance().preset(name=preset, with_cookies=True).add(opts).get_all() + + data = extract_info( + config=ytdlp_opts, + url=url, + debug=False, + no_archive=True, + follow_redirect=True, + ) + + if "formats" in data: + for index, item in enumerate(data["formats"]): + if "cookies" in item and len(item["cookies"]) > 0: + cookies = [f"{c.key}={c.value}" for c in LenientSimpleCookie(item["cookies"]).values()] + if len(cookies) > 0: + data["formats"][index]["h_cookies"] = "; ".join(cookies) + data["formats"][index]["h_cookies"] = data["formats"][index]["h_cookies"].strip() - data = get_video_info(url=url, ytdlp_opts=opts, no_archive=True) - self.cache.set(key=self.cache.hash(url), value=data, ttl=300) data["_cached"] = { - "key": self.cache.hash(url), + "status": "miss", + "key": key, "ttl": 300, "ttl_left": 300, "expires": time.time() + 300, } - return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=self.encoder.encode) + self.cache.set(key=key, value=data, ttl=300) + + return web.Response(body=json.dumps(data, indent=4), status=web.HTTPOk.status_code) except Exception as e: LOG.error(f"Error encountered while grabbing video info '{url}'. '{e}'.") LOG.exception(e) @@ -505,6 +539,7 @@ class HttpAPI(Common): data={ "error": "failed to get video info.", "message": str(e), + "formats": [], }, status=web.HTTPInternalServerError.status_code, ) @@ -536,17 +571,17 @@ class HttpAPI(Common): tasks = [] response = [] - def get_video_info_wrapper(id: str, url: str) -> tuple[str, dict]: + def info_wrapper(id: str, url: str) -> tuple[str, dict]: try: return ( id, - get_video_info( - url=url, - ytdlp_opts={ + extract_info( + config={ "proxy": self.config.ytdl_options.get("proxy", None), "simulate": True, "dump_single_json": True, }, + url=url, no_archive=True, ), ) @@ -575,9 +610,7 @@ class HttpAPI(Common): continue tasks.append( - asyncio.get_event_loop().run_in_executor( - None, lambda id=id, url=url: get_video_info_wrapper(id=id, url=url) - ) + asyncio.get_event_loop().run_in_executor(None, lambda id=id, url=url: info_wrapper(id=id, url=url)) ) if len(tasks) > 0: diff --git a/app/library/Utils.py b/app/library/Utils.py index dc73d4aa..a192ead6 100644 --- a/app/library/Utils.py +++ b/app/library/Utils.py @@ -30,37 +30,6 @@ class StreamingError(Exception): """Raised when an error occurs during streaming.""" -def get_video_info(url: str, ytdlp_opts: dict | None = None, no_archive: bool = True) -> Any | dict[str, Any] | None: - """ - Extracts video information from the given URL. - - Args: - url (str): URL to extract information from. - ytdlp_opts (dict): Additional options to pass to yt-dlp. - no_archive (bool): Do not use download archive. - - Returns: - dict: Video information. - - """ - params: dict = { - "quiet": True, - "color": "no_color", - "extract_flat": True, - "ignoreerrors": True, - "skip_download": True, - "ignore_no_formats_error": True, - } - - if ytdlp_opts: - params = {**params, **ytdlp_opts} - - if no_archive and "download_archive" in params: - del params["download_archive"] - - return yt_dlp.YoutubeDL(params=params).extract_info(url, download=False) - - def calc_download_path(base_path: str, folder: str | None = None, create_path: bool = True) -> str: """ Calculates download path and prevents folder traversal. @@ -93,7 +62,13 @@ def calc_download_path(base_path: str, folder: str | None = None, create_path: b return download_path -def extract_info(config: dict, url: str, debug: bool = False) -> dict: +def extract_info( + config: dict, + url: str, + debug: bool = False, + no_archive: bool = False, + follow_redirect: bool = False, +) -> dict: """ Extracts video information from the given URL. @@ -101,6 +76,8 @@ def extract_info(config: dict, url: str, debug: bool = False) -> dict: config (dict): Configuration options. url (str): URL to extract information from. debug (bool): Enable debug logging. + no_archive (bool): Disable download archive. + follow_redirect (bool): Follow URL redirects. Returns: dict: Video information. @@ -144,7 +121,15 @@ def extract_info(config: dict, url: str, debug: bool = False) -> dict: params["logger"] = log_wrapper - return yt_dlp.YoutubeDL(params=params).extract_info(url, download=False) + if no_archive and "download_archive" in params: + del params["download_archive"] + + data = yt_dlp.YoutubeDL(params=params).extract_info(url, download=False) + + if follow_redirect and "_type" in data and "url" == data["_type"]: + return extract_info(config, data["url"], debug=debug, no_archive=no_archive, follow_redirect=follow_redirect) + + return data def merge_dict(source: dict, destination: dict) -> dict: diff --git a/ui/layouts/default.vue b/ui/layouts/default.vue index d65dff04..88326724 100644 --- a/ui/layouts/default.vue +++ b/ui/layouts/default.vue @@ -17,33 +17,45 @@