Added new option to view url info

This commit is contained in:
ArabCoders 2025-01-11 16:39:24 +03:00
parent b7d2a2a282
commit b86f13f2d8
7 changed files with 292 additions and 21 deletions

View file

@ -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.

View file

@ -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 = {}

View file

@ -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)

133
app/library/cache.py Normal file
View file

@ -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)

63
ui/components/GetInfo.vue Normal file
View file

@ -0,0 +1,63 @@
<template>
<div>
<div class="modal is-active">
<div class="modal-background" @click="closeModal"></div>
<div class="modal-content" style="width:60vw;">
<div style="font-size:30vh; width: 99%" class="has-text-centered" v-if="isLoading">
<i class="fas fa-circle-notch fa-spin"></i>
</div>
<div v-else>
<div class="p-0 m-0" style="position: relative">
<div class="content" style="white-space: pre;">
<code class="p-4 is-block" v-text="data" />
<button class="button is-small m-4" @click="() => copyText(JSON.stringify(data, null, 4))"
style="position: absolute; top:0; right:0;">
<span class="icon"><i class="fas fa-copy"></i></span>
</button>
</div>
</div>
</div>
</div>
<button class="modal-close is-large" aria-label="close" @click="closeModal"></button>
</div>
</div>
</template>
<script setup>
const config = useConfigStore()
const emitter = defineEmits(['closeModel'])
const isLoading = ref(false)
const data = ref({})
const props = defineProps({
link: {
type: String,
default: ''
},
})
const closeModal = () => emitter('closeModel')
const eventFunc = e => {
if (e.key === 'Escape') {
emitter('closeModel')
}
}
onMounted(async () => {
window.addEventListener('keydown', eventFunc)
const url = config.app.url_host + config.app.url_prefix + 'api/url/info?url=' + encodePath(props.link)
try {
isLoading.value = true
const response = await fetch(url);
data.value = await response.json();
} catch (e) {
console.log(e)
}
finally {
isLoading.value = false
}
})
onUnmounted(() => window.removeEventListener('keydown', eventFunc))
</script>

View file

@ -2,7 +2,7 @@
<main class="columns mt-2">
<div class="column">
<div class="box">
<div class="columns is-multiline">
<div class="columns is-multiline is-mobile">
<div class="column is-12">
<div class="control has-icons-left">
<input type="url" class="input" id="url" placeholder="Video or playlist link"
@ -12,7 +12,7 @@
</span>
</div>
</div>
<div class="column is-5">
<div class="column is-4-tablet is-12-mobile">
<div class="field has-addons">
<div class="control">
<a href="#" class="button is-static">Preset</a>
@ -28,7 +28,7 @@
</div>
</div>
</div>
<div class="column is-5">
<div class="column is-6-tablet is-12-mobile">
<div class="field has-addons" v-tooltip="'Folder relative to ' + config.app.download_path">
<div class="control">
<a href="#" class="button is-static">Save in</a>
@ -49,13 +49,13 @@
</div>
<div class="column">
<button type="submit" class="button is-info" @click="showAdvanced = !showAdvanced"
v-tooltip="'Show advanced options'" :class="{ 'is-loading': !socket.isConnected }"
:disabled="!socket.isConnected">
:class="{ 'is-loading': !socket.isConnected }" :disabled="!socket.isConnected">
<span class="icon"><i class="fa-solid fa-cog" /></span>
<span>Opts</span>
</button>
</div>
</div>
<div class="columns is-multiline" v-if="showAdvanced">
<div class="columns is-multiline is-mobile" v-if="showAdvanced">
<div class="column is-12">
<div class="field">
<label class="label is-inline" for="output_format"
@ -72,7 +72,7 @@
</span>
</div>
</div>
<div class="column is-6">
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="ytdlpConfig"
v-tooltip="'Extends current global yt-dlp config. (JSON)'">
@ -91,7 +91,7 @@
</span>
</div>
</div>
<div class="column is-6">
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="ytdlpCookies" v-tooltip="'JSON exported cookies for downloading.'">
yt-dlp Cookies
@ -106,7 +106,14 @@
</span>
</div>
</div>
<div class="column is-12 has-text-right">
<div class="column is-6-tablet is-4-mobile has-text-left">
<button type="submit" class="button is-info" @click="getInfo" :class="{ 'is-loading': !socket.isConnected }"
:disabled="!socket.isConnected || addInProgress || !url">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Info</span>
</button>
</div>
<div class="column is-6-tablet is-6-mobile has-text-right">
<div class="field">
<div class="control">
<button type="submit" class="button is-danger" @click="resetConfig" :disabled="!socket.isConnected"
@ -125,6 +132,7 @@
<datalist id="folders" v-if="config?.folders">
<option v-for="dir in config.folders" :key="dir" :value="dir" />
</datalist>
<GetInfo v-if="get_info && url" :link="url" @closeModel="get_info = false" />
</main>
</template>
@ -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;

View file

@ -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: [],