Merge pull request #354 from arabcoders/dev

Added toggle for task handler & convert_url
This commit is contained in:
Abdulmohsen 2025-07-27 16:57:27 +03:00 committed by GitHub
commit 33fbf4bda8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 1131 additions and 862 deletions

19
.vscode/launch.json vendored
View file

@ -1,7 +1,4 @@
{ {
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
@ -18,17 +15,17 @@
"cwd": "${workspaceFolder}/ui", "cwd": "${workspaceFolder}/ui",
"env": { "env": {
"NUXT_API_URL": "http://localhost:8081/api/", "NUXT_API_URL": "http://localhost:8081/api/",
"NUXT_PUBLIC_WSS": ":8081/", "NUXT_PUBLIC_WSS": ":8081/"
}, },
"console": "internalConsole", "console": "internalConsole",
"outputCapture": "std", "outputCapture": "std"
}, },
{ {
"name": "Python: main.py", "name": "Python: main.py ",
"type": "debugpy", "type": "debugpy",
"request": "launch", "request": "launch",
"program": "app/main.py", "program": "app/main.py",
"console": "internalConsole", "console": "integratedTerminal",
"justMyCode": true, "justMyCode": true,
"env": { "env": {
"YTP_CONFIG_PATH": "${workspaceFolder}/var/config", "YTP_CONFIG_PATH": "${workspaceFolder}/var/config",
@ -36,7 +33,9 @@
"YTP_TEMP_PATH": "${workspaceFolder}/var/tmp", "YTP_TEMP_PATH": "${workspaceFolder}/var/tmp",
"PYDEVD_DISABLE_FILE_VALIDATION": "1", "PYDEVD_DISABLE_FILE_VALIDATION": "1",
"YTP_IGNORE_UI": "true" "YTP_IGNORE_UI": "true"
} },
"subProcess": true,
"postDebugTask": "kill-debugpy"
}, },
{ {
"name": "Node: Generate UI", "name": "Node: Generate UI",
@ -65,14 +64,14 @@
"YTP_CONFIG_PATH": "${workspaceFolder}/var/config", "YTP_CONFIG_PATH": "${workspaceFolder}/var/config",
"YTP_DOWNLOAD_PATH": "${workspaceFolder}/var/downloads", "YTP_DOWNLOAD_PATH": "${workspaceFolder}/var/downloads",
"YTP_TEMP_PATH": "${workspaceFolder}/var/tmp", "YTP_TEMP_PATH": "${workspaceFolder}/var/tmp",
"YTP_LOG_LEVEL": "DEBUG", "YTP_LOG_LEVEL": "DEBUG"
} }
}, },
{ {
"name": "Python: Attach To Process", "name": "Python: Attach To Process",
"type": "debugpy", "type": "debugpy",
"request": "attach", "request": "attach",
"processId": "${command:pickProcess}", "processId": "${command:pickProcess}"
}, },
{ {
"name": "Python: Attach To Container", "name": "Python: Attach To Container",

View file

@ -111,7 +111,7 @@
"youtu" "youtu"
], ],
"css.styleSheets": [ "css.styleSheets": [
"ui/assets/css/*.css" "ui/app/assets/css/*.css"
], ],
"spellright.language": [ "spellright.language": [
"en" "en"

22
.vscode/tasks.json vendored Normal file
View file

@ -0,0 +1,22 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "kill-debugpy",
"type": "shell",
"command": "pkill -f debugpy || true",
"problemMatcher": [],
"presentation": {
"echo": false,
"reveal": "never",
"focus": false,
"panel": "dedicated",
"showReuseMessage": false,
"clear": false
},
"runOptions": {
"runOn": "folderOpen"
}
}
]
}

7
API.md
View file

@ -142,8 +142,9 @@ or an error:
**Query Parameters**: **Query Parameters**:
- `url=<video-url>` (required) - `url=<video-url>` (required)
- `preset=<preset-name>` (optional) - The preset to use for extracting info - `preset=<preset-name>` (optional) - The preset to use for extracting info.
- `force=true` (optional) - Force fetch new info instead of using cache - `force=true` (optional) - Force fetch new info instead of using cache.
- `args=<yt-dlp-command-opts>` (optional) - The yt-dlp command options to apply to the info extraction.
**Response** (example): **Response** (example):
```json ```json
@ -153,6 +154,8 @@ or an error:
"extractor": "youtube", "extractor": "youtube",
"_cached": { "_cached": {
"status": "miss|hit", "status": "miss|hit",
"preset": "<preset-name>",
"cli_args": "<yt-dlp-command-opts>",
"key": "<hash>", "key": "<hash>",
"ttl": 300, "ttl": 300,
"ttl_left": 299.82, "ttl_left": 299.82,

View file

@ -58,6 +58,7 @@ class Scheduler(metaclass=Singleton):
for job in self._jobs: for job in self._jobs:
try: try:
self._jobs[job].stop() self._jobs[job].stop()
LOG.debug(f"Stopped job '{job}'.")
except Exception as e: except Exception as e:
LOG.exception(e) LOG.exception(e)
LOG.error(f"Failed to stop job '{job}'. Error message '{e!s}'.") LOG.error(f"Failed to stop job '{job}'. Error message '{e!s}'.")

View file

@ -35,6 +35,7 @@ class Task:
template: str = "" template: str = ""
cli: str = "" cli: str = ""
auto_start: bool = True auto_start: bool = True
handler_enabled: bool = True
def serialize(self) -> dict: def serialize(self) -> dict:
return self.__dict__ return self.__dict__
@ -100,7 +101,8 @@ class Tasks(metaclass=Singleton):
return Tasks._instance return Tasks._instance
async def on_shutdown(self, _: web.Application): async def on_shutdown(self, _: web.Application):
pass self.clear(shutdown=True)
self._task_handler.on_shutdown(_)
def attach(self, _: web.Application): def attach(self, _: web.Application):
""" """
@ -116,6 +118,7 @@ class Tasks(metaclass=Singleton):
lambda data, _, **kwargs: self.save(**data.data), # noqa: ARG005 lambda data, _, **kwargs: self.save(**data.data), # noqa: ARG005
f"{__class__.__name__}.add", f"{__class__.__name__}.add",
) )
self._task_handler.load()
def get_all(self) -> list[Task]: def get_all(self) -> list[Task]:
"""Return the tasks.""" """Return the tasks."""
@ -155,7 +158,7 @@ class Tasks(metaclass=Singleton):
self._tasks.append(task) self._tasks.append(task)
if not task.timer or "[only_handler]" in task.name: if not task.timer:
continue continue
try: try:
@ -361,12 +364,21 @@ class Tasks(metaclass=Singleton):
class HandleTask: class HandleTask:
_tasks: Tasks _tasks: Tasks
_handlers: list[type]
_scheduler: Scheduler
_config: Config
_task_name: str
def __init__(self, scheduler: Scheduler, tasks: Tasks, config: Config) -> None: def __init__(self, scheduler: Scheduler, tasks: Tasks, config: Config) -> None:
self._tasks = tasks self._tasks = tasks
self._scheduler = scheduler
self._config = config
self._task_name: str = f"{__class__.__name__}._dispatcher"
def load(self) -> None:
self._handlers: list[type] = self._discover() self._handlers: list[type] = self._discover()
timer = config.tasks_handler_timer timer: str = self._config.tasks_handler_timer
try: try:
from cronsim import CronSim from cronsim import CronSim
@ -375,18 +387,26 @@ class HandleTask:
timer = "15 */1 * * *" timer = "15 */1 * * *"
LOG.error(f"Invalid timer format. '{e!s}'. Defaulting to '{timer}'.") LOG.error(f"Invalid timer format. '{e!s}'. Defaulting to '{timer}'.")
scheduler.add( self._scheduler.add(
timer=timer, timer=timer,
func=self._dispatcher, func=self._dispatcher,
id=f"{__class__.__name__}._dispatcher", id=f"{__class__.__name__}._dispatcher",
) )
def on_shutdown(self, _: web.Application) -> None:
"""
Handle shutdown event.
Args:
_: web.Application: The aiohttp application.
"""
if self._scheduler.has(self._task_name):
self._scheduler.remove(self._task_name)
def _dispatcher(self): def _dispatcher(self):
for task in self._tasks.get_all(): for task in self._tasks.get_all():
if "[no_handler]" in task.name: if not task.handler_enabled:
continue
if not task.timer and "[only_handler]" not in task.name:
continue continue
try: try:

View file

@ -373,14 +373,18 @@ def check_id(file: Path) -> bool | str:
id = match.groupdict().get("id") id = match.groupdict().get("id")
for f in file.parent.iterdir(): try:
if id not in f.stem: for f in file.parent.iterdir():
continue if id not in f.stem:
continue
if f.suffix != file.suffix: if f.suffix != file.suffix:
continue continue
return f.absolute() return f.absolute()
except OSError as e:
LOG.error(f"Error checking file '{file}': {e!s}")
return False
return False return False

View file

@ -171,7 +171,6 @@ class Main:
self._app, self._app,
host=host, host=host,
port=port, port=port,
reuse_port="win32" != sys.platform,
loop=asyncio.get_event_loop(), loop=asyncio.get_event_loop(),
access_log=HTTP_LOGGER, access_log=HTTP_LOGGER,
print=started, print=started,

View file

@ -324,6 +324,7 @@ async def prepare_zip_file(request: Request, config: Config, cache: Cache):
for f in json: for f in json:
if not isinstance(f, str): if not isinstance(f, str):
continue continue
ref, status = get_file(download_path=config.download_path, file=f) ref, status = get_file(download_path=config.download_path, file=f)
if status == web.HTTPNotFound.status_code: if status == web.HTTPNotFound.status_code:
continue continue

View file

