Merge pull request #334 from arabcoders/dev

Make it possible to cancel download and keep the downloaded tmp data
This commit is contained in:
Abdulmohsen 2025-07-11 19:58:33 +03:00 committed by GitHub
commit f4ec06cef9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 614 additions and 539 deletions

View file

@ -4,18 +4,20 @@ import logging
import multiprocessing import multiprocessing
import os import os
import re import re
import signal
import time import time
from datetime import UTC, datetime from datetime import UTC, datetime
from email.utils import formatdate from email.utils import formatdate
from pathlib import Path from pathlib import Path
import yt_dlp import yt_dlp.utils
from .config import Config from .config import Config
from .Events import EventBus, Events from .Events import EventBus, Events
from .ffprobe import ffprobe from .ffprobe import ffprobe
from .ItemDTO import ItemDTO from .ItemDTO import ItemDTO
from .Utils import delete_dir, extract_info, extract_ytdlp_logs, load_cookies from .Utils import delete_dir, extract_info, extract_ytdlp_logs, load_cookies
from .ytdlp import YTDLP
from .YTDLPOpts import YTDLPOpts from .YTDLPOpts import YTDLPOpts
@ -264,10 +266,17 @@ class Download:
params["logger"] = NestedLogger(self.logger) params["logger"] = NestedLogger(self.logger)
cls = yt_dlp.YoutubeDL(params=params) cls = YTDLP(params=params)
self.started_time = int(time.time()) self.started_time = int(time.time())
def mark_cancelled(*_):
cls._interrupted = True
cls.to_screen("[info] Interrupt received, exiting cleanly...")
raise SystemExit(130) # noqa: TRY301
signal.signal(signal.SIGUSR1, mark_cancelled)
if isinstance(self.info_dict, dict) and len(self.info_dict) > 1: if isinstance(self.info_dict, dict) and len(self.info_dict) > 1:
self.logger.debug(f"Downloading '{self.info.url}' using pre-info.") self.logger.debug(f"Downloading '{self.info.url}' using pre-info.")
cls.process_ie_result( cls.process_ie_result(
@ -275,10 +284,10 @@ class Download:
download=True, download=True,
extra_info={k: v for k, v in self.info.extras.items() if k not in self.info_dict}, extra_info={k: v for k, v in self.info.extras.items() if k not in self.info_dict},
) )
ret = cls._download_retcode ret: int = cls._download_retcode
else: else:
self.logger.debug(f"Downloading using url: {self.info.url}") self.logger.debug(f"Downloading using url: {self.info.url}")
ret = cls.download(url_list=[self.info.url]) ret: int = cls.download(url_list=[self.info.url])
self.status_queue.put({"id": self.id, "status": "finished" if ret == 0 else "error"}) self.status_queue.put({"id": self.id, "status": "finished" if ret == 0 else "error"})
except yt_dlp.utils.ExistingVideoReached as exc: except yt_dlp.utils.ExistingVideoReached as exc:
@ -333,7 +342,7 @@ class Download:
return False return False
self.cancel_in_progress = True self.cancel_in_progress = True
procId = self.proc.ident procId: int | None = self.proc.ident
self.logger.info(f"Closing PID='{procId}' download process.") self.logger.info(f"Closing PID='{procId}' download process.")
@ -387,7 +396,10 @@ class Download:
try: try:
self.logger.info(f"Killing download process: '{self.proc.ident}'.") self.logger.info(f"Killing download process: '{self.proc.ident}'.")
self.proc.kill() if self.proc.pid and "posix" == os.name:
os.kill(self.proc.pid, signal.SIGUSR1)
else:
self.proc.kill()
return True return True
except Exception as e: except Exception as e:
self.logger.error(f"Failed to kill process: '{self.proc.ident}'. {e}") self.logger.error(f"Failed to kill process: '{self.proc.ident}'. {e}")
@ -398,9 +410,9 @@ class Download:
if self.temp_keep is True or not self.temp_path: if self.temp_keep is True or not self.temp_path:
return return
if "finished" != self.info.status and self.is_live: if "finished" != self.info.status and self.info.downloaded_bytes > 0:
self.logger.warning( self.logger.warning(
f"Keeping live temp folder '{self.temp_path}', as the reported status is not finished '{self.info.status}'." f"Keeping temp folder '{self.temp_path}', as the reported status is not finished '{self.info.status}'."
) )
return return

View file

@ -10,7 +10,7 @@ from pathlib import Path
from sqlite3 import Connection from sqlite3 import Connection
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import yt_dlp import yt_dlp.utils
from aiohttp import web from aiohttp import web
from .ag_utils import ag from .ag_utils import ag

View file

@ -15,11 +15,11 @@ from http.cookiejar import MozillaCookieJar
from pathlib import Path from pathlib import Path
from typing import TypeVar from typing import TypeVar
import yt_dlp
from Crypto.Cipher import AES from Crypto.Cipher import AES
from yt_dlp.utils import age_restricted, match_str from yt_dlp.utils import age_restricted, match_str
from .LogWrapper import LogWrapper from .LogWrapper import LogWrapper
from .ytdlp import YTDLP
LOG: logging.Logger = logging.getLogger("Utils") LOG: logging.Logger = logging.getLogger("Utils")
@ -47,7 +47,7 @@ REMOVE_KEYS: list = [
}, },
] ]
YTDLP_INFO_CLS: yt_dlp.YoutubeDL = None YTDLP_INFO_CLS: YTDLP = None
ALLOWED_SUBS_EXTENSIONS: tuple[str] = (".srt", ".vtt", ".ass") ALLOWED_SUBS_EXTENSIONS: tuple[str] = (".srt", ".vtt", ".ass")
@ -189,7 +189,7 @@ def extract_info(
if no_archive and "download_archive" in params: if no_archive and "download_archive" in params:
del params["download_archive"] del params["download_archive"]
data = yt_dlp.YoutubeDL(params=params).extract_info(url, download=False) data = YTDLP(params=params).extract_info(url, download=False)
if data and follow_redirect and "_type" in data and "url" == data["_type"]: if data and follow_redirect and "_type" in data and "url" == data["_type"]:
return extract_info( return extract_info(
@ -208,7 +208,7 @@ def extract_info(
if not data["is_premiere"]: if not data["is_premiere"]:
data["is_premiere"] = "video" == data.get("media_type") and "is_upcoming" == data.get("live_status") data["is_premiere"] = "video" == data.get("media_type") and "is_upcoming" == data.get("live_status")
return yt_dlp.YoutubeDL.sanitize_info(data) if sanitize_info else data return YTDLP.sanitize_info(data) if sanitize_info else data
def merge_dict(source: dict, destination: dict) -> dict: def merge_dict(source: dict, destination: dict) -> dict:
@ -1103,7 +1103,7 @@ def get_archive_id(url: str) -> tuple[bool, dict[str | None, str | None, str | N
} }
if YTDLP_INFO_CLS is None: if YTDLP_INFO_CLS is None:
YTDLP_INFO_CLS = yt_dlp.YoutubeDL( YTDLP_INFO_CLS = YTDLP(
params={ params={
"color": "no_color", "color": "no_color",
"extract_flat": True, "extract_flat": True,

12
app/library/ytdlp.py Normal file
View file

@ -0,0 +1,12 @@
import yt_dlp
class YTDLP(yt_dlp.YoutubeDL):
_interrupted = False
def _delete_downloaded_files(self, *args, **kwargs):
if self._interrupted:
self.to_screen("[info] Cancelled — skipping temp cleanup.")
return None
return super()._delete_downloaded_files(*args, **kwargs)

12
ui/@types/conditions.d.ts vendored Normal file
View file

@ -0,0 +1,12 @@
export type ConditionItem = {
id?: string
name: string
filter: string
cli: string
[key: string]: any
}
export type ImportedConditionItem = ConditionItem & {
_type: string
_version: string
}

20
ui/@types/item.d.ts vendored Normal file
View file

@ -0,0 +1,20 @@
export type item_request = {
/** Unique identifier for the item */
id?: string|null,
/** URL of the item to download */
url: string,
/** Preset to use for the download */
preset?: string,
/** Where to save the downloaded item */
folder?: string,
/** Output template for the downloaded item */
template?: string,
/** Additional command line options for yt-dlp */
cli?: string,
/** Cookies file for the download */
cookies?: string,
/** Auto start the download */
auto_start?: boolean,
/** Extras data for the item */
extras?: Record<string, any>,
}

18
ui/@types/responses.d.ts vendored Normal file
View file

@ -0,0 +1,18 @@
export type error_response = {
/** The error message */
error: string,
}
export type convert_args_response = {
/** The converted CLI args */
opts?: Record<string, any>,
/** The output template if was provided */
output_template?: string,
/** The download path if was provided */
download_path?: string,
/** The download format if was provided */
format?: string,
/** The removed options from the original CLI args if any. */
removed_options?: string[],
}

View file

@ -206,77 +206,37 @@
</main> </main>
</template> </template>
<script setup> <script setup lang="ts">
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
const emitter = defineEmits(['cancel', 'submit'])
import { decode } from '~/utils/importer' import { decode } from '~/utils/importer'
import type { ConditionItem, ImportedConditionItem } from '~/@types/conditions'
const props = defineProps({ const emitter = defineEmits<{
reference: { (e: 'cancel'): void
type: String, (e: 'submit', payload: { reference: string | null | undefined, item: ConditionItem }): void
required: false, }>()
default: null,
},
item: {
type: Object,
required: true,
},
addInProgress: {
type: Boolean,
required: false,
default: false,
},
})
const test_data = ref({ show: false, url: '', data: { status: null, data: {} }, in_progress: false }) const props = defineProps<{
reference?: string | null
item: ConditionItem
addInProgress?: boolean
}>()
const toast = useNotification() const toast = useNotification()
const box = useConfirm()
const form = reactive(JSON.parse(JSON.stringify(props.item)))
const import_string = ref('')
const showImport = useStorage('showImport', false) const showImport = useStorage('showImport', false)
const box = useConfirm()
const run_test = async () => { const form = reactive<ConditionItem>(JSON.parse(JSON.stringify(props.item)))
if (!test_data.value.url) { const import_string = ref('')
toast.error('The URL is required for testing.', { force: true }) const test_data = ref<{
return show: boolean,
} url: string,
in_progress: boolean,
data: { status: boolean | null, data: Record<string, any> }
}>({ show: false, url: '', in_progress: false, data: { status: null, data: {} } })
try { const checkInfo = async (): Promise<void> => {
new URL(test_data.value.url) const required: (keyof ConditionItem)[] = ['name', 'filter', 'cli']
} catch (e) {
toast.error('The URL is invalid.', { force: true })
return
}
test_data.value.in_progress = true
test_data.value.data.status = null
try {
const response = await request('/api/conditions/test', {
method: 'POST',
body: JSON.stringify({
url: test_data.value.url,
condition: form.filter,
}),
})
const json = await response.json()
if (!response.ok) {
toast.error(json.message || json.error || 'Unknown error', { force: true })
return
}
test_data.value.data = json
} catch (e) {
toast.error(`Failed to test condition. ${error.message}`)
} finally {
test_data.value.in_progress = false
}
}
const checkInfo = async () => {
const required = ['name', 'filter', 'cli']
for (const key of required) { for (const key of required) {
if (!form[key]) { if (!form[key]) {
@ -285,15 +245,15 @@ const checkInfo = async () => {
} }
} }
if (form?.cli && '' !== form.cli) { if (form.cli && '' !== form.cli.trim()) {
const options = await convertOptions(form.cli) const options = await convertOptions(form.cli)
if (null === options) { if (options === null) {
return return
} }
form.cli = form.cli.trim() form.cli = form.cli.trim()
} }
let copy = JSON.parse(JSON.stringify(form)) const copy: ConditionItem = JSON.parse(JSON.stringify(form))
for (const key in copy) { for (const key in copy) {
if (typeof copy[key] !== 'string') { if (typeof copy[key] !== 'string') {
@ -305,32 +265,68 @@ const checkInfo = async () => {
emitter('submit', { reference: toRaw(props.reference), item: toRaw(copy) }) emitter('submit', { reference: toRaw(props.reference), item: toRaw(copy) })
} }
const convertOptions = async args => { const convertOptions = async (args: string): Promise<any | null> => {
try { try {
const response = await convertCliOptions(args) const response = await convertCliOptions(args)
return response.opts return response.opts
} catch (e) { } catch (e: any) {
toast.error(e.message) toast.error(e.message)
} }
return null return null
} }
const importItem = async () => { const run_test = async (): Promise<void> => {
let val = import_string.value.trim() if (!test_data.value.url) {
toast.error('The URL is required for testing.', { force: true })
return
}
try {
new URL(test_data.value.url)
} catch {
toast.error('The URL is invalid.', { force: true })
return
}
test_data.value.in_progress = true
test_data.value.data.status = null
try {
const response = await request('/api/conditions/test', {
method: 'POST',
body: JSON.stringify({ url: test_data.value.url, condition: form.filter })
})
const json = await response.json()
if (!response.ok) {
toast.error(json.message || json.error || 'Unknown error', { force: true })
return
}
test_data.value.data = json
} catch (error: any) {
toast.error(`Failed to test condition. ${error.message}`)
} finally {
test_data.value.in_progress = false
}
}
const importItem = async (): Promise<void> => {
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 ImportedConditionItem
if (!item?._type || 'condition' !== item._type) { if (!item?._type || item._type !== 'condition') {
toast.error(`Invalid import string. Expected type 'condition', got '${item._type ?? 'unknown'}'.`) toast.error(`Invalid import string. Expected type 'condition', got '${item._type ?? 'unknown'}'.`)
return return
} }
if ((form.filter || form.cli) && false === box.confirm('This will overwrite the current data. Are you sure?', true)) { if ((form.filter || form.cli) && !box.confirm('Overwrite the current form fields?', true)) {
return return
} }
@ -348,14 +344,14 @@ 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 import string. ${e.message}`) toast.error(`Failed to parse import string. ${e.message}`)
} }
} }
const show_data = () => { const show_data = (): string => {
if (!test_data.value.data?.data || !Object.keys(test_data.value.data?.data).length) { if (!test_data.value.data?.data || Object.keys(test_data.value.data.data).length === 0) {
return 'No data to show.' return 'No data to show.'
} }

View file

@ -19,40 +19,32 @@
</vTooltip> </vTooltip>
</template> </template>
<script setup> <script setup lang="ts">
import { ref } from 'vue'
import { request } from '~/utils/index' import { request } from '~/utils/index'
const props = defineProps({ const props = defineProps < {
image: { image?: string
type: String, title?: string
required: false, loader?: () => Promise < void>
}, privacy ?: boolean
title: { } > ()
type: String,
required: false
},
loader: {
Type: Function,
required: false,
},
privacy: {
type: Boolean,
required: false,
default: true
}
});
const cache = useSessionCache() const cache = useSessionCache()
const toast = useNotification() const toast = useNotification()
const url = ref() const url = ref < string | null > (null)
const error = ref(false) const error = ref(false)
const isPreloading = ref(false) const isPreloading = ref(false)
let loadTimer = null; let loadTimer: ReturnType<typeof setTimeout> | null = null
const cancelRequest = new AbortController(); const cancelRequest = new AbortController()
const defaultLoader = async () => { const defaultLoader = async (): Promise<void> => {
try { try {
if (!props.image) {
return
}
if (cache.has(props.image)) { if (cache.has(props.image)) {
url.value = cache.get(props.image) url.value = cache.get(props.image)
return return
@ -60,18 +52,16 @@ const defaultLoader = async () => {
const response = await request(props.image, { signal: cancelRequest.signal }) const response = await request(props.image, { signal: cancelRequest.signal })
if (200 !== response.status) { if (!response.ok) {
toast.error(`ImageView Request error. ${response.status}: ${response.statusText}`) toast.error(`ImageView Request error. ${response.status}: ${response.statusText}`)
return; return
} }
const objUrl = URL.createObjectURL(await response.blob()); const objUrl = URL.createObjectURL(await response.blob())
cache.set(props.image, objUrl) cache.set(props.image, objUrl)
url.value = objUrl
url.value = objUrl; } catch (e: any) {
} catch (e) { if (e === 'not_needed') {
if ('not_needed' === e) {
return return
} }
console.error(e) console.error(e)
@ -81,25 +71,28 @@ const defaultLoader = async () => {
} }
} }
const stopTimer = async () => { const stopTimer = async (): Promise<void> => {
if (error.value) { if (error.value) {
return return
} }
if (url.value) { if (url.value) {
isPreloading.value = false isPreloading.value = false
url.value = null; url.value = null
return; return
} }
await awaiter(() => isPreloading.value) await awaiter(() => isPreloading.value)
clearTimeout(loadTimer) if (loadTimer !== null) {
clearTimeout(loadTimer)
}
isPreloading.value = false isPreloading.value = false
url.value = null; url.value = null
cancelRequest.abort('not_needed') cancelRequest.abort('not_needed')
} }
const loadContent = async () => { const loadContent = async (): Promise<void> => {
if (props.loader) { if (props.loader) {
return props.loader() return props.loader()
} }
@ -107,11 +100,18 @@ const loadContent = async () => {
return defaultLoader() return defaultLoader()
} }
const clearCache = async () => { const clearCache = async (): Promise<void> => {
if (!props.image) return
cache.remove(props.image) cache.remove(props.image)
url.value = ''; url.value = ''
return loadContent() return loadContent()
} }
const pImg = e => e.target.naturalHeight > e.target.naturalWidth ? e.target.classList.add('image-portrait') : null const pImg = (e: Event): void => {
const target = e.target as HTMLImageElement
if (target.naturalHeight > target.naturalWidth) {
target.classList.add('image-portrait')
}
}
</script> </script>

View file

@ -1,3 +1,9 @@
<style scoped>
code {
color: var(--bulma-code) !important
}
</style>
<template> <template>
<div> <div>
<div class="modal is-active" v-if="false === externalModel"> <div class="modal is-active" v-if="false === externalModel">
@ -31,62 +37,41 @@
</div> </div>
</template> </template>
<style scoped>
code {
color: var(--bulma-code) !important
}
</style>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { request } from '~/utils/index' import { request } from '~/utils/index'
const toast = useNotification() const toast = useNotification()
const emitter = defineEmits<{ (e: 'closeModel'): void }>()
const emitter = defineEmits(['closeModel']) const props = defineProps<{
const isLoading = ref<Boolean>(false) link?: string
preset?: string
useUrl?: boolean
externalModel?: boolean
}>()
const isLoading = ref<boolean>(false)
const data = ref<any>({}) const data = ref<any>({})
const props = defineProps({ const handle_event = (e: KeyboardEvent): void => {
link: { if (e.key === 'Escape') {
type: String, emitter('closeModel')
default: '',
required: false,
},
preset: {
type: String,
default: '',
required: false,
},
useUrl: {
type: Boolean,
default: false,
required: false,
},
externalModel: {
type: Boolean,
default: false,
required: false,
},
})
const handle_event = (e: KeyboardEvent) => {
if (e.key !== 'Escape') {
return
} }
emitter('closeModel')
} }
onMounted(async () => { onMounted(async (): Promise<void> => {
document.addEventListener('keydown', handle_event) document.addEventListener('keydown', handle_event)
let url = props.useUrl ? props.link : '/api/yt-dlp/url/info'; let url = props.useUrl ? props.link || '' : '/api/yt-dlp/url/info'
if (!props.useUrl) { if (!props.useUrl) {
let params = new URLSearchParams(); const params = new URLSearchParams()
if (props.preset) { if (props.preset) {
params.append('preset', props.preset); params.append('preset', props.preset)
} }
params.append('url', props.link); params.append('url', props.link || '')
url += '?' + params.toString(); url += '?' + params.toString()
} }
try { try {
@ -96,10 +81,9 @@ onMounted(async () => {
try { try {
data.value = JSON.parse(body) data.value = JSON.parse(body)
} catch (e) { } catch {
data.value = body data.value = body
} }
} catch (e: any) { } catch (e: any) {
console.error(e) console.error(e)
toast.error(`Error: ${e.message}`) toast.error(`Error: ${e.message}`)
@ -108,5 +92,7 @@ onMounted(async () => {
} }
}) })
onBeforeUnmount(() => document.removeEventListener('keydown', handle_event)) onBeforeUnmount(() => {
document.removeEventListener('keydown', handle_event)
})
</script> </script>

View file

@ -1,76 +1,77 @@
<template> <template>
<div ref="targetEl" :style="`min-height:${fixedMinHeight ? fixedMinHeight : (minHeight)}px`"> <div ref="targetEl" :style="`min-height:${fixedMinHeight || props.minHeight || 0}px`">
<slot v-if="shouldRender"/> <slot v-if="shouldRender" />
</div> </div>
</template> </template>
<script> <script setup lang="ts">
import {nextTick, ref} from 'vue' import { ref, nextTick, onBeforeUnmount } from 'vue'
import {useIntersectionObserver} from '@vueuse/core' import { useIntersectionObserver } from '@vueuse/core'
function onIdle(cb = () => { const props = defineProps<{
}) { renderOnIdle?: boolean
if ("requestIdleCallback" in window) { unrender?: boolean
window.requestIdleCallback(cb); minHeight?: number
unrenderDelay?: number
}>()
const shouldRender = ref(false)
const targetEl = ref<HTMLElement | null>(null)
const fixedMinHeight = ref(0)
let unrenderTimer: ReturnType<typeof setTimeout> | null = null
let renderTimer: ReturnType<typeof setTimeout> | null = null
function onIdle(cb: () => void): void {
if ('requestIdleCallback' in window) {
(window as any).requestIdleCallback(cb)
} else { } else {
setTimeout(() => nextTick(cb), 300); setTimeout(() => nextTick(cb), 300)
} }
} }
export default { const { stop } = useIntersectionObserver(targetEl, ([{ isIntersecting }]) => {
props: { if (isIntersecting) {
renderOnIdle: Boolean, if (unrenderTimer) clearTimeout(unrenderTimer)
unrender: Boolean,
minHeight: Number,
unrenderDelay: {
type: Number,
default: 6000,
},
},
setup(props) {
const shouldRender = ref(false);
const targetEl = ref();
const fixedMinHeight = ref(0);
let unrenderTimer;
let renderTimer;
const {stop} = useIntersectionObserver(targetEl, ([{isIntersecting}]) => { renderTimer = setTimeout(() => { shouldRender.value = true }, props.unrender ? 200 : 0)
if (isIntersecting) {
// perhaps the user re-scrolled to a component that was set to unrender. In that case stop the un-rendering timer
clearTimeout(unrenderTimer);
// if we're dealing under-rendering lets add a waiting period of 200ms before rendering.
// If a component enters the viewport and also leaves it within 200ms it will not render at all.
// This saves work and improves performance when user scrolls very fast
renderTimer = setTimeout(() => (shouldRender.value = true), props.unrender ? 200 : 0);
shouldRender.value = true;
if (!props.unrender) {
stop();
}
} else if (props.unrender) {
// if the component was set to render, cancel that
clearTimeout(renderTimer);
unrenderTimer = setTimeout(() => {
if (targetEl.value?.clientHeight) {
fixedMinHeight.value = targetEl.value.clientHeight;
}
shouldRender.value = false;
}, props.unrenderDelay);
}
}, {
rootMargin: "600px",
}
);
if (props.renderOnIdle) { shouldRender.value = true
onIdle(() => {
shouldRender.value = true; if (!props.unrender) {
if (!props.unrender) { stop()
stop(); }
} }
}); else if (props.unrender) {
if (renderTimer) {
clearTimeout(renderTimer)
} }
return {targetEl, shouldRender, fixedMinHeight}; unrenderTimer = setTimeout(() => {
}, if (targetEl.value?.clientHeight) {
}; fixedMinHeight.value = targetEl.value.clientHeight
}
shouldRender.value = false
}, props.unrenderDelay ?? 6000)
}
}, { rootMargin: '600px', })
if (props.renderOnIdle) {
onIdle(() => {
shouldRender.value = true
if (!props.unrender) {
stop()
}
})
}
onBeforeUnmount(() => {
if (renderTimer) {
clearTimeout(renderTimer)
}
if (unrenderTimer) {
clearTimeout(unrenderTimer)
}
})
</script> </script>

View file

@ -1,67 +1,45 @@
<template> <template>
<div class="notification" :class="message_class"> <div class="notification" :class="props.message_class">
<button class="delete" @click="$emit('close')" v-if="!useToggle && useClose"></button> <button class="delete" @click="emit('close')" v-if="!props.useToggle && props.useClose"></button>
<div @click="$emit('toggle')" class="is-clickable is-pulled-right is-unselectable" v-if="useToggle">
<div @click="emit('toggle')" class="is-clickable is-pulled-right is-unselectable" v-if="props.useToggle">
<span class="icon"> <span class="icon">
<i class="fas" :class="{ 'fa-arrow-up': toggle, 'fa-arrow-down': !toggle }"></i> <i class="fas" :class="{ 'fa-arrow-up': props.toggle, 'fa-arrow-down': !props.toggle }"></i>
</span> </span>
<span>{{ toggle ? 'Close' : 'Open' }}</span> <span>{{ props.toggle ? 'Close' : 'Open' }}</span>
</div> </div>
<div class="notification-title is-unselectable" :class="{ 'is-clickable': useToggle }" v-if="title || icon"
@click="useToggle ? $emit('toggle', toggle) : null"> <div class="notification-title is-unselectable" :class="{ 'is-clickable': props.useToggle }"
<template v-if="icon"> v-if="props.title || props.icon" @click="props.useToggle ? emit('toggle', props.toggle) : null">
<template v-if="props.icon">
<span class="icon-text"> <span class="icon-text">
<span class="icon"><i :class="icon"></i></span> <span class="icon"><i :class="props.icon"></i></span>
<span>{{ title }}</span> <span>{{ props.title }}</span>
</span> </span>
</template> </template>
<template v-else>{{ title }}</template> <template v-else>{{ props.title }}</template>
</div> </div>
<div class="notification-content content" v-if="false === useToggle || toggle">
<template v-if="message">{{ message }}</template> <div class="notification-content content" v-if="!props.useToggle || props.toggle">
<template v-if="props.message">{{ props.message }}</template>
<slot /> <slot />
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
defineProps({ const props = defineProps < {
title: { title?: string
type: String, icon?: string
default: null, message?: string
required: false message_class?: string
}, useToggle?: boolean
icon: { toggle?: boolean
type: String, useClose?: boolean
default: null, } > ()
required: false
},
message: {
type: String,
default: null,
required: false
},
message_class: {
type: String,
default: 'is-info',
required: false
},
useToggle: {
type: Boolean,
default: false,
required: false
},
toggle: {
type: Boolean,
default: false,
required: false
},
useClose: {
type: Boolean,
default: false,
required: false
}
})
defineEmits(['toggle', 'close']) const emit = defineEmits < {
(e: 'toggle', value ?: boolean): void
(e: 'close'): void
}> ()
</script> </script>

View file

@ -7,33 +7,31 @@
<div class="column is-12"> <div class="column is-12">
<label class="label is-inline is-unselectable" for="url"> <label class="label is-inline is-unselectable" for="url">
<span class="icon"><i class="fa-solid fa-link" /></span> <span class="icon"><i class="fa-solid fa-link" /></span>
URLs URLs separated by <span class="is-bold">{{ getSeparatorsName(separator) }}</span>
</label> </label>
<div class="field is-grouped"> <div class="field is-grouped">
<div class="control is-expanded"> <div class="control is-expanded">
<input type="text" class="input" id="url" placeholder="Video or playlist link" <input type="text" class="input" id="url" placeholder="URLs to download"
:disabled="!socket.isConnected || addInProgress" v-model="form.url"> :disabled="!socket.isConnected || addInProgress" v-model="form.url">
</div> </div>
<div class="control is-align-content-space-around" v-if="!config.app.basic_mode" <div class="control">
v-tooltip="'Auto start the download after adding it.'"> <button type="submit" class="button is-primary"
<input id="auto_start" type="checkbox" v-model="auto_start" :disabled="addInProgress" :class="{ 'is-loading': !socket.isConnected || addInProgress }"
class="switch is-success" /> :disabled="!socket.isConnected || addInProgress || !form?.url">
<label for="auto_start" class="is-unselectable">Start</label> <span class="icon"><i class="fa-solid fa-plus" /></span>
<span>Add</span>
</button>
</div> </div>
</div> </div>
<span class="help is-bold is-unselectable">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>You can add multiple URLs separated by <code
class="is-bold">{{ getSeparatorsName(separator) }}</code>.</span>
</span>
</div> </div>
<div class="column is-4-tablet is-12-mobile" v-if="!config.app.basic_mode"> <div class="column is-4-tablet is-12-mobile" v-if="!config.app.basic_mode">
<div class="field has-addons"> <div class="field has-addons">
<div class="control"> <div class="control" @click="show_description = !show_description">
<a href="#" class="button is-static"> <label class="button is-static">
<span class="icon"><i class="fa-solid fa-sliders" /></span> <span class="icon"><i class="fa-solid fa-sliders" /></span>
<span>Preset</span> <span>Preset</span>
</a> </label>
</div> </div>
<div class="control is-expanded"> <div class="control is-expanded">
<div class="select is-fullwidth"> <div class="select is-fullwidth">
@ -55,13 +53,14 @@
</div> </div>
</div> </div>
</div> </div>
<div class="column is-6-tablet is-12-mobile" v-if="!config.app.basic_mode"> <div class="column is-6-tablet is-12-mobile" v-if="!config.app.basic_mode">
<div class="field has-addons" v-tooltip="'Folder relative to ' + config.app.download_path"> <div class="field has-addons" v-tooltip="'Folder relative to ' + config.app.download_path">
<div class="control"> <div class="control">
<a href="#" class="button is-static"> <label class="button is-static">
<span class="icon"><i class="fa-solid fa-folder" /></span> <span class="icon"><i class="fa-solid fa-folder" /></span>
<span>Save in</span> <span>Save in</span>
</a> </label>
</div> </div>
<div class="control is-expanded"> <div class="control is-expanded">
<input type="text" class="input is-fullwidth" id="path" v-model="form.folder" placeholder="Default" <input type="text" class="input is-fullwidth" id="path" v-model="form.folder" placeholder="Default"
@ -69,23 +68,17 @@
</div> </div>
</div> </div>
</div> </div>
<div class="column">
<button type="submit" class="button is-primary"
:class="{ 'is-loading': !socket.isConnected || addInProgress }"
:disabled="!socket.isConnected || addInProgress || !form?.url">
<span class="icon"><i class="fa-solid fa-plus" /></span>
<span>Add</span>
</button>
</div>
<div class="column" v-if="!config.app.basic_mode"> <div class="column" v-if="!config.app.basic_mode">
<button type="button" class="button is-info" @click="showAdvanced = !showAdvanced" <button type="button" class="button is-info" @click="showAdvanced = !showAdvanced"
:class="{ 'is-loading': !socket.isConnected }" :disabled="!socket.isConnected"> :class="{ 'is-loading': !socket.isConnected }" :disabled="!socket.isConnected">
<span class="icon"><i class="fa-solid fa-cog" /></span> <span class="icon"><i class="fa-solid fa-cog" /></span>
<span>Opts</span> <span>Advanced Options</span>
</button> </button>
</div> </div>
<div class="column is-12" <div class="column is-12"
v-if="!config.app.basic_mode && !hasFormatInConfig && get_preset(form.preset)?.description"> v-if="show_description && !config.app.basic_mode && !hasFormatInConfig && get_preset(form.preset)?.description">
<div class="is-overflow-auto" style="max-height: 150px;"> <div class="is-overflow-auto" style="max-height: 150px;">
<div class="is-ellipsis is-clickable" @click="expand_description"> <div class="is-ellipsis is-clickable" @click="expand_description">
<span class="icon"><i class="fa-solid fa-info" /></span> {{ get_preset(form.preset)?.description }} <span class="icon"><i class="fa-solid fa-info" /></span> {{ get_preset(form.preset)?.description }}
@ -97,44 +90,36 @@
<div class="columns is-multiline is-mobile" v-if="showAdvanced && !config.app.basic_mode"> <div class="columns is-multiline is-mobile" v-if="showAdvanced && !config.app.basic_mode">
<div class="column is-4-tablet is-12-mobile" v-if="!config.app.basic_mode"> <div class="column is-4-tablet is-12-mobile" v-if="!config.app.basic_mode">
<div class="field"> <div class="field">
<label class="label is-inline is-unselectable"> <input id="auto_start" type="checkbox" v-model="auto_start" :disabled="addInProgress"
<span class="icon"><i class="fa-solid fa-object-ungroup" /></span> class="switch is-success" />
<span>URLs Separator</span> <label for="auto_start" class="is-unselectable">
{{ auto_start ? 'Auto start' : 'Manual start' }}
</label> </label>
<div class="control">
<div class="select is-fullwidth">
<select class="is-fullwidth" :disabled="!socket.isConnected || addInProgress" v-model="separator">
<option v-for="(sep, index) in separators" :key="`sep-${index}`" :value="sep.value">
{{ sep.name }} ({{ sep.value }})
</option>
</select>
</div>
</div>
<span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Use this to separate multiple URLs in the input field.</span>
</span>
</div> </div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span class="is-bold">Whether to start the download automatically or wait.</span>
</span>
</div> </div>
<div class="column is-8-tablet is-12-mobile"> <div class="column is-8-tablet is-12-mobile">
<div class="field"> <div class="field has-addons">
<label class="label is-inline is-unselectable" for="output_format"
v-tooltip="'Default: ' + config.app.output_template">
<span class="icon"><i class="fa-solid fa-file" /></span>
Output Template
</label>
<div class="control"> <div class="control">
<input type="text" class="input" v-model="form.template" id="output_format" <label for="output_format" class="button is-static is-unselectable">
:disabled="!socket.isConnected || addInProgress" <span class="icon"><i class="fa-solid fa-file" /></span>
placeholder="Uses default output template naming if empty."> <span>Template</span>
</label>
</div>
<div class="control is-expanded">
<input type="text" class="input" v-model="form.template" id="output_format"
:disabled="!socket.isConnected || addInProgress" :placeholder="get_output_template()">
</div> </div>
<span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>All output template naming options can be found at <NuxtLink target="_blank"
to="https://github.com/yt-dlp/yt-dlp#output-template">this page</NuxtLink>.</span>
</span>
</div> </div>
<span class="help is-bold is-unselectable">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>All output template naming options can be found at <NuxtLink target="_blank"
to="https://github.com/yt-dlp/yt-dlp#output-template">this page</NuxtLink>.</span>
</span>
</div> </div>
<div class="column is-6-tablet is-12-mobile"> <div class="column is-6-tablet is-12-mobile">
@ -154,7 +139,7 @@
<span>Check <NuxtLink target="_blank" to="https://github.com/yt-dlp/yt-dlp#general-options">this page <span>Check <NuxtLink target="_blank" to="https://github.com/yt-dlp/yt-dlp#general-options">this page
</NuxtLink> for more info. <span class="has-text-danger">Not all options are supported <NuxtLink </NuxtLink> for more info. <span class="has-text-danger">Not all options are supported <NuxtLink
target="_blank" target="_blank"
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L24-L46">some are to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26-L48">some are
ignored</NuxtLink>. Use with caution these options can break yt-dlp or the frontend.</span> ignored</NuxtLink>. Use with caution these options can break yt-dlp or the frontend.</span>
</span> </span>
</span> </span>
@ -222,7 +207,7 @@
<div class="control"> <div class="control">
<button type="button" class="button is-danger" @click="resetConfig" <button type="button" class="button is-danger" @click="resetConfig"
:disabled="!socket.isConnected || form?.id" v-tooltip="'Reset local settings'"> :disabled="!!(!socket.isConnected || form?.id)" v-tooltip="'Reset local settings'">
<span class="icon"><i class="fa-solid fa-rotate-left" /></span> <span class="icon"><i class="fa-solid fa-rotate-left" /></span>
<span>Reset</span> <span>Reset</span>
</button> </button>
@ -237,47 +222,36 @@
<datalist id="folders" v-if="config?.folders"> <datalist id="folders" v-if="config?.folders">
<option v-for="dir in config.folders" :key="dir" :value="dir" /> <option v-for="dir in config.folders" :key="dir" :value="dir" />
</datalist> </datalist>
<ConfirmDialog v-if="dialog_confirm.visible" :visible="dialog_confirm.visible" :title="dialog_confirm.title"
:message="dialog_confirm.message" :options="dialog_confirm.options" @confirm="dialog_confirm.confirm"
@cancel="() => dialog_confirm.visible = false" />
</main> </main>
</template> </template>
<script setup> <script setup lang="ts">
import 'assets/css/bulma-switch.css' import 'assets/css/bulma-switch.css'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import type { item_request } from '~/@types/item'
import { getSeparatorsName, separators } from '~/utils/utils'
const props = defineProps({ const props = defineProps<{ item?: Partial<item_request> }>()
item: { const emitter = defineEmits<{
type: Object, (e: 'getInfo', url: string, preset: string | undefined): void
required: false, (e: 'clear_form'): void
default: () => { }, (e: 'remove_archive', url: string): void
}, }>()
})
const emitter = defineEmits(['getInfo', 'clear_form', 'remove_archive'])
const config = useConfigStore() const config = useConfigStore()
const socket = useSocketStore() const socket = useSocketStore()
const toast = useNotification() const toast = useNotification()
const box = useConfirm()
const separators = [ const showAdvanced = useStorage<boolean>('show_advanced', false)
{ name: 'Comma', value: ',', }, const separator = useStorage<string>('url_separator', separators[0].value)
{ name: 'Semicolon', value: ';', }, const auto_start = useStorage<boolean>('auto_start', true)
{ name: 'Colon', value: ':', }, const show_description = useStorage<boolean>('show_description', true)
{ name: 'Pipe', value: '|', },
{ name: 'Space', value: ' ', }
]
const getSeparatorsName = (value) => { const addInProgress = ref<boolean>(false)
const sep = separators.find(s => s.value === value)
return sep ? `${sep.name} (${value})` : 'Unknown'
}
const showAdvanced = useStorage('show_advanced', false) const form = useStorage<item_request>('local_config_v1', {
const separator = useStorage('url_separator', separators[0].value)
const auto_start = useStorage('auto_start', true)
const addInProgress = ref(false)
const form = useStorage('local_config_v1', {
id: null, id: null,
url: '', url: '',
preset: config.app.default_preset, preset: config.app.default_preset,
@ -286,6 +260,14 @@ const form = useStorage('local_config_v1', {
template: '', template: '',
folder: '', folder: '',
extras: {}, extras: {},
}) as Ref<item_request>
const dialog_confirm = ref({
visible: false,
title: 'Confirm Action',
confirm: () => { },
message: '',
options: [],
}) })
const addDownload = async () => { const addDownload = async () => {
@ -296,9 +278,9 @@ const addDownload = async () => {
} }
} }
const request_data = [] const request_data = [] as Array<item_request>
form.value.url.split(separator.value).forEach(async (url) => { form.value.url.split(separator.value).forEach(async (url: string) => {
if (!url.trim()) { if (!url.trim()) {
return return
} }
@ -311,7 +293,7 @@ const addDownload = async () => {
cookies: config.app.basic_mode ? '' : form.value.cookies, cookies: config.app.basic_mode ? '' : form.value.cookies,
cli: config.app.basic_mode ? null : form.value.cli, cli: config.app.basic_mode ? null : form.value.cli,
auto_start: config.app.basic_mode ? true : auto_start.value auto_start: config.app.basic_mode ? true : auto_start.value
} } as item_request
if (form.value?.extras && Object.keys(form.value.extras).length > 0) { if (form.value?.extras && Object.keys(form.value.extras).length > 0) {
data.extras = form.value.extras data.extras = form.value.extras
@ -334,7 +316,7 @@ const addDownload = async () => {
return return
} }
data.forEach(item => { data.forEach((item: Record<string, any>) => {
if (false !== item.status) { if (false !== item.status) {
return return
} }
@ -344,7 +326,7 @@ const addDownload = async () => {
form.value.url = '' form.value.url = ''
emitter('clear_form') emitter('clear_form')
} }
catch (e) { catch (e: any) {
console.error(e) console.error(e)
toast.error(`Error: ${e.message}`) toast.error(`Error: ${e.message}`)
} finally { } finally {
@ -353,12 +335,13 @@ const addDownload = async () => {
} }
const resetConfig = () => { const resetConfig = () => {
if (true !== box.confirm('Reset your local configuration?')) { dialog_confirm.value.visible = true
return dialog_confirm.value.message = `Reset local configuration?`
} dialog_confirm.value.confirm = reset_config
}
const reset_config = () => {
form.value = { form.value = {
id: null,
url: '', url: '',
preset: config.app.default_preset, preset: config.app.default_preset,
cookies: '', cookies: '',
@ -366,15 +349,15 @@ const resetConfig = () => {
template: '', template: '',
folder: '', folder: '',
extras: {}, extras: {},
} } as item_request
showAdvanced.value = false showAdvanced.value = false
separator.value = separators[0].value
toast.success('Local configuration has been reset.') toast.success('Local configuration has been reset.')
dialog_confirm.value.visible = false
} }
const convertOptions = async args => { const convertOptions = async (args: string) => {
try { try {
const response = await convertCliOptions(args) const response = await convertCliOptions(args)
@ -387,7 +370,7 @@ const convertOptions = async args => {
} }
return response.opts return response.opts
} catch (e) { } catch (e: any) {
toast.error(e.message) toast.error(e.message)
} }
@ -418,36 +401,30 @@ onMounted(async () => {
} }
if (props?.item) { if (props?.item) {
Object.keys(props.item).forEach(key => { const updates: Partial<item_request> = {}
if (key in form.value) { const keys = Object.keys(props.item) as (keyof item_request)[]
let value = props.item[key] for (const key of keys) {
if ('extras' === key) { const value = props.item[key]
value = JSON.parse(JSON.stringify(props.item[key])) updates[key] = key === 'extras' ? JSON.parse(JSON.stringify(value)) : value!
} }
form.value[key] = value form.value = { ...form.value, ...updates }
} emitter('clear_form')
})
emitter('clear_form');
} }
await nextTick() await nextTick()
if (!separators.some(s => s.value === separator.value)) { if (!separators.some(s => s.value === separator.value)) {
separator.value = separators[0].value separator.value = separators[0].value
} }
}) })
const hasFormatInConfig = computed(() => { const hasFormatInConfig = computed((): boolean => !!form.value.cli?.match(/(?<!\S)(-f|--format)(=|\s)(\S+)/))
if (!form.value?.cli) {
return false
}
return /(?<!\S)(-f|--format)(=|\s)(\S+)/.test(form.value.cli)
})
const filter_presets = (flag = true) => config.presets.filter(item => item.default === flag) const filter_presets = (flag: boolean = true) => config.presets.filter(item => item.default === flag)
const get_preset = name => config.presets.find(item => item.name === name) const get_preset = (name: string | undefined) => config.presets.find(item => item.name === name)
const expand_description = e => toggleClass(e.target, ['is-ellipsis', 'is-pre-wrap']) const expand_description = (e: Event) => toggleClass(e.target, ['is-ellipsis', 'is-pre-wrap'])
const removeFromArchive = async url => { const removeFromArchive = async (url: string) => {
try { try {
const req = await request(`/api/archive/0`, { const req = await request(`/api/archive/0`, {
credentials: 'include', credentials: 'include',
@ -463,8 +440,19 @@ const removeFromArchive = async url => {
} }
toast.success(data.message ?? `Removed item from archive.`) toast.success(data.message ?? `Removed item from archive.`)
} catch (e) { } catch (e: any) {
toast.error(`Error: ${e.message}`) toast.error(`Error: ${e.message}`)
} }
} }
const get_output_template = () => {
if (form.value.preset && !hasFormatInConfig.value) {
const preset = config.presets.find(p => p.name === form.value.preset)
if (preset && preset.template) {
return preset.template
}
}
return config.app.output_template || '%(title)s.%(ext)s'
}
</script> </script>

View file

@ -351,7 +351,7 @@ const importItem = async () => {
} }
if (form.target) { if (form.target) {
if (false === box.confirm('This will overwrite the current form fields. Are you sure?', true)) { if (false === box.confirm('Overwrite the current form fields?', true)) {
return return
} }
} }

View file

@ -523,7 +523,7 @@ const updateProgress = (item) => {
} }
const confirmCancel = item => { const confirmCancel = item => {
if (true !== box.confirm(`Are you sure you want to cancel (${item.title})?`)) { if (true !== box.confirm(`Cancel '${item.title}'?`)) {
return false return false
} }
cancelItems(item._id) cancelItems(item._id)
@ -531,7 +531,7 @@ const confirmCancel = item => {
} }
const cancelSelected = () => { const cancelSelected = () => {
if (true !== box.confirm('Are you sure you want to cancel selected items?')) { if (true !== box.confirm(`Cancel '${selectedElms.value.length}' selected items?`)) {
return false; return false;
} }
cancelItems(selectedElms.value) cancelItems(selectedElms.value)

View file

@ -51,7 +51,7 @@
<div class="field"> <div class="field">
<label class="label is-unselectable" for="random_bg_opacity"> <label class="label is-unselectable" for="random_bg_opacity">
Background visibility <code>{{ parseFloat(1.0 - bg_opacity).toFixed(2) }}</code> Background visibility <code>{{ parseFloat(String(1.0 - bg_opacity)).toFixed(2) }}</code>
</label> </label>
<div class="control"> <div class="control">
<input id="random_bg_opacity" style="width: 100%" type="range" v-model="bg_opacity" min="0.50" <input id="random_bg_opacity" style="width: 100%" type="range" v-model="bg_opacity" min="0.50"
@ -69,6 +69,19 @@
</div> </div>
</div> </div>
<div class="field">
<label class="label is-unselectable" for="show_thumbnail">URLs Separator</label>
<div class="control">
<div class="select is-fullwidth">
<select class="is-fullwidth" v-model="separator">
<option v-for="(sep, index) in separators" :key="`sep-${index}`" :value="sep.value">
{{ sep.name }} ({{ sep.value }})
</option>
</select>
</div>
</div>
</div>
</div> </div>
<div class="column is-6"> <div class="column is-6">
<div class="field"> <div class="field">
@ -126,24 +139,20 @@
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import { POSITION } from 'vue-toastification' import { POSITION } from 'vue-toastification'
import { separators } from '~/utils/utils'
defineProps({ defineProps<{ isLoading: boolean }>()
isLoading: {
type: Boolean,
required: true
}
})
const bg_enable = useStorage('random_bg', true)
const bg_opacity = useStorage('random_bg_opacity', 0.95)
const selectedTheme = useStorage('theme', 'auto')
const allow_toasts = useStorage('allow_toasts', true)
const reduce_confirm = useStorage('reduce_confirm', false)
const toast_position = useStorage('toast_position', POSITION.TOP_RIGHT)
const toast_dismiss_on_click = useStorage('toast_dismiss_on_click', true)
const show_thumbnail = useStorage('show_thumbnail', true)
const bg_enable = useStorage<boolean>('random_bg', true)
const bg_opacity = useStorage<number>('random_bg_opacity', 0.95)
const selectedTheme = useStorage<'auto' | 'light' | 'dark'>('theme', 'auto')
const allow_toasts = useStorage<boolean>('allow_toasts', true)
const reduce_confirm = useStorage<boolean>('reduce_confirm', false)
const toast_position = useStorage<POSITION>('toast_position', POSITION.TOP_RIGHT)
const toast_dismiss_on_click = useStorage<boolean>('toast_dismiss_on_click', true)
const show_thumbnail = useStorage<boolean>('show_thumbnail', true)
const separator = useStorage<string>('url_separator', separators[0].value)
</script> </script>

View file

@ -328,6 +328,9 @@ 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) {
form.auto_start = true
}
}) })
const checkInfo = async () => { const checkInfo = async () => {
@ -384,7 +387,7 @@ const importItem = async () => {
} }
if (form.url || form.timer) { if (form.url || form.timer) {
if (false === box.confirm('This will overwrite the current form fields. Are you sure?', true)) { if (false === box.confirm('Overwrite the current form fields?', true)) {
return return
} }
} }

View file

@ -16,7 +16,7 @@
</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(false)" :class="{ 'is-loading': isLoading }"
:disabled="!socket.isConnected || isLoading" v-if="items && items.length > 0"> :disabled="!socket.isConnected || isLoading" v-if="items && items.length > 0">
<span class="icon"><i class="fas fa-refresh" /></span> <span class="icon"><i class="fas fa-refresh" /></span>
</button> </button>
@ -30,8 +30,8 @@
</div> </div>
<div class="column is-12" v-if="toggleForm"> <div class="column is-12" v-if="toggleForm">
<ConditionForm :addInProgress="addInProgress" :reference="itemRef" :item="item" @cancel="resetForm(true)" <ConditionForm :addInProgress="addInProgress" :reference="itemRef" :item="item as ConditionItem"
@submit="updateItem" /> @cancel="resetForm(true)" @submit="updateItem" />
</div> </div>
<div class="column is-12" v-if="!toggleForm"> <div class="column is-12" v-if="!toggleForm">
@ -114,18 +114,19 @@
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { request } from '~/utils/index' import { request } from '~/utils/index'
import { encode } from '~/utils/importer' import { encode } from '~/utils/importer'
import type { ConditionItem, ImportedConditionItem } from '~/@types/conditions'
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 items = ref([]) const items = ref<ConditionItem[]>([])
const item = ref({}) const item = ref<Partial<ConditionItem>>({})
const itemRef = ref("") const itemRef = ref<string | null | undefined>("")
const toggleForm = ref(false) const toggleForm = ref(false)
const isLoading = ref(false) const isLoading = ref(false)
const initialLoad = ref(true) const initialLoad = ref(true)
@ -146,15 +147,15 @@ watch(() => socket.isConnected, async () => {
} }
}) })
const reloadContent = async (fromMounted = false) => {
const reloadContent = async (fromMounted = false): Promise<void> => {
try { try {
isLoading.value = true isLoading.value = true
const response = await request("/api/conditions") const response = await request('/api/conditions')
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 (data.length < 1) {
return return
@ -162,17 +163,16 @@ const reloadContent = async (fromMounted = false) => {
items.value = data items.value = data
} catch (e) { } catch (e) {
if (fromMounted) { if (!fromMounted) {
return console.error(e)
toast.error('Failed to fetch page content.')
} }
console.error(e)
toast.error("Failed to fetch page content.")
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
} }
const resetForm = (closeForm = false) => { const resetForm = (closeForm = false): void => {
item.value = {} item.value = {}
itemRef.value = null itemRef.value = null
addInProgress.value = false addInProgress.value = false
@ -181,30 +181,29 @@ const resetForm = (closeForm = false) => {
} }
} }
const updateItems = async items => { const updateItems = async (newItems: ConditionItem[]): Promise<boolean> => {
let data let data: any
try { try {
addInProgress.value = true addInProgress.value = true
const local_items = [] const validItems = newItems.map(({ id, name, filter, cli }) => {
for (const item of items) {
const { id, name, filter, cli } = item
if (!name || !filter || !cli) { if (!name || !filter || !cli) {
toast.error("Name, filter and cli are required.") toast.error('Name, filter and cli are required.')
return false throw new Error('Missing fields')
} }
local_items.push({ id, name, filter, cli }) return { id, name, filter, cli }
}
const response = await request("/api/conditions", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(local_items),
}) })
data = await response.json() const response = await request('/api/conditions', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(validItems),
})
if (200 !== response.status) { data = await response.json() as ConditionItem[]
if (response.status !== 200) {
toast.error(`Failed to update items. ${data.error}`) toast.error(`Failed to update items. ${data.error}`)
return false return false
} }
@ -212,44 +211,46 @@ const updateItems = async items => {
items.value = data items.value = data
resetForm(true) resetForm(true)
return true return true
} catch (e) { } catch (e: any) {
toast.error(`Failed to update items. ${data?.error}. ${e.message}`) toast.error(`Failed to update items. ${data?.error ?? ''} ${e.message}`)
} finally { } finally {
addInProgress.value = false addInProgress.value = false
} }
return false
} }
const deleteItem = async (item) => { const deleteItem = async (cond: ConditionItem): Promise<void> => {
if (true !== box.confirm(`Delete '${item.name}'?`, true)) { if (!box.confirm(`Delete '${cond.name}'?`, true)) {
return return
} }
const index = items.value.findIndex((t) => t?.id === item.id) const index = items.value.findIndex(t => t?.id === cond.id)
if (index > -1) { if (-1 === index) {
items.value.splice(index, 1) toast.error('Item not found.')
} else {
toast.error("Item not found.")
return return
} }
items.value.splice(index, 1)
const status = await updateItems(items.value) const status = await updateItems(items.value)
if (status) {
if (!status) { toast.success('Item deleted.')
return
} }
toast.success("Item deleted.")
} }
const updateItem = async ({ reference, item }) => { const updateItem = async ({ reference, item: updatedItem, }: {
item = cleanObject(item, remove_keys) reference: string | null | undefined,
item: ConditionItem
}): Promise<void> => {
updatedItem = cleanObject(updatedItem, remove_keys)
if (reference) { if (reference) {
const index = items.value.findIndex(t => t?.id === reference) const index = items.value.findIndex(t => t?.id === reference)
if (index > -1) { if (index !== -1) {
items.value[index] = item items.value[index] = updatedItem
} }
} else { } else {
items.value.push(item) items.value.push(updatedItem)
} }
const status = await updateItems(items.value) const status = await updateItems(items.value)
@ -257,36 +258,32 @@ const updateItem = async ({ reference, item }) => {
return return
} }
toast.success(`Item ${reference ? "updated" : "added"}.`) toast.success(`Item ${reference ? 'updated' : 'added'}.`)
resetForm(true) resetForm(true)
} }
const editItem = _item => { const editItem = (_item: ConditionItem): void => {
item.value = _item item.value = { ..._item }
itemRef.value = _item.id itemRef.value = _item.id
toggleForm.value = true toggleForm.value = true
} }
onMounted(async () => (socket.isConnected ? await reloadContent(true) : "")) const exportItem = (cond: ConditionItem): void => {
const clone: Partial<ImportedConditionItem> = JSON.parse(JSON.stringify(cond))
delete clone.id
const exportItem = item => { const userData: ImportedConditionItem = {
let data = JSON.parse(JSON.stringify(item)) ...Object.fromEntries(Object.entries(clone).filter(([_, v]) => !!v)),
if ("id" in data) { _type: 'condition',
delete data['id'] _version: '1.0',
} } as ImportedConditionItem
let userData = {} copyText(encode(userData))
for (const key of Object.keys(data)) {
if (!data[key]) {
continue
}
userData[key] = data[key]
}
userData['_type'] = 'condition'
userData['_version'] = '1.0'
return copyText(encode(userData))
} }
onMounted(async () => {
if (socket.isConnected) {
await reloadContent(true)
}
})
</script> </script>

View file

@ -18,27 +18,28 @@
</p> </p>
<p class="control"> <p class="control">
<button class="button is-danger is-light" v-tooltip.bottom="'Filter'" <button class="button is-danger is-light" @click="toggleFilter = !toggleFilter">
@click="toggleFilter = !toggleFilter">
<span class="icon"><i class="fas fa-filter" /></span> <span class="icon"><i class="fas fa-filter" /></span>
<span class="is-hidden-mobile">Filter</span>
</button> </button>
</p> </p>
<p class="control" v-if="!config.app.basic_mode && false === config.app.basic_mode"> <p class="control" v-if="!config.app.basic_mode && false === config.app.basic_mode">
<button class="button is-warning" @click="pauseDownload" v-if="false === config.paused" <button class="button is-warning" @click="pauseDownload" v-if="false === config.paused">
v-tooltip.bottom="'Pause non-active downloads.'">
<span class="icon"><i class="fas fa-pause" /></span> <span class="icon"><i class="fas fa-pause" /></span>
<span class="is-hidden-mobile">Pause</span>
</button> </button>
<button class="button is-danger" @click="socket.emit('resume', {})" v-else <button class="button is-danger" @click="socket.emit('resume', {})" v-else
v-tooltip.bottom="'Resume downloading.'"> v-tooltip.bottom="'Resume downloading.'">
<span class="icon"><i class="fas fa-play" /></span> <span class="icon"><i class="fas fa-play" /></span>
<span class="is-hidden-mobile">Resume</span>
</button> </button>
</p> </p>
<p class="control" v-if="!config.app.basic_mode && false === config.app.basic_mode"> <p class="control" v-if="!config.app.basic_mode && false === config.app.basic_mode">
<button v-tooltip.bottom="'Toggle download form'" class="button is-primary has-tooltip-bottom" <button class="button is-primary has-tooltip-bottom" @click="config.showForm = !config.showForm">
@click="config.showForm = !config.showForm">
<span class="icon"><i class="fa-solid fa-plus" /></span> <span class="icon"><i class="fa-solid fa-plus" /></span>
<span class="is-hidden-mobile">New Download</span>
</button> </button>
</p> </p>
@ -47,6 +48,7 @@
@click="() => changeDisplay()"> @click="() => changeDisplay()">
<span class="icon"><i class="fa-solid" <span class="icon"><i class="fa-solid"
:class="{ 'fa-table': display_style === 'cards', 'fa-table-list': display_style === 'list' }" /></span> :class="{ 'fa-table': display_style === 'cards', 'fa-table-list': display_style === 'list' }" /></span>
<span class="is-hidden-mobile">{{ display_style === 'cards' ? 'Cards' : 'List' }}</span>
</button> </button>
</p> </p>
@ -66,15 +68,19 @@
<Queue @getInfo="(url: string, preset: string = '') => view_info(url, false, preset)" :thumbnails="show_thumbnail" <Queue @getInfo="(url: string, preset: string = '') => view_info(url, false, preset)" :thumbnails="show_thumbnail"
:query="query" @getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)" @clear_search="query = ''" /> :query="query" @getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)" @clear_search="query = ''" />
<History @getInfo="(url: string, preset: string = '') => view_info(url, false, preset)" <History @getInfo="(url: string, preset: string = '') => view_info(url, false, preset)"
@add_new="item => toNewDownload(item)" :query="query" :thumbnails="show_thumbnail" @add_new="(item: item_request) => toNewDownload(item)" :query="query" :thumbnails="show_thumbnail"
@getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)" @clear_search="query = ''" /> @getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)" @clear_search="query = ''" />
<GetInfo v-if="info_view.url" :link="info_view.url" :preset="info_view.preset" :useUrl="info_view.useUrl" <GetInfo v-if="info_view.url" :link="info_view.url" :preset="info_view.preset" :useUrl="info_view.useUrl"
@closeModel="close_info()" /> @closeModel="close_info()" />
<ConfirmDialog v-if="dialog_confirm.visible" :visible="dialog_confirm.visible" :title="dialog_confirm.title"
:html_message="dialog_confirm.html_message" :options="dialog_confirm.options" @confirm="dialog_confirm.confirm"
@cancel="() => dialog_confirm.visible = false" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import type { item_request } from '~/@types/item'
const config = useConfigStore() const config = useConfigStore()
const stateStore = useStateStore() const stateStore = useStateStore()
@ -90,9 +96,16 @@ const info_view = ref({
preset: '', preset: '',
useUrl: false, useUrl: false,
}) as Ref<{ url: string, preset: string, useUrl: boolean }> }) as Ref<{ url: string, preset: string, useUrl: boolean }>
const item_form = ref({}) const item_form = ref<item_request | object>({})
const query = ref() const query = ref()
const toggleFilter = ref(false) const toggleFilter = ref(false)
const dialog_confirm = ref({
visible: false,
title: 'Confirm Action',
confirm: () => { },
html_message: '',
options: [],
})
watch(toggleFilter, () => { watch(toggleFilter, () => {
if (!toggleFilter.value) { if (!toggleFilter.value) {
@ -122,12 +135,18 @@ watch(() => stateStore.queue, () => {
useHead({ title: `YTPTube: ( ${Object.keys(stateStore.queue).length || 0}/${config.app.max_workers} | ${Object.keys(stateStore.history).length || 0} )` }) useHead({ title: `YTPTube: ( ${Object.keys(stateStore.queue).length || 0}/${config.app.max_workers} | ${Object.keys(stateStore.history).length || 0} )` })
}, { deep: true }) }, { deep: true })
const pauseDownload = () => {
if (false === box.confirm('Are you sure you want to pause all non-active downloads?')) {
return false
}
socket.emit('pause', {}) const pauseDownload = () => {
dialog_confirm.value.visible = true
dialog_confirm.value.html_message = `<span class="is-bold">Pause All non-active downloads?</span><br>
<span class="has-text-danger">
<span class="icon"><i class="fa-solid fa-exclamation-triangle"></i></span>
<span>This will not stop downloads that are currently in progress.</span>
</span>`
dialog_confirm.value.confirm = () => {
socket.emit('pause', {})
dialog_confirm.value.visible = false
}
} }
const close_info = () => { const close_info = () => {
@ -152,7 +171,7 @@ watch(() => info_view.value.url, v => {
const changeDisplay = () => display_style.value = display_style.value === 'cards' ? 'list' : 'cards' const changeDisplay = () => display_style.value = display_style.value === 'cards' ? 'list' : 'cards'
const toNewDownload = async (item: any) => { const toNewDownload = async (item: item_request) => {
if (!item) { if (!item) {
return return
} }

View file

@ -236,7 +236,7 @@ const updateData = async notifications => {
} }
const deleteItem = async item => { const deleteItem = async item => {
if (true !== box.confirm(`Are you sure you want to delete notification target (${item.name})?`)) { if (true !== box.confirm(`Delete '${item.name}'?`)) {
return return
} }
@ -296,7 +296,7 @@ const editItem = item => {
const join_events = events => (!events || events.length < 1) ? 'ALL' : events.map(e => ucFirst(e)).join(', ') const join_events = events => (!events || events.length < 1) ? 'ALL' : events.map(e => ucFirst(e)).join(', ')
const sendTest = async () => { const sendTest = async () => {
if (true !== box.confirm('Are you sure you want to send a test notification?')) { if (true !== box.confirm('Send test notification?')) {
return return
} }

View file

@ -385,26 +385,6 @@ const formatBytes = (bytes, decimals = 2) => {
} }
/**
* Convert options to JSON
*
* @param {string} opts
*
* @returns {Promise<string>}
*/
const convertCliOptions = async opts => {
const response = await request('/api/yt-dlp/convert', {
method: 'POST',
body: JSON.stringify({ args: opts }),
});
const data = await response.json()
if (200 !== response.status) {
throw new Error(`Error: (${response.status}): ${data.error}`)
}
return data
}
/** /**
* Check if array or object has data. * Check if array or object has data.
@ -513,7 +493,6 @@ export {
getQueryParams, getQueryParams,
makeDownload, makeDownload,
formatBytes, formatBytes,
convertCliOptions,
has_data, has_data,
toggleClass, toggleClass,
cleanObject, cleanObject,

45
ui/utils/utils.ts Normal file
View file

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