From b86f13f2d8eaf96c257d07e4ae9d73ea107cc551 Mon Sep 17 00:00:00 2001 From: ArabCoders Date: Sat, 11 Jan 2025 16:39:24 +0300 Subject: [PATCH] Added new option to view url info --- README.md | 6 +- app/library/HttpAPI.py | 55 +++++++++++++- app/library/Utils.py | 18 ++++- app/library/cache.py | 133 ++++++++++++++++++++++++++++++++++ ui/components/GetInfo.vue | 63 ++++++++++++++++ ui/components/NewDownload.vue | 28 ++++--- ui/stores/ConfigStore.js | 10 +-- 7 files changed, 292 insertions(+), 21 deletions(-) create mode 100644 app/library/cache.py create mode 100644 ui/components/GetInfo.vue diff --git a/README.md b/README.md index 28f77d56..554b28ff 100644 --- a/README.md +++ b/README.md @@ -256,10 +256,8 @@ The `config/tasks.json`, is a json file, which can be used to queue URLs for dow "output_template": "", // (Folder: string) Optional field. Where to store the downloads relative to the main download path. "folder":"", - // (Format: string) Optional field. Format as specified in Web GUI. Defaults to "any". - "format": "", - // (Quality: string), Optional field. Quality as specified in Web GUI. Defaults to "best". - "quality": "", + // (preset: string) Optional field. The default preset to use for the download. if omitted, it will use the default preset. + "preset": "" }, { // (URL: string) **REQUIRED**, URL to the content. diff --git a/app/library/HttpAPI.py b/app/library/HttpAPI.py index b884515d..b07cbdfa 100644 --- a/app/library/HttpAPI.py +++ b/app/library/HttpAPI.py @@ -13,17 +13,18 @@ import magic from aiohttp import web from aiohttp.web import Request, RequestHandler, Response +from .cache import Cache from .common import common from .config import Config from .DownloadQueue import DownloadQueue from .Emitter import Emitter from .encoder import Encoder +from .ffprobe import ffprobe from .M3u8 import M3u8 from .Playlist import Playlist from .Segments import Segments from .Subtitle import Subtitle -from .Utils import validate_url, calcDownloadPath, StreamingError -from .ffprobe import ffprobe +from .Utils import StreamingError, calcDownloadPath, getVideoInfo, validate_url LOG = logging.getLogger("http_api") MIME = magic.Magic(mime=True) @@ -50,6 +51,7 @@ class HttpAPI(common): self.encoder = encoder self.emitter = emitter self.queue = queue + self.cache = Cache() def route(method: str, path: str): """ @@ -259,6 +261,55 @@ class HttpAPI(common): return web.json_response(data=tasks, status=web.HTTPOk.status_code, dumps=self.encoder.encode) + @route("GET", "api/url/info") + async def get_info(self, request: Request) -> Response: + url = request.query.get("url") + if not url: + return web.json_response(data={"error": "URL is required."}, status=web.HTTPForbidden.status_code) + + try: + validate_url(url) + except ValueError as e: + return web.json_response(data={"error": str(e)}, status=web.HTTPForbidden.status_code) + + try: + key = self.cache.hash(url) + + if self.cache.has(key): + data = self.cache.get(key) + data["_cached"] = { + "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) + + opts = { + "proxy": self.config.ytdl_options.get("proxy", None), + } + + data = getVideoInfo(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), + "ttl": 300, + "ttl_left": 300, + "expires": time.time() + 300, + } + + return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=self.encoder.encode) + except Exception as e: + LOG.error(f"Error encountered while grabbing video info '{url}'. '{e}'.") + LOG.exception(e) + return web.json_response( + data={ + "error": "failed to get video info.", + "message": str(e), + }, + status=web.HTTPInternalServerError.status_code, + ) + @route("POST", "api/add_batch") async def add_batch(self, request: Request) -> Response: status = {} diff --git a/app/library/Utils.py b/app/library/Utils.py index 7eb6267f..68c6e27a 100644 --- a/app/library/Utils.py +++ b/app/library/Utils.py @@ -77,17 +77,33 @@ def get_opts(preset: str, ytdl_opts: dict) -> dict: LOG.debug(f"Using preset '{preset}', altered options: {opts}") return opts +def getVideoInfo(url: str, ytdlp_opts: dict = None, no_archive: bool = True) -> Any | dict[str, Any] | None: + """ + Extracts video information from the given URL. -def getVideoInfo(url: str, ytdlp_opts: dict = None) -> Any | dict[str, Any] | None: + 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().extract_info(url, download=False) diff --git a/app/library/cache.py b/app/library/cache.py new file mode 100644 index 00000000..a8a2e5d5 --- /dev/null +++ b/app/library/cache.py @@ -0,0 +1,133 @@ +import hashlib +import time +import threading +from typing import Any, Optional, Dict, Tuple + + +class SingletonMeta(type): + """ + A metaclass that creates a Singleton base class when called. + """ + + _instances: Dict[type, Any] = {} + _lock = threading.Lock() # Ensures thread-safe singleton creation + + def __call__(cls, *args: Any, **kwargs: Any) -> Any: + # Thread-safe instance creation + with cls._lock: + if cls not in cls._instances: + instance = super().__call__(*args, **kwargs) + cls._instances[cls] = instance + return cls._instances[cls] + + +class Cache(metaclass=SingletonMeta): + def __init__(self) -> None: + """ + Initialize the Cache. + """ + # Prevent reinitialization in singleton context. + if hasattr(self, "_initialized") and self._initialized: + return + self._cache: Dict[str, Tuple[Any, Optional[float]]] = {} + self._lock = threading.Lock() + self._initialized = True + + def set(self, key: str, value: Any, ttl: Optional[float] = None) -> None: + """ + Synchronously set a value in the cache with an optional time-to-live. + If ttl is None, the entry never expires. + """ + expire_at = None if ttl is None else time.time() + ttl + with self._lock: + self._cache[key] = (value, expire_at) + + def get(self, key: str, default: Optional[Any] = None) -> Optional[Any]: + """ + Synchronously retrieve a value from the cache if it exists and hasn't expired. + """ + with self._lock: + entry = self._cache.get(key) + if entry is None: + return default + + value, expire_at = entry + if expire_at is not None and time.time() >= expire_at: + del self._cache[key] + return default + return value + + def has(self, key: str) -> bool: + """ + Synchronously check if a key exists in the cache and hasn't expired. + """ + with self._lock: + entry = self._cache.get(key) + if entry is None: + return False + + _, expire_at = entry + if expire_at is not None and time.time() >= expire_at: + del self._cache[key] + return False + return True + + def delete(self, key: str) -> None: + """ + Synchronously remove a key from the cache if it exists. + """ + with self._lock: + self._cache.pop(key, None) + + def clear(self) -> None: + """ + Synchronously clear all items from the cache. + """ + with self._lock: + self._cache.clear() + + def hash(self, key: str) -> str: + """ + Generate a SHA-256 hash for the given input string. + + :param key (str): The string to hash. + :return: A hexadecimal SHA-256 hash of the input string. + """ + return hashlib.sha256(key.encode("utf-8")).hexdigest() + + # Asynchronous counterparts for non-blocking interfaces + async def aset(self, key: str, value: Any, ttl: Optional[float] = None) -> None: + """ + Asynchronously set a value in the cache. + """ + self.set(key, value, ttl) + + async def aget(self, key: str, default: Optional[Any] = None) -> Optional[Any]: + """ + Asynchronously retrieve a value from the cache. + """ + return self.get(key, default) + + async def ahas(self, key: str) -> bool: + """ + Asynchronously check if a key exists in the cache. + """ + return self.has(key) + + async def adelete(self, key: str) -> None: + """ + Asynchronously remove a key from the cache. + """ + self.delete(key) + + async def aclear(self) -> None: + """ + Asynchronously clear all items from the cache. + """ + self.clear() + + async def ahash(self, key: str) -> str: + """ + Asynchronously generate a SHA-256 hash for the given input string. + """ + return self.hash(key=key) diff --git a/ui/components/GetInfo.vue b/ui/components/GetInfo.vue new file mode 100644 index 00000000..450ace30 --- /dev/null +++ b/ui/components/GetInfo.vue @@ -0,0 +1,63 @@ + + + diff --git a/ui/components/NewDownload.vue b/ui/components/NewDownload.vue index 36f2a40e..ae3662f1 100644 --- a/ui/components/NewDownload.vue +++ b/ui/components/NewDownload.vue @@ -2,7 +2,7 @@
-
+
-
+
Preset @@ -28,7 +28,7 @@
-
+
Save in @@ -49,13 +49,13 @@
-
+
-
+
-
+
-
+
+ +
+
@@ -143,6 +151,8 @@ const downloadPath = useStorage('downloadPath', null) const url = useStorage('downloadUrl', null) const showAdvanced = useStorage('show_advanced', false) const addInProgress = ref(false) +const get_info = ref(false) +const getInfo = () => get_info.value = true const addDownload = () => { addInProgress.value = true; diff --git a/ui/stores/ConfigStore.js b/ui/stores/ConfigStore.js index 07d98d8d..4873ef64 100644 --- a/ui/stores/ConfigStore.js +++ b/ui/stores/ConfigStore.js @@ -1,5 +1,7 @@ +import { useStorage } from '@vueuse/core' + const CONFIG_KEYS = { - showForm: false, + showForm: useStorage('showForm', false), app: { download_path: '/downloads', keep_archive: false, @@ -14,10 +16,8 @@ const CONFIG_KEYS = { }, presets: [ { - 'name': 'Default - Use default yt-dlp format', - 'format': 'default', - 'postprocessors': [], - 'args': {} + 'name': 'Default', + 'format': 'default' } ], folders: [],