@ -105,6 +105,8 @@ async def get_info(request: Request, cache: Cache, config: Config) -> Response:
status=web.HTTPBadRequest.status_code, status=web.HTTPBadRequest.status_code,
) )
opts = YTDLPOpts.get_instance()
preset: str = request.query.get("preset", config.default_preset) preset: str = request.query.get("preset", config.default_preset)
exists: Preset | None = Presets.get_instance().get(preset) exists: Preset | None = Presets.get_instance().get(preset)
if not exists: if not exists:
@ -114,14 +116,28 @@ async def get_info(request: Request, cache: Cache, config: Config) -> Response:
status=web.HTTPBadRequest.status_code, status=web.HTTPBadRequest.status_code,
) )
if cli_args := request.query.get("args", None):
try:
arg_converter(cli_args, dumps=True)
opts = opts.add_cli(cli_args, from_user=True)
except Exception as e:
err = str(e).strip()
err = err.split("\n")[-1] if "\n" in err else err
err = err.replace("main.py: error: ", "").strip().capitalize()
return web.json_response(
data={"error": f"Failed to parse command options for yt-dlp. '{err}'."},
status=web.HTTPBadRequest.status_code,
)
try: try:
key: str = cache.hash(f"{preset}:{url}") key: str = cache.hash(f"{preset}:{url}:{cli_args or ''}")
if cache.has(key) and not request.query.get("force", False): if cache.has(key) and not request.query.get("force", False):
data: Any | None = cache.get(key) data: Any | None = cache.get(key)
data["_cached"] = { data["_cached"] = {
"status": "hit", "status": "hit",
"preset": preset, "preset": preset,
"cli_args": cli_args,
"key": key, "key": key,
"ttl": data.get("_cached", {}).get("ttl", 300), "ttl": data.get("_cached", {}).get("ttl", 300),
"ttl_left": data.get("_cached", {}).get("expires", time.time() + 300) - time.time(), "ttl_left": data.get("_cached", {}).get("expires", time.time() + 300) - time.time(),
@ -129,20 +145,18 @@ async def get_info(request: Request, cache: Cache, config: Config) -> Response:
} }
return web.Response(body=json.dumps(data, indent=4, default=str), status=web.HTTPOk.status_code) return web.Response(body=json.dumps(data, indent=4, default=str), status=web.HTTPOk.status_code)
opts: dict = {}
if ytdlp_proxy := config.get_ytdlp_args().get("proxy", None): if ytdlp_proxy := config.get_ytdlp_args().get("proxy", None):
opts["proxy"] = ytdlp_proxy opts = opts.add({"proxy": ytdlp_proxy})
logs: list = [] logs: list = []
ytdlp_opts: dict = { ytdlp_opts: dict = {
**opts.get_all(),
"callback": { "callback": {
"func": lambda _, msg: logs.append(msg), "func": lambda _, msg: logs.append(msg),
"level": logging.WARNING, "level": logging.WARNING,
"name": "callback-logger", "name": "callback-logger",
}, },
**YTDLPOpts.get_instance().preset(name=preset).add(opts).get_all(),
} }
data = extract_info( data = extract_info(
@ -177,6 +191,7 @@ async def get_info(request: Request, cache: Cache, config: Config) -> Response:
data["_cached"] = { data["_cached"] = {
"status": "miss", "status": "miss",
"preset": preset, "preset": preset,
"cli_args": cli_args,
"key": key, "key": key,
"ttl": 300, "ttl": 300,
"ttl_left": 300, "ttl_left": 300,

View file

@ -345,3 +345,11 @@ hr {
word-break: break-word; word-break: break-word;
text-wrap: auto; text-wrap: auto;
} }
table.is-fixed {
table-layout: fixed;
}
div.is-centered {
justify-content: center;
}

View file

@ -161,7 +161,7 @@
<div class="field is-grouped is-grouped-centered"> <div class="field is-grouped is-grouped-centered">
<div class="control" v-if="item.status != 'finished' || !item.filename"> <div class="control" v-if="item.status != 'finished' || !item.filename">
<button class="button is-warning is-fullwidth is-small" v-tooltip="'Retry download'" <button class="button is-warning is-fullwidth is-small" v-tooltip="'Retry download'"
@click="(event) => retryItem(item, event)"> @click="() => retryItem(item, true)">
<span class="icon"><i class="fa-solid fa-rotate-right" /></span> <span class="icon"><i class="fa-solid fa-rotate-right" /></span>
</button> </button>
</div> </div>
@ -188,7 +188,8 @@
<hr class="dropdown-divider" /> <hr class="dropdown-divider" />
</template> </template>
<template v-else-if="isEmbedable(item.url)"> <template v-else-if="isEmbedable(item.url)">
<NuxtLink class="dropdown-item has-text-danger" @click="embed_url = getEmbedable(item.url)"> <NuxtLink class="dropdown-item has-text-danger"
@click="embed_url = getEmbedable(item.url) as string">
<span class="icon"><i class="fa-solid fa-play" /></span> <span class="icon"><i class="fa-solid fa-play" /></span>
<span>Play video</span> <span>Play video</span>
</NuxtLink> </NuxtLink>
@ -279,7 +280,8 @@
v-if="item.extras?.thumbnail" /> v-if="item.extras?.thumbnail" />
<img v-else src="/images/placeholder.png" /> <img v-else src="/images/placeholder.png" />
</span> </span>
<span v-else-if="isEmbedable(item.url)" @click="embed_url = getEmbedable(item.url)" class="play-overlay"> <span v-else-if="isEmbedable(item.url)" @click="embed_url = getEmbedable(item.url) as string"
class="play-overlay">
<div class="play-icon embed-icon"></div> <div class="play-icon embed-icon"></div>
<img @load="e => pImg(e)" :src="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))" <img @load="e => pImg(e)" :src="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))"
v-if="item.extras?.thumbnail" /> v-if="item.extras?.thumbnail" />
@ -322,7 +324,7 @@
</div> </div>
<div class="columns is-mobile is-multiline"> <div class="columns is-mobile is-multiline">
<div class="column is-half-mobile" v-if="item.status != 'finished' || !item.filename"> <div class="column is-half-mobile" v-if="item.status != 'finished' || !item.filename">
<a class="button is-warning is-fullwidth" @click="(event) => retryItem(item, event)"> <a class="button is-warning is-fullwidth" @click="() => retryItem(item, false)">
<span class="icon-text is-block"> <span class="icon-text is-block">
<span class="icon"><i class="fa-solid fa-rotate-right" /></span> <span class="icon"><i class="fa-solid fa-rotate-right" /></span>
<span>Retry</span> <span>Retry</span>
@ -360,7 +362,8 @@
</template> </template>
<template v-else-if="isEmbedable(item.url)"> <template v-else-if="isEmbedable(item.url)">
<NuxtLink class="dropdown-item has-text-danger" @click="embed_url = getEmbedable(item.url)"> <NuxtLink class="dropdown-item has-text-danger"
@click="embed_url = getEmbedable(item.url) as string">
<span class="icon"><i class="fa-solid fa-play" /></span> <span class="icon"><i class="fa-solid fa-play" /></span>
<span>Play video</span> <span>Play video</span>
</NuxtLink> </NuxtLink>
@ -459,24 +462,22 @@
@cancel="() => dialog_confirm.visible = false" /> @cancel="() => dialog_confirm.visible = false" />
</div> </div>
</template> </template>
<script setup lang="ts">
<script setup>
import { useStorage } from '@vueuse/core'
import moment from 'moment' import moment from 'moment'
import { useStorage } from '@vueuse/core'
import type { StoreItem } from '~/types/store'
const emitter = defineEmits(['getInfo', 'add_new', 'getItemInfo', 'clear_search']) const emitter = defineEmits<{
(e: 'getInfo', url: string, preset: string): void
(e: 'add_new', item: Partial<StoreItem>): void
(e: 'getItemInfo', id: string): void
(e: 'clear_search'): void
}>()
const props = defineProps({ const props = defineProps<{
thumbnails: { thumbnails?: boolean
type: Boolean, query?: string
default: true }>()
},
query: {
type: String,
required: false,
default: '',
}
})
const config = useConfigStore() const config = useConfigStore()
const stateStore = useStateStore() const stateStore = useStateStore()
@ -484,19 +485,25 @@ const socket = useSocketStore()
const toast = useNotification() const toast = useNotification()
const box = useConfirm() const box = useConfirm()
const showCompleted = useStorage('showCompleted', true) const showCompleted = useStorage<boolean>('showCompleted', true)
const hideThumbnail = useStorage('hideThumbnailHistory', false) const hideThumbnail = useStorage<boolean>('hideThumbnailHistory', false)
const direction = useStorage('sortCompleted', 'desc') const direction = useStorage<'asc' | 'desc'>('sortCompleted', 'desc')
const display_style = useStorage('display_style', 'cards') const display_style = useStorage<'cards' | 'list'>('display_style', 'cards')
const bg_enable = useStorage('random_bg', true) const bg_enable = useStorage<boolean>('random_bg', true)
const bg_opacity = useStorage('random_bg_opacity', 0.95) const bg_opacity = useStorage<number>('random_bg_opacity', 0.95)
const selectedElms = ref([]) const selectedElms = ref<string[]>([])
const masterSelectAll = ref(false) const masterSelectAll = ref(false)
const table_container = ref(false) const table_container = ref(false)
const embed_url = ref('') const embed_url = ref('')
const video_item = ref(null) const video_item = ref<StoreItem | null>(null)
const dialog_confirm = ref({ const dialog_confirm = ref<{
visible: boolean
title: string
confirm: (opts?: any) => void
message: string
options: { key: string; label: string }[]
}>({
visible: false, visible: false,
title: 'Confirm Action', title: 'Confirm Action',
confirm: () => { }, confirm: () => { },
@ -506,60 +513,57 @@ const dialog_confirm = ref({
], ],
}) })
const showThumbnails = computed(() => props.thumbnails && !hideThumbnail.value) const showThumbnails = computed(() => (props.thumbnails || true) && !hideThumbnail.value)
const playVideo = item => video_item.value = item const playVideo = (item: StoreItem) => { video_item.value = item }
const closeVideo = () => video_item.value = null const closeVideo = () => { video_item.value = null }
const filteredItems = items => !props.query ? items : items.filter(filterItem) const filteredItems = (items: StoreItem[]) => !props.query ? items : items.filter(filterItem)
const filterItem = item => { const filterItem = (item: StoreItem) => {
const q = props.query?.toLowerCase() const q = props.query?.toLowerCase()
if (!q) { if (!q) {
return true return true
} }
return Object.values(item).some(v => typeof v === 'string' && v.toLowerCase().includes(q)) return Object.values(item).some(v =>
typeof v === 'string' && v.toLowerCase().includes(q)
)
} }
watch(masterSelectAll, (value) => { watch(masterSelectAll, (value) => {
for (const key in stateStore.history) { if (value) {
const element = stateStore.history[key] selectedElms.value = Object.values(stateStore.history).map((element: StoreItem) => element._id)
if (value) { } else {
selectedElms.value.push(element._id) selectedElms.value = []
} else {
selectedElms.value = []
}
} }
}) })
const sortCompleted = computed(() => { const sortCompleted = computed(() => {
const thisDirection = direction.value const thisDirection = direction.value
return Object.values(stateStore.history).sort((a, b) => { return Object.values(stateStore.history as Record<string, StoreItem>).sort((a, b) => {
if ('asc' === thisDirection) { if ('asc' === thisDirection) {
return new Date(a.datetime) - new Date(b.datetime) return new Date(a.datetime).getTime() - new Date(b.datetime).getTime()
} }
return new Date(b.datetime) - new Date(a.datetime) return new Date(b.datetime).getTime() - new Date(a.datetime).getTime()
}) })
}) })
const hasSelected = computed(() => selectedElms.value.length > 0) const hasSelected = computed(() => selectedElms.value.length > 0)
const hasItems = computed(() => filteredItems(sortCompleted.value).length > 0) const hasItems = computed(() => filteredItems(sortCompleted.value as StoreItem[]).length > 0)
const showMessage = (item) => { const showMessage = (item: StoreItem) => {
if (!item?.msg || item.msg === item?.error) { if (!item?.msg || item.msg === item?.error) {
return false return false
} }
return (item.msg?.length || 0) > 0
return item.msg.length > 0
} }
const hasIncomplete = computed(() => { const hasIncomplete = computed(() => {
if (Object.keys(stateStore.history)?.length < 0) { if (Object.keys(stateStore.history)?.length < 0) {
return false return false
} }
for (const key in stateStore.history) { for (const key in stateStore.history) {
const element = stateStore.history[key] const element = stateStore.history[key] as StoreItem
if (element.status !== 'finished') { if (element.status !== 'finished') {
return true return true
} }
@ -571,9 +575,8 @@ const hasCompleted = computed(() => {
if (Object.keys(stateStore.history)?.length < 0) { if (Object.keys(stateStore.history)?.length < 0) {
return false return false
} }
for (const key in stateStore.history) { for (const key in stateStore.history) {
const element = stateStore.history[key] const element = stateStore.history[key] as StoreItem
if (element.status === 'finished') { if (element.status === 'finished') {
return true return true
} }
@ -585,9 +588,8 @@ const hasDownloaded = computed(() => {
if (Object.keys(stateStore.history)?.length < 0) { if (Object.keys(stateStore.history)?.length < 0) {
return false return false
} }
for (const key in stateStore.history) { for (const key in stateStore.history) {
const element = stateStore.history[key] const element = stateStore.history[key] as StoreItem
if (element.status === 'finished' && element.filename) { if (element.status === 'finished' && element.filename) {
return true return true
} }
@ -600,18 +602,19 @@ const deleteSelectedItems = () => {
toast.error('No items selected.') toast.error('No items selected.')
return return
} }
let msg = `${config.app.remove_files ? 'Remove' : 'Clear'} '${selectedElms.value.length}' items?` let msg = `${config.app.remove_files ? 'Remove' : 'Clear'} '${selectedElms.value.length}' items?`
if (true === config.app.remove_files) { if (true === config.app.remove_files) {
msg += ' This will remove any associated files if they exists.' msg += ' This will remove any associated files if they exists.'
} }
if (false === box.confirm(msg, config.app.remove_files)) { if (false === box.confirm(msg, config.app.remove_files)) {
return return
} }
for (const key in selectedElms.value) { for (const key in selectedElms.value) {
const item = stateStore.history[selectedElms.value[key]] const item_id = selectedElms.value[key]
if (!item_id) {
continue
}
const item = stateStore.get('history', item_id, {} as StoreItem) as StoreItem
if ('finished' === item.status) { if ('finished' === item.status) {
socket.emit('archive_item', item) socket.emit('archive_item', item)
} }
@ -628,10 +631,9 @@ const clearCompleted = () => {
if (false === box.confirm(msg)) { if (false === box.confirm(msg)) {
return return
} }
for (const key in stateStore.history) { for (const key in stateStore.history) {
if ('finished' === ag(stateStore.get('history', key, {}), 'status')) { if ('finished' === ag(stateStore.get('history', key, {} as StoreItem), 'status')) {
socket.emit('item_delete', { id: stateStore.history[key]._id, remove_file: false, }) socket.emit('item_delete', { id: stateStore.history[key]?._id, remove_file: false, })
} }
} }
} }
@ -640,18 +642,17 @@ const clearIncomplete = () => {
if (false === box.confirm('Clear all in-complete downloads?')) { if (false === box.confirm('Clear all in-complete downloads?')) {
return return
} }
for (const key in stateStore.history) { for (const key in stateStore.history) {
if (stateStore.history[key].status !== 'finished') { if ((stateStore.history[key] as StoreItem).status !== 'finished') {
socket.emit('item_delete', { socket.emit('item_delete', {
id: stateStore.history[key]._id, id: stateStore.history[key]?._id,
remove_file: false, remove_file: false,
}) })
} }
} }
} }
const setIcon = item => { const setIcon = (item: StoreItem) => {
if ('finished' === item.status) { if ('finished' === item.status) {
if (!item.filename) { if (!item.filename) {
return 'fa-solid fa-exclamation' return 'fa-solid fa-exclamation'
@ -661,73 +662,59 @@ const setIcon = item => {
} }
return item.is_live ? 'fa-solid fa-globe' : 'fa-solid fa-circle-check' return item.is_live ? 'fa-solid fa-globe' : 'fa-solid fa-circle-check'
} }
if ('error' === item.status) { if ('error' === item.status) {
return 'fa-solid fa-circle-xmark' return 'fa-solid fa-circle-xmark'
} }
if ('cancelled' === item.status) { if ('cancelled' === item.status) {
return 'fa-solid fa-eject' return 'fa-solid fa-eject'
} }
if ('not_live' === item.status) { if ('not_live' === item.status) {
return item.extras?.is_premiere ? 'fa-solid fa-star' : 'fa-solid fa-headset' return item.extras?.is_premiere ? 'fa-solid fa-star' : 'fa-solid fa-headset'
} }
if ('skip' === item.status) { if ('skip' === item.status) {
return 'fa-solid fa-ban' return 'fa-solid fa-ban'
} }
return 'fa-solid fa-circle' return 'fa-solid fa-circle'
} }
const setIconColor = item => { const setIconColor = (item: StoreItem) => {
if ('finished' === item.status) { if ('finished' === item.status) {
if (!item.filename) { if (!item.filename) {
return 'has-text-warning' return 'has-text-warning'
} }
return 'has-text-success' return 'has-text-success'
} }
if ('not_live' === item.status) { if ('not_live' === item.status) {
return 'has-text-info' return 'has-text-info'
} }
if ('cancelled' === item.status || "skip" === item.status) {
if ('cancelled' === item.status || "skipped" === item.status) {
return 'has-text-warning' return 'has-text-warning'
} }
return 'has-text-danger' return 'has-text-danger'
} }
const setStatus = item => { const setStatus = (item: StoreItem) => {
if ('finished' === item.status) { if ('finished' === item.status) {
if (item.extras?.is_premiere) { if (item.extras?.is_premiere) {
return 'Premiered' return 'Premiered'
} }
return item.is_live ? 'Streamed' : 'Completed' return item.is_live ? 'Streamed' : 'Completed'
} }
if ('error' === item.status) { if ('error' === item.status) {
return 'Error' return 'Error'
} }
if ('cancelled' === item.status) { if ('cancelled' === item.status) {
return 'Cancelled' return 'Cancelled'
} }
if ('not_live' === item.status) { if ('not_live' === item.status) {
if (item.extras?.is_premiere) { if (item.extras?.is_premiere) {
return 'Premiere' return 'Premiere'
} }
return display_style.value === 'cards' ? 'Stream' : 'Live' return display_style.value === 'cards' ? 'Stream' : 'Live'
} }
if ('skip' === item.status) { if ('skip' === item.status) {
return 'Skipped' return 'Skipped'
} }
return item.status return item.status
} }
@ -735,9 +722,8 @@ const retryIncomplete = () => {
if (false === box.confirm('Retry all incomplete downloads?')) { if (false === box.confirm('Retry all incomplete downloads?')) {
return false return false
} }
for (const key in stateStore.history) { for (const key in stateStore.history) {
const item = stateStore.get('history', key, {}) const item = stateStore.get('history', key, {} as StoreItem) as StoreItem
if ('finished' === item.status) { if ('finished' === item.status) {
continue continue
} }
@ -745,96 +731,85 @@ const retryIncomplete = () => {
} }
} }
const addArchiveDialog = (item) => { const addArchiveDialog = (item: StoreItem) => {
dialog_confirm.value.visible = true dialog_confirm.value.visible = true
dialog_confirm.value.title = 'Archive Item' dialog_confirm.value.title = 'Archive Item'
dialog_confirm.value.message = `Archive '${item.title ?? item.id ?? item.url ?? '??'}'?` dialog_confirm.value.message = `Archive '${item.title || item.id || item.url || '??'}'?`
dialog_confirm.value.confirm = opts => archiveItem(item, opts) dialog_confirm.value.confirm = (opts: any) => archiveItem(item, opts)
} }
const archiveItem = async (item, opts = {}) => { const archiveItem = async (item: StoreItem, opts = {}) => {
try { try {
const req = await request(`/api/archive/${item._id}`, { const req = await request(`/api/archive/${item._id}`, {
credentials: 'include', credentials: 'include',
method: 'POST', method: 'POST',
}) })
const data = await req.json() const data = await req.json()
dialog_confirm.value.visible = false dialog_confirm.value.visible = false
if (!req.ok) { if (!req.ok) {
toast.error(data.error) toast.error(data.error)
return return
} }
toast.success(data.message ?? `Archived '${item.title || item.id || item.url || '??'}'.`)
toast.success(data.message ?? `Archived '${item.title ?? item.id ?? item.url ?? '??'}'.`) } catch (e: any) {
} catch (e) {
console.error(e) console.error(e)
} }
if (!(opts as any)?.remove_history) {
if (!opts?.remove_history) {
return return
} }
socket.emit('item_delete', { id: item._id, remove_file: false }) socket.emit('item_delete', { id: item._id, remove_file: false })
} }
const removeItem = item => { const removeItem = (item: StoreItem) => {
let msg = `${config.app.remove_files ? 'Remove' : 'Clear'} '${item.title ?? item.id ?? item.url ?? '??'}'?` let msg = `${config.app.remove_files ? 'Remove' : 'Clear'} '${item.title || item.id || item.url || '??'}'?`
if (item.status === 'finished' && config.app.remove_files) { if (item.status === 'finished' && config.app.remove_files) {
msg += ' This will remove any associated files if they exists.' msg += ' This will remove any associated files if they exists.'
} }
if (false === box.confirm(msg, Boolean(item.filename && config.app.remove_files))) {
if (false === box.confirm(msg, item.filename && config.app.remove_files)) {
return false return false
} }
socket.emit('item_delete', { socket.emit('item_delete', {
id: item._id, id: item._id,
remove_file: config.app.remove_files remove_file: config.app.remove_files
}) })
} }
const retryItem = (item, re_add = false) => { const retryItem = (item: StoreItem, re_add = false) => {
const item_req = { const item_req: Partial<StoreItem> = {
url: item.url, url: item.url,
preset: item.preset, preset: item.preset,
folder: item.folder, folder: item.folder,
cookies: item.cookies, cookies: item.cookies,
template: item.template, template: item.template,
cli: item?.cli, cli: item?.cli,
extras: toRaw(item.extras ?? {}) ?? {}, extras: toRaw(item?.extras || {}) ?? {},
}; }
socket.emit('item_delete', { id: item._id, remove_file: false }) socket.emit('item_delete', { id: item._id, remove_file: false })
if (true === re_add) { if (true === re_add) {
toast.info('Cleared the item from history, and added it to the new download form.') toast.info('Cleared the item from history, and added it to the new download form.')
emitter('add_new', item_req) emitter('add_new', item_req)
return return
} }
socket.emit('add_url', item_req) socket.emit('add_url', item_req)
} }
const pImg = e => e.target.naturalHeight > e.target.naturalWidth ? e.target.classList.add('image-portrait') : null const pImg = (e: Event) => {
const target = e.target as HTMLImageElement
target.naturalHeight > target.naturalWidth && target.classList.add('image-portrait')
}
watch(video_item, v => { watch(video_item, v => {
if (!bg_enable.value) { if (!bg_enable.value) {
return return
} }
document.querySelector('body')?.setAttribute("style", `opacity: ${v ? 1 : bg_opacity.value}`)
document.querySelector('body').setAttribute("style", `opacity: ${v ? 1 : bg_opacity.value}`)
}) })
watch(embed_url, v => { watch(embed_url, v => {
if (!bg_enable.value) { if (!bg_enable.value) {
return return
} }
document.querySelector('body')?.setAttribute("style", `opacity: ${v ? 1 : bg_opacity.value}`)
document.querySelector('body').setAttribute("style", `opacity: ${v ? 1 : bg_opacity.value}`)
}) })
const downloadSelected = async () => { const downloadSelected = async () => {
@ -842,100 +817,89 @@ const downloadSelected = async () => {
toast.error('No items selected.') toast.error('No items selected.')
return return
} }
let files_list: string[] = []
let files_list = []
for (const key in selectedElms.value) { for (const key in selectedElms.value) {
const item = stateStore.history[selectedElms.value[key]] const item_id = selectedElms.value[key]
if (!item_id) {
continue
}
const item = stateStore.get('history', item_id, {} as StoreItem) as StoreItem
if ('finished' !== item.status || !item.filename) { if ('finished' !== item.status || !item.filename) {
continue continue
} }
files_list.push(item.folder ? item.folder + '/' + item.filename : item.filename) files_list.push(item.folder ? item.folder + '/' + item.filename : item.filename)
} }
selectedElms.value = [] selectedElms.value = []
try { try {
const response = await request('/api/file/download', { const response = await request('/api/file/download', {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
body: JSON.stringify(files_list), body: JSON.stringify(files_list),
}); })
const json = await response.json()
const json = await response.json();
if (!response.ok) { if (!response.ok) {
toast.error(json.error || 'Failed to start download.'); toast.error(json.error || 'Failed to start download.')
return return
} }
const token = json.token const token = json.token
const body = document.querySelector('body') const body = document.querySelector('body')
const link = document.createElement('a'); const link = document.createElement('a')
link.href = uri(`/api/file/download/${token}`); link.href = uri(`/api/file/download/${token}`)
link.setAttribute('target', '_blank'); link.setAttribute('target', '_blank')
body.appendChild(link); body?.appendChild(link)
link.click(); link.click()
body.removeChild(link); body?.removeChild(link)
} catch (e) { } catch (e: any) {
console.error(e); console.error(e)
toast.error(`Error: ${e.message}`); toast.error(`Error: ${e.message}`)
return; return
} }
} }
const toggle_class = e => ['is-text-overflow', 'is-word-break'].forEach(c => e.currentTarget.classList.toggle(c)) const toggle_class = (e: Event) => ['is-text-overflow', 'is-word-break'].forEach(c => (e.currentTarget as HTMLElement).classList.toggle(c))
const removeFromArchiveDialog = (item) => { const removeFromArchiveDialog = (item: StoreItem) => {
dialog_confirm.value.visible = true dialog_confirm.value.visible = true
dialog_confirm.value.title = 'Remove from Archive' dialog_confirm.value.title = 'Remove from Archive'
dialog_confirm.value.message = `Remove '${item.title ?? item.id ?? item.url ?? '??'}' from archive?` dialog_confirm.value.message = `Remove '${item.title || item.id || item.url || '??'}' from archive?`
dialog_confirm.value.confirm = () => removeFromArchive(item) dialog_confirm.value.confirm = () => removeFromArchive(item)
} }
const removeFromArchive = async (item, opts) => { const removeFromArchive = async (item: StoreItem, opts?: { remove_history?: boolean }) => {
try { try {
const req = await request(`/api/archive/${item._id}`, { const req = await request(`/api/archive/${item._id}`, {
credentials: 'include', credentials: 'include',
method: 'DELETE', method: 'DELETE',
}) })
const data = await req.json() const data = await req.json()
if (!req.ok) { if (!req.ok) {
toast.error(data.error) toast.error(data.error)
return return
} }
toast.success(data.message || `Removed '${item.title || item.id || item.url || '??'}' from archive.`)
toast.success(data.message ?? `Removed '${item.title ?? item.id ?? item.url ?? '??'}' from archive.`) } catch (e: any) {
} catch (e) {
console.error(e) console.error(e)
toast.error(`Error: ${e.message}`) toast.error(`Error: ${e.message}`)
} finally { } finally {
dialog_confirm.value.visible = false dialog_confirm.value.visible = false
} }
if (opts?.remove_history) { if (opts?.remove_history) {
socket.emit('item_delete', { id: item._id, remove_file: false }) socket.emit('item_delete', { id: item._id, remove_file: false })
} }
} }
const is_queued = item => { const is_queued = (item: StoreItem) => {
if (!item?.status || 'not_live' !== item.status) { if (!item?.status || 'not_live' !== item.status) {
return '' return ''
} }
return item.live_in || item.extras?.live_in || item.extras?.release_in ? 'fa-spin fa-spin-10' : '' return item.live_in || item.extras?.live_in || item.extras?.release_in ? 'fa-spin fa-spin-10' : ''
} }
const makePath = item => { const makePath = (item: StoreItem) => {
if (!item?.filename) { if (!item?.filename) {
return '' return ''
} }
const real_path = eTrim(item.download_dir, '/') + '/' + sTrim(item.filename, '/') const real_path = eTrim(item.download_dir, '/') + '/' + sTrim(item.filename, '/')
return real_path.replace(config.app.download_path, '').replace(/^\//, '') return real_path.replace(config.app.download_path, '').replace(/^\//, '')
} }
</script> </script>

View file

@ -198,7 +198,7 @@
</label> </label>
<div class="columns is-multiline is-mobile"> <div class="columns is-multiline is-mobile">
<template v-for="_, key in form.request.headers" :key="key"> <template v-for="_, key in form.request.headers" :key="key">
<div class="column is-5"> <div class="column is-5" v-if="form.request.headers[key]">
<div class="field"> <div class="field">
<div class="control has-icons-left"> <div class="control has-icons-left">
<input type="text" class="input" v-model="form.request.headers[key].key" <input type="text" class="input" v-model="form.request.headers[key].key"
@ -211,7 +211,7 @@
<span>The header key to send with the notification.</span> <span>The header key to send with the notification.</span>
</span> </span>
</div> </div>
<div class="column is-6"> <div class="column is-6" v-if="form.request.headers[key]">
<div class="field"> <div class="field">
<div class="control has-icons-left"> <div class="control has-icons-left">
<input type="text" class="input" v-model="form.request.headers[key].value" <input type="text" class="input" v-model="form.request.headers[key].value"
@ -267,11 +267,14 @@
</main> </main>
</template> </template>
<script setup> <script setup lang="ts">
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import type { notification, notificationImport } from '~/types/notification'
const emitter = defineEmits(['cancel', 'submit'])
const toast = useNotification()
const box = useConfirm()
const emitter = defineEmits(['cancel', 'submit']);
const toast = useNotification();
const props = defineProps({ const props = defineProps({
reference: { reference: {
type: String, type: String,
@ -279,11 +282,11 @@ const props = defineProps({
default: null, default: null,
}, },
allowedEvents: { allowedEvents: {
type: Array, type: Array as () => string[],
required: true, required: true,
}, },
item: { item: {
type: Object, type: Object as () => notification,
required: true, required: true,
}, },
addInProgress: { addInProgress: {
@ -293,71 +296,75 @@ const props = defineProps({
}, },
}) })
const form = reactive(props.item); const form = reactive<notification>({ ...props.item })
const requestMethods = ['POST', 'PUT']; const requestMethods = ['POST', 'PUT']
const requestType = ['json', 'form']; const requestType = ['json', 'form']
const showImport = useStorage('showImport', false); const showImport = useStorage('showImport', false)
const import_string = ref(''); const import_string = ref('')
const box = useConfirm()
onMounted(() => { onMounted(() => {
if (!form.request.data_key) { if (!form.request.data_key) {
form.request.data_key = 'data'; form.request.data_key = 'data'
} }
}); })
const checkInfo = async () => { const checkInfo = async () => {
let required; let required: string[]
if (!isApprise.value) { if (!isApprise.value) {
required = ['name', 'request.url', 'request.method', 'request.type', 'request.data_key']; required = ['name', 'request.url', 'request.method', 'request.type', 'request.data_key']
} else { } else {
required = ['name', 'request.url']; required = ['name', 'request.url']
} }
for (const key of required) { for (const key of required) {
if (key.includes('.')) { if (key.includes('.')) {
const [parent, child] = key.split('.'); const [parent, child] = key.split('.') as [keyof typeof form, string]
if (!form[parent][child]) { const parentObj = form[parent] as Record<string, any> | undefined
toast.error(`The field ${parent}.${child} is required.`);
return; if (!parentObj || !parentObj[child]) {
toast.error(`The field ${parent}.${child} is required.`)
return
}
} else {
const value = (form as Record<string, any>)[key]
if (!value) {
toast.error(`The field ${key} is required.`)
return
} }
} else if (!form[key]) {
toast.error(`The field ${key} is required.`);
return;
} }
} }
if (!isApprise.value) { if (!isApprise.value) {
try { try {
new URL(form.request.url); new URL(form.request.url)
} catch (e) { } catch (_) {
toast.error('Invalid URL'); toast.error('Invalid URL')
return; return
} }
} }
let headers = [] const headers = []
for (const header of form.request.headers) { for (const header of form.request.headers) {
if (!header.key || !header.value) { if (!header.key || !header.value) {
continue continue
} }
headers.push({ key: String(header.key).trim(), value: String(header.value).trim() }) headers.push({ key: String(header.key).trim(), value: String(header.value).trim() })
} }
form.request.headers = headers
form.request.headers = headers; emitter('submit', { reference: toRaw(props.reference), item: toRaw(form) })
emitter('submit', { reference: toRaw(props.reference), item: toRaw(form) });
} }
const importItem = async () => { const importItem = async () => {
let val = import_string.value.trim() const val = import_string.value.trim()
if (!val) { if (!val) {
toast.error('The import string is required.') toast.error('The import string is required.')
return return
} }
try { try {
const item = decode(val) const item = decode(val) as notificationImport
if ('notification' !== item._type) { if ('notification' !== item._type) {
toast.error(`Invalid import string. Expected type 'notification', got '${item._type}'.`) toast.error(`Invalid import string. Expected type 'notification', got '${item._type}'.`)
@ -365,7 +372,7 @@ const importItem = async () => {
return return
} }
if (form.target) { if (form.name || form.request?.url) {
if (false === box.confirm('Overwrite the current form fields?', true)) { if (false === box.confirm('Overwrite the current form fields?', true)) {
return return
} }
@ -375,24 +382,25 @@ const importItem = async () => {
form.name = item.name form.name = item.name
} }
if (item.url) { if (!form.request) {
form.url = item.url form.request = {} as any
} }
if (item.request) { if (item.request) {
form.request = item.request form.request = item.request
} }
if (item.data_key) { if (item.request?.data_key) {
form.data_key = item.data_key form.request.data_key = item.request.data_key
} }
if (item.on) { if (item.on) {
form.on = item.on form.on = item.on
} }
import_string.value = '' import_string.value = ''
} catch (e) { } catch (e: any) {
console.error(e) console.error(e)
toast.error(`Failed to import task. ${e.message}`) toast.error(`Failed to import task. ${e.message}`)
} }

View file

@ -1,3 +1,46 @@
<style scoped>
.notification-item {
border-left: 4px solid transparent;
padding-left: 0.75rem;
border-bottom: 1px solid #f5f5f5;
}
.notification-info {
border-color: var(--bulma-info);
}
.notification-success {
border-color: var(--bulma-primary);
}
.notification-warning {
border-color: var(--bulma-warning);
}
.notification-error {
border-color: var(--bulma-danger);
}
.notification-list {
max-height: 300px;
overflow-y: auto;
}
.notification-message {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
max-width: 280px;
}
.notification-message.expanded {
white-space: normal;
word-break: break-word;
max-width: 100%;
}
</style>
<template> <template>
<div class="navbar-item has-dropdown is-hoverable"> <div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link"> <a class="navbar-link">
@ -73,64 +116,21 @@
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import moment from 'moment' import moment from 'moment'
const store = useNotificationStore() const store = useNotificationStore()
const copiedId = ref(null)
const expandedId = ref(null)
const toggleExpand = id => expandedId.value = expandedId.value === id ? null : id const copiedId = ref<string | null>(null)
const expandedId = ref<string | null>(null)
const copy_text = (id, text) => { const toggleExpand = (id: string) => expandedId.value = expandedId.value === id ? null : id
const copy_text = (id: string, text: string): void => {
copiedId.value = id copiedId.value = id
copyText(text, false, false) copyText(text, false, false)
setTimeout(() => { setTimeout(() => {
if (copiedId.value === id) { if (copiedId.value === id) copiedId.value = null
copiedId.value = null
}
}, 2000) }, 2000)
} }
</script> </script>
<style scoped>
.notification-item {
border-left: 4px solid transparent;
padding-left: 0.75rem;
border-bottom: 1px solid #f5f5f5;
}
.notification-info {
border-color: var(--bulma-info);
}
.notification-success {
border-color: var(--bulma-primary);
}
.notification-warning {
border-color: var(--bulma-warning);
}
.notification-error {
border-color: var(--bulma-danger);
}
.notification-list {
max-height: 300px;
overflow-y: auto;
}
.notification-message {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
max-width: 280px;
}
.notification-message.expanded {
white-space: normal;
word-break: break-word;
max-width: 100%;
}
</style>

View file

@ -230,102 +230,74 @@
</main> </main>
</template> </template>
<script setup> <script setup lang="ts">
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import type { Preset, PresetImport } from '~/types/presets'
const emitter = defineEmits(['cancel', 'submit']); const emitter = defineEmits<{
(event: 'cancel'): void
(event: 'submit', payload: { reference: string | null, preset: Preset }): void
}>()
const props = defineProps({ const props = defineProps<{
reference: { reference?: string | null
type: String, preset: Partial<Preset>
required: false, addInProgress?: boolean
default: null, presets?: Preset[]
}, }>()
preset: {
type: Object,
required: true,
},
addInProgress: {
type: Boolean,
required: false,
default: false,
},
presets: {
type: Array,
required: false,
default: () => [],
},
})
const config = useConfigStore() const config = useConfigStore()
const toast = useNotification() const toast = useNotification()
const form = reactive(JSON.parse(JSON.stringify(props.preset))) const form = reactive<Preset>(JSON.parse(JSON.stringify(props.preset)))
const import_string = ref('') const import_string = ref<string>('')
const showImport = useStorage('showImport', false) const showImport = useStorage<boolean>('showImport', false)
const box = useConfirm() const selected_preset = ref<string>('')
const selected_preset = ref('')
onMounted(() => { const checkInfo = async (): Promise<void> => {
if (props.preset?.cli && '' !== props.preset?.cli) {
return
}
if (props.preset?.args && (typeof props.preset.args === 'object')) {
form.args = JSON.stringify(props.preset.args, null, 2)
}
if (props.preset?.postprocessors && (typeof props.preset.postprocessors === 'object')) {
form.postprocessors = JSON.stringify(props.preset.postprocessors, null, 2)
}
})
const checkInfo = async () => {
for (const key of ['name']) { for (const key of ['name']) {
if (!form[key]) { if (!form[key as keyof Preset]) {
toast.error(`The ${key} field is required.`); toast.error(`The ${key} field is required.`)
return return
} }
} }
if (form?.cli && '' !== form.cli) { if (form.cli && '' !== form.cli) {
const options = await convertOptions(form.cli); const options = await convertOptions(form.cli)
if (null === options) { if (null === options) {
return return
} }
form.cli = form.cli.trim() form.cli = form.cli.trim()
} }
let copy = JSON.parse(JSON.stringify(form)); const copy: Preset = JSON.parse(JSON.stringify(form))
let usedName = false
const name = String(form.name).trim().toLowerCase()
let usedName = false; props.presets?.forEach(p => {
let name = String(form.name).trim().toLowerCase();
props.presets.forEach(p => {
if (p.id === props.reference) { if (p.id === props.reference) {
return; return
} }
if (String(p.name).toLowerCase() === name) { if (String(p.name).toLowerCase() === name) {
usedName = true; usedName = true
} }
}); })
if (true === usedName) { if (usedName) {
toast.error('The preset name is already in use.'); toast.error('The preset name is already in use.')
return; return
} }
for (const key in copy) { for (const key in copy) {
if (typeof copy[key] !== 'string') { const val = copy[key as keyof Preset]
continue if ('string' === typeof val) {
(copy as any)[key] = val.trim()
} }
copy[key] = copy[key].trim()
} }
emitter('submit', { reference: toRaw(props.reference), preset: toRaw(copy) }); emitter('submit', { reference: toRaw(props.reference ?? null), preset: toRaw(copy) })
} }
const convertOptions = async args => { const convertOptions = async (args: string): Promise<Record<string, any> | null> => {
try { try {
const response = await convertCliOptions(args) const response = await convertCliOptions(args)
@ -337,23 +309,22 @@ const convertOptions = async args => {
form.folder = response.download_path form.folder = response.download_path
} }
return response.opts return response.opts as Record<string, any>
} catch (e) { } catch (e: any) {
toast.error(e.message) toast.error(e.message)
return null
} }
return null;
} }
const importItem = async () => { const importItem = async (): Promise<void> => {
let val = import_string.value.trim() const val = import_string.value.trim()
if (!val) { if (!val) {
toast.error('The import string is required.') toast.error('The import string is required.')
return return
} }
try { try {
const item = decode(val) const item = decode(val) as PresetImport
if (!item?._type || 'preset' !== item._type) { if (!item?._type || 'preset' !== item._type) {
toast.error(`Invalid import string. Expected type 'preset', got '${item._type ?? 'unknown'}'.`) toast.error(`Invalid import string. Expected type 'preset', got '${item._type ?? 'unknown'}'.`)
@ -368,16 +339,6 @@ const importItem = async () => {
form.cli = item.cli form.cli = item.cli
} }
// -- backwards compatibility for old presets.
if (item.format) {
if (!item?.cli) {
form.cli = `--format '${item.format}'`
} else {
form.cli = `--format '${item.format}'\n${form.cli}`
}
form.cli = form.cli.trim()
}
if (item.template) { if (item.template) {
form.template = item.template form.template = item.template
} }
@ -392,15 +353,15 @@ const importItem = async () => {
import_string.value = '' import_string.value = ''
showImport.value = false showImport.value = false
} catch (e) { } catch (e: any) {
console.error(e) console.error(e)
toast.error(`Failed to parse. ${e.message}`) toast.error(`Failed to parse. ${e.message}`)
} }
} }
const filter_presets = (flag = true) => config.presets.filter(item => item.default === flag) const filter_presets = (flag = true): Preset[] => config.presets.filter(item => item.default === flag)
const import_existing_preset = async () => { const import_existing_preset = async (): Promise<void> => {
if (!selected_preset.value) { if (!selected_preset.value) {
return return
} }

View file

@ -108,7 +108,7 @@
<td> <td>
<div class="progress-bar is-unselectable"> <div class="progress-bar is-unselectable">
<div class="progress-percentage">{{ updateProgress(item) }}</div> <div class="progress-percentage">{{ updateProgress(item) }}</div>
<div class="progress" :style="{ width: percentPipe(item.percent) + '%' }"></div> <div class="progress" :style="{ width: percentPipe(item.percent as number) + '%' }"></div>
</div> </div>
</td> </td>
<td class="has-text-centered is-text-overflow is-unselectable"> <td class="has-text-centered is-text-overflow is-unselectable">
@ -119,7 +119,8 @@
<Dropdown icons="fa-solid fa-cogs" @open_state="s => table_container = !s" <Dropdown icons="fa-solid fa-cogs" @open_state="s => table_container = !s"
:button_classes="'is-small'" label="Actions"> :button_classes="'is-small'" label="Actions">
<template v-if="isEmbedable(item.url)"> <template v-if="isEmbedable(item.url)">
<NuxtLink class="dropdown-item has-text-danger" @click="embed_url = getEmbedable(item.url)"> <NuxtLink class="dropdown-item has-text-danger"
@click="embed_url = getEmbedable(item.url) as string">
<span class="icon"><i class="fa-solid fa-play" /></span> <span class="icon"><i class="fa-solid fa-play" /></span>
<span>Play video</span> <span>Play video</span>
</NuxtLink> </NuxtLink>
@ -202,7 +203,8 @@
</header> </header>
<div v-if="showThumbnails" class="card-image"> <div v-if="showThumbnails" class="card-image">
<figure class="image is-3by1"> <figure class="image is-3by1">
<span v-if="isEmbedable(item.url)" @click="embed_url = getEmbedable(item.url)" class="play-overlay"> <span v-if="isEmbedable(item.url)" @click="embed_url = getEmbedable(item.url) as string"
class="play-overlay">
<div class="play-icon embed-icon"></div> <div class="play-icon embed-icon"></div>
<img @load="e => pImg(e)" :src="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))" <img @load="e => pImg(e)" :src="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))"
v-if="item.extras?.thumbnail" /> v-if="item.extras?.thumbnail" />
@ -220,7 +222,7 @@
<div class="column is-12"> <div class="column is-12">
<div class="progress-bar is-unselectable"> <div class="progress-bar is-unselectable">
<div class="progress-percentage">{{ updateProgress(item) }}</div> <div class="progress-percentage">{{ updateProgress(item) }}</div>
<div class="progress" :style="{ width: percentPipe(item.percent) + '%' }"></div> <div class="progress" :style="{ width: percentPipe(item.percent as number) + '%' }"></div>
</div> </div>
</div> </div>
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable"> <div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
@ -267,7 +269,8 @@
<div class="column is-half-mobile"> <div class="column is-half-mobile">
<Dropdown icons="fa-solid fa-cogs" @open_state="s => table_container = !s" label="Actions"> <Dropdown icons="fa-solid fa-cogs" @open_state="s => table_container = !s" label="Actions">
<template v-if="isEmbedable(item.url)"> <template v-if="isEmbedable(item.url)">
<NuxtLink class="dropdown-item has-text-danger" @click="embed_url = getEmbedable(item.url)"> <NuxtLink class="dropdown-item has-text-danger"
@click="embed_url = getEmbedable(item.url) as string">
<span class="icon"><i class="fa-solid fa-play" /></span> <span class="icon"><i class="fa-solid fa-play" /></span>
<span>Play video</span> <span>Play video</span>
</NuxtLink> </NuxtLink>
@ -312,26 +315,27 @@
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import moment from 'moment' import moment from 'moment'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import type { StoreItem } from '~/types/store'
const emitter = defineEmits<{
(e: 'getInfo', url: string, preset: string): void
(e: 'getItemInfo', id: string): void
(e: 'clear_search'): void
}>()
const props = defineProps<{
thumbnails?: boolean
query?: string
}>()
const emitter = defineEmits(['getInfo', 'clear_search', 'getItemInfo'])
const props = defineProps({
thumbnails: {
type: Boolean,
default: true
},
query: {
type: String,
required: false,
default: ''
}
})
const config = useConfigStore() const config = useConfigStore()
const stateStore = useStateStore() const stateStore = useStateStore()
const socket = useSocketStore() const socket = useSocketStore()
const box = useConfirm() const box = useConfirm()
const toast = useNotification()
const showQueue = useStorage('showQueue', true) const showQueue = useStorage('showQueue', true)
const hideThumbnail = useStorage('hideThumbnailQueue', false) const hideThumbnail = useStorage('hideThumbnailQueue', false)
@ -339,135 +343,116 @@ const display_style = useStorage('display_style', 'cards')
const bg_enable = useStorage('random_bg', true) const bg_enable = useStorage('random_bg', true)
const bg_opacity = useStorage('random_bg_opacity', 0.95) const bg_opacity = useStorage('random_bg_opacity', 0.95)
const selectedElms = ref([]) const selectedElms = ref<string[]>([])
const masterSelectAll = ref(false) const masterSelectAll = ref(false)
const embed_url = ref('') const embed_url = ref('')
const table_container = ref(false) const table_container = ref(false)
const showThumbnails = computed(() => props.thumbnails && !hideThumbnail.value) const showThumbnails = computed(() => !!props.thumbnails && !hideThumbnail.value)
watch(masterSelectAll, (value) => { watch(masterSelectAll, (value) => {
for (const key in stateStore.queue) { if (value) {
const element = stateStore.queue[key] selectedElms.value = Object.values(stateStore.queue).map((element: StoreItem) => element._id)
if (value) { } else {
selectedElms.value.push(element._id) selectedElms.value = []
} else {
selectedElms.value = []
}
} }
}) })
const filteredItems = computed(() => { const filteredItems = computed<StoreItem[]>(() => {
const q = props.query?.toLowerCase(); const q = props.query?.toLowerCase()
if (!q) { if (!q) {
return Object.values(stateStore.queue) return Object.values(stateStore.queue)
} }
return Object.values(stateStore.queue).filter(i => Object.values(i).some(v => typeof v === 'string' && v.toLowerCase().includes(q))); return Object.values(stateStore.queue).filter((i: StoreItem) =>
}); Object.values(i).some(v => typeof v === 'string' && v.toLowerCase().includes(q))
)
})
const hasSelected = computed(() => selectedElms.value.length > 0) const hasSelected = computed(() => 0 < selectedElms.value.length)
const hasQueuedItems = computed(() => stateStore.count('queue') > 0) const hasQueuedItems = computed(() => 0 < stateStore.count('queue'))
const hasManualStart = computed(() => { const hasManualStart = computed(() => {
if (stateStore.count('queue') < 0) { if (0 > stateStore.count('queue')) {
return false return false
} }
for (const key in stateStore.queue) { for (const key in stateStore.queue) {
const item = stateStore.queue[key] const item = stateStore.queue[key] as StoreItem
if (!item.status && item.auto_start === false) { if (!item.status && false === item.auto_start) {
return true return true
} }
} }
return false return false
}) })
const hasPausable = computed(() => { const hasPausable = computed(() => {
if (stateStore.count('queue') < 0) { if (0 > stateStore.count('queue')) {
return false return false
} }
for (const key in stateStore.queue) { for (const key in stateStore.queue) {
const item = stateStore.queue[key] const item = stateStore.queue[key] as StoreItem
if (!item.status && item.auto_start === true) { if (!item.status && true === item.auto_start) {
return true return true
} }
} }
return false return false
}) })
const setIcon = (item: StoreItem): string => {
const setIcon = item => {
if (!item.auto_start) { if (!item.auto_start) {
return 'fa-hourglass-half' return 'fa-hourglass-half'
} }
if ('downloading' === item.status && item.is_live) { if ('downloading' === item.status && item.is_live) {
return 'fa-globe fa-spin' return 'fa-globe fa-spin'
} }
if ('downloading' === item.status) { if ('downloading' === item.status) {
return 'fa-download' return 'fa-download'
} }
if ('postprocessing' === item.status) { if ('postprocessing' === item.status) {
return 'fa-cog fa-spin' return 'fa-cog fa-spin'
} }
if (null === item.status && true === config.paused) { if (null === item.status && true === config.paused) {
return 'fa-pause-circle' return 'fa-pause-circle'
} }
if (!item.status) { if (!item.status) {
return 'fa-question' return 'fa-question'
} }
return 'fa-spinner fa-spin' return 'fa-spinner fa-spin'
} }
const setStatus = item => { const setStatus = (item: StoreItem): string => {
if (!item.auto_start) { if (!item.auto_start) {
return 'Pending' return 'Pending'
} }
if (null === item.status && true === config.paused) { if (null === item.status && true === config.paused) {
return 'Paused' return 'Paused'
} }
if ('downloading' === item.status && item.is_live) { if ('downloading' === item.status && item.is_live) {
return 'Streaming' return 'Streaming'
} }
if ('preparing' === item.status) { if ('preparing' === item.status) {
return ag(item, 'extras.external_downloader') ? 'External-DL' : 'Preparing..'; // @ts-ignore
return ag(item, 'extras.external_downloader') ? 'External-DL' : 'Preparing..'
} }
if (!item.status) { if (!item.status) {
return 'Unknown...' return 'Unknown...'
} }
return ucFirst(item.status) return ucFirst(item.status)
} }
const setIconColor = item => { const setIconColor = (item: StoreItem): string => {
if (item.status === 'downloading') { if ('downloading' === item.status) {
return 'has-text-success' return 'has-text-success'
} }
if ('postprocessing' === item.status) { if ('postprocessing' === item.status) {
return 'has-text-info' return 'has-text-info'
} }
if (!item.auto_start || (null === item.status && true === config.paused)) { if (!item.auto_start || (null === item.status && true === config.paused)) {
return 'has-text-warning' return 'has-text-warning'
} }
return '' return ''
} }
const ETAPipe = value => { const ETAPipe = (value: number | null): string => {
if (value === null || 0 === value) { if (null === value || 0 === value) {
return 'Live' return 'Live'
} }
if (value < 60) { if (value < 60) {
@ -481,11 +466,10 @@ const ETAPipe = value => {
return `${hours}h ${Math.floor(minutes / 60)}m ${Math.round(minutes % 60)}s` return `${hours}h ${Math.floor(minutes / 60)}m ${Math.round(minutes % 60)}s`
} }
const speedPipe = value => { const speedPipe = (value: number | null): string => {
if (null === value || 0 === value) { if (null === value || 0 === value) {
return '0KB/s' return '0KB/s'
} }
const k = 1024 const k = 1024
const dm = 2 const dm = 2
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s', 'PB/s', 'EB/s', 'ZB/s', 'YB/s'] const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s', 'PB/s', 'EB/s', 'ZB/s', 'YB/s']
@ -493,46 +477,39 @@ const speedPipe = value => {
return parseFloat((value / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i] return parseFloat((value / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
} }
const percentPipe = value => { const percentPipe = (value: number | null): string => {
if (value === null || 0 === value) { if (null === value || 0 === value) {
return '00.00' return '00.00'
} }
return parseFloat(value).toFixed(2) return parseFloat(String(value)).toFixed(2)
} }
const updateProgress = (item) => { const updateProgress = (item: StoreItem): string => {
let string = '' let string = ''
if (!item.auto_start) { if (!item.auto_start) {
return 'Manual start' return 'Manual start'
} }
if (null === item.status && true === config.paused) { if (null === item.status && true === config.paused) {
return 'Global Pause' return 'Global Pause'
} }
if ('postprocessing' === item.status) { if ('postprocessing' === item.status) {
return 'Post-processors are running.' return 'Post-processors are running.'
} }
if ('preparing' === item.status) { if ('preparing' === item.status) {
// @ts-ignore
return ag(item, 'extras.external_downloader') ? 'External downloader.' : 'Preparing' return ag(item, 'extras.external_downloader') ? 'External downloader.' : 'Preparing'
} }
if (null != item.status) { if (null != item.status) {
string += item.percent && !item.is_live ? percentPipe(item.percent) + '%' : 'Live' string += item.percent && !item.is_live ? percentPipe(item.percent) + '%' : 'Live'
} }
string += item.speed ? ' - ' + speedPipe(item.speed) : ' - Waiting..' string += item.speed ? ' - ' + speedPipe(item.speed) : ' - Waiting..'
if (null != item.status && item.eta) { if (null != item.status && item.eta) {
string += ' - ' + ETAPipe(item.eta) string += ' - ' + ETAPipe(item.eta)
} }
return string
return string;
} }
const confirmCancel = item => { const confirmCancel = (item: StoreItem) => {
if (true !== box.confirm(`Cancel '${item.title}'?`)) { if (true !== box.confirm(`Cancel '${item.title}'?`)) {
return false return false
} }
@ -542,97 +519,84 @@ const confirmCancel = item => {
const cancelSelected = () => { const cancelSelected = () => {
if (true !== box.confirm(`Cancel '${selectedElms.value.length}' selected items?`)) { if (true !== box.confirm(`Cancel '${selectedElms.value.length}' selected items?`)) {
return false; return false
} }
cancelItems(selectedElms.value) cancelItems(selectedElms.value)
selectedElms.value = [] selectedElms.value = []
return true; return true
} }
const cancelItems = item => { const cancelItems = (item: string | string[]) => {
const items = [] const items: string[] = []
if ('object' === typeof item) {
if (typeof item === 'object') {
for (const key in item) { for (const key in item) {
items.push(item[key]) items.push((item as any)[key])
} }
} else { } else {
items.push(item) items.push(item)
} }
if (0 > items.length) {
if (items.length < 0) {
return return
} }
items.forEach(id => socket.emit('item_cancel', id)) items.forEach(id => socket.emit('item_cancel', id))
} }
const startItem = item => socket.emit('item_start', item._id) const startItem = (item: StoreItem) => socket.emit('item_start', item._id)
const pauseItem = item => socket.emit('item_pause', item._id) const pauseItem = (item: StoreItem) => socket.emit('item_pause', item._id)
const startItems = () => { const startItems = () => {
if (selectedElms.value.length < 1) { if (1 > selectedElms.value.length) {
return return
} }
let filtered: string[] = []
let filtered = []
selectedElms.value.forEach(id => { selectedElms.value.forEach(id => {
const item = stateStore.get('queue', id) const item = stateStore.get('queue', id) as StoreItem
if (item && !item.auto_start && !item.status) { if (item && !item.auto_start && !item.status) {
filtered.push(id) filtered.push(id)
} }
}) })
selectedElms.value = [] selectedElms.value = []
if (1 > filtered.length) {
if (filtered.length < 1) {
toast.error('No eligible items to start.') toast.error('No eligible items to start.')
return return
} }
if (true !== box.confirm(`Start '${filtered.length}' selected items?`)) { if (true !== box.confirm(`Start '${filtered.length}' selected items?`)) {
return false return false
} }
filtered.forEach(id => socket.emit('item_start', id)) filtered.forEach(id => socket.emit('item_start', id))
} }
const pauseSelected = () => { const pauseSelected = () => {
if (selectedElms.value.length < 1) { if (1 > selectedElms.value.length) {
return return
} }
let filtered: string[] = []
let filtered = []
selectedElms.value.forEach(id => { selectedElms.value.forEach(id => {
const item = stateStore.get('queue', id) const item = stateStore.get('queue', id) as StoreItem
if (item && item.auto_start && !item.status) { if (item && item.auto_start && !item.status) {
filtered.push(id) filtered.push(id)
} }
}) })
selectedElms.value = [] selectedElms.value = []
if (1 > filtered.length) {
if (filtered.length < 1) {
toast.error('No eligible items to pause.') toast.error('No eligible items to pause.')
return return
} }
if (true !== box.confirm(`Pause '${filtered.length}' selected items?`)) { if (true !== box.confirm(`Pause '${filtered.length}' selected items?`)) {
return false return false
} }
filtered.forEach(id => socket.emit('item_pause', id)) filtered.forEach(id => socket.emit('item_pause', id))
} }
const pImg = e => e.target.naturalHeight > e.target.naturalWidth ? e.target.classList.add('image-portrait') : null const pImg = (e: Event) => {
const target = e.target as HTMLImageElement
target.naturalHeight > target.naturalWidth ? target.classList.add('image-portrait') : null
}
watch(embed_url, v => { watch(embed_url, v => {
if (!bg_enable.value) { if (!bg_enable.value) {
return return
} }
document.querySelector('body').setAttribute("style", `opacity: ${v ? 1 : bg_opacity.value}`) document.querySelector('body')?.setAttribute('style', `opacity: ${v ? 1 : bg_opacity.value}`)
}) })
</script> </script>

View file

@ -1,20 +1,10 @@
<template> <template>
<main class="columns mt-2 is-multiline"> <main class="columns mt-2 is-multiline">
<div class="column is-12" v-if="form?.url && is_yt_handle(form.url)"> <div class="column is-12" v-if="form?.url && is_yt_handle(form.url)">
<Message title="Warning" class="is-background-warning-80 has-text-dark" icon="fas fa-info-circle"> <Message title="Information" class="is-info is-background-info-80 has-text-dark" icon="fas fa-info-circle">
<span> <span>You are using a YouTube link with <b>@handle</b> instead of <b>channel_id</b>. To activate RSS feed
<ul> support for URL click on the <NuxtLink @click="async () => form.url = await convert_url(form.url)"><b>Convert
<li>You are using a YouTube link with handle instead of channel_id. To activate RSS feed support for URL</b></NuxtLink> link.</span>
channel, you need to use
the channel ID. For example, <code>https://www.youtube.com/channel/UCUi3_cffYenmMTuWEsLHzqg</code>
</li>
<li>
To get youtube channel_id simply visit the page, click on <b>more about this channel</b>, scroll down to
<b>share
channel</b>, click on <code>Copy channel id</code>.
</li>
</ul>
</span>
</Message> </Message>
</div> </div>
<div class="column is-12"> <div class="column is-12">
@ -79,7 +69,7 @@
</div> </div>
<span class="help"> <span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span>The name is used to identify this specific task.</span> <span class="is-bold">The name is used to identify this specific task.</span>
</span> </span>
</div> </div>
</div> </div>
@ -89,17 +79,20 @@
<label class="label is-inline" for="url"> <label class="label is-inline" for="url">
<span class="icon"><i class="fa-solid fa-link" /></span> <span class="icon"><i class="fa-solid fa-link" /></span>
URL URL
<template v-if="is_yt_handle(form.url)">
- <NuxtLink @click="async () => form.url = await convert_url(form.url)">Convert URL</NuxtLink>
</template>
</label> </label>
<div class="control has-icons-left"> <div class="control has-icons-left">
<input type="url" class="input" id="url" v-model="form.url" :disabled="addInProgress"> <input type="url" class="input" id="url" v-model="form.url"
<span class="icon is-small is-left"><i class="fa-solid fa-link" /></span> :disabled="addInProgress || convertInProgress"
placeholder="https://www.youtube.com/channel/UCUi3_cffYenmMTuWEsLHzqg">
<span class="icon is-small is-left"><i class="fa-solid fa-link"
:class="{ 'fa-spin': convertInProgress }" /></span>
</div> </div>
<span class="help"> <span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span>The channel or playlist URL. For youtube there is rss feed support if you use URL with <span class="is-bold">The channel or playlist URL.</span>
channel_id or playlist_id. For example, https://www.youtube.com/<span
class="has-text-danger">channel/UCUi3_cffYenmMTuWEsLHzqg</span>
</span>
</span> </span>
</div> </div>
</div> </div>
@ -130,7 +123,7 @@
</div> </div>
<span class="help"> <span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span>Select the preset to use for this URL. <span class="text-has-danger">If the <span class="is-bold">Select the preset to use for this URL. <span class="text-has-danger">If the
<code>-f, --format</code> <span class="has-text-danger"> <code>-f, --format</code> <span class="has-text-danger">
argument is present in the command line options, the preset and all argument is present in the command line options, the preset and all
it's options will be ignored.</span></span> it's options will be ignored.</span></span>
@ -151,7 +144,7 @@
</div> </div>
<span class="help"> <span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span> <span class="is-bold">
The CRON timer expression to use for this task. If not set, the task will be disabled. For more The CRON timer expression to use for this task. If not set, the task will be disabled. For more
information on CRON expressions, see <NuxtLink to="https://crontab.guru/" target="_blank"> information on CRON expressions, see <NuxtLink to="https://crontab.guru/" target="_blank">
crontab.guru</NuxtLink>. crontab.guru</NuxtLink>.
@ -172,8 +165,8 @@
</div> </div>
<span class="help"> <span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span>Current download folder: <code>{{ get_download_folder() }}</code>. All folders are <span class="is-bold">Current download folder: <code>{{ get_download_folder() }}</code>. All folders
sub-folders of <code>{{ config.app.download_path }}</code>.</span> are sub-folders of <code>{{ config.app.download_path }}</code>.</span>
</span> </span>
</div> </div>
</div> </div>
@ -190,14 +183,14 @@
</div> </div>
<span class="help"> <span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span>Current output format: <code>{{ get_output_template() }}</code>.</span> <span class="is-bold">Current output format: <code>{{ get_output_template() }}</code>.</span>
</span> </span>
</div> </div>
</div> </div>
<div class="column is-6-tablet is-12-mobile"> <div class="column is-6-tablet is-12-mobile">
<div class="field"> <div class="field">
<label class="label is-inline" for="output_template"> <label class="label is-inline" for="auto_start">
<span class="icon"><i class="fa-solid fa-circle-play" /></span> <span class="icon"><i class="fa-solid fa-circle-play" /></span>
Auto Start Auto Start
</label> </label>
@ -210,7 +203,29 @@
</div> </div>
<span class="help"> <span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span>Whether to automatically start downloading or just queue them in paused state.</span> <span class="is-bold">Whether to automatically start downloading or just queue them in paused
state.</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="handler_enabled">
<span class="icon"><i class="fa-solid fa-rss" /></span>
Enable Handler
</label>
<div class="control is-unselectable">
<input id="handler_enabled" type="checkbox" v-model="form.handler_enabled" :disabled="addInProgress"
class="switch is-success" />
<label for="handler_enabled" class="is-unselectable">
{{ form.handler_enabled ? 'Yes' : 'No' }}
</label>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span class="is-bold">Some URLs like YouTube channels/playlists can be monitored using RSS feed,
this option works regardless of the timer being set or not.</span>
</span> </span>
</div> </div>
</div> </div>
@ -228,7 +243,7 @@
</div> </div>
<span class="help"> <span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span>yt-dlp cli arguments. Check <NuxtLink target="_blank" <span class="is-bold">yt-dlp cli arguments. Check <NuxtLink target="_blank"
to="https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#general-options">this page</NuxtLink>. to="https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#general-options">this page</NuxtLink>.
For more info. <span class="has-text-danger">Not all options are supported some are ignored. Use For more info. <span class="has-text-danger">Not all options are supported some are ignored. Use
with caution those arguments can break yt-dlp or the frontend.</span> with caution those arguments can break yt-dlp or the frontend.</span>
@ -260,22 +275,19 @@
</div> </div>
<div class="column is-12"> <div class="column is-12">
<Message title="Tips" class="is-background-info-80 has-text-dark" icon="fas fa-info-circle"> <Message title="Tips" class="is-info is-background-info-80 has-text-dark" icon="fas fa-info-circle">
<span> <span>
<ul> <ul>
<li>To enable YouTube RSS feed monitoring, The task URL must include a <code>channel_id</code> or <li>To enable YouTube RSS feed monitoring, The task URL must include a <code>channel_id</code> or
<code>playlist_id</code>. Other link types wont work. <code>playlist_id</code>. Other link types wont work.
</li> </li>
<li>RSS monitoring runs every hour alongside the actual task execution. It checks each feed for new videos <li>RSS monitoring runs every hour alongside the actual task execution. Regardless if the task has timer set
and automatically queues any you havent downloaded yet.</li> or not. To opt out of RSS monitoring for a specific task, simply disable the <code>Enable Handler</code>
<li>To opt out of RSS monitoring for a specific task, append <code>[no_handler]</code> to that tasks name. option. To have the task only monitor RSS feed, <b>do not set timer</b>.</li>
</li>
<li>To have the task only monitor RSS feed, do not set timer and add <code>[only_handler]</code> to that
tasks name.</li>
<li>RSS Feed monitoring will only work if you have <code>--download-archive</code> set in command options <li>RSS Feed monitoring will only work if you have <code>--download-archive</code> set in command options
for ytdlp.cli, preset or task.</li> for ytdlp.cli, preset or task. If you don't have <code>--download-archive</code> set but
<li>If you don't have <code>--download-archive</code> set but <code>YTP_KEEP_ARCHIVE</code> environment <code>YTP_KEEP_ARCHIVE</code> environment option is set to <code>true</code> which is the default, It will
option is set to <code>true</code> which is the default, It will also work. also work.
</li> </li>
</ul> </ul>
</span> </span>
@ -288,52 +300,52 @@
</main> </main>
</template> </template>
<script setup> <script lang="ts" setup>
import 'assets/css/bulma-switch.css' import 'assets/css/bulma-switch.css'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import { CronExpressionParser } from 'cron-parser' import { CronExpressionParser } from 'cron-parser'
import type { exported_task, task_item } from '~/types/tasks'
const props = defineProps({ const props = defineProps<{
reference: { reference?: string | null | undefined
type: String, task: task_item
required: false, addInProgress?: boolean
default: null, }>()
},
task: {
type: Object,
required: true,
},
addInProgress: {
type: Boolean,
required: false,
default: false,
},
})
const emitter = defineEmits(['cancel', 'submit']) const emitter = defineEmits<{
(e: 'cancel'): void
(e: 'submit', payload: { reference: string | null | undefined, task: task_item }): void
}>()
const toast = useNotification() const toast = useNotification()
const config = useConfigStore() const config = useConfigStore()
const box = useConfirm() const box = useConfirm()
const showImport = useStorage('showImport', false) const showImport = useStorage('showImport', false)
const import_string = ref('') const convertInProgress = ref<boolean>(false)
const import_string = ref<string>('')
const CHANNEL_REGEX = /^https?:\/\/(?:www\.)?youtube\.com\/(?:(?:channel\/(?<channelId>UC[0-9A-Za-z_-]{22}))|(?:c\/(?<customName>[A-Za-z0-9_-]+))|(?:user\/(?<userName>[A-Za-z0-9_-]+))|(?:@(?<handle>[A-Za-z0-9_-]+)))\/?$/; const CHANNEL_REGEX = /^https?:\/\/(?:www\.)?youtube\.com\/(?:(?:channel\/(?<channelId>UC[0-9A-Za-z_-]{22}))|(?:c\/(?<customName>[A-Za-z0-9_-]+))|(?:user\/(?<userName>[A-Za-z0-9_-]+))|(?:@(?<handle>[A-Za-z0-9_-]+)))(?<suffix>\/.*)?\/?$/
const form = reactive(props.task) const form = reactive<task_item>({ ...props.task })
onMounted(() => { onMounted(() => {
if (!props.task?.preset || '' === props.task.preset) { if (!props.task?.preset || '' === props.task.preset) {
form.preset = toRaw(config.app.default_preset) form.preset = toRaw(config.app.default_preset)
} }
if (typeof form.auto_start === 'undefined' || form.auto_start === null) {
if (typeof form.auto_start === 'undefined' || null === form.auto_start) {
form.auto_start = true form.auto_start = true
} }
if (typeof form.handler_enabled === 'undefined' || null === form.handler_enabled) {
form.handler_enabled = true
}
}) })
const checkInfo = async () => { const checkInfo = async (): Promise<void> => {
const required = ['name', 'url'] const required = ['name', 'url'] as const
for (const key of required) { for (const key of required) {
if (!form[key]) { if (!form[key]) {
toast.error(`The ${key} field is required.`) toast.error(`The ${key} field is required.`)
@ -344,7 +356,7 @@ const checkInfo = async () => {
if (form.timer) { if (form.timer) {
try { try {
CronExpressionParser.parse(form.timer) CronExpressionParser.parse(form.timer)
} catch (e) { } catch (e: any) {
console.error(e) console.error(e)
toast.error(`Invalid CRON expression. ${e.message}`) toast.error(`Invalid CRON expression. ${e.message}`)
return return
@ -353,31 +365,29 @@ const checkInfo = async () => {
try { try {
new URL(form.url) new URL(form.url)
} catch (e) { } catch {
toast.error('Invalid URL') toast.error('Invalid URL')
return return
} }
if (form?.cli && '' !== form.cli) { if (form.cli && '' !== form.cli) {
const options = await convertOptions(form.cli) const options = await convertOptions(form.cli)
if (null === options) { if (null === options) return
return form.cli = form.cli.trim()
}
form.cli = form.cli.trim(" ")
} }
emitter('submit', { reference: toRaw(props.reference), task: toRaw(form) }) emitter('submit', { reference: toRaw(props.reference), task: toRaw(form) })
} }
const importItem = async () => { const importItem = async (): Promise<void> => {
let val = import_string.value.trim() const val = import_string.value.trim()
if (!val) { if (!val) {
toast.error('The import string is required.') toast.error('The import string is required.')
return return
} }
try { try {
const item = decode(val) const item = decode(val) as exported_task
if ('task' !== item._type) { if ('task' !== item._type) {
toast.error(`Invalid import string. Expected type 'task', got '${item._type}'.`) toast.error(`Invalid import string. Expected type 'task', got '${item._type}'.`)
@ -391,34 +401,15 @@ const importItem = async () => {
} }
} }
if (item.name) { form.name = item.name ?? form.name
form.name = item.name form.url = item.url ?? form.url
} form.template = item.template ?? form.template
form.timer = item.timer ?? form.timer
if (item.url) { form.folder = item.folder ?? form.folder
form.url = item.url form.cli = item.cli ?? form.cli
} form.auto_start = item.auto_start ?? true
if (item.template) {
form.template = item.template
}
if (item.timer) {
form.timer = item.timer
}
if (item.folder) {
form.folder = item.folder
}
if (item.cli) {
form.cli = item.cli
}
form.auto_start = item?.auto_start ?? true
if (item.preset) { if (item.preset) {
// -- check if the preset exists in config.presets
const preset = config.presets.find(p => p.name === item.preset) const preset = config.presets.find(p => p.name === item.preset)
if (!preset) { if (!preset) {
toast.warning(`Preset '${item.preset}' not found. Preset will be set to default.`) toast.warning(`Preset '${item.preset}' not found. Preset will be set to default.`)
@ -429,13 +420,13 @@ const importItem = async () => {
} }
import_string.value = '' import_string.value = ''
} catch (e) { } catch (e: any) {
console.error(e) console.error(e)
toast.error(`Failed to import string. ${e.message}`) toast.error(`Failed to import string. ${e.message}`)
} }
} }
const convertOptions = async args => { const convertOptions = async (args: string): Promise<Record<string, any> | null> => {
try { try {
const response = await convertCliOptions(args) const response = await convertCliOptions(args)
@ -447,52 +438,81 @@ const convertOptions = async args => {
form.folder = response.download_path form.folder = response.download_path
} }
return response.opts return response.opts as Record<string, any>
} catch (e) { } catch (e: any) {
toast.error(e.message) toast.error(e.message)
} }
return null return null
} }
const hasFormatInConfig = computed(() => { const hasFormatInConfig = computed<boolean>(() => !!form.cli && /(?<!\S)(-f|--format)(=|\s)(\S+)/.test(form.cli))
if (!form?.cli) {
return false
}
return /(?<!\S)(-f|--format)(=|\s)(\S+)/.test(form.cli)
})
const filter_presets = (flag = true) => config.presets.filter(item => item.default === flag) const filter_presets = (flag = true) => config.presets.filter(item => item.default === flag)
const get_download_folder = () => { const get_download_folder = (): string => {
if (form.preset && !hasFormatInConfig.value) { if (form.preset && false === hasFormatInConfig.value) {
const preset = config.presets.find(p => p.name === form.preset) const preset = config.presets.find(p => p.name === form.preset)
if (preset && preset.folder) { if (preset?.folder) {
return preset.folder.replace(config.app.download_path, '') return preset.folder.replace(config.app.download_path, '')
} }
} }
return '/' return '/'
} }
const get_output_template = () => { const get_output_template = (): string => {
if (form.preset && !hasFormatInConfig.value) { if (form.preset && false === hasFormatInConfig.value) {
const preset = config.presets.find(p => p.name === form.preset) const preset = config.presets.find(p => p.name === form.preset)
if (preset && preset.template) { if (preset?.template) {
return preset.template return preset.template
} }
} }
return config.app.output_template || '%(title)s.%(ext)s' return config.app.output_template || '%(title)s.%(ext)s'
} }
function is_yt_handle(url) { const is_yt_handle = (url: string): boolean => {
let m = url.match(CHANNEL_REGEX); if (!url || '' === url) {
return false
}
const m = url.match(CHANNEL_REGEX)
if (m?.groups) { if (m?.groups) {
if (m.groups?.channelId) { return !m.groups.channelId
return false
}
return true
} }
return false return false
} }
const convert_url = async (url: string): Promise<string> => {
if (!url || '' === url) {
return url
}
const m = url.match(CHANNEL_REGEX)
if (!m?.groups || !m.groups.handle) {
return url
}
const params = new URLSearchParams()
params.append('url', url)
params.append('args', '-I0')
try {
convertInProgress.value = true
const resp = await request('/api/yt-dlp/url/info?' + params.toString(), { credentials: 'include' })
const body = await resp.json()
const channel_id = ag(body, 'channel_id', null)
console.log('convert_url', { url, channel_id, body })
if (channel_id) {
return url.replace(`/@${m.groups.handle}`, `/channel/${channel_id}`)
}
} catch (e: any) {
console.error(e)
toast.error(`Error: ${e.message}`)
} finally {
convertInProgress.value = false
}
return url
}
</script> </script>

View file

@ -28,6 +28,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import Hls from 'hls.js' import Hls from 'hls.js'
import type { StoreItem } from '~/types/store'
type video_track_element = { type video_track_element = {
file: string, file: string,
@ -58,7 +59,7 @@ const toast = useNotification()
const props = defineProps({ const props = defineProps({
item: { item: {
type: Object, type: Object as () => StoreItem,
default: () => ({}), default: () => ({}),
} }
}) })
@ -122,8 +123,8 @@ onMounted(async () => {
if (props.item.extras?.thumbnail) { if (props.item.extras?.thumbnail) {
thumbnail.value = '/api/thumbnail?url=' + encodePath(props.item.extras.thumbnail) thumbnail.value = '/api/thumbnail?url=' + encodePath(props.item.extras.thumbnail)
} else { } else {
if (response?.sidecar?.image && response.sidecar.image.length > 0) { if (response.sidecar?.image?.[0]?.file) {
thumbnail.value = makeDownload(config, { "filename": response.sidecar.image[0]['file'] }) thumbnail.value = makeDownload(config, { "filename": response.sidecar.image[0].file })
} }
} }

View file

@ -192,9 +192,10 @@
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import moment from 'moment' import moment from 'moment'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import type { FileItem, FileBrowserResponse } from '~/types/filebrowser'
const route = useRoute() const route = useRoute()
const toast = useNotification() const toast = useNotification()
@ -206,26 +207,32 @@ const bg_opacity = useStorage('random_bg_opacity', 0.95)
const sort_by = useStorage('sort_by', 'name') const sort_by = useStorage('sort_by', 'name')
const sort_order = useStorage('sort_order', 'asc') const sort_order = useStorage('sort_order', 'asc')
const isLoading = ref(false) const isLoading = ref<boolean>(false)
const initialLoad = ref(true) const initialLoad = ref<boolean>(true)
const items = ref([]) const items = ref<FileItem[]>([])
const path = ref(`/${route.params.slug?.length > 0 ? route.params.slug?.join('/') : ''}`) const path = ref<string>((() => {
const table_container = ref(false) const slug = route.params.slug
const search = ref('') if (Array.isArray(slug) && slug.length > 0) {
const show_filter = ref(false) return '/' + slug.join('/')
}
return '/'
})())
const table_container = ref<boolean>(false)
const search = ref<string>('')
const show_filter = ref<boolean>(false)
const filteredItems = computed(() => { const filteredItems = computed<FileItem[]>(() => {
if (!search.value) { if (!search.value) {
return sortedItems(items.value) return sortedItems(items.value)
} }
const searchLower = search.value.toLowerCase() const searchLower = search.value.toLowerCase()
return sortedItems(items.value.filter(item => { return sortedItems(items.value.filter((item: FileItem) => {
return item.name.toLowerCase().includes(searchLower) return item.name.toLowerCase().includes(searchLower)
})) }))
}) })
const sortedItems = items => { const sortedItems = (items: FileItem[]): FileItem[] => {
if (!items || items.length < 1) { if (!items || items.length < 1) {
return [] return []
@ -247,7 +254,7 @@ const sortedItems = items => {
return items.sort((a, b) => { return items.sort((a, b) => {
let aDate = new Date(a.mtime) let aDate = new Date(a.mtime)
let bDate = new Date(b.mtime) let bDate = new Date(b.mtime)
return 'asc' === sort_order.value ? aDate - bDate : bDate - aDate return 'asc' === sort_order.value ? aDate.getTime() - bDate.getTime() : bDate.getTime() - aDate.getTime()
}) })
} }
@ -260,8 +267,8 @@ const sortedItems = items => {
return items return items
} }
const model_item = ref() const model_item = ref<any>()
const closeModel = () => model_item.value = null const closeModel = (): void => { model_item.value = null }
watch(() => config.app.basic_mode, async () => { watch(() => config.app.basic_mode, async () => {
if (!config.app.basic_mode) { if (!config.app.basic_mode) {
@ -284,7 +291,7 @@ watch(() => socket.isConnected, async () => {
} }
}) })
const handleClick = item => { const handleClick = (item: FileItem): void => {
if ('video' === item.content_type) { if ('video' === item.content_type) {
model_item.value = { model_item.value = {
"type": 'video', "type": 'video',
@ -325,10 +332,10 @@ const handleClick = item => {
return return
} }
window.location = makeDownload(config, { "filename": item.path, "folder": "", "extras": {} }) window.location.href = makeDownload(config, { "filename": item.path, "folder": "", "extras": {} })
} }
const reloadContent = async (dir = '/', fromMounted = false) => { const reloadContent = async (dir: string = '/', fromMounted: boolean = false): Promise<void> => {
try { try {
isLoading.value = true isLoading.value = true
@ -346,11 +353,7 @@ const reloadContent = async (dir = '/', fromMounted = false) => {
items.value = [] items.value = []
const data = await response.json() const data = await response.json() as FileBrowserResponse
if (data.length < 1) {
return
}
if (!data?.contents || data.contents.length > 0) { if (!data?.contents || data.contents.length > 0) {
items.value = data.contents items.value = data.contents
@ -368,7 +371,7 @@ const reloadContent = async (dir = '/', fromMounted = false) => {
} }
useHead({ title: decodeURIComponent(title) }) useHead({ title: decodeURIComponent(title) })
} catch (e) { } catch (e: any) {
if (fromMounted) { if (fromMounted) {
return return
} }
@ -379,17 +382,17 @@ const reloadContent = async (dir = '/', fromMounted = false) => {
} }
} }
const event_handler = e => { const event_handler = (e: Event): void => {
const evt = e as PopStateEvent
if (!e.state) { if (!evt.state) {
return return
} }
if (e.state.path === path.value || e.state.path === window.location.pathname) { if (evt.state.path === path.value || evt.state.path === window.location.pathname) {
return return
} }
reloadContent(e.state.path, true) reloadContent(evt.state.path, true)
} }
onMounted(async () => { onMounted(async () => {
@ -397,12 +400,12 @@ onMounted(async () => {
await reloadContent(path.value, true) await reloadContent(path.value, true)
} }
document.addEventListener('popstate', event_handler) document.addEventListener('popstate', event_handler as EventListener)
}) })
onBeforeUnmount(() => document.removeEventListener('popstate', event_handler)) onBeforeUnmount(() => document.removeEventListener('popstate', event_handler as EventListener))
const makeBreadCrumb = path => { const makeBreadCrumb = (path: string): { name: string, link: string, path: string }[] => {
const baseLink = '/' const baseLink = '/'
path = path.replace(/^\/+/, '').replace(/\/+$/, '') path = path.replace(/^\/+/, '').replace(/\/+$/, '')
@ -428,16 +431,15 @@ const makeBreadCrumb = path => {
return links return links
} }
watch(model_item, v => { watch(model_item, v => {
if (!bg_enable.value) { if (!bg_enable.value) {
return return
} }
document.querySelector('body').setAttribute("style", `opacity: ${v ? 1 : bg_opacity.value}`) document.querySelector('body')?.setAttribute("style", `opacity: ${v ? 1 : bg_opacity.value}`)
}) })
const setIcon = item => { const setIcon = (item: FileItem): string => {
if ('link' === item.type) { if ('link' === item.type) {
return 'fa-link' return 'fa-link'
} }
@ -460,7 +462,7 @@ const setIcon = item => {
return 'fa-file' return 'fa-file'
} }
const changeSort = by => { const changeSort = (by: string): void => {
if (!['name', 'size', 'date', 'type'].includes(by)) { if (!['name', 'size', 'date', 'type'].includes(by)) {
return return
} }
@ -472,17 +474,17 @@ const changeSort = by => {
sort_order.value = 'asc' === sort_order.value ? 'desc' : 'asc' sort_order.value = 'asc' === sort_order.value ? 'desc' : 'asc'
} }
const toggleFilter = () => { const toggleFilter = (): void => {
show_filter.value = !show_filter.value show_filter.value = !show_filter.value
if (!show_filter.value) { if (!show_filter.value) {
search.value = '' search.value = ''
return return
} }
awaitElement('#search', e => e.focus()) awaitElement('#search', (e: Element) => (e as HTMLElement).focus())
} }
const createDirectory = async (dir) => { const createDirectory = async (dir: string): Promise<void> => {
if (!config.app.browser_control_enabled) { if (!config.app.browser_control_enabled) {
return return
} }
@ -497,13 +499,13 @@ const createDirectory = async (dir) => {
return return
} }
await actionRequest({ path: dir }, 'directory', { new_dir: new_dir }, (item, action, data) => { await actionRequest({ path: dir } as FileItem, 'directory', { new_dir: new_dir }, (item, action, data) => {
reloadContent(path.value, true) reloadContent(path.value, true)
toast.success(`Successfully created '${new_dir}'.`) toast.success(`Successfully created '${new_dir}'.`)
}) })
} }
const handleAction = async (action, item) => { const handleAction = async (action: string, item: FileItem): Promise<void> => {
if (!config.app.browser_control_enabled) { if (!config.app.browser_control_enabled) {
return return
} }
@ -561,7 +563,12 @@ const handleAction = async (action, item) => {
} }
} }
const actionRequest = async (item, action, data, cb) => { const actionRequest = async (
item: FileItem,
action: string,
data: Record<string, any>,
cb: (item: FileItem, action: string, data: any) => void
): Promise<any> => {
if (!config.app.browser_control_enabled) { if (!config.app.browser_control_enabled) {
return return
} }
@ -591,10 +598,9 @@ const actionRequest = async (item, action, data, cb) => {
} }
return response return response
} catch (error) { } catch (error: any) {
console.error(error) console.error(error)
toast.error(`Failed to perform action: ${error.message}`) toast.error(`Failed to perform action: ${error.message}`)
} }
} }
</script> </script>

View file

@ -41,7 +41,7 @@
<header class="card-header"> <header class="card-header">
<div class="card-header-title is-text-overflow is-block" v-text="cond.name" /> <div class="card-header-title is-text-overflow is-block" v-text="cond.name" />
<div class="card-header-icon"> <div class="card-header-icon">
<a class="has-text-primary" v-tooltip="'Export item.'" @click.prevent="exportItem(cond)"> <a class="has-text-info" v-tooltip="'Export item.'" @click.prevent="exportItem(cond)">
<span class="icon"><i class="fa-solid fa-file-export" /></span> <span class="icon"><i class="fa-solid fa-file-export" /></span>
</a> </a>
<button @click="cond.raw = !cond.raw"> <button @click="cond.raw = !cond.raw">
@ -240,7 +240,7 @@ const updateItem = async ({ reference, item: updatedItem, }: {
reference: string | null | undefined, reference: string | null | undefined,
item: ConditionItem item: ConditionItem
}): Promise<void> => { }): Promise<void> => {
updatedItem = cleanObject(updatedItem, remove_keys) updatedItem = cleanObject(updatedItem, remove_keys) as ConditionItem
if (reference) { if (reference) {
const index = items.value.findIndex(t => t?.id === reference) const index = items.value.findIndex(t => t?.id === reference)

View file

@ -172,7 +172,7 @@ watch(() => config.app.file_logging, async v => {
const filteredItems = computed(() => { const filteredItems = computed(() => {
const raw = query.value.trim().toLowerCase() const raw = query.value.trim().toLowerCase()
const contextMatch = raw.match(/context:(\d+)/) const contextMatch = raw.match(/context:(\d+)/)
const context = contextMatch ? parseInt(contextMatch[1], 10) : 0 const context = contextMatch ? parseInt(String(contextMatch[1]), 10) : 0
const searchTerm = raw.replace(/context:\d+/, '').trim() const searchTerm = raw.replace(/context:\d+/, '').trim()
if (!searchTerm) { if (!searchTerm) {
@ -191,7 +191,7 @@ const filteredItems = computed(() => {
}) })
Array.from(matchedIndexes).sort((a: any, b: any) => a - b).forEach((index: any) => { Array.from(matchedIndexes).sort((a: any, b: any) => a - b).forEach((index: any) => {
result.push(logs.value[index]) result.push(logs.value[index] as log_line)
}) })
return result return result
@ -276,21 +276,20 @@ const scrollToBottom = (fast = false) => {
}) })
} }
const log_handler = (data: log_line) => {
logs.value.push(data)
nextTick(() => {
if (autoScroll.value && bottomMarker.value) {
bottomMarker.value.scrollIntoView({ behavior: 'smooth' })
}
})
}
onMounted(async () => { onMounted(async () => {
await fetchLogs() await fetchLogs()
socket.on('log_lines', log_handler)
socket.emit('subscribe', 'log_lines') socket.emit('subscribe', 'log_lines')
socket.on('log_lines', (data: any) => {
logs.value.push(data)
nextTick(() => {
if (autoScroll.value && bottomMarker.value) {
bottomMarker.value.scrollIntoView({ behavior: 'smooth' })
}
})
})
if (bg_enable.value) { if (bg_enable.value) {
document.querySelector('body')?.setAttribute("style", `opacity: 1.0`) document.querySelector('body')?.setAttribute("style", `opacity: 1.0`)
} }
@ -298,7 +297,7 @@ onMounted(async () => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
socket.emit('unsubscribe', 'log_lines') socket.emit('unsubscribe', 'log_lines')
socket.off('log_lines') socket.off('log_lines', log_handler)
if (bg_enable.value) { if (bg_enable.value) {
document.querySelector('body')?.setAttribute("style", `opacity: ${bg_opacity.value}`) document.querySelector('body')?.setAttribute("style", `opacity: ${bg_opacity.value}`)
} }
@ -306,7 +305,7 @@ onBeforeUnmount(() => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
socket.emit('unsubscribe', 'log_lines') socket.emit('unsubscribe', 'log_lines')
socket.off('log_lines') socket.off('log_lines', log_handler)
if (scrollTimeout) clearTimeout(scrollTimeout) if (scrollTimeout) clearTimeout(scrollTimeout)
}) })

View file

@ -1,17 +1,3 @@
<style scoped>
table.is-fixed {
table-layout: fixed;
}
div.table-container {
overflow: hidden;
}
div.is-centered {
justify-content: center;
}
</style>
<template> <template>
<div> <div>
<div class="mt-1 columns is-multiline"> <div class="mt-1 columns is-multiline">
@ -37,7 +23,15 @@ div.is-centered {
</button> </button>
</p> </p>
<p class="control" v-if="notifications.length > 0"> <p class="control" v-if="notifications.length > 0">
<button class="button is-info" @click="reloadContent" :class="{ 'is-loading': isLoading }" <button v-tooltip.bottom="'Change display style'" class="button has-tooltip-bottom"
@click="() => display_style = display_style === 'cards' ? 'list' : 'cards'">
<span class="icon"><i class="fa-solid"
:class="{ 'fa-table': display_style === 'cards', 'fa-table-list': display_style === 'list' }" /></span>
</button>
</p>
<p class="control" v-if="notifications.length > 0">
<button class="button is-info" @click="reloadContent()" :class="{ 'is-loading': isLoading }"
:disabled="!socket.isConnected || isLoading || notifications.length < 1"> :disabled="!socket.isConnected || isLoading || notifications.length < 1">
<span class="icon"><i class="fas fa-refresh"></i></span> <span class="icon"><i class="fas fa-refresh"></i></span>
</button> </button>
@ -52,65 +46,126 @@ div.is-centered {
</div> </div>
<div class="column is-12" v-if="toggleForm"> <div class="column is-12" v-if="toggleForm">
<NotificationForm :addInProgress="addInProgress" :reference="targetRef" :item="target" <NotificationForm :addInProgress="addInProgress" :reference="targetRef as string" :item="target"
@cancel="resetForm(true);" @submit="updateItem" :allowedEvents="allowedEvents" /> @cancel="resetForm(true);" @submit="updateItem" :allowedEvents="allowedEvents" />
</div> </div>
<div class="column is-12" v-if="!toggleForm"> <div class="column is-12" v-if="!toggleForm && notifications && notifications.length > 0">
<div class="columns is-multiline" v-if="notifications && notifications.length > 0"> <template v-if="'list' === display_style">
<div class="column is-6" v-for="item in notifications" :key="item.id"> <div class="table-container">
<div class="card is-flex is-full-height is-flex-direction-column"> <table class="table is-striped is-hoverable is-fullwidth is-bordered"
<header class="card-header"> style="min-width: 850px; table-layout: fixed;">
<div class="card-header-title is-text-overflow is-block"> <thead>
{{ item.request.method.toUpperCase() }}({{ ucFirst(item.request.type) }}) @ <tr class="has-text-centered is-unselectable">
<NuxtLink target="_blank" :href="item.request.url">{{ item.name }}</NuxtLink> <th width="80%">
<span class="icon"><i class="fa-solid fa-paper-plane" /></span>
<span>Targets</span>
</th>
<th width="20%">
<span class="icon"><i class="fa-solid fa-gear" /></span>
<span>Actions</span>
</th>
</tr>
</thead>
<tbody>
<tr v-for="item in notifications" :key="item.id">
<td class="is-text-overflow is-vcentered">
<div>
{{ item.request.method.toUpperCase() }}({{ ucFirst(item.request.type) }}) @
<NuxtLink target="_blank" :href="item.request.url">{{ item.name }}</NuxtLink>
</div>
<div class="is-unselectable">
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-list-ul" /></span>
<span>On: {{ join_events(item.on) }}</span>
</span>
</div>
</td>
<td class="is-vcentered is-items-center">
<div class="field is-grouped is-grouped-centered">
<div class="control">
<button class="button is-info is-small is-fullwidth" v-tooltip="'Export'"
@click="exportItem(item)">
<span class="icon"><i class="fa-solid fa-file-export" /></span>
</button>
</div>
<div class="control">
<button class="button is-warning is-small is-fullwidth" v-tooltip="'Edit'"
@click="editItem(item)">
<span class="icon"><i class="fa-solid fa-cog" /></span>
</button>
</div>
<div class="control">
<button class="button is-danger is-small is-fullwidth" v-tooltip="'Delete'"
@click="deleteItem(item)">
<span class="icon"><i class="fa-solid fa-trash" /></span>
</button>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<template v-else>
<div class="columns is-multiline" v-if="notifications && notifications.length > 0">
<div class="column is-6" v-for="item in notifications" :key="item.id">
<div class="card is-flex is-full-height is-flex-direction-column">
<header class="card-header">
<div class="card-header-title is-text-overflow is-block">
{{ item.request.method.toUpperCase() }}({{ ucFirst(item.request.type) }}) @
<NuxtLink target="_blank" :href="item.request.url">{{ item.name }}</NuxtLink>
</div>
<div class="card-header-icon">
<a class="has-text-info" v-tooltip="'Export target.'" @click.prevent="exportItem(item)">
<span class="icon"><i class="fa-solid fa-file-export" /></span>
</a>
<button @click="item.raw = !item.raw">
<span class="icon"><i class="fa-solid"
:class="{ 'fa-arrow-down': !item?.raw, 'fa-arrow-up': item?.raw }" /></span>
</button>
</div>
</header>
<div class="card-content is-flex-grow-1">
<div class="content">
<p>
<span class="icon"><i class="fa-solid fa-list-ul" /></span>
<span>On: {{ join_events(item.on) }}</span>
</p>
<p v-if="item.request?.headers && item.request.headers.length > 0">
<span class="icon"><i class="fa-solid fa-heading" /></span>
<span>{{item.request.headers.map(h => h.key).join(', ')}}</span>
</p>
</div>
</div> </div>
<div class="card-header-icon"> <div class="card-content" v-if="item?.raw">
<a class="has-text-primary" v-tooltip="'Export target.'" @click.prevent="exportItem(item)"> <div class="content">
<span class="icon"><i class="fa-solid fa-file-export" /></span> <pre><code>{{ filterItem(item) }}</code></pre>
</a> </div>
<button @click="item.raw = !item.raw">
<span class="icon"><i class="fa-solid"
:class="{ 'fa-arrow-down': !item?.raw, 'fa-arrow-up': item?.raw }" /></span>
</button>
</div> </div>
</header> <div class="card-footer mt-auto">
<div class="card-content is-flex-grow-1"> <div class="card-footer-item">
<div class="content"> <button class="button is-warning is-fullwidth" @click="editItem(item);">
<p> <span class="icon"><i class="fa-solid fa-trash-can" /></span>
<span class="icon"><i class="fa-solid fa-list-ul" /></span> <span>Edit</span>
<span>On: {{ join_events(item.on) }}</span> </button>
</p> </div>
<p v-if="item.request?.headers && item.request.headers.length > 0"> <div class="card-footer-item">
<span class="icon"><i class="fa-solid fa-heading" /></span> <button class="button is-danger is-fullwidth" @click="deleteItem(item)">
<span>{{item.request.headers.map(h => h.key).join(', ')}}</span> <span class="icon"><i class="fa-solid fa-trash" /></span>
</p> <span>Delete</span>
</div> </button>
</div> </div>
<div class="card-content" v-if="item?.raw">
<div class="content">
<pre><code>{{ filterItem(item) }}</code></pre>
</div>
</div>
<div class="card-footer mt-auto">
<div class="card-footer-item">
<button class="button is-warning is-fullwidth" @click="editItem(item);">
<span class="icon"><i class="fa-solid fa-trash-can" /></span>
<span>Edit</span>
</button>
</div>
<div class="card-footer-item">
<button class="button is-danger is-fullwidth" @click="deleteItem(item)">
<span class="icon"><i class="fa-solid fa-trash" /></span>
<span>Delete</span>
</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </template>
<Message title="No Endpoints" class="is-background-warning-80 has-text-dark" icon="fas fa-exclamation-circle" </div>
v-if="!notifications || notifications.length < 1">
<div class="column is-12" v-if="!toggleForm && (!notifications || notifications.length < 1)">
<Message title="No Endpoints" class="is-background-warning-80 has-text-dark" icon="fas fa-exclamation-circle">
There are no notifications endpoints configured to receive web notifications. There are no notifications endpoints configured to receive web notifications.
</Message> </Message>
</div> </div>
@ -139,16 +194,30 @@ div.is-centered {
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { useStorage } from '@vueuse/core'
import type { notification, notificationImport } from '~/types/notification'
const toast = useNotification() const toast = useNotification()
const config = useConfigStore() const config = useConfigStore()
const socket = useSocketStore() const socket = useSocketStore()
const box = useConfirm() const box = useConfirm()
const display_style = useStorage<string>("tasks_display_style", "cards")
const allowedEvents = ref([]) const allowedEvents = ref<string[]>([])
const notifications = ref([]) const notifications = ref<notification[]>([])
const target = ref({}) const target = ref<notification>({
const targetRef = ref('') name: '',
on: [],
request: {
method: 'POST',
url: '',
type: 'json',
headers: [],
data_key: '',
},
})
const targetRef = ref<string | null>('')
const toggleForm = ref(false) const toggleForm = ref(false)
const isLoading = ref(false) const isLoading = ref(false)
const initialLoad = ref(true) const initialLoad = ref(true)
@ -206,6 +275,7 @@ const resetForm = (closeForm = false) => {
url: '', url: '',
type: 'json', type: 'json',
headers: [], headers: [],
data_key: '',
}, },
} }
targetRef.value = null targetRef.value = null
@ -214,13 +284,13 @@ const resetForm = (closeForm = false) => {
} }
} }
const updateData = async notifications => { const updateData = async (items: notification[]) => {
const response = await request('/api/notifications', { const response = await request('/api/notifications', {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(notifications), body: JSON.stringify(items),
}) })
const data = await response.json() const data = await response.json()
@ -234,13 +304,13 @@ const updateData = async notifications => {
return true return true
} }
const deleteItem = async item => { const deleteItem = async (item: notification) => {
if (true !== box.confirm(`Delete '${item.name}'?`)) { if (true !== box.confirm(`Delete '${item.name}'?`)) {
return return
} }
const index = notifications.value.findIndex(i => i?.id === item.id) const index = notifications.value.findIndex(i => i?.id === item.id)
if (index > -1) { if (0 <= index) {
notifications.value.splice(index, 1) notifications.value.splice(index, 1)
} else { } else {
toast.error('Notification target not found.') toast.error('Notification target not found.')
@ -249,18 +319,21 @@ const deleteItem = async item => {
} }
const status = await updateData(notifications.value) const status = await updateData(notifications.value)
if (!status) return
if (!status) {
return
}
toast.success('Notification target deleted.') toast.success('Notification target deleted.')
} }
const updateItem = async ({ reference, item }) => { const updateItem = async ({
reference,
item,
}: {
reference: string | null;
item: notification;
}) => {
if (reference) { if (reference) {
const index = notifications.value.findIndex(i => i?.id === reference) const index = notifications.value.findIndex(i => i?.id === reference)
if (index > -1) { if (0 <= index) {
notifications.value[index] = item notifications.value[index] = item
} }
} else { } else {
@ -269,10 +342,7 @@ const updateItem = async ({ reference, item }) => {
try { try {
const status = await updateData(notifications.value) const status = await updateData(notifications.value)
if (!status) return
if (!status) {
return
}
toast.success(`Notification target ${reference ? 'updated' : 'added'}.`) toast.success(`Notification target ${reference ? 'updated' : 'added'}.`)
resetForm(true) resetForm(true)
@ -281,18 +351,19 @@ const updateItem = async ({ reference, item }) => {
} }
} }
const filterItem = item => { const filterItem = (item: notification) => {
const { raw, ...rest } = item const { raw, ...rest } = item as any
return JSON.stringify(rest, null, 2) return JSON.stringify(rest, null, 2)
} }
const editItem = item => { const editItem = (item: notification) => {
target.value = item target.value = item
targetRef.value = item.id targetRef.value = item.id ?? null
toggleForm.value = true toggleForm.value = true
} }
const join_events = events => (!events || events.length < 1) ? 'ALL' : events.map(e => ucFirst(e)).join(', ') const join_events = (events: string[]) =>
!events || events.length < 1 ? 'ALL' : events.map(e => ucFirst(e)).join(', ')
const sendTest = async () => { const sendTest = async () => {
if (true !== box.confirm('Send test notification?')) { if (true !== box.confirm('Send test notification?')) {
@ -310,7 +381,7 @@ const sendTest = async () => {
} }
toast.success('Test notification sent.') toast.success('Test notification sent.')
} catch (e) { } catch (e: any) {
console.error(e) console.error(e)
toast.error(`Failed to send test notification. ${e.message}`) toast.error(`Failed to send test notification. ${e.message}`)
} finally { } finally {
@ -320,23 +391,26 @@ const sendTest = async () => {
onMounted(async () => socket.isConnected ? await reloadContent(true) : '') onMounted(async () => socket.isConnected ? await reloadContent(true) : '')
const exportItem = async item => { const exportItem = async (item: notification) => {
let data = JSON.parse(JSON.stringify(item)) const data: notificationImport = {
...JSON.parse(JSON.stringify(item)),
_type: 'notification',
_version: '1.0',
}
const keys = ['id', 'raw'] const keys = ['id', 'raw']
keys.forEach(k => { keys.forEach(k => {
if (data.hasOwnProperty(k)) { if (Object.prototype.hasOwnProperty.call(data, k)) {
delete data[k] delete (data as any)[k]
} }
}) })
// -- Remove authorization headers if (data.request?.headers?.length) {
if (data.request?.headers && data.request.headers.length > 0) { data.request.headers = data.request.headers.filter(
data.request.headers = data.request.headers.filter(h => h.key.toLowerCase() !== 'authorization') h => 'authorization' !== h.key.toLowerCase(),
)
} }
data['_type'] = 'notification' copyText(encode(data))
data['_version'] = '1.0'
return copyText(encode(data))
} }
</script> </script>

View file

@ -1,13 +1,3 @@
<style scoped>
table.is-fixed {
table-layout: fixed;
}
div.is-centered {
justify-content: center;
}
</style>
<template> <template>
<main> <main>
<div class="mt-1 columns is-multiline"> <div class="mt-1 columns is-multiline">
@ -35,7 +25,7 @@ div.is-centered {
</button> </button>
</p> </p>
<p class="control"> <p class="control">
<button class="button is-info" @click="reloadContent" :class="{ 'is-loading': isLoading }" <button class="button is-info" @click="reloadContent()" :class="{ 'is-loading': isLoading }"
:disabled="!socket.isConnected || isLoading" v-if="presets && presets.length > 0"> :disabled="!socket.isConnected || isLoading" v-if="presets && presets.length > 0">
<span class="icon"><i class="fas fa-refresh" /></span> <span class="icon"><i class="fas fa-refresh" /></span>
</button> </button>
@ -71,15 +61,19 @@ div.is-centered {
</thead> </thead>
<tbody> <tbody>
<tr v-for="item in presetsNoDefault" :key="item.id"> <tr v-for="item in presetsNoDefault" :key="item.id">
<td class="is-vcentered"> <td class="is-text-overflow is-vcentered">
<div class="is-text-overflow"> <div> {{ item.name }}</div>
{{ item.name }} <div class="is-unselectable">
<span class="icon-text" v-if="item.cookies">
<span class="icon"><i class="fa-solid fa-cookie" /></span>
<span>Cookies</span>
</span>
</div> </div>
</td> </td>
<td class="is-vcentered is-items-center"> <td class="is-vcentered is-items-center">
<div class="field is-grouped is-grouped-centered"> <div class="field is-grouped is-grouped-centered">
<div class="control"> <div class="control">
<button class="button is-primary is-small is-fullwidth" v-tooltip="'Export'" <button class="button is-info is-small is-fullwidth" v-tooltip="'Export'"
@click="exportItem(item)"> @click="exportItem(item)">
<span class="icon"><i class="fa-solid fa-file-export" /></span> <span class="icon"><i class="fa-solid fa-file-export" /></span>
</button> </button>
@ -111,7 +105,7 @@ div.is-centered {
<header class="card-header"> <header class="card-header">
<div class="card-header-title is-text-overflow is-block" v-text="item.name" /> <div class="card-header-title is-text-overflow is-block" v-text="item.name" />
<div class="card-header-icon"> <div class="card-header-icon">
<button class="has-text-primary" v-tooltip="'Export'" @click="exportItem(item)"> <button class="has-text-info" v-tooltip="'Export'" @click="exportItem(item)">
<span class="icon"><i class="fa-solid fa-file-export" /></span> <span class="icon"><i class="fa-solid fa-file-export" /></span>
</button> </button>
<button @click="item.raw = !item.raw"> <button @click="item.raw = !item.raw">
@ -210,24 +204,25 @@ div.is-centered {
</main> </main>
</template> </template>
<script setup> <script setup lang="ts">
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import type { Preset } from '~/types/presets'
const toast = useNotification() const toast = useNotification()
const config = useConfigStore() const config = useConfigStore()
const socket = useSocketStore() const socket = useSocketStore()
const box = useConfirm() const box = useConfirm()
const display_style = useStorage("preset_display_style", "cards") const display_style = useStorage<string>('preset_display_style', 'cards')
const presets = ref([]) const presets = ref<Preset[]>([])
const preset = ref({}) const preset = ref<Partial<Preset>>({})
const presetRef = ref("") const presetRef = ref<string | null>('')
const toggleForm = ref(false) const toggleForm = ref(false)
const isLoading = ref(true) const isLoading = ref(true)
const initialLoad = ref(true) const initialLoad = ref(true)
const addInProgress = ref(false) const addInProgress = ref(false)
const remove_keys = ["raw", "toggle_description"] const remove_keys = ['raw', 'toggle_description']
const presetsNoDefault = computed(() => presets.value.filter((t) => !t.default)) const presetsNoDefault = computed(() => presets.value.filter((t) => !t.default))
@ -237,7 +232,7 @@ watch(
if (!config.app.basic_mode) { if (!config.app.basic_mode) {
return return
} }
await navigateTo("/") await navigateTo('/')
}, },
) )
@ -251,24 +246,24 @@ watch(() => socket.isConnected, async () => {
const reloadContent = async (fromMounted = false) => { const reloadContent = async (fromMounted = false) => {
try { try {
isLoading.value = true isLoading.value = true
const response = await request("/api/presets") const response = await request('/api/presets')
if (fromMounted && !response.ok) { if (fromMounted && !response.ok) {
return return
} }
const data = await response.json() const data = await response.json()
if (data.length < 1) { if (0 === data.length) {
return return
} }
presets.value = data presets.value = data
} catch (e) { } catch (e: any) {
if (fromMounted) { if (fromMounted) {
return return
} }
console.error(e) console.error(e)
toast.error("Failed to fetch page content.") toast.error('Failed to fetch page content.')
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
@ -283,14 +278,14 @@ const resetForm = (closeForm = false) => {
} }
} }
const updatePresets = async (items) => { const updatePresets = async (items: Preset[]): Promise<boolean | undefined> => {
let data let data: any
try { try {
addInProgress.value = true addInProgress.value = true
const response = await request("/api/presets", { const response = await request('/api/presets', {
method: "PUT", method: 'PUT',
headers: { "Content-Type": "application/json" }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(items.filter((t) => !t.default)), body: JSON.stringify(items.filter((t) => !t.default)),
}) })
@ -304,44 +299,49 @@ const updatePresets = async (items) => {
presets.value = data presets.value = data
resetForm(true) resetForm(true)
return true return true
} catch (e) { } catch (e: any) {
toast.error(`Failed to update presets. ${data?.error}. ${e.message}`) toast.error(`Failed to update presets. ${data?.error}. ${e.message}`)
} finally { } finally {
addInProgress.value = false addInProgress.value = false
} }
} }
const deleteItem = async (item) => { const deleteItem = async (item: Preset) => {
if (true !== box.confirm(`Delete preset '${item.name}'?`, true)) { if (true !== box.confirm(`Delete preset '${item.name}'?`, true)) {
return return
} }
const index = presets.value.findIndex((t) => t?.id === item.id) const index = presets.value.findIndex((t) => t?.id === item.id)
if (index > -1) { if (-1 === index) {
presets.value.splice(index, 1) toast.error('Preset not found.')
} else {
toast.error("Preset not found.")
return return
} }
const status = await updatePresets(presets.value) presets.value.splice(index, 1)
const status = await updatePresets(presets.value)
if (!status) { if (!status) {
return return
} }
toast.success("Preset deleted.") toast.success('Preset deleted.')
} }
const updateItem = async ({ reference, preset }) => { const updateItem = async ({
preset = cleanObject(preset, remove_keys) reference,
preset: item,
}: {
reference: string | null
preset: Preset
}) => {
item = cleanObject(item, remove_keys) as Preset
if (reference) { if (reference) {
const index = presets.value.findIndex((t) => t?.id === reference) const index = presets.value.findIndex((t) => t?.id === reference)
if (index > -1) { if (-1 !== index) {
presets.value[index] = preset presets.value[index] = item
} }
} else { } else {
presets.value.push(preset) presets.value.push(item)
} }
const status = await updatePresets(presets.value) const status = await updatePresets(presets.value)
@ -349,66 +349,56 @@ const updateItem = async ({ reference, preset }) => {
return return
} }
toast.success(`Preset ${reference ? "updated" : "added"}.`) toast.success(`Preset ${reference ? 'updated' : 'added'}.`)
resetForm(true) resetForm(true)
} }
const filterItem = item => { const filterItem = (item: Preset) => {
const rest = cleanObject(item, remove_keys) const rest = cleanObject(item, remove_keys)
if ("default" in rest) { if ('default' in rest) {
delete rest.default delete rest.default
} }
return JSON.stringify(rest, null, 2) return JSON.stringify(rest, null, 2)
} }
const editItem = item => { const editItem = (item: Preset) => {
preset.value = JSON.parse(filterItem(item)) preset.value = JSON.parse(filterItem(item))
presetRef.value = item.id presetRef.value = item.id ?? null
toggleForm.value = true toggleForm.value = true
} }
onMounted(async () => (socket.isConnected ? await reloadContent(true) : "")) onMounted(async () => (socket.isConnected ? await reloadContent(true) : ''))
const exportItem = item => { const exportItem = (item: Preset) => {
let data = JSON.parse(JSON.stringify(item)) const keys = ['id', 'default', 'raw', 'cookies', 'toggle_description']
const keys = ["id", "default", "raw", "cookies", "toggle_description"] const data = JSON.parse(JSON.stringify(item))
keys.forEach(key => {
for (const key of keys) {
if (key in data) { if (key in data) {
delete data[key] delete data[key]
} }
}) }
let userData = {}
const userData: Record<string, any> = {}
for (const key of Object.keys(data)) { for (const key of Object.keys(data)) {
if (!data[key]) { if (data[key]) {
continue userData[key] = data[key]
} }
userData[key] = data[key]
} }
if (config?.app?.ytdlp_cli) { if (config?.app?.ytdlp_cli) {
const val = `# exported from ytdlp.cli #\n${config.app.ytdlp_cli}\n# exported from ytdlp.cli #\n` const val = `# exported from ytdlp.cli #\n${config.app.ytdlp_cli}\n# exported from ytdlp.cli #\n`
if (userData.cli) { userData.cli = userData.cli ? val + '\n' + userData.cli : val
userData.cli = val + "\n" + userData.cli
} else {
userData.cli = val
}
} }
userData['_type'] = 'preset' userData['_type'] = 'preset'
userData['_version'] = '2.5' userData['_version'] = '2.5'
return copyText(encode(userData)) copyText(encode(userData))
} }
const calcPath = (path) => { const calcPath = (path?: string): string => {
const loc = config.app.download_path || "/downloads" const loc = config.app.download_path || '/downloads'
return path ? loc + '/' + sTrim(path, '/') : loc
if (path) {
return loc + "/" + sTrim(path, "/")
}
return loc
} }
</script> </script>

View file

@ -1,13 +1,3 @@
<style scoped>
table.is-fixed {
table-layout: fixed;
}
div.is-centered {
justify-content: center;
}
</style>
<template> <template>
<main> <main>
<div class="mt-1 columns is-multiline"> <div class="mt-1 columns is-multiline">
@ -66,8 +56,8 @@ div.is-centered {
<div class="columns is-multiline" v-if="toggleForm"> <div class="columns is-multiline" v-if="toggleForm">
<div class="column is-12"> <div class="column is-12">
<TaskForm :addInProgress="addInProgress" :reference="taskRef" :task="task" @cancel="resetForm(true);" <TaskForm :addInProgress="addInProgress" :reference="taskRef" :task="task as task_item"
@submit="updateItem" /> @cancel="resetForm(true);" @submit="updateItem" />
</div> </div>
</div> </div>
@ -149,7 +139,7 @@ div.is-centered {
{{ remove_tags(item.name) }} {{ remove_tags(item.name) }}
</NuxtLink> </NuxtLink>
</div> </div>
<div> <div class="is-unselectable">
<span class="icon-text"> <span class="icon-text">
<span class="icon"> <span class="icon">
<i class="fa-solid" <i class="fa-solid"
@ -160,6 +150,11 @@ div.is-centered {
</span> </span>
</span> </span>
&nbsp; &nbsp;
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-rss" /></span>
<span>{{ item.handler_enabled ? 'Enabled' : 'Disabled' }}</span>
</span>
&nbsp;
<span class="icon-text" v-if="item.preset"> <span class="icon-text" v-if="item.preset">
<span class="icon"><i class="fa-solid fa-tv" /></span> <span class="icon"><i class="fa-solid fa-tv" /></span>
<span>{{ item.preset ?? config.app.default_preset }}</span> <span>{{ item.preset ?? config.app.default_preset }}</span>
@ -185,7 +180,7 @@ div.is-centered {
</div> </div>
<div class="control"> <div class="control">
<button class="button is-primary is-small is-fullwidth" v-tooltip="'Export'" <button class="button is-info is-small is-fullwidth" v-tooltip="'Export'"
@click="exportItem(item)"> @click="exportItem(item)">
<span class="icon"><i class="fa-solid fa-file-export" /></span> <span class="icon"><i class="fa-solid fa-file-export" /></span>
</button> </button>
@ -230,11 +225,17 @@ div.is-centered {
<div class="control"> <div class="control">
<span class="icon" v-tooltip="`${item.auto_start ? 'Auto' : 'Manual'} start`"> <span class="icon" v-tooltip="`${item.auto_start ? 'Auto' : 'Manual'} start`">
<i class="fa-solid" <i class="fa-solid"
:class="{ 'fa-circle-pause': item.auto_start, 'fa-circle-play': !item.auto_start }" /> :class="{ 'fa-circle-pause has-text-success': item.auto_start, 'fa-circle-play has-text-danger': !item.auto_start }" />
</span> </span>
</div> </div>
<div class="control"> <div class="control">
<a class="has-text-primary" v-tooltip="'Export task.'" @click.prevent="exportItem(item)"> <span class="icon" v-tooltip="`RSS monitoring is ${item.handler_enabled ? 'enabled' : 'disabled'}`">
<i class="fa-solid fa-rss"
:class="{ 'has-text-success': item.handler_enabled, 'has-text-danger': !item.handler_enabled }" />
</span>
</div>
<div class="control">
<a class="has-text-info" v-tooltip="'Export task.'" @click.prevent="exportItem(item)">
<span class="icon"><i class="fa-solid fa-file-export" /></span> <span class="icon"><i class="fa-solid fa-file-export" /></span>
</a> </a>
</div> </div>
@ -544,7 +545,7 @@ const deleteItem = async (item: task_item) => {
toast.success('Task deleted.') toast.success('Task deleted.')
} }
const updateItem = async ({ reference, task }: { reference?: string, task: task_item }) => { const updateItem = async ({ reference, task }: { reference?: string | null | undefined, task: task_item }) => {
if (reference) { if (reference) {
// -- find the task index. // -- find the task index.
const index = tasks.value.findIndex((t) => t?.id === reference) const index = tasks.value.findIndex((t) => t?.id === reference)

View file

@ -0,0 +1,126 @@
<!DOCTYPE html>
<html lang="en" data-n-head-ssr="" data-n-head-ssr-body="">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>YTPTube Loading...</title>
<style>
html, body {
margin: 0;
padding: 0;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: #fff;
color: #111;
user-select: none;
overflow: hidden;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
transition: background-color 0.3s ease, color 0.3s ease;
}
@media (prefers-color-scheme: dark) {
html, body {
background-color: #121212;
color: #eee;
}
}
.loader-container {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
svg.ytptube-loading {
width: 420px;
height: 105px;
stroke: #FF0000;
stroke-width: 4.5px;
stroke-linecap: round;
stroke-linejoin: round;
fill: none;
stroke-dasharray: 700;
stroke-dashoffset: 700;
animation: stroke-move 3s linear infinite;
user-select: none;
}
@media (prefers-color-scheme: dark) {
svg.ytptube-loading {
stroke: #FF4444;
}
}
@keyframes stroke-move {
100% {
stroke-dashoffset: -700;
}
}
.spinner {
margin: 20px auto 0;
width: 64px;
height: 64px;
border: 8px solid transparent;
border-top-color: #FF0000;
border-radius: 50%;
animation: spin 1.2s linear infinite;
filter: drop-shadow(0 0 6px rgba(255,0,0,0.8));
}
@media (prefers-color-scheme: dark) {
.spinner {
border-top-color: #FF4444;
filter: drop-shadow(0 0 8px #FF4444);
}
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.subtitle {
margin-top: 14px;
font-size: 1rem;
color: inherit;
opacity: 0.8;
user-select: none;
font-weight: 600;
letter-spacing: 0.1em;
}
</style>
</head>
<body>
<div class="loader-container" role="img" aria-label="Loading YTPTube">
<svg class="ytptube-loading" viewBox="0 0 420 105" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
<!-- Y -->
<path d="M10 10 L25 37.5 L10 65" />
<path d="M25 37.5 L40 10" />
<!-- T -->
<path d="M50 10 L90 10" />
<path d="M70 10 L70 65" />
<!-- P -->
<path d="M100 65 L100 10 L130 10 Q145 10 145 27.5 Q145 45 130 45 L100 45" />
<!-- T -->
<path d="M160 10 L200 10" />
<path d="M180 10 L180 65" />
<!-- U -->
<path d="M210 10 L210 50 Q210 65 230 65 Q250 65 250 50 L250 10" />
<!-- B -->
<path d="M270 10 L270 65" />
<path d="M270 10 Q300 10 300 25 Q300 45 270 45" />
<path d="M270 45 Q300 45 300 60 Q300 65 270 65" />
<!-- E -->
<path d="M320 10 L320 65" />
<path d="M320 10 L360 10" />
<path d="M320 37.5 L350 37.5" />
<path d="M320 65 L360 65" />
</svg>
<div class="spinner"></div>
<div class="subtitle">Loading, please wait...</div>
</div>
</body>
</html>

View file

@ -20,7 +20,7 @@ export const useSocketStore = defineStore('socket', () => {
event.forEach(e => socket.value?.on(e, (...args) => true === withEvent ? callback(e, ...args) : callback(...args))) event.forEach(e => socket.value?.on(e, (...args) => true === withEvent ? callback(e, ...args) : callback(...args)))
} }
const off = (event: string | string[], callback: (...args: any[]) => void): any => { const off = (event: string | string[], callback?: (...args: any[]) => void): any => {
if (!Array.isArray(event)) { if (!Array.isArray(event)) {
event = [event] event = [event]
} }

20
ui/app/types/filebrowser.d.ts vendored Normal file
View file

@ -0,0 +1,20 @@
type FileItem = {
type: 'file' | 'dir' | 'link'
content_type: 'image' | 'video' | 'text' | 'subtitle' | 'metadata' | 'dir' | string
name: string
path: string
size: number
mime: string
mtime: string
ctime: string
is_dir: boolean
is_file: boolean
is_symlink: boolean
}
type FileBrowserResponse = {
path: string
contents: FileItem[]
}
export type { FileItem, FileBrowserResponse }

27
ui/app/types/notification.d.ts vendored Normal file
View file

@ -0,0 +1,27 @@
type notificationRequestHeaderItem = {
key: string;
value: string;
};
type notificationRequest = {
data_key: string;
headers: notificationRequestHeaderItem[];
method: string;
type: string;
url: string;
};
type notification = {
id?: string;
name: string;
request: notificationRequest;
on: string[];
raw?: boolean;
};
type notificationImport = notification & {
_type: 'notification';
_version: string;
};
export type { notificationRequestHeaderItem, notification, notificationRequest, notificationImport };

19
ui/app/types/presets.d.ts vendored Normal file
View file

@ -0,0 +1,19 @@
type Preset = {
id?: string
name: string
cli: string
cookies: string
default: boolean
description: string
folder: string
template: string
raw?:boolean
toggle_description?: boolean
}
type PresetImport = Preset & {
_type: 'preset'
_version: string
}
export type { Preset, PresetImport }

View file

@ -1,5 +1,5 @@
export type ItemStatus = 'finished' | 'preparing' | 'error' | 'cancelled' | 'downloading' | 'postprocessing' | 'not_live' | null; type ItemStatus = 'finished' | 'preparing' | 'error' | 'cancelled' | 'downloading' | 'postprocessing' | 'not_live' | 'skip' | null;
export type StoreItem = { type StoreItem = {
/** Unique identifier for the item */ /** Unique identifier for the item */
_id: string _id: string
/** Error message if available */ /** Error message if available */
@ -54,6 +54,15 @@ export type StoreItem = {
thumbnail?: string thumbnail?: string
/** The uploader of the item if available */ /** The uploader of the item if available */
uploader?: string uploader?: string
/** Uploader name if available */
is_audio?: boolean
/** If the item has audio stream */
is_video?: boolean
/** If the item has video stream */
live_in?: string
/** Live stream start time if available */
is_premiere?: boolean
/** If the item is a premiere */
} }
/** The item temporary filename */ /** The item temporary filename */
tmpfilename?: string | null tmpfilename?: string | null
@ -74,3 +83,5 @@ export type StoreItem = {
/** Time remaining for the item download if available */ /** Time remaining for the item download if available */
eta?: number | null eta?: number | null
} }
export type { ItemStatus, StoreItem }

View file

@ -9,6 +9,7 @@ type task_item = {
timer?: string, timer?: string,
in_progress?: boolean, in_progress?: boolean,
auto_start?: boolean, auto_start?: boolean,
handler_enabled?: boolean,
} }
type exported_task = task_item & { _type: string, _version: string } type exported_task = task_item & { _type: string, _version: string }

View file

@ -1,4 +1,5 @@
import type { convert_args_response } from "~/types/responses"; import type { convert_args_response } from "~/types/responses";
import type { StoreItem } from "~/types/store";
const runtimeConfig = useRuntimeConfig() const runtimeConfig = useRuntimeConfig()
const toast = useNotification() const toast = useNotification()
@ -437,7 +438,7 @@ const getQueryParams = (url: string = window.location.search): Record<string, st
* @param base - The base endpoint type (default: 'api/download'). * @param base - The base endpoint type (default: 'api/download').
* @returns The fully constructed download URI. * @returns The fully constructed download URI.
*/ */
const makeDownload = (config: any, item: { folder?: string; filename: string }, base: string = 'api/download'): string => { const makeDownload = (config: any, item: StoreItem | { folder?: string; filename: string }, base: string = 'api/download'): string => {
let baseDir = 'api/player/m3u8/video/' let baseDir = 'api/player/m3u8/video/'
if ('m3u8' !== base) { if ('m3u8' !== base) {
baseDir = `${base}/` baseDir = `${base}/`
@ -448,6 +449,10 @@ const makeDownload = (config: any, item: { folder?: string; filename: string },
baseDir += item.folder + '/' baseDir += item.folder + '/'
} }
if (!item.filename) {
return ''
}
const url = `/${sTrim(baseDir, '/')}${encodePath(item.filename)}` const url = `/${sTrim(baseDir, '/')}${encodePath(item.filename)}`
return uri('m3u8' === base ? `${url}.m3u8` : url) return uri('m3u8' === base ? `${url}.m3u8` : url)
} }