Added new option to view url info
This commit is contained in:
parent
b7d2a2a282
commit
b86f13f2d8
7 changed files with 292 additions and 21 deletions
|
|
@ -256,10 +256,8 @@ The `config/tasks.json`, is a json file, which can be used to queue URLs for dow
|
||||||
"output_template": "",
|
"output_template": "",
|
||||||
// (Folder: string) Optional field. Where to store the downloads relative to the main download path.
|
// (Folder: string) Optional field. Where to store the downloads relative to the main download path.
|
||||||
"folder":"",
|
"folder":"",
|
||||||
// (Format: string) Optional field. Format as specified in Web GUI. Defaults to "any".
|
// (preset: string) Optional field. The default preset to use for the download. if omitted, it will use the default preset.
|
||||||
"format": "",
|
"preset": ""
|
||||||
// (Quality: string), Optional field. Quality as specified in Web GUI. Defaults to "best".
|
|
||||||
"quality": "",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// (URL: string) **REQUIRED**, URL to the content.
|
// (URL: string) **REQUIRED**, URL to the content.
|
||||||
|
|
|
||||||
|
|
@ -13,17 +13,18 @@ import magic
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from aiohttp.web import Request, RequestHandler, Response
|
from aiohttp.web import Request, RequestHandler, Response
|
||||||
|
|
||||||
|
from .cache import Cache
|
||||||
from .common import common
|
from .common import common
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .DownloadQueue import DownloadQueue
|
from .DownloadQueue import DownloadQueue
|
||||||
from .Emitter import Emitter
|
from .Emitter import Emitter
|
||||||
from .encoder import Encoder
|
from .encoder import Encoder
|
||||||
|
from .ffprobe import ffprobe
|
||||||
from .M3u8 import M3u8
|
from .M3u8 import M3u8
|
||||||
from .Playlist import Playlist
|
from .Playlist import Playlist
|
||||||
from .Segments import Segments
|
from .Segments import Segments
|
||||||
from .Subtitle import Subtitle
|
from .Subtitle import Subtitle
|
||||||
from .Utils import validate_url, calcDownloadPath, StreamingError
|
from .Utils import StreamingError, calcDownloadPath, getVideoInfo, validate_url
|
||||||
from .ffprobe import ffprobe
|
|
||||||
|
|
||||||
LOG = logging.getLogger("http_api")
|
LOG = logging.getLogger("http_api")
|
||||||
MIME = magic.Magic(mime=True)
|
MIME = magic.Magic(mime=True)
|
||||||
|
|
@ -50,6 +51,7 @@ class HttpAPI(common):
|
||||||
self.encoder = encoder
|
self.encoder = encoder
|
||||||
self.emitter = emitter
|
self.emitter = emitter
|
||||||
self.queue = queue
|
self.queue = queue
|
||||||
|
self.cache = Cache()
|
||||||
|
|
||||||
def route(method: str, path: str):
|
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)
|
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")
|
@route("POST", "api/add_batch")
|
||||||
async def add_batch(self, request: Request) -> Response:
|
async def add_batch(self, request: Request) -> Response:
|
||||||
status = {}
|
status = {}
|
||||||
|
|
|
||||||
|
|
@ -77,17 +77,33 @@ def get_opts(preset: str, ytdl_opts: dict) -> dict:
|
||||||
LOG.debug(f"Using preset '{preset}', altered options: {opts}")
|
LOG.debug(f"Using preset '{preset}', altered options: {opts}")
|
||||||
return 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 = {
|
params: dict = {
|
||||||
"quiet": True,
|
"quiet": True,
|
||||||
"color": "no_color",
|
"color": "no_color",
|
||||||
"extract_flat": True,
|
"extract_flat": True,
|
||||||
|
"ignoreerrors": True,
|
||||||
|
"skip_download": True,
|
||||||
|
"ignore_no_formats_error": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
if ytdlp_opts:
|
if ytdlp_opts:
|
||||||
params = {**params, **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)
|
return yt_dlp.YoutubeDL().extract_info(url, download=False)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
133
app/library/cache.py
Normal file
133
app/library/cache.py
Normal 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
63
ui/components/GetInfo.vue
Normal 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>
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
<main class="columns mt-2">
|
<main class="columns mt-2">
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<div class="box">
|
<div class="box">
|
||||||
<div class="columns is-multiline">
|
<div class="columns is-multiline is-mobile">
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<div class="control has-icons-left">
|
<div class="control has-icons-left">
|
||||||
<input type="url" class="input" id="url" placeholder="Video or playlist link"
|
<input type="url" class="input" id="url" placeholder="Video or playlist link"
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-5">
|
<div class="column is-4-tablet is-12-mobile">
|
||||||
<div class="field has-addons">
|
<div class="field has-addons">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<a href="#" class="button is-static">Preset</a>
|
<a href="#" class="button is-static">Preset</a>
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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="field has-addons" v-tooltip="'Folder relative to ' + config.app.download_path">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<a href="#" class="button is-static">Save in</a>
|
<a href="#" class="button is-static">Save in</a>
|
||||||
|
|
@ -49,13 +49,13 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<button type="submit" class="button is-info" @click="showAdvanced = !showAdvanced"
|
<button type="submit" class="button is-info" @click="showAdvanced = !showAdvanced"
|
||||||
v-tooltip="'Show advanced options'" :class="{ 'is-loading': !socket.isConnected }"
|
:class="{ 'is-loading': !socket.isConnected }" :disabled="!socket.isConnected">
|
||||||
:disabled="!socket.isConnected">
|
|
||||||
<span class="icon"><i class="fa-solid fa-cog" /></span>
|
<span class="icon"><i class="fa-solid fa-cog" /></span>
|
||||||
|
<span>Opts</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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="column is-12">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label is-inline" for="output_format"
|
<label class="label is-inline" for="output_format"
|
||||||
|
|
@ -72,7 +72,7 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-6">
|
<div class="column is-6-tablet is-12-mobile">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label is-inline" for="ytdlpConfig"
|
<label class="label is-inline" for="ytdlpConfig"
|
||||||
v-tooltip="'Extends current global yt-dlp config. (JSON)'">
|
v-tooltip="'Extends current global yt-dlp config. (JSON)'">
|
||||||
|
|
@ -91,7 +91,7 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-6">
|
<div class="column is-6-tablet is-12-mobile">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label is-inline" for="ytdlpCookies" v-tooltip="'JSON exported cookies for downloading.'">
|
<label class="label is-inline" for="ytdlpCookies" v-tooltip="'JSON exported cookies for downloading.'">
|
||||||
yt-dlp Cookies
|
yt-dlp Cookies
|
||||||
|
|
@ -106,7 +106,14 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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="field">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button type="submit" class="button is-danger" @click="resetConfig" :disabled="!socket.isConnected"
|
<button type="submit" class="button is-danger" @click="resetConfig" :disabled="!socket.isConnected"
|
||||||
|
|
@ -125,6 +132,7 @@
|
||||||
<datalist id="folders" v-if="config?.folders">
|
<datalist id="folders" v-if="config?.folders">
|
||||||
<option v-for="dir in config.folders" :key="dir" :value="dir" />
|
<option v-for="dir in config.folders" :key="dir" :value="dir" />
|
||||||
</datalist>
|
</datalist>
|
||||||
|
<GetInfo v-if="get_info && url" :link="url" @closeModel="get_info = false" />
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -143,6 +151,8 @@ const downloadPath = useStorage('downloadPath', null)
|
||||||
const url = useStorage('downloadUrl', null)
|
const url = useStorage('downloadUrl', null)
|
||||||
const showAdvanced = useStorage('show_advanced', false)
|
const showAdvanced = useStorage('show_advanced', false)
|
||||||
const addInProgress = ref(false)
|
const addInProgress = ref(false)
|
||||||
|
const get_info = ref(false)
|
||||||
|
const getInfo = () => get_info.value = true
|
||||||
|
|
||||||
const addDownload = () => {
|
const addDownload = () => {
|
||||||
addInProgress.value = true;
|
addInProgress.value = true;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
|
import { useStorage } from '@vueuse/core'
|
||||||
|
|
||||||
const CONFIG_KEYS = {
|
const CONFIG_KEYS = {
|
||||||
showForm: false,
|
showForm: useStorage('showForm', false),
|
||||||
app: {
|
app: {
|
||||||
download_path: '/downloads',
|
download_path: '/downloads',
|
||||||
keep_archive: false,
|
keep_archive: false,
|
||||||
|
|
@ -14,10 +16,8 @@ const CONFIG_KEYS = {
|
||||||
},
|
},
|
||||||
presets: [
|
presets: [
|
||||||
{
|
{
|
||||||
'name': 'Default - Use default yt-dlp format',
|
'name': 'Default',
|
||||||
'format': 'default',
|
'format': 'default'
|
||||||
'postprocessors': [],
|
|
||||||
'args': {}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
folders: [],
|
folders: [],
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue