Merge pull request #349 from arabcoders/dev
Some checks failed
Build WebView wrappers / build (amd64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (amd64, windows-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, macos-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, ubuntu-latest) (push) Has been cancelled
Build WebView wrappers / build (arm64, windows-latest) (push) Has been cancelled

Fix upgrading and installing pip packages
This commit is contained in:
Abdulmohsen 2025-07-24 17:26:08 +03:00 committed by GitHub
commit 6ba83ac00b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 2439 additions and 2263 deletions

15
.vscode/settings.json vendored
View file

@ -25,6 +25,7 @@
"copyts", "copyts",
"creationflags", "creationflags",
"cronsim", "cronsim",
"dailymotion",
"datas", "datas",
"dateparser", "dateparser",
"daterange", "daterange",
@ -40,10 +41,12 @@
"forceprint", "forceprint",
"fribidi", "fribidi",
"getpid", "getpid",
"gibibytes",
"gpac", "gpac",
"hiddenimports", "hiddenimports",
"hookspath", "hookspath",
"httpx", "httpx",
"kibibytes",
"levelno", "levelno",
"libcurl", "libcurl",
"libjavascriptcoregtk", "libjavascriptcoregtk",
@ -53,6 +56,7 @@
"matroska", "matroska",
"mbed", "mbed",
"mccabe", "mccabe",
"mebibytes",
"MEIPASS", "MEIPASS",
"Microformat", "Microformat",
"microformats", "microformats",
@ -60,6 +64,7 @@
"movflags", "movflags",
"mpegts", "mpegts",
"msvideo", "msvideo",
"mult",
"multidict", "multidict",
"muxdelay", "muxdelay",
"nodesc", "nodesc",
@ -77,6 +82,7 @@
"pypi", "pypi",
"pyside", "pyside",
"pywebview", "pywebview",
"qstr",
"qtpy", "qtpy",
"quicktime", "quicktime",
"rejecttitle", "rejecttitle",
@ -85,17 +91,24 @@
"SIGUSR", "SIGUSR",
"smhd", "smhd",
"socketio", "socketio",
"sstr",
"tebibytes",
"tiktok",
"timespec", "timespec",
"tmpfilename", "tmpfilename",
"ungroup", "ungroup",
"unnegated",
"Unraid",
"upgrader", "upgrader",
"urandom", "urandom",
"urlsafe", "urlsafe",
"usegmt", "usegmt",
"ustr",
"vcodec", "vcodec",
"vconvert", "vconvert",
"writedescription", "writedescription",
"xerror" "xerror",
"youtu"
], ],
"css.styleSheets": [ "css.styleSheets": [
"ui/assets/css/*.css" "ui/assets/css/*.css"

View file

@ -0,0 +1,13 @@
import os
import sys
from pathlib import Path
if base_dir := os.environ.get("YTP_CONFIG_PATH"):
base_dir = Path(base_dir)
user_site = base_dir / f"python{sys.version_info.major}.{sys.version_info.minor}-packages"
if not user_site.exists():
user_site.mkdir(parents=True, exist_ok=True)
if user_site.is_dir() and str(user_site) not in sys.path:
sys.path.insert(0, str(user_site))

View file

@ -650,6 +650,7 @@ class DownloadQueue(metaclass=Singleton):
if downloaded is True and id_dict: if downloaded is True and id_dict:
message = f"'{id_dict.get('id')}': The URL '{item.url}' is already downloaded and recorded in archive." message = f"'{id_dict.get('id')}': The URL '{item.url}' is already downloaded and recorded in archive."
LOG.error(message) LOG.error(message)
await self._notify.emit(Events.LOG_INFO, title="Already Downloaded", message=message)
return {"status": "error", "msg": message} return {"status": "error", "msg": message}
started: float = time.perf_counter() started: float = time.perf_counter()

View file

@ -1,13 +1,20 @@
import importlib import importlib
import importlib.metadata
import logging import logging
import os import os
import subprocess import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
import httpx
LOG = logging.getLogger("package_installer") LOG = logging.getLogger("package_installer")
def parse_version(v: str) -> tuple[int, ...]:
return tuple(int("".join(filter(str.isdigit, part)) or "0") for part in v.strip().split("."))
class Packages: class Packages:
def __init__(self, env: str | None, file: str | None, upgrade: bool = False): def __init__(self, env: str | None, file: str | None, upgrade: bool = False):
from_env = env.split() if env else [] from_env = env.split() if env else []
@ -30,30 +37,68 @@ class Packages:
class PackageInstaller: class PackageInstaller:
""" user_site: Path | None = None
This class is responsible for installing and upgrading pip packages.
""" def __init__(self):
if base_dir := os.environ.get("YTP_CONFIG_PATH"):
base_dir = Path(base_dir)
self.user_site = base_dir / f"python{sys.version_info.major}.{sys.version_info.minor}-packages"
self.user_site.mkdir(parents=True, exist_ok=True)
if str(self.user_site) not in sys.path:
sys.path.insert(0, str(self.user_site))
def action(self, pkg: str, upgrade: bool = False): def action(self, pkg: str, upgrade: bool = False):
try: current_version = self._get_installed_version(pkg)
importlib.import_module(pkg)
if upgrade is False: if upgrade and current_version:
LOG.info(f"'{pkg}' is already installed. Skipping upgrades. as requested.") latest_version = self._get_latest_version(pkg)
if latest_version and parse_version(current_version) >= parse_version(latest_version):
LOG.info(f"'{pkg}' is already the latest version ({current_version}). Skipping upgrade.")
return return
LOG.info(f"'{pkg}' is already installed. Checking for upgrades...") if current_version:
subprocess.run( LOG.info(f"'{pkg}' is already installed (version: {current_version}). Proceeding with upgrade...")
[sys.executable, "-m", "pip", "install", "--upgrade", pkg], else:
check=True,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
)
except ImportError:
LOG.info(f"'{pkg}' is not installed. Installing...") LOG.info(f"'{pkg}' is not installed. Installing...")
subprocess.run(
[sys.executable, "-m", "pip", "install", pkg], self._install_pkg(pkg)
check=True,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, def _get_installed_version(self, pkg: str) -> str | None:
) try:
return importlib.metadata.version(pkg)
except importlib.metadata.PackageNotFoundError:
return None
def _get_latest_version(self, pkg: str) -> str | None:
url = f"https://pypi.org/pypi/{pkg}/json"
try:
with httpx.Client(timeout=5.0) as client:
resp = client.get(url)
if 200 == resp.status_code:
return resp.json()["info"]["version"]
LOG.warning(f"Failed to fetch '{pkg}' info: HTTP {resp.status_code}")
except Exception as e:
LOG.warning(f"Error while querying PyPI for '{pkg}': {e}")
return None
def _install_pkg(self, pkg: str):
subprocess.run(
[
sys.executable,
"-m",
"pip",
"install",
"--upgrade",
"--disable-pip-version-check",
"--target",
str(self.user_site),
"--no-warn-script-location",
pkg,
],
check=True,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
)
def check(self, pkgs: Packages): def check(self, pkgs: Packages):
""" """
@ -63,7 +108,7 @@ class PackageInstaller:
pkgs (GetPackages): the class with packages and their settings. pkgs (GetPackages): the class with packages and their settings.
""" """
if not pkgs.has_packages(): if not pkgs.has_packages() or not self.user_site:
return return
LOG.info(f"Checking for user pip packages: {', '.join(pkgs.packages)}") LOG.info(f"Checking for user pip packages: {', '.join(pkgs.packages)}")

View file

@ -1,5 +1,6 @@
import logging import logging
import uuid import uuid
from collections import OrderedDict
from aiohttp import web from aiohttp import web
from aiohttp.web import Request, Response from aiohttp.web import Request, Response
@ -185,7 +186,11 @@ async def conditions_test(request: Request, encoder: Encoder, cache: Cache, conf
) )
return web.json_response( return web.json_response(
data={"status": status, "condition": cond, "data": data}, data={
"status": status,
"condition": cond,
"data": OrderedDict(sorted(data.items(), key=lambda item: len(str(item[1])))),
},
status=web.HTTPOk.status_code, status=web.HTTPOk.status_code,
dumps=encoder.encode, dumps=encoder.encode,
) )

View file

@ -40,3 +40,29 @@ async def debug_asyncio(config: Config, encoder: Encoder) -> Response:
dumps=encoder.encode, dumps=encoder.encode,
) )
@route("GET", "api/dev/pip", "check_pip_packages")
async def check_pip_packages(config: Config, encoder: Encoder) -> Response:
pkgs = config.pip_packages.split(" ") if config.pip_packages else []
if not pkgs:
return web.json_response(
data={"message": "No pip packages configured."},
status=web.HTTPOk.status_code,
)
def _get_installed_version(pkg: str) -> str | None:
try:
import importlib.metadata
return importlib.metadata.version(pkg)
except importlib.metadata.PackageNotFoundError:
return None
response = {}
for pkg in pkgs:
if not (pkg := pkg.strip()):
continue
response.update({pkg: _get_installed_version(pkg)})
return web.json_response(data=response, status=web.HTTPOk.status_code, dumps=encoder.encode)

View file

@ -181,23 +181,22 @@
<div class="field"> <div class="field">
<span class="is-bold" :class="{ <span class="is-bold" :class="{
'has-text-success': true === test_data?.data?.status, 'has-text-success': true === logic_test,
'has-text-danger': false === test_data?.data?.status, 'has-text-danger': false === logic_test,
}"> }">
<span class="icon"><i class="fa-solid" :class="{ <span class="icon"><i class="fa-solid" :class="{
'fa-check': true === test_data.data.status, 'fa-check': true === logic_test,
'fa-xmark': false === test_data.data.status, 'fa-xmark': false === logic_test,
'fa-question': null === (test_data.data.status || null), 'fa-question': null === logic_test,
}" /></span> }" /></span>
Filter Status: {{ test_data?.data?.status === null ? 'Not tested' : test_data?.data?.status ? Filter Status:
'Matched' : 'Not matched' }} <template v-if="null === test_data?.data?.status">Not tested</template>
<template v-else>{{ logic_test ? 'Matched' : 'Not matched' }}</template>
</span> </span>
</div> </div>
<div class="field"> <div class="field">
<pre style="height:60vh;"><code>{{ show_data() }}</code></pre> <pre style="height:60vh;"><code>{{ show_data() }}</code></pre>
</div> </div>
</div> </div>
</div> </div>
</form> </form>
@ -209,6 +208,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import { decode } from '~/utils/importer' import { decode } from '~/utils/importer'
import { match_str } from '~/utils/ytdlp'
import type { ConditionItem, ImportedConditionItem } from '~/types/conditions' import type { ConditionItem, ImportedConditionItem } from '~/types/conditions'
const emitter = defineEmits<{ const emitter = defineEmits<{
@ -232,8 +232,11 @@ const test_data = ref<{
show: boolean, show: boolean,
url: string, url: string,
in_progress: boolean, in_progress: boolean,
changed: boolean,
data: { status: boolean | null, data: Record<string, any> } data: { status: boolean | null, data: Record<string, any> }
}>({ show: false, url: '', in_progress: false, data: { status: null, data: {} } }) }>({ show: false, url: '', in_progress: false, changed: false, data: { status: null, data: {} } })
watch(() => form.filter, () => test_data.value.changed = true)
const checkInfo = async (): Promise<void> => { const checkInfo = async (): Promise<void> => {
const required: (keyof ConditionItem)[] = ['name', 'filter', 'cli'] const required: (keyof ConditionItem)[] = ['name', 'filter', 'cli']
@ -304,6 +307,7 @@ const run_test = async (): Promise<void> => {
} }
test_data.value.data = json test_data.value.data = json
test_data.value.changed = false
} catch (error: any) { } catch (error: any) {
toast.error(`Failed to test condition. ${error.message}`) toast.error(`Failed to test condition. ${error.message}`)
} finally { } finally {
@ -357,4 +361,21 @@ const show_data = (): string => {
return JSON.stringify(test_data.value.data.data, null, 2) return JSON.stringify(test_data.value.data.data, null, 2)
} }
const logic_test = computed(() => {
if (!test_data.value.data || !test_data.value.data.data) {
return null
}
if (!test_data.value.changed) {
return test_data.value.data.status
}
try {
return match_str(form.filter, test_data.value.data.data, true)
} catch (e: any) {
console.error(e)
return false
}
})
</script> </script>

View file

@ -232,7 +232,7 @@
import 'assets/css/bulma-switch.css' import 'assets/css/bulma-switch.css'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import type { item_request } from '~/types/item' import type { item_request } from '~/types/item'
import { getSeparatorsName, separators } from '~/utils/utils' import { getSeparatorsName, separators } from '~/utils/index'
const props = defineProps<{ item?: Partial<item_request> }>() const props = defineProps<{ item?: Partial<item_request> }>()
const emitter = defineEmits<{ const emitter = defineEmits<{
@ -245,7 +245,7 @@ const socket = useSocketStore()
const toast = useNotification() const toast = useNotification()
const showAdvanced = useStorage<boolean>('show_advanced', false) const showAdvanced = useStorage<boolean>('show_advanced', false)
const separator = useStorage<string>('url_separator', separators[0].value) const separator = useStorage<string>('url_separator', separators[0]?.value ?? ',')
const auto_start = useStorage<boolean>('auto_start', true) const auto_start = useStorage<boolean>('auto_start', true)
const show_description = useStorage<boolean>('show_description', true) const show_description = useStorage<boolean>('show_description', true)
@ -414,7 +414,7 @@ onMounted(async () => {
await nextTick() await nextTick()
if (!separators.some(s => s.value === separator.value)) { if (!separators.some(s => s.value === separator.value)) {
separator.value = separators[0].value separator.value = separators[0]?.value ?? '.'
} }
}) })
@ -422,7 +422,7 @@ const hasFormatInConfig = computed((): boolean => !!form.value.cli?.match(/(?<!\
const filter_presets = (flag: boolean = true) => config.presets.filter(item => item.default === flag) const filter_presets = (flag: boolean = true) => config.presets.filter(item => item.default === flag)
const get_preset = (name: string | undefined) => config.presets.find(item => item.name === name) const get_preset = (name: string | undefined) => config.presets.find(item => item.name === name)
const expand_description = (e: Event) => toggleClass(e.target, ['is-ellipsis', 'is-pre-wrap']) const expand_description = (e: Event) => toggleClass(e.target as HTMLElement, ['is-ellipsis', 'is-pre-wrap'])
const removeFromArchive = async (url: string) => { const removeFromArchive = async (url: string) => {
try { try {

View file

@ -142,7 +142,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import { POSITION } from 'vue-toastification' import { POSITION } from 'vue-toastification'
import { separators } from '~/utils/utils' import { separators } from '~/utils/index'
defineProps<{ isLoading: boolean }>() defineProps<{ isLoading: boolean }>()
@ -154,5 +154,5 @@ const reduce_confirm = useStorage<boolean>('reduce_confirm', false)
const toast_position = useStorage<POSITION>('toast_position', POSITION.TOP_RIGHT) const toast_position = useStorage<POSITION>('toast_position', POSITION.TOP_RIGHT)
const toast_dismiss_on_click = useStorage<boolean>('toast_dismiss_on_click', true) const toast_dismiss_on_click = useStorage<boolean>('toast_dismiss_on_click', true)
const show_thumbnail = useStorage<boolean>('show_thumbnail', true) const show_thumbnail = useStorage<boolean>('show_thumbnail', true)
const separator = useStorage<string>('url_separator', separators[0].value) const separator = useStorage<string>('url_separator', separators[0]?.value ?? ',')
</script> </script>

View file

@ -12,20 +12,20 @@
"web-types": "./web-types.json", "web-types": "./web-types.json",
"dependencies": { "dependencies": {
"@pinia/nuxt": "^0.11.2", "@pinia/nuxt": "^0.11.2",
"@sentry/nuxt": "^9.40.0", "@sentry/nuxt": "^9.41.0",
"@vueuse/core": "^13.5.0", "@vueuse/core": "^13.5.0",
"@vueuse/nuxt": "^13.5.0", "@vueuse/nuxt": "^13.5.0",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"cron-parser": "^5.3.0", "cron-parser": "^5.3.0",
"cronstrue": "^3.1.0", "cronstrue": "^3.2.0",
"floating-vue": "^5.2.2", "floating-vue": "^5.2.2",
"hls.js": "^1.6.7", "hls.js": "^1.6.7",
"moment": "^2.30.1", "moment": "^2.30.1",
"nuxt": "^4.0.0", "nuxt": "^4.0.1",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"vue": "^3.5.17", "vue": "^3.5.18",
"vue-router": "^4.5.1", "vue-router": "^4.5.1",
"vue-toastification": "2.0.0-rc.5" "vue-toastification": "2.0.0-rc.5"
} }

File diff suppressed because it is too large Load diff

View file

@ -1,94 +0,0 @@
/**
* Cache class
*
* @class
* @param {string} engine - The engine to use for caching. Either 'session' or 'local'.
* @throws {Error} If the engine is not supported.
*/
class Cache {
supportedEngines = ['session', 'local']
constructor(engine = 'session') {
if (!this.supportedEngines.includes(engine)) {
throw new Error(`Engine '${engine}' not supported.`)
}
if ('session' === engine) {
this.storage = window.sessionStorage
} else {
this.storage = window.localStorage
}
}
/**
* Set a value in the cache
*
* @param key {string}
* @param value {*}
* @param ttl {number|null} - Time to live in milliseconds. If null, the value will not expire.
*/
set(key, value, ttl = null) {
this.storage.setItem(key, JSON.stringify({value, ttl, time: Date.now()}))
}
/**
* Get a value from the cache
*
* @param key {string}
* @returns {any}
*/
get(key) {
const item = this.storage.getItem(key)
if (null === item) {
return null
}
try {
const {value, ttl, time} = JSON.parse(item)
if (null !== ttl && Date.now() - time > ttl) {
this.remove(key)
return null
}
return value
} catch (e) {
return item
}
}
/**
* Remove a value from the cache
*
* @param key {string}
*/
remove(key) {
this.storage.removeItem(key)
}
/**
* Clear the cache
*
* @param {function|null} filter - A filter function to remove only specific keys.
*/
clear(filter = null) {
if (null !== filter) {
Object.keys(this.storage ?? {}).filter(filter).forEach(k => this.storage.removeItem(k))
} else {
this.storage.clear()
}
}
/**
* Check if a key is in the cache
*
* @param key {string}
* @returns {boolean}
*/
has(key) {
return null !== this.get(key)
}
}
const useSessionCache = () => new Cache('session')
const useLocalCache = () => new Cache('local')
export {useSessionCache, useLocalCache}

64
ui/utils/cache.ts Normal file
View file

@ -0,0 +1,64 @@
type CacheEngine = 'session' | 'local'
interface CachedItem<T = any> {
value: T
ttl: number | null
time: number
}
class Cache {
private storage: Storage
private supportedEngines: CacheEngine[] = ['session', 'local']
constructor(engine: CacheEngine = 'session') {
if (!this.supportedEngines.includes(engine)) {
throw new Error(`Engine '${engine}' not supported.`)
}
this.storage = 'session' === engine ? window.sessionStorage : window.localStorage
}
set<T = any>(key: string, value: T, ttl: number | null = null): void {
const item: CachedItem<T> = { value, ttl, time: Date.now() }
this.storage.setItem(key, JSON.stringify(item))
}
get<T = any>(key: string): T | null {
const item = this.storage.getItem(key)
if (null === item) {
return null
}
try {
const parsed: CachedItem<T> = JSON.parse(item)
if (null !== parsed.ttl && Date.now() - parsed.time > parsed.ttl) {
this.remove(key)
return null
}
return parsed.value
} catch {
return item as unknown as T
}
}
remove(key: string): void {
this.storage.removeItem(key)
}
clear(filter: ((key: string) => boolean) | null = null): void {
if (null !== filter) {
Object.keys(this.storage).filter(filter).forEach(k => this.storage.removeItem(k))
return
}
this.storage.clear()
}
has(key: string): boolean {
return null !== this.get(key)
}
}
const useLocalCache = (): Cache => new Cache('local')
const useSessionCache = (): Cache => new Cache('session')
export { useSessionCache, useLocalCache, Cache }

View file

@ -1,79 +1,86 @@
const sources = [ export type EmbedSource = {
name: string
url: string
regex: RegExp
}
const sources: EmbedSource[] = [
{ {
name: 'youtube', name: 'youtube',
url: 'https://www.youtube-nocookie.com/embed/', url: 'https://www.youtube-nocookie.com/embed/{id}',
regex: /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:shorts\/|[^\/\n\s]+\/\S+\/|(?:v|e(?:mbed)?)\/|\S*?[?&]v=)|youtu\.be\/)(?<id>[a-zA-Z0-9_-]{11})/, regex: /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:shorts\/|[^\/\n\s]+\/\S+\/|(?:v|e(?:mbed)?)\/|\S*?[?&]v=)|youtu\.be\/)(?<id>[a-zA-Z0-9_-]{11})/,
}, },
{ {
name: "instagram_post", name: "instagram_post",
url: "https://www.instagram.com/p/", url: "https://www.instagram.com/p/{id}",
regex: /https?:\/\/(?:www\.)?instagram\.com\/p\/(?<id>[^/?#&]+)/, regex: /https?:\/\/(?:www\.)?instagram\.com\/p\/(?<id>[^/?#&]+)/,
}, },
{ {
name: "instagram_story", name: "instagram_story",
url: "https://www.instagram.com/stories/highlights/", url: "https://www.instagram.com/stories/highlights/{id}",
regex: /https?:\/\/(?:www\.)?instagram\.com\/stories\/highlights\/(?<id>[^/?#&]+)/, regex: /https?:\/\/(?:www\.)?instagram\.com\/stories\/highlights\/(?<id>[^/?#&]+)/,
}, },
{ {
name: "twitter", name: "twitter",
url: "https://twitter.com/i/status/", url: "https://twitter.com/i/status/{id}",
regex: /https?:\/\/(?:www\.)?(twitter\.com|x\.com)\/.+?\/status\/(?<id>[^/?#&]+)/, regex: /https?:\/\/(?:www\.)?(twitter\.com|x\.com)\/.+?\/status\/(?<id>[^/?#&]+)/,
}, },
{ {
name: "facebook", name: "facebook",
url: "https://www.facebook.com/plugins/post.php?href=", url: "https://www.facebook.com/plugins/post.php?href={id}",
regex: /https?:\/\/(?:www\.)?facebook\.com\/(?:[^/?#&]+)\/posts\/(?<id>[^/?#&]+)/, regex: /https?:\/\/(?:www\.)?facebook\.com\/(?:[^/?#&]+)\/posts\/(?<id>[^/?#&]+)/,
}, },
{ {
name: "tiktok", name: "tiktok",
url: "https://www.tiktok.com/embed/", url: "https://www.tiktok.com/embed/{id}",
regex: /https?:\/\/(?:www\.)?tiktok\.com\/(?:[^/?#&]+)\/video\/(?<id>[^/?#&]+)/, regex: /https?:\/\/(?:www\.)?tiktok\.com\/(?:[^/?#&]+)\/video\/(?<id>[^/?#&]+)/,
}, },
{ {
name: "vimeo", name: "vimeo",
url: "https://player.vimeo.com/video/", url: "https://player.vimeo.com/video/{id}",
regex: /https?:\/\/(?:www\.)?vimeo\.com(?:\/[^/?#&]+)?\/(?<id>[^/?#&]+)/, regex: /https?:\/\/(?:www\.)?vimeo\.com(?:\/[^/?#&]+)?\/(?<id>[^/?#&]+)/,
}, },
{ {
name: "spotify", name: "spotify",
url: "https://open.spotify.com/embed/", url: "https://open.spotify.com/embed/{id}",
regex: /https?:\/\/(?:open\.)?spotify\.com\/(?:[^/?#&]+)\/(?<id>[^/?#&]+)/, regex: /https?:\/\/(?:open\.)?spotify\.com\/(?:[^/?#&]+)\/(?<id>[^/?#&]+)/,
}, },
{ {
name: "twitch_vod", name: "twitch_vod",
url: "https://player.twitch.tv/?parent={origin}&video=", url: "https://player.twitch.tv/?parent={origin}&video={id}",
regex: /https?:\/\/(?:www\.)?twitch\.tv\/(?:[^/?#&]+)\/(?<id>[^/?#&]+)/, regex: /https?:\/\/(?:www\.)?twitch\.tv\/(?:[^/?#&]+)\/(?<id>[^/?#&]+)/,
}, },
{ {
name: "twitch_clip", name: "twitch_clip",
url: "https://clips.twitch.tv/embed?parent={origin}&clip=", url: "https://clips.twitch.tv/embed?parent={origin}&clip={id}",
regex: /https?:\/\/(?:www\.)?twitch\.tv\/(?:[^/?#&]+)\/clip\/(?<id>[^/?#&]+)/, regex: /https?:\/\/(?:www\.)?twitch\.tv\/(?:[^/?#&]+)\/clip\/(?<id>[^/?#&]+)/,
}, },
{ {
name: "twitch_channel", name: "twitch_channel",
url: "https://player.twitch.tv/?parent={origin}&channel=", url: "https://player.twitch.tv/?parent={origin}&channel={id}",
regex: /https?:\/\/(?:www\.)?twitch\.tv\/(?<id>[^/?#&]+)/, regex: /https?:\/\/(?:www\.)?twitch\.tv\/(?<id>[^/?#&]+)/,
}, },
{ {
name: "dailymotion", name: "dailymotion",
url: "https://www.dailymotion.com/embed/video/", url: "https://www.dailymotion.com/embed/video/{id}",
regex: /^.+dailymotion.com\/(video|hub)\/(?<id>[^_]+)[^#]*(#video=([^_&]+))?/, regex: /^.+dailymotion.com\/(video|hub)\/(?<id>[^_]+)[^#]*(#video=([^_&]+))?/,
}, },
] ]
const isEmbedable = url => { const isEmbedable = (url: string): boolean => sources.some(source => source.regex.test(url))
return sources.some(source => source.regex.test(url));
}
const getEmbedable = url => { const getEmbedable = (url: string): string | null => {
const source = sources.find(source => source.regex.test(url)); const source = sources.find(source => source.regex.test(url))
if (!source) { if (!source) {
return null; return null
} }
source.url.replace(/\{origin\}/g, window.location.origin); const match = source.regex.exec(url)
if (!match || !match.groups?.id) {
return null
}
return source.url + source.regex.exec(url)['groups'].id; return source.url.replace(/\{origin\}/g, window.location.origin).replace(/\{id\}/g, match.groups.id)
} }
export { isEmbedable, getEmbedable }; export { isEmbedable, getEmbedable }

View file

@ -1,502 +0,0 @@
const toast = useNotification()
const runtimeConfig = useRuntimeConfig()
const AG_SEPARATOR = '.'
/**
* Get value from object or function
*
* @param {Function|*} obj
* @returns {*}
*/
const getValue = (obj) => 'function' === typeof obj ? obj() : obj
/**
* Get value from object or function and return default value if it's undefined or null
*
* @param {Object|Array|any} obj The object to get the value from.
* @param {string} path The path to the value.
* @param {*} defaultValue The default value to return if the path is not found.
*
* @returns {*} The value at the path or the default value.
*/
const ag = (obj, path, defaultValue = null) => {
const keys = path.split(AG_SEPARATOR)
let at = obj
for (let key of keys) {
if (typeof at === 'object' && null !== at && key in at) {
at = at[key]
} else {
return getValue(defaultValue)
}
}
return getValue(null === at ? defaultValue : at)
}
/**
* Set value in object by path
*
* @param {Object} obj The object to set the value in.
* @param {string} path The path to the value.
* @param {*} value The value to set.
*
* @returns {Object} The object with the value set.
*/
const ag_set = (obj, path, value) => {
const keys = path.split(AG_SEPARATOR)
let at = obj
while (keys.length > 0) {
if (keys.length === 1) {
if (typeof at === 'object' && at !== null) {
at[keys.shift()] = value
} else {
throw new Error(`Cannot set value at this path (${path}) because it's not an object.`)
}
} else {
const key = keys.shift()
if (!at[key]) {
at[key] = {}
}
at = at[key]
}
}
return obj
}
/**
* Wait for an element to be loaded in the DOM
*
* @param {string} sel The selector of the element.
* @param {Function} callback The callback function.
*
* @returns {void}
*/
const awaitElement = (sel, callback) => {
let interval = undefined
let $elm = document.querySelector(sel)
if ($elm) {
callback($elm, sel)
return
}
interval = setInterval(() => {
let $elm = document.querySelector(sel)
if ($elm) {
clearInterval(interval)
callback($elm, sel)
}
}, 200)
}
/**
* Replace tags in text with values from context
*
* @param {string} text The text with tags
* @param {object} context The context with values
*
* @returns {string} The text with replaced tags
*/
const r = (text, context = {}) => {
const tagLeft = '{'
const tagRight = '}'
if (!text.includes(tagLeft) || !text.includes(tagRight)) {
return text
}
const pattern = new RegExp(`${tagLeft}([\\w_.]+)${tagRight}`, 'g')
const matches = text.match(pattern)
if (!matches) {
return text
}
let replacements = {}
matches.forEach(match => replacements[match] = ag(context, match.slice(1, -1), ''))
for (let key in replacements) {
text = text.replace(new RegExp(key, 'g'), replacements[key])
}
return text
}
const copyText = (str, notify = true, store = false) => {
if (navigator.clipboard) {
navigator.clipboard.writeText(str).then(() => {
if (notify) {
toast.success('Text copied to clipboard.')
}
}).catch((error) => {
console.error('Failed to copy.', error)
if (notify) {
toast.error('Failed to copy to clipboard.')
}
})
return
}
const el = document.createElement('textarea')
el.value = str
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
if (notify) {
toast.success('Text copied to clipboard.', { store: store })
}
}
/**
* Dispatch custom event.
*
* @param {string} eventName The name of the event.
* @param {object} detail The detail object.
*
* @returns {Boolean} The return value of dispatchEvent.
*/
const dEvent = (eventName, detail = {}) => window.dispatchEvent(new CustomEvent(eventName, { detail }))
/**
* Make pagination
*
* @param {number} current The current page.
* @param {number} last The last page.
* @param {number} delta The delta.
*
* @returns {Array} The pagination array.
*/
const makePagination = (current, last, delta = 5) => {
let pagination = []
if (last < 2) {
return pagination
}
const strR = '-'.repeat(9 + `${last}`.length)
const left = current - delta, right = current + delta + 1
for (let i = 1; i <= last; i++) {
if (i === 1 || i === last || (i >= left && i < right)) {
if (i === left && i > 2) {
pagination.push({
page: 0, text: strR, selected: false,
})
}
pagination.push({
page: i, text: `Page #${i}`, selected: i === current
})
if (i === right - 1 && i < last - 1) {
pagination.push({
page: 0, text: strR, selected: false,
})
}
}
}
return pagination
}
/**
* Encodes a path string to be used in a URL
*
* @param {string} item - The path string to encode
*
* @returns {string} - The encoded path string
*/
const encodePath = item => {
// -- manually encode #
if (!item) {
return item
}
item = item.replace(/#/g, '%23')
try {
return item.split('/').map(decodeURIComponent).map(encodeURIComponent).join('/')
} catch (e) {
console.error('Error encoding path:', e, item)
return item
}
}
/**
* Request content from the API. This function will automatically add the API token to the request headers.
* And prefix the URL with the API URL and path.
*
* @param {string} url - The URL to request
* @param {RequestInit} options - The request options
*
* @returns {Promise<Response>} - The response from the API
*/
const request = (url, options = {}) => {
options = options || {}
options.method = options.method || 'GET'
options.headers = options.headers || {}
options.withCredentials = true
if (undefined === options.headers['Content-Type']) {
if (!(options?.body instanceof FormData)) {
options.headers['Content-Type'] = 'application/json'
}
}
if (undefined === options.headers['Accept']) {
options.headers['Accept'] = 'application/json'
}
if (url.startsWith('/')) {
options.credentials = 'same-origin'
}
return fetch(url.startsWith('/') ? uri(url) : url, options)
}
/**
* Remove the ANSI colors from the text
*
* @param {string} text - The text to remove the colors from
*
* @returns {string} - The text without the colors.
*/
const removeANSIColors = text => text?.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '') ?? text
const dec2hex = dec => dec.toString(16).padStart(2, "0")
const makeId = len => Array.from(window.crypto.getRandomValues(new Uint8Array((len || 40) / 2)), dec2hex).join('')
const basename = (path, ext = '') => {
if (!path) {
return ''
}
const segments = path.replace(/\\/g, '/').split('/')
let base = segments.pop()
while (segments.length && base === '') {
base = segments.pop()
}
if (ext && base.endsWith(ext) && base !== ext) {
base = base.substring(0, base.length - ext.length)
}
return base
}
const dirname = filePath => {
const lastIndex = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'))
if (-1 === lastIndex) {
return '.'
}
if (0 === lastIndex) {
return filePath[0]
}
return filePath.substring(0, lastIndex)
}
const iTrim = (str, delim, position = 'both') => {
if (!str) {
return str
}
if (!delim) {
throw new Error('Delimiter is required')
}
if ("]" === delim) {
delim = "\\]"
}
if ("\\" === delim) {
delim = "\\\\"
}
if (['both', 'start'].includes(position)) {
str = str.replace(new RegExp("^[" + delim + "]+", "g"), "")
}
if (['both', 'end'].includes(position)) {
str = str.replace(new RegExp("[" + delim + "]+$", "g"), "")
}
return str
}
const eTrim = (str, delim) => iTrim(str, delim, 'end')
const sTrim = (str, delim) => iTrim(str, delim, 'start')
const ucFirst = str => (!str) ? str : str.charAt(0).toUpperCase() + str.slice(1)
/**
* Get query parameters from the URL
*
* @param {string} url - The URL to get the query parameters from
*
* @returns {Object} - The query parameters
*/
const getQueryParams = (url = window.location.search) => Object.fromEntries(new URLSearchParams(url).entries())
/**
* Make download URL.
*
* @param {Object} config
* @param {Object} item
* @param {string} base
*
* @returns {string} The download URL
*/
const makeDownload = (config, item, base = 'api/download') => {
let baseDir = 'api/player/m3u8/video/';
if ('m3u8' !== base) {
baseDir = `${base}/`;
}
if (item.folder) {
item.folder = item.folder.replace(/#/g, '%23');
baseDir += item.folder + '/';
}
let url = `/${sTrim(baseDir, '/')}${encodePath(item.filename)}`;
return uri(('m3u8' === base) ? url + '.m3u8' : url);
}
/**
* Format a file size
*
* @param {number} bytes
* @param {number} decimals
*
* @returns {string} The formatted file size
*/
const formatBytes = (bytes, decimals = 2) => {
if (!+bytes) return '0 Bytes'
const k = 1024
const dm = decimals < 0 ? 0 : decimals
const sizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`
}
/**
* Check if array or object has data.
*
* @param {string} item - The item to check
* @returns {boolean} - True if the item has data, false otherwise.
*/
const has_data = item => {
if (!item) {
return false
}
if (typeof item === 'string') {
try {
item = JSON.parse(item)
} catch (e) {
return true
}
}
try {
if (typeof item === 'object') {
return Object.keys(item).length > 0
}
return item.length > 0
} catch (e) {
console.error(e)
}
return false
}
const toggleClass = (target, className) => {
if (Array.isArray(className)) {
className.forEach(name => toggleClass(target, name))
return;
}
if (target.classList.contains(className)) {
target.classList.remove(className)
return;
}
target.classList.add(className)
}
const cleanObject = (item, fields = []) => {
if (!item || typeof item !== 'object' || fields.length < 1) {
return item
}
const cleaned = {}
for (const key of Object.keys(item)) {
if (false === fields.includes(key)) {
cleaned[key] = item[key]
}
}
return cleaned
}
const uri = u => {
if (!u || '/' === runtimeConfig.app.baseURL || !u.startsWith('/')) {
return u
}
if (true === u.startsWith(runtimeConfig.app.baseURL)) {
return u
}
return eTrim(runtimeConfig.app.baseURL, '/') + '/' + sTrim(u, '/');
}
const formatTime = seconds => {
const hrs = Math.floor(seconds / 3600)
const mins = Math.floor((seconds % 3600) / 60)
const secs = Math.floor(seconds % 60)
const pad = (n) => n.toString().padStart(2, '0')
if (hrs > 0) return `${pad(hrs)}:${pad(mins)}:${pad(secs)}`
if (mins > 0) return `${pad(mins)}:${pad(secs)}`
return `${secs}`
}
const sleep = (seconds) => new Promise(resolve => setTimeout(resolve, seconds * 1000))
export {
ag_set,
ag,
awaitElement,
copyText,
dEvent,
makePagination,
request,
r,
encodePath,
removeANSIColors,
makeId,
basename,
iTrim,
eTrim,
sTrim,
ucFirst,
dirname,
getQueryParams,
makeDownload,
formatBytes,
has_data,
toggleClass,
cleanObject,
uri,
formatTime,
sleep,
}

599
ui/utils/index.ts Normal file
View file

@ -0,0 +1,599 @@
import type { convert_args_response } from "~/types/responses";
const runtimeConfig = useRuntimeConfig()
const toast = useNotification()
const AG_SEPARATOR = '.'
const separators = [
{ name: 'Comma', value: ',', },
{ name: 'Semicolon', value: ';', },
{ name: 'Colon', value: ':', },
{ name: 'Pipe', value: '|', },
{ name: 'Space', value: ' ', }
]
/**
* Get value from object or function.
*
* @param obj - A function or value.
* @returns The result of calling the function if it's a function, otherwise the value.
*/
const getValue = <T>(obj: (() => T) | T): T => {
return 'function' === typeof obj ? (obj as () => T)() : obj
}
/**
* Safely get a value from a nested object using a path string. Returns default value if not found.
*
* @param obj - The object to get the value from.
* @param path - Dot-delimited string path (e.g. 'a.b.c').
* @param defaultValue - The fallback value if path is not found or value is null/undefined.
* @returns The value at the path or the default value.
*/
const ag = <T = any>(obj: any, path: string, defaultValue: T | null = null): T | null => {
const keys = path.split(AG_SEPARATOR)
let at = obj
for (const key of keys) {
if ('object' === typeof at && null !== at && key in at) {
at = at[key]
} else {
return getValue(defaultValue)
}
}
return getValue(null === at ? defaultValue : at)
}
/**
* Set a value in a nested object using a dot-delimited path.
*
* @param obj - The object to modify.
* @param path - Dot-delimited string path (e.g. 'a.b.c').
* @param value - The value to set.
* @returns The original object with the updated value.
* @throws Error if a path segment is not an object.
*/
const ag_set = (obj: Record<string, any>, path: string, value: any): Record<string, any> => {
const keys = path.split(AG_SEPARATOR)
let at: any = obj
while (keys.length > 0) {
if (1 === keys.length) {
if ('object' === typeof at && null !== at) {
at[keys.shift()!] = value
} else {
throw new Error(`Cannot set value at this path (${path}) because it's not an object.`)
}
} else {
const key = keys.shift()!
if (!at[key] || 'object' !== typeof at[key]) {
at[key] = {}
}
at = at[key]
}
}
return obj
}
/**
* Wait for an element to appear in the DOM, then invoke a callback.
*
* @param sel - CSS selector for the target element.
* @param callback - Function to execute when the element is found.
*/
const awaitElement = (sel: string, callback: (el: Element, selector: string) => void): void => {
const $elm = document.querySelector(sel)
if ($elm) {
callback($elm, sel)
return
}
const interval = setInterval(() => {
const $elm = document.querySelector(sel)
if ($elm) {
clearInterval(interval)
callback($elm, sel)
}
}, 200)
}
/**
* Replace template tags in a string with values from a context object.
*
* @param text - The input string containing tags in `{key}` format.
* @param context - An object whose keys are used for tag replacement.
* @returns The string with all matching tags replaced.
*/
const r = (text: string, context: Record<string, any> = {}): string => {
const tagLeft = '{'
const tagRight = '}'
if (!text.includes(tagLeft) || !text.includes(tagRight)) {
return text
}
const pattern = new RegExp(`${tagLeft}([\\w_.]+)${tagRight}`, 'g')
const matches = text.match(pattern)
if (!matches) return text
const replacements: Record<string, string> = {}
matches.forEach(match => {
const key = match.slice(1, -1)
replacements[match] = String(ag(context, key, ''))
})
for (const key in replacements) {
text = text.replace(new RegExp(key, 'g'), String(replacements[key]))
}
return text
}
/**
* Dispatch a custom event on the global window object.
*
* @param eventName - The name of the custom event.
* @param detail - Optional detail payload to include in the event.
* @returns Whether the event was successfully dispatched.
*/
const dEvent = (eventName: string, detail: Record<string, any> = {}): boolean => {
return window.dispatchEvent(new CustomEvent(eventName, { detail }))
}
/**
* Generate a pagination list based on current page, total pages, and delta range.
*
* @param current - The current active page number.
* @param last - The last page number.
* @param delta - How many pages to show before/after the current page.
* @returns An array of pagination entries including optional gaps.
*/
const makePagination = (current: number, last: number, delta: number = 5): Array<{ page: number; text: string; selected: boolean }> => {
const pagination: Array<{ page: number; text: string; selected: boolean }> = []
if (last < 2) {
return pagination
}
const strR = '-'.repeat(9 + `${last}`.length)
const left = current - delta
const right = current + delta + 1
for (let i = 1; i <= last; i++) {
if (1 === i || last === i || (i >= left && i < right)) {
if (i === left && i > 2) {
pagination.push({ page: 0, text: strR, selected: false })
}
pagination.push({ page: i, text: `Page #${i}`, selected: i === current })
if (i === right - 1 && i < last - 1) {
pagination.push({ page: 0, text: strR, selected: false })
}
}
}
return pagination
}
/**
* Safely encode a path string for use in a URL.
* Will manually encode '#' and ensure valid URI segments.
*
* @param item - The input path string.
* @returns The URL-encoded path.
*/
const encodePath = (item: string): string => {
if (!item) {
return item
}
item = item.replace(/#/g, '%23')
try {
return item.split('/').map(decodeURIComponent).map(encodeURIComponent).join('/')
} catch (e) {
console.error('Error encoding path:', e, item)
return item
}
}
/**
* Request content from the API with automatic token handling and URL prefixing.
*
* @param url - The URL to request. If relative, it will be passed through the `uri` helper.
* @param options - Optional fetch options, automatically extended with common headers and credentials.
* @returns The fetch Response promise.
*/
const request = (url: string, options: RequestInit = {}): Promise<Response> => {
options.method = options.method || 'GET'
options.headers = options.headers || {}; (options as any).withCredentials = true
if (undefined === (options.headers as Record<string, any>)['Content-Type']) {
if (!(options?.body instanceof FormData)) {
; (options.headers as Record<string, any>)['Content-Type'] = 'application/json'
}
}
if (undefined === (options.headers as Record<string, any>)['Accept']) {
; (options.headers as Record<string, any>)['Accept'] = 'application/json'
}
if (url.startsWith('/')) {
options.credentials = 'same-origin'
}
return fetch(url.startsWith('/') ? uri(url) : url, options)
}
/**
* Remove ANSI color codes from a string.
*
* @param text - The text potentially containing ANSI codes.
* @returns A string without ANSI escape sequences.
*/
const removeANSIColors = (text: string): string => {
return text?.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '') ?? text
}
/**
* Convert a decimal value to a 2-character hex string.
*
* @param dec - Decimal value between 0-255.
* @returns The hex string.
*/
const dec2hex = (dec: number): string => dec.toString(16).padStart(2, '0')
/**
* Generate a random ID using crypto.
*
* @param len - The length of the ID in characters (must be even).
* @returns A random hex ID string.
*/
const makeId = (len: number = 40): string => Array.from(window.crypto.getRandomValues(new Uint8Array(len / 2)), dec2hex).join('')
/**
* Return the basename of a given path.
*
* @param path - The input path.
* @param ext - Optional extension to strip from the basename.
* @returns The last segment of the path, minus the extension if matched.
*/
const basename = (path: string, ext: string = ''): string => {
if (!path) return ''
const segments = path.replace(/\\/g, '/').split('/')
let base = segments.pop() || ''
while (segments.length && base === '') {
base = segments.pop() || ''
}
if (ext && base.endsWith(ext) && base !== ext) {
base = base.substring(0, base.length - ext.length)
}
return base
}
/**
* Return the directory portion of a path.
*
* @param filePath - The input file path.
* @returns The directory part of the path.
*/
const dirname = (filePath: string): string => {
const lastIndex = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'))
if (-1 === lastIndex) {
return '.'
}
if (0 === lastIndex) {
return filePath[0] ?? '.'
}
return filePath.substring(0, lastIndex)
}
/**
* Copy text to clipboard with optional notification and storage flag.
*
* @param str - The string to copy.
* @param notify - Whether to show a toast notification (default: true).
* @param store - Whether to persist the notification (optional).
*/
const copyText = (str: string, notify: boolean = true, store: boolean = false): void => {
if (navigator.clipboard) {
navigator.clipboard.writeText(str).then(() => {
if (notify) toast.success('Text copied to clipboard.')
}).catch((error) => {
console.error('Failed to copy.', error)
if (notify) toast.error('Failed to copy to clipboard.')
})
return
}
const el = document.createElement('textarea')
el.value = str
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
if (notify) {
toast.success('Text copied to clipboard.', { store })
}
}
/**
* Trim delimiter characters from a string at specified positions.
*
* @param str - The input string to trim.
* @param delim - The delimiter character to trim.
* @param position - Where to trim the delimiter from: 'start', 'end', or 'both'. Defaults to 'both'.
* @returns The trimmed string.
* @throws Will throw an error if `delim` is not provided.
*/
const iTrim = (str: string, delim: string, position: 'start' | 'end' | 'both' = 'both'): string => {
if (!str) {
return str
}
if (!delim) {
throw new Error('Delimiter is required')
}
if (']' === delim) {
delim = '\\]'
}
if ('\\' === delim) {
delim = '\\\\'
}
if (['both', 'start'].includes(position)) {
str = str.replace(new RegExp(`^[${delim}]+`, 'g'), '')
}
if (['both', 'end'].includes(position)) {
str = str.replace(new RegExp(`[${delim}]+$`, 'g'), '')
}
return str
}
/**
* Trim delimiter characters from the end of a string.
*
* @param str - The input string to trim.
* @param delim - The delimiter character to trim.
* @returns The trimmed string.
*/
const eTrim = (str: string, delim: string): string => iTrim(str, delim, 'end')
/**
* Trim delimiter characters from the start of a string.
*
* @param str - The input string to trim.
* @param delim - The delimiter character to trim.
* @returns The trimmed string.
*/
const sTrim = (str: string, delim: string): string => iTrim(str, delim, 'start')
/**
* Uppercase the first character of the string.
*
* @param str - The input string.
* @returns The string with the first character capitalized.
*/
const ucFirst = (str: string): string => (!str) ? str : str.charAt(0).toUpperCase() + str.slice(1)
/**
* Get the name of a separator based on its value
*
* @param {string} value - The separator value
*
* @returns {string} The name of the separator, or 'Unknown' if not found
*/
const getSeparatorsName = (value: string): string => {
const sep = separators.find(s => s.value === value)
return sep ? `${sep.name} (${value})` : 'Unknown'
}
/**
* Convert options to JSON
*
* @param {string} opts
*
* @returns {Promise<convert_args_response>} The converted options
*/
const convertCliOptions = async (opts: string): Promise<convert_args_response> => {
const response = await request('/api/yt-dlp/convert', {
method: 'POST',
body: JSON.stringify({ args: opts }),
});
const data = await response.json()
if (200 !== response.status) {
throw new Error(`Error: (${response.status}): ${data.error}`)
}
return data
}
/**
* Get query parameters from a URL string or the current location.
*
* @param url - The full URL or search string (default: current URL's search).
* @returns A key-value map of query parameters.
*/
const getQueryParams = (url: string = window.location.search): Record<string, string> => {
return Object.fromEntries(new URLSearchParams(url).entries())
}
/**
* Build a download URL based on config and item metadata.
*
* @param config - The application config object.
* @param item - The item containing filename/folder.
* @param base - The base endpoint type (default: 'api/download').
* @returns The fully constructed download URI.
*/
const makeDownload = (config: any, item: { folder?: string; filename: string }, base: string = 'api/download'): string => {
let baseDir = 'api/player/m3u8/video/'
if ('m3u8' !== base) {
baseDir = `${base}/`
}
if (item.folder) {
item.folder = item.folder.replace(/#/g, '%23')
baseDir += item.folder + '/'
}
const url = `/${sTrim(baseDir, '/')}${encodePath(item.filename)}`
return uri('m3u8' === base ? `${url}.m3u8` : url)
}
/**
* Format bytes to a human-readable string.
*
* @param bytes - The number of bytes.
* @param decimals - Number of decimal places to include.
* @returns A formatted size string (e.g., '2.00 MiB').
*/
const formatBytes = (bytes: number, decimals: number = 2): string => {
if (!+bytes) {
return '0 Bytes'
}
const k = 1024
const dm = decimals < 0 ? 0 : decimals
const sizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`
}
/**
* Check if input has non-empty data.
*
* @param item - The input (object, array, or JSON string).
* @returns True if it contains data, false otherwise.
*/
const has_data = (item: any): boolean => {
if (!item) {
return false
}
if ('string' === typeof item) {
try {
item = JSON.parse(item)
} catch {
return true
}
}
try {
if ('object' === typeof item) return Object.keys(item).length > 0
return item.length > 0
} catch (e) {
console.error(e)
return false
}
}
/**
* Toggle a class on a DOM element. Supports array of class names.
*
* @param target - The HTML element to toggle classes on.
* @param className - Class name or array of class names.
*/
const toggleClass = (target: HTMLElement, className: string | string[]): void => {
if (Array.isArray(className)) {
className.forEach(cls => toggleClass(target, cls))
return
}
if (target.classList.contains(className)) {
target.classList.remove(className)
} else {
target.classList.add(className)
}
}
/**
* Remove specified fields from an object.
*
* @param item - Input object.
* @param fields - Keys to exclude.
* @returns A new object without the excluded keys.
*/
const cleanObject = <T extends Record<string, any>>(item: T, fields: string[] = []): Partial<T> => {
if (!item || typeof item !== 'object' || fields.length < 1) return item
const cleaned: Partial<T> = {}
for (const key of Object.keys(item)) {
if (!fields.includes(key)) {
cleaned[key as keyof T] = item[key]
}
}
return cleaned
}
/**
* Prefix URL with baseURL if needed.
*
* @param u - The input path.
* @returns The fully prefixed URI.
*/
const uri = (u: string): string => {
if (!u || '/' === runtimeConfig.app.baseURL || !u.startsWith('/')) {
return u
}
if (u.startsWith(runtimeConfig.app.baseURL)) {
return u
}
return `${eTrim(runtimeConfig.app.baseURL, '/')}/${sTrim(u, '/')}`
}
/**
* Format seconds into HH:MM:SS or MM:SS.
*
* @param seconds - Time in seconds.
* @returns A time string.
*/
const formatTime = (seconds: number): string => {
const hrs = Math.floor(seconds / 3600)
const mins = Math.floor((seconds % 3600) / 60)
const secs = Math.floor(seconds % 60)
const pad = (n: number): string => n.toString().padStart(2, '0')
if (hrs > 0) {
return `${pad(hrs)}:${pad(mins)}:${pad(secs)}`
}
if (mins > 0) {
return `${pad(mins)}:${pad(secs)}`
}
return `${secs}`
}
/**
* Pause execution for a number of seconds.
*
* @param seconds - Number of seconds to sleep.
* @returns A promise that resolves after the delay.
*/
const sleep = (seconds: number): Promise<void> => new Promise(resolve => setTimeout(resolve, seconds * 1000))
export {
convertCliOptions, separators, getSeparatorsName, iTrim, eTrim, sTrim, ucFirst,
getValue, ag, ag_set, awaitElement, r, copyText, dEvent, makePagination, encodePath,
request, removeANSIColors, dec2hex, makeId, basename, dirname, getQueryParams,
makeDownload,
formatBytes,
has_data,
toggleClass,
cleanObject,
uri,
formatTime,
sleep
}

View file

@ -1,45 +0,0 @@
import type { convert_args_response } from "~/types/responses";
const separators = [
{ name: 'Comma', value: ',', },
{ name: 'Semicolon', value: ';', },
{ name: 'Colon', value: ':', },
{ name: 'Pipe', value: '|', },
{ name: 'Space', value: ' ', }
]
/**
* Get the name of a separator based on its value
*
* @param {string} value - The separator value
*
* @returns {string} The name of the separator, or 'Unknown' if not found
*/
const getSeparatorsName = (value: string): string => {
const sep = separators.find(s => s.value === value)
return sep ? `${sep.name} (${value})` : 'Unknown'
}
/**
* Convert options to JSON
*
* @param {string} opts
*
* @returns {Promise<convert_args_response>} The converted options
*/
const convertCliOptions = async (opts: string): Promise<convert_args_response> => {
const response = await request('/api/yt-dlp/convert', {
method: 'POST',
body: JSON.stringify({ args: opts }),
});
const data = await response.json()
if (200 !== response.status) {
throw new Error(`Error: (${response.status}): ${data.error}`)
}
return data
}
export { convertCliOptions, separators, getSeparatorsName }

View file

@ -1,6 +1,10 @@
const NUMBER_RE = /\d+(?:\.\d+)?/; const NUMBER_RE = /\d+(?:\.\d+)?/;
function match_str(filterStr: string, dct: Record<string, any>, incomplete: boolean | Set<string> = false): boolean { function match_str(filterStr: string, dct: Record<string, any>, incomplete: boolean | Set<string> = false): boolean {
if (!filterStr) {
return true;
}
return filterStr return filterStr
.split(/(?<!\\)&/) .split(/(?<!\\)&/)
.every(filterPart => _match_one(filterPart.replace(/\\&/g, '&'), dct, incomplete)); .every(filterPart => _match_one(filterPart.replace(/\\&/g, '&'), dct, incomplete));
@ -9,15 +13,22 @@ function match_str(filterStr: string, dct: Record<string, any>, incomplete: bool
function lookup_unit_table(unitTable: Record<string, number>, s: string, strict = false): number | null { function lookup_unit_table(unitTable: Record<string, number>, s: string, strict = false): number | null {
const numRe = strict ? NUMBER_RE : /\d+[,.]?\d*/; const numRe = strict ? NUMBER_RE : /\d+[,.]?\d*/;
const unitsRe = Object.keys(unitTable).map(u => u.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'); const unitsRe = Object.keys(unitTable).map(u => u.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
const re = new RegExp(`^(?<num>${numRe.source})\s*(?<unit>${unitsRe})\b`, strict ? '' : 'i'); const re = new RegExp(`^(?<num>${numRe.source})\\s*(?<unit>${unitsRe})\\b`, strict ? '' : 'i');
const m = s.match(re); const m = s.match(re);
if (!m || !m.groups) return null; if (!m?.groups?.num || !m.groups.unit) {
return null;
const num = parseFloat(m.groups.num.replace(',', '.')); }
const mult = unitTable[m.groups.unit];
return Math.round(num * mult); const numStr = m.groups.num.replace(',', '.');
const unit = m.groups.unit;
const mult = unitTable[unit];
if (undefined === mult) {
return null;
}
return Math.round(parseFloat(numStr) * mult);
} }
function parse_filesize(s: string | null): number | null { function parse_filesize(s: string | null): number | null {
@ -35,73 +46,109 @@ function parse_filesize(s: string | null): number | null {
} }
function parse_duration(s: string): number | null { function parse_duration(s: string): number | null {
if (!s.trim()) return null; if (!s.trim()) {
return null;
}
const durationRe = /^(?:(?:(?<days>\d+):)?(?<hours>\d+):)?(?<mins>\d+):(?<secs>\d{1,2})(?<ms>[.:]\d+)?Z?$/; const durationRe = /^(?:(?:(?<days>\d+):)?(?<hours>\d+):)?(?<mins>\d+):(?<secs>\d{1,2})(?<ms>[.:]\d+)?Z?$/;
const m = s.match(durationRe); const m = s.match(durationRe);
if (!m || !m.groups) return null; if (!m?.groups) {
return null;
}
const { days, hours, mins, secs, ms } = m.groups; const { days, hours, mins, secs, ms } = m.groups;
return ( return (
(parseFloat(days || '0') * 86400) + (parseFloat(days ?? '0') * 86400) +
(parseFloat(hours || '0') * 3600) + (parseFloat(hours ?? '0') * 3600) +
(parseFloat(mins || '0') * 60) + (parseFloat(mins ?? '0') * 60) + parseFloat(secs ?? '0') + parseFloat((ms ?? '0').replace(':', '.'))
parseFloat(secs || '0') +
parseFloat((ms || '0').replace(':', '.'))
); );
} }
function _match_one(filterPart: string, dct: Record<string, any>, incomplete: boolean | Set<string>): boolean { function _match_one(filterPart: string, dct: Record<string, any>, incomplete: boolean | Set<string>): boolean {
const STRING_OPERATORS: Record<string, Function> = { const STRING_OPERATORS: Record<string, (a: string, b: string) => boolean> = {
'*=': (a: string, v: string) => a.includes(v), '*=': (a, b) => a.includes(b),
'^=': (a: string, v: string) => a.startsWith(v), '^=': (a, b) => a.startsWith(b),
'$=': (a: string, v: string) => a.endsWith(v), '$=': (a, b) => a.endsWith(b),
'~=': (a: string, v: string) => new RegExp(v).test(a), '~=': (a, b) => new RegExp(b).test(a),
}; };
const COMPARISON_OPERATORS: Record<string, Function> = { const COMPARISON_OPERATORS: Record<string, (a: any, b: any) => boolean> = {
...STRING_OPERATORS, '*=': STRING_OPERATORS['*=']!,
'<=': (a: any, b: any) => a <= b, '^=': STRING_OPERATORS['^=']!,
'<': (a: any, b: any) => a < b, '$=': STRING_OPERATORS['$=']!,
'>=': (a: any, b: any) => a >= b, '~=': STRING_OPERATORS['~=']!,
'>': (a: any, b: any) => a > b, '<=': (a, b) => a <= b,
'=': (a: any, b: any) => a == b, '<': (a, b) => a < b,
'>=': (a, b) => a >= b,
'>': (a, b) => a > b,
'=': (a, b) => a == b,
}; };
const match = filterPart.trim().match(/^([a-z_]+)\s*(!)?\s*([*^$~=<>]+)\s*(?:"(.+)"|'(.+)'|(.+))$/); const isIncomplete = typeof incomplete === 'boolean' ? (_: string) => incomplete : (k: string) => incomplete.has(k);
if (match) { const cmpOps = Object.keys(COMPARISON_OPERATORS).map(op => op.replace(/([.*+?^${}()|\[\]\\])/g, '\\$1')).join('|');
const [, key, negation, op, qstr, sstr, ustr] = match; const operatorRe = new RegExp(`^(?<key>[a-z_]+)\\s*(?<negation>!\\s*)?(?<op>${cmpOps})(?<noneInclusive>\\s*\\?)?\\s*(?:(?<quote>["'])(?<quoted>.+?)(?:\\k<quote>)|(?<plain>.+))$`);
const value = qstr || sstr || ustr;
const actual = dct[key];
const compValue = parse_filesize(value) || parse_duration(value) || value; const m = filterPart.trim().match(operatorRe);
if (m?.groups) {
const { key, negation, op, noneInclusive, quote, quoted, plain } = m.groups;
const unnegatedOp = COMPARISON_OPERATORS[op!];
if (!unnegatedOp) {
throw new Error(`Invalid operator '${op}'`);
}
const _cmp = COMPARISON_OPERATORS[op]; const opFn = negation ? (a: any, b: any) => !unnegatedOp(a, b) : unnegatedOp;
if (actual === undefined) return incomplete === true || (incomplete instanceof Set && incomplete.has(key));
return negation ? !_cmp(actual, compValue) : _cmp(actual, compValue); let value = quoted ?? plain ?? '';
if (quote) value = value.replace(new RegExp(`\\\\${quote}`, 'g'), quote);
const actual = dct[key!];
let numeric: number | null = null;
if (typeof actual === 'number') {
numeric = Number(value);
if (Number.isNaN(numeric)) {
numeric = parse_filesize(value) ?? parse_filesize(`${value}B`) ?? parse_duration(value);
}
}
if (numeric !== null && op! in STRING_OPERATORS) {
throw new Error(`Operator ${op} only supports string values!`);
}
if (actual == null) {
return isIncomplete(key!) || Boolean(noneInclusive);
}
return opFn(actual, numeric !== null ? numeric : value);
} }
const UNARY_OPERATORS: Record<string, Function> = { const UNARY_OPERATORS: Record<string, (v: any) => boolean> = {
'': (v: any) => v != null, '': v => typeof v === 'boolean' ? v === true : v != null,
'!': (v: any) => v == null, '!': v => typeof v === 'boolean' ? v === false : v == null,
}; };
const unaryMatch = filterPart.trim().match(/^(!)?([a-z_]+)$/); const unaryRe = new RegExp(`^(?<op>!?)\\s*(?<key>[a-z_]+)$`);
const mu = filterPart.trim().match(unaryRe);
if (mu?.groups) {
const { op, key } = mu.groups;
const actual = dct[key!];
if (unaryMatch) { if (actual === undefined && isIncomplete(key!)) {
const [, op, key] = unaryMatch; return true;
const actual = dct[key]; }
if (actual === undefined && (incomplete === true || (incomplete instanceof Set && incomplete.has(key)))) return true; const unaryOp = UNARY_OPERATORS[op!];
return UNARY_OPERATORS[op || ''](actual); if (!unaryOp) {
throw new Error(`Invalid unary operator '${op}'`);
}
return unaryOp(actual);
} }
throw new Error(`Invalid filter part '${filterPart}'`); throw new Error(`Invalid filter part '${filterPart}'`);
} }
export { match_str, lookup_unit_table, parse_filesize, parse_duration, _match_one } export { match_str, lookup_unit_table, parse_filesize, parse_duration, _match_one }

1610
uv.lock

File diff suppressed because it is too large Load diff