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

Add experimental simple view mode
This commit is contained in:
Abdulmohsen 2025-09-27 21:05:58 +03:00 committed by GitHub
commit 1103b93f00
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1069 additions and 251 deletions

View file

@ -47,8 +47,10 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install uv
uses: astral-sh/setup-uv@v4
uses: astral-sh/setup-uv@v6.7
with:
enable-cache: true
save-cache: true
version: "latest"
- name: Install Python dependencies
@ -160,7 +162,7 @@ jobs:
echo "APP_SHA=${SHA}" >> "$GITHUB_ENV"
echo "APP_DATE=${DATE}" >> "$GITHUB_ENV"
echo "APP_BRANCH=${BRANCH}" >> "$GITHUB_ENV"
sed -i \
-e "s/^APP_VERSION = \".*\"/APP_VERSION = \"${VERSION}\"/" \
-e "s/^APP_COMMIT_SHA = \".*\"/APP_COMMIT_SHA = \"${SHA}\"/" \

View file

@ -45,6 +45,9 @@ class Config(metaclass=Singleton):
temp_path: str = "/tmp"
"""The path to the temporary directory."""
simple_mode: bool = False
"""Enable simple mode."""
temp_keep: bool = False
"""Keep temporary files after the download is complete."""
@ -244,6 +247,7 @@ class Config(metaclass=Singleton):
"prevent_premiere_live",
"temp_disabled",
"allow_internal_urls",
"simple_mode",
)
"The variables that are booleans."
@ -259,6 +263,7 @@ class Config(metaclass=Singleton):
"default_preset",
"instance_title",
"console_enabled",
"simple_mode",
"browser_control_enabled",
"file_logging",
"base_path",

View file

@ -358,6 +358,10 @@ div.is-centered {
justify-content: center;
}
.is-overflow-visible {
overflow: visible !important
}
.modal-content-max {
width: calc(100% - 20px);
max-width: 1344px;

View file

@ -268,6 +268,7 @@ import { useStorage } from '@vueuse/core'
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue'
import type { AutoCompleteOptions } from '~/types/autocomplete';
import type { ConditionItem, ImportedConditionItem } from '~/types/conditions'
import {useConfirm} from '~/composables/useConfirm'
const emitter = defineEmits<{
(e: 'cancel'): void
@ -410,7 +411,7 @@ const importItem = async (): Promise<void> => {
return
}
if ((form.filter || form.cli || Object.keys(form.extras).length > 0) && !(await box.confirm('Overwrite the current form fields?', true))) {
if ((form.filter || form.cli || Object.keys(form.extras).length > 0) && !(await box.confirm('Overwrite the current form fields?'))) {
return
}

View file

@ -450,6 +450,7 @@
import moment from 'moment'
import { useStorage } from '@vueuse/core'
import type { StoreItem } from '~/types/store'
import {useConfirm} from '~/composables/useConfirm'
const emitter = defineEmits<{
(e: 'getInfo', url: string, preset: string, cli: string): void
@ -588,7 +589,7 @@ const deleteSelectedItems = async () => {
if (true === config.app.remove_files) {
msg += ' This will remove any associated files if they exists.'
}
if (false === (await box.confirm(msg, config.app.remove_files))) {
if (false === (await box.confirm(msg))) {
return
}
for (const key in selectedElms.value) {
@ -750,7 +751,7 @@ const removeItem = async (item: StoreItem) => {
if (item.status === 'finished' && config.app.remove_files) {
msg += ' This will remove any associated files if they exists.'
}
if (false === (await box.confirm(msg, Boolean(item.filename && config.app.remove_files)))) {
if (false === (await box.confirm(msg))) {
return false
}

View file

@ -308,6 +308,7 @@
<script setup lang="ts">
import { useStorage } from '@vueuse/core'
import type { notification, notificationImport } from '~/types/notification'
import {useConfirm} from '~/composables/useConfirm'
const emitter = defineEmits(['cancel', 'submit'])
const toast = useNotification()
@ -412,7 +413,7 @@ const importItem = async () => {
}
if (form.name || form.request?.url) {
if (false === (await box.confirm('Overwrite the current form fields?', true))) {
if (false === (await box.confirm('Overwrite the current form fields?'))) {
return
}
}

View file

@ -313,6 +313,7 @@
import moment from 'moment'
import { useStorage } from '@vueuse/core'
import type { StoreItem } from '~/types/store'
import {useConfirm} from '~/composables/useConfirm'
const emitter = defineEmits<{
(e: 'getInfo', url: string, preset: string, cli: string): void

View file

@ -12,6 +12,25 @@
<div class="columns is-multiline">
<div class="column is-6">
<div class="field">
<label class="label is-unselectable">
<span class="icon"><i class="fa-solid fa-computer" /></span>
Page View
</label>
<div class="control">
<div class="control">
<input id="random_bg" type="checkbox" class="switch is-success" v-model="simpleMode">
<label for="random_bg" class="is-unselectable">
&nbsp;{{ simpleMode ? 'Simple View (Experimental)' : 'Regular View (Default)' }}
</label>
</div>
</div>
<p class="help is-bold has-text-danger">
<span class="icon"> <i class="fa-solid fa-info-circle" /></span>
The simple view is experimental and will change without notice and maybe even deleted in the future.
</p>
</div>
<div class="field">
<label class="label is-unselectable">Color scheme</label>
<div class="control">
@ -35,7 +54,7 @@
<div class="field">
<label class="label is-unselectable">
Backgrounds
Show Background
<template v-if="bg_enable">
<NuxtLink @click="$emit('reload_bg')" class="is-bold">Reload</NuxtLink>
<span class="icon" v-if="isLoading"><i class="fa fa-spin fa-spinner" /></span>
@ -44,7 +63,7 @@
<div class="control">
<input id="random_bg" type="checkbox" class="switch is-success" v-model="bg_enable">
<label for="random_bg" class="is-unselectable">
&nbsp;{{ bg_enable ? 'Enabled' : 'Disabled' }}
&nbsp;{{ bg_enable ? 'Yes' : 'No' }}
</label>
</div>
</div>
@ -59,17 +78,7 @@
</div>
</div>
<div class="field">
<label class="label is-unselectable" for="show_thumbnail">Show Videos Thumbnail when possible</label>
<div class="control">
<input id="show_thumbnail" type="checkbox" class="switch is-success" v-model="show_thumbnail">
<label for="show_thumbnail" class="is-unselectable">
&nbsp;{{ show_thumbnail ? 'Enabled' : 'Disabled' }}
</label>
</div>
</div>
<div class="field">
<div class="field" v-if="!simpleMode">
<label class="label is-unselectable" for="show_thumbnail">URLs Separator</label>
<div class="control">
<div class="select is-fullwidth">
@ -85,27 +94,28 @@
</div>
<div class="column is-6">
<div class="field">
<label class="label is-unselectable" for="reduce_confirm">Reduce confirm box usage</label>
<label class="label is-unselectable" for="show_thumbnail">Show Thumbnails</label>
<div class="control">
<input id="reduce_confirm" type="checkbox" class="switch is-success" v-model="reduce_confirm">
<label for="reduce_confirm" class="is-unselectable">
&nbsp;{{ reduce_confirm ? 'Enabled' : 'Disabled' }}
<input id="show_thumbnail" type="checkbox" class="switch is-success" v-model="show_thumbnail">
<label for="show_thumbnail" class="is-unselectable">
&nbsp;{{ show_thumbnail ? 'Yes' : 'No' }}
</label>
</div>
<p class="help is-bold"> Show videos thumbnail if available.</p>
</div>
<div class="field">
<label class="label" for="allow_toasts">Show notifications toasts</label>
<label class="label" for="allow_toasts">Show notifications</label>
<div class="control">
<input id="allow_toasts" type="checkbox" class="switch is-success" v-model="allow_toasts">
<label for="allow_toasts" class="is-unselectable">
&nbsp;{{ allow_toasts ? 'Enabled' : 'Disabled' }}
&nbsp;{{ allow_toasts ? 'Yes' : 'No' }}
</label>
</div>
</div>
<div class="field" v-if="allow_toasts">
<label class="label">Toasts position</label>
<label class="label">Notification position</label>
<div class="control">
<div class="select is-fullwidth">
<select v-model="toast_position">
@ -121,12 +131,12 @@
</div>
<div class="field" v-if="allow_toasts">
<label class="label" for="dismiss_on_click">Dismiss toasts on click</label>
<label class="label" for="dismiss_on_click">Dismiss notification on click</label>
<div class="control">
<input id="dismiss_on_click" type="checkbox" class="switch is-success"
v-model="toast_dismiss_on_click">
<label for="dismiss_on_click" class="is-unselectable">
&nbsp;{{ toast_dismiss_on_click ? 'Enabled' : 'Disabled' }}
&nbsp;{{ toast_dismiss_on_click ? 'Yes' : 'No' }}
</label>
</div>
</div>
@ -142,6 +152,7 @@
<script setup lang="ts">
import { useStorage } from '@vueuse/core'
import { POSITION } from 'vue-toastification'
import { useConfigStore } from '~/stores/ConfigStore'
defineProps<{ isLoading: boolean }>()
defineEmits<{ (e: 'reload_bg'): void }>()
@ -150,9 +161,9 @@ 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 ?? ',')
const simpleMode = useStorage<boolean>('simple_mode', useConfigStore().app.simple_mode || false)
</script>

View file

@ -0,0 +1,764 @@
<template>
<div class="basic-wrapper">
<div class="form-container" :class="{ 'is-centered': shouldCenterForm }">
<section class="download-form box">
<form class="download-form__body" autocomplete="off" @submit.prevent="addDownload">
<label class="label" for="download-url">
What you would like to download?
<span class="is-pulled-right">
<span class="icon has-text-primary is-pointer" @click="$emit('show_settings')" v-tooltip="'Settings'"><i
class="fas fa-cogs" /></span>
</span>
</label>
<div class="field has-addons">
<div class="control">
<button type="button" class="button is-info" @click="() => showPresets = !showPresets">
<span class="icon"><i class="fas" :class="showPresets ? 'fa-chevron-up' : 'fa-chevron-down'" /></span>
</button>
</div>
<div class="control is-expanded">
<input id="download-url" v-model="formUrl" :disabled="!socketStore.isConnected || addInProgress"
class="input" placeholder="https://..." type="url" required>
</div>
<div class="control">
<button type="submit" class="button is-primary" :class="{ 'is-loading': addInProgress }"
:disabled="!socketStore.isConnected || addInProgress || !formUrl.trim()">
<span class="icon"><i class="fas fa-plus" /></span>
<span>Add</span>
</button>
</div>
</div>
<div class="field has-addons" v-if="showPresets">
<div class="control">
<label class="button is-static">
<span class="icon"><i class="fas fa-sliders" /></span>
<span>Preset</span>
</label>
</div>
<div class="control is-expanded">
<div class="select is-fullwidth">
<select id="preset" class="is-fullwidth" :disabled="!socketStore.isConnected || addInProgress"
v-model="formPreset.preset">
<optgroup label="Custom presets" v-if="presets.filter(p => !p?.default).length > 0">
<option v-for="cPreset in filter_presets(false)" :key="cPreset.name" :value="cPreset.name">
{{ cPreset.name }}
</option>
</optgroup>
<optgroup label="Default presets">
<option v-for="dPreset in filter_presets(true)" :key="dPreset.name" :value="dPreset.name">
{{ dPreset.name }}
</option>
</optgroup>
</select>
</div>
</div>
</div>
</form>
</section>
</div>
<Transition name="queue-fade">
<section v-if="hasAnyItems" key="queue" class="queue-section">
<TransitionGroup name="queue-card" tag="div" class="columns is-multiline queue-card-columns">
<div v-for="entry in displayItems" :key="entry.item._id" class="column is-12-mobile is-6-tablet">
<article class="queue-card card" :class="{ 'is-history': 'history' === entry.source }">
<div v-if="'queue' === entry.source && shouldShowProgress(entry.item)"
class="progress-bar is-unselectable mb-3">
<div class="progress-percentage">{{ updateProgress(entry.item) }}</div>
<div class="progress" :style="{ width: getProgressWidth(entry.item) }"></div>
</div>
<div class="card-content">
<article class="media">
<figure class="media-left">
<figure class="image is-16by9 queue-thumb" :class="{ 'is-clickable': isEmbedable(entry.item.url) }"
role="presentation" @click="openPlayer(entry.item)">
<img :src="resolveThumbnail(entry)" :alt="entry.item.title || 'Video thumbnail'" loading="lazy">
<span v-if="getDurationLabel(entry.item)" class="queue-thumb__badge">
{{ getDurationLabel(entry.item) }}
</span>
<span
v-if="entry.item.filename && entry.item.status === 'finished' || isEmbedable(entry.item.url)"
class="queue-thumb__overlay">
<span class="icon circle-border"
:class="{ 'has-text-danger': isEmbedable(entry.item.url) && (!entry.item.filename || entry.item.status !== 'finished') }">
<i class="fas fa-play" />
</span>
</span>
</figure>
</figure>
<div class="media-content is-grid">
<p class="title is-6 mb-0 queue-title">
<NuxtLink target="_blank" :href="entry.item.url">{{ entry.item.title }}</NuxtLink>
</p>
<div class="field is-grouped is-unselectable">
<div class="control">
<span class="tag is-size-7 has-text-weight-semibold is-uppercase"
:class="getSourceTagClass(entry)">
{{ getSourceLabel(entry) }}
</span>
</div>
<div class="control">
<span class="tag is-size-7 has-text-weight-semibold" :class="getStatusClass(entry.item)">
{{ getStatusLabel(entry.item) }}
</span>
</div>
<div class="control">
<span class="tag" :date-datetime="entry.item.datetime" v-rtime="entry.item.datetime" />
</div>
</div>
<p class="content is-size-7 has-text-grey queue-description">
<template v-if="entry.item.error || showMessage(entry.item)">
<template v-if="entry.item.error">
<span class="has-text-danger">{{ entry.item.error }}</span>
</template>
<template v-if="showMessage(entry.item)">
<span class="has-text-danger">{{ entry.item.msg }}</span>
</template>
</template>
<template v-else>
{{ getDescription(entry.item) || 'No description available.' }}
</template>
</p>
</div>
</article>
<div v-if="'queue' === entry.source" class="buttons are-small queue-actions mt-3">
<button v-if="canStart(entry.item)" class="button is-success is-light" type="button"
@click="startQueueItem(entry.item)">
<span class="icon"><i class="fas fa-circle-play" /></span>
<span>Start</span>
</button>
<button v-if="canPause(entry.item)" class="button is-warning is-light" type="button"
@click="pauseQueueItem(entry.item)">
<span class="icon"><i class="fas fa-pause" /></span>
<span>Pause</span>
</button>
<button class="button is-warning" type="button" @click="cancelDownload(entry.item)">
<span class="icon"><i class="fas fa-xmark" /></span>
<span>Cancel</span>
</button>
</div>
<div v-else class="buttons are-small queue-actions mt-3">
<a v-if="getDownloadLink(entry.item)" class="button is-link" :href="getDownloadLink(entry.item)"
:download="getDownloadName(entry.item)">
<span class="icon"><i class="fas fa-download" /></span>
<span>Download</span>
</a>
<button v-if="entry.item.status != 'finished' || !entry.item.filename" class="button is-info is-light"
type="button" @click="requeueItem(entry.item)">
<span class="icon"><i class="fas fa-rotate-right" /></span>
<span>Requeue</span>
</button>
<button class="button is-danger" type="button" @click="deleteHistoryItem(entry.item)">
<span class="icon"><i class="fas fa-trash" /></span>
<span>Delete</span>
</button>
</div>
</div>
</article>
</div>
</TransitionGroup>
</section>
</Transition>
<div class="modal is-active" v-if="videoItem">
<div class="modal-background" @click="closePlayer"></div>
<div class="modal-content is-unbounded-model">
<VideoPlayer type="default" :isMuted="false" autoplay="true" :isControls="true" :item="videoItem"
class="is-fullwidth" @closeModel="closePlayer" />
</div>
<button class="modal-close is-large" aria-label="close" @click="closePlayer"></button>
</div>
<div class="modal is-active" v-if="embedUrl">
<div class="modal-background" @click="closePlayer"></div>
<div class="modal-content is-unbounded-model">
<EmbedPlayer :url="embedUrl" @closeModel="closePlayer" />
</div>
<button class="modal-close is-large" aria-label="close" @click="closePlayer"></button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useStorage } from '@vueuse/core'
import type { item_request } from '~/types/item'
import type { ItemStatus, StoreItem } from '~/types/store'
import { useNotification } from '~/composables/useNotification'
import { useConfigStore } from '~/stores/ConfigStore'
import { useStateStore } from '~/stores/StateStore'
import { useSocketStore } from '~/stores/SocketStore'
import { isEmbedable, getEmbedable } from '~/utils/embedable'
import EmbedPlayer from '~/components/EmbedPlayer.vue'
import { ag, encodePath, formatTime, makeDownload, request, stripPath, ucFirst, uri } from '~/utils'
defineEmits<{ (e: 'show_settings'): void }>()
const configStore = useConfigStore()
const stateStore = useStateStore()
const socketStore = useSocketStore()
const toast = useNotification()
const { app, paused, presets } = storeToRefs(configStore)
const { queue, history } = storeToRefs(stateStore)
const embedUrl = ref<string>('')
const videoItem = ref<StoreItem | null>(null)
const formUrl = ref<string>('')
const formPreset = ref<{ preset: string }>({ preset: app.value.default_preset || '' })
const addInProgress = ref<boolean>(false)
const showPresets = ref<boolean>(false)
const show_thumbnail = useStorage<boolean>('show_thumbnail', true)
const sortByNewest = (items: StoreItem[]): StoreItem[] => items.slice().sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0))
const queueItems = computed<StoreItem[]>(() => sortByNewest(Object.values(queue.value ?? {})))
const historyEntries = computed<StoreItem[]>(() => sortByNewest(Object.values(history.value ?? {})))
const downloadingStatuses: ReadonlySet<ItemStatus | null> = new Set(['downloading', 'postprocessing', 'preparing'])
const isDownloading = (status: ItemStatus | null): boolean => downloadingStatuses.has(status)
const downloadingItems = computed<StoreItem[]>(() => queueItems.value.filter(item => isDownloading(item.status)))
const queuedItems = computed<StoreItem[]>(() => queueItems.value.filter(item => !isDownloading(item.status)))
type DisplayEntry = { item: StoreItem; source: 'queue' | 'history' }
const displayItems = computed<DisplayEntry[]>(() => [
...downloadingItems.value.map(item => ({ item, source: 'queue' as const })),
...queuedItems.value.map(item => ({ item, source: 'queue' as const })),
...historyEntries.value.map(item => ({ item, source: 'history' as const })),
])
const hasActiveQueue = computed<boolean>(() => queueItems.value.length > 0)
const hasAnyItems = computed<boolean>(() => hasActiveQueue.value || historyEntries.value.length > 0)
const shouldCenterForm = computed<boolean>(() => 0 === queueItems.value.length && 0 === historyEntries.value.length)
const addDownload = async (): Promise<void> => {
const url = formUrl.value.trim()
if (!url) {
toast.error('Please enter a valid URL.')
return
}
const payload: item_request[] = [{
url,
preset: formPreset.value.preset || app.value.default_preset,
auto_start: true,
}]
try {
addInProgress.value = true
const response = await request('/api/history', {
method: 'POST',
body: JSON.stringify(payload),
})
const data = await response.json()
if (!response.ok) {
toast.error(data?.error ?? 'Failed to add download.')
return
}
const failures = Array.isArray(data) ? data.filter((item: Record<string, any>) => false === item?.status) : []
if (failures.length > 0) {
failures.forEach((item: Record<string, any>) => toast.error(item?.msg ?? 'Failed to add download.'))
return
}
formUrl.value = ''
formPreset.value.preset = app.value.default_preset || ''
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to add download.'
toast.error(message)
} finally {
addInProgress.value = false
}
}
const resolveThumbnail = (entry: DisplayEntry): string => {
if (!show_thumbnail.value) {
return '/images/placeholder.png'
}
const { item, source } = entry
const sidecarImage = item.sidecar?.image?.[0]?.file
if ('history' === source && sidecarImage) {
const relativePath = stripPath(app.value.download_path ?? '', sidecarImage)
return uri(`/api/download/${encodeURIComponent(relativePath)}`)
}
const remoteThumb = ag<string | null>(item, 'extras.thumbnail', null)
if (remoteThumb) {
return uri(`/api/thumbnail?id=${item._id}&url=${encodePath(remoteThumb)}`)
}
return '/images/placeholder.png'
}
const openPlayer = (item: StoreItem): void => {
if (item.filename && item.status === 'finished') {
videoItem.value = item
return
}
if (!isEmbedable(item.url)) {
return
}
const embed = getEmbedable(item.url)
if (embed) {
embedUrl.value = embed
}
}
const closePlayer = (): void => {
embedUrl.value = ''
videoItem.value = null
}
const getSourceLabel = (entry: DisplayEntry): string => {
if ('history' === entry.source) {
return 'History'
}
if (isDownloading(entry.item.status)) {
return 'Active'
}
return 'Queued'
}
const getSourceTagClass = (entry: DisplayEntry): string => {
if ('history' === entry.source) {
return 'is-light'
}
if (isDownloading(entry.item.status)) {
return 'is-info is-light'
}
return 'is-warning is-light'
}
const getDescription = (item: StoreItem): string => {
const direct = (item.description ?? '').toString().trim()
if (direct) {
return direct
}
const extrasDescription = ag<string | null>(item, 'extras.description', null)?.toString().trim()
if (extrasDescription) {
return extrasDescription
}
const errorDescription = item.error?.trim()
if (errorDescription) {
return errorDescription
}
const message = ag<string | null>(item, 'msg', null)?.toString().trim()
if (message) {
return message
}
return ''
}
const getDurationLabel = (item: StoreItem): string | null => {
const duration = ag<number | null>(item, 'extras.duration', null)
if (null == duration || Number.isNaN(duration) || 0 >= duration) {
return null
}
return formatTime(duration)
}
const statusOverrides: Record<string, string> = {
downloading: 'Downloading',
postprocessing: 'Post-processing',
preparing: 'Preparing',
finished: 'Completed',
error: 'Error',
cancelled: 'Cancelled',
not_live: 'Not live yet',
skip: 'Skipped',
}
const getStatusLabel = (item: StoreItem): string => {
if (null === item.status) {
return 'Queued'
}
return statusOverrides[item.status] ?? ucFirst(item.status.replace(/_/g, ' '))
}
const statusColorMap: Record<string, string> = {
downloading: 'has-text-info',
postprocessing: 'has-text-link',
preparing: 'has-text-link',
finished: 'has-text-success',
error: 'has-text-danger',
cancelled: 'has-text-grey',
not_live: 'has-text-warning',
skip: 'has-text-grey',
}
const getStatusClass = (item: StoreItem): string => {
if (null === item.status) {
return 'has-text-grey'
}
return statusColorMap[item.status] ?? 'has-text-info'
}
const shouldShowProgress = (item: StoreItem): boolean => isDownloading(item.status) || null === item.status
const percentPipe = (value: number | null): string => {
if (null === value || Number.isNaN(value)) {
return '00.00'
}
return parseFloat(String(value)).toFixed(2)
}
const ETAPipe = (value: number | null): string => {
if (null === value || 0 === value) {
return 'Live'
}
if (value < 60) {
return `${Math.round(value)}s`
}
if (value < 3600) {
return `${Math.floor(value / 60)}m ${Math.round(value % 60)}s`
}
const hours = Math.floor(value / 3600)
const minutes = value % 3600
return `${hours}h ${Math.floor(minutes / 60)}m ${Math.round(minutes % 60)}s`
}
const speedPipe = (value: number | null): string => {
if (null === value || 0 === value) {
return '0KB/s'
}
const k = 1024
const dm = 2
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s', 'PB/s', 'EB/s', 'ZB/s', 'YB/s']
const i = Math.floor(Math.log(value) / Math.log(k))
return `${parseFloat((value / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`
}
const updateProgress = (item: StoreItem): string => {
let text = ''
if (!item.auto_start) {
return 'Manual start'
}
if (null === item.status && true === paused.value) {
return 'Global Pause'
}
if ('postprocessing' === item.status) {
return 'Post-processors are running.'
}
if ('preparing' === item.status) {
return ag(item, 'extras.external_downloader') ? 'External downloader.' : 'Preparing'
}
if (null != item.status) {
text += item.percent && !item.is_live ? `${percentPipe(item.percent)}%` : 'Live'
}
text += item.speed ? ` - ${speedPipe(item.speed)}` : ' - Waiting..'
if (null != item.status && item.eta) {
text += ` - ${ETAPipe(item.eta)}`
}
return text
}
const getProgressWidth = (item: StoreItem): string => {
const percent = parseFloat(percentPipe(item.percent ?? 0))
const clamped = Number.isNaN(percent) ? 0 : Math.min(100, Math.max(0, percent))
return `${clamped}%`
}
const canStart = (item: StoreItem): boolean => !item.status && false === item.auto_start
const canPause = (item: StoreItem): boolean => !item.status && true === item.auto_start
const startQueueItem = (item: StoreItem): void => {
socketStore.emit('item_start', item._id)
}
const pauseQueueItem = (item: StoreItem): void => {
socketStore.emit('item_pause', item._id)
}
const cancelDownload = (item: StoreItem): void => {
socketStore.emit('item_cancel', item._id)
}
const getDownloadLink = (item: StoreItem): string => {
if (!item.filename) {
return ''
}
return makeDownload(app.value, item)
}
const getDownloadName = (item: StoreItem): string => {
if (!item.filename) {
return 'download'
}
const segments = item.filename.split('/')
return segments[segments.length - 1] || 'download'
}
const requeueItem = (item: StoreItem): void => {
if (!item.url) {
toast.error('Unable to requeue item; missing URL.')
return
}
const payload: item_request = {
url: item.url,
preset: item.preset || app.value.default_preset,
folder: item.folder,
template: item.template,
cookies: item.cookies,
cli: item.cli,
auto_start: item.auto_start ?? true,
}
if (item.extras && Object.keys(item.extras).length > 0) {
payload.extras = JSON.parse(JSON.stringify(item.extras))
}
socketStore.emit('item_delete', { id: item._id, remove_file: false })
socketStore.emit('add_url', payload)
}
const deleteHistoryItem = (item: StoreItem): void => {
socketStore.emit('item_delete', { id: item._id, remove_file: app.value.remove_files })
toast.info('Removed from history queue.')
}
const filter_presets = (flag: boolean = true) => presets.value.filter(item => item.default === flag)
const showMessage = (item: StoreItem) => {
if (!item?.msg || item.msg === item?.error) {
return false
}
return (item.msg?.length || 0) > 0
}
</script>
<style scoped>
.basic-wrapper {
min-height: calc(100vh - 6rem);
display: flex;
flex-direction: column;
align-items: center;
gap: 2.5rem;
padding: 2rem 1rem 4rem;
}
.form-container {
width: 100%;
display: flex;
justify-content: center;
transition: transform 0.45s cubic-bezier(0.25, 0.8, 0.25, 1),
filter 0.35s ease;
will-change: transform;
}
.form-container.is-centered {
transform: translateY(30vh);
filter: drop-shadow(0 18px 32px rgba(10, 10, 10, 0.25));
}
.download-form {
width: min(560px, 100%);
transition: box-shadow 0.45s ease;
}
.download-form__body {
display: flex;
flex-direction: column;
gap: 1rem;
}
.queue-section {
width: min(1100px, 100%);
display: flex;
flex-direction: column;
gap: 2rem;
transform-origin: top center;
}
.queue-card-columns {
margin-top: 0.75rem;
}
.queue-card {
height: 100%;
transition: transform 0.3s ease, box-shadow 0.35s ease;
}
.queue-card:hover {
transform: translateY(-4px);
box-shadow: 0 14px 30px rgba(10, 10, 10, 0.12);
}
.queue-card .card-content {
display: flex;
flex-direction: column;
height: 100%;
}
.queue-card .media-left {
margin-right: 1rem;
}
.queue-thumb {
position: relative;
width: 12rem;
max-width: 100%;
border-radius: 0.5rem;
overflow: hidden;
}
.queue-thumb img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.queue-thumb.is-clickable {
cursor: pointer;
}
.queue-thumb__overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0);
color: #fff;
transition: background-color 0.2s ease;
pointer-events: none;
z-index: 0;
}
.queue-thumb.is-clickable:hover .queue-thumb__overlay {
background: rgba(0, 0, 0, 0.45);
}
.queue-thumb__badge {
position: absolute;
top: 0.5rem;
right: 0.5rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
background: rgba(0, 0, 0, 0.65);
color: #fff;
font-size: 0.7rem;
line-height: 1;
z-index: 1;
}
.queue-title {
overflow: hidden;
}
.queue-title a {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.queue-description {
max-height: 4.5rem;
min-height: 2.5rem;
overflow: hidden;
display: -webkit-box;
line-clamp: 3;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
word-break: break-word;
overflow-wrap: anywhere;
}
.queue-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem;
margin-top: auto;
}
.queue-fade-enter-active,
.queue-fade-leave-active {
transition: opacity 0.35s ease, transform 0.45s ease;
}
.queue-fade-enter-from,
.queue-fade-leave-to {
opacity: 0;
transform: translateY(18px);
}
.queue-card-enter-active,
.queue-card-leave-active {
transition: opacity 0.3s ease, transform 0.35s ease;
}
.queue-card-enter-from,
.queue-card-leave-to {
opacity: 0;
transform: translateY(12px);
}
.queue-card-leave-active {
position: absolute;
}
@media (max-width: 768px) {
.form-container.is-centered {
transform: translateY(18vh);
}
.queue-card .media {
flex-direction: column;
align-items: flex-start;
}
.queue-card .media-left {
margin-right: 0;
margin-bottom: 1rem;
width: 100%;
}
.queue-thumb {
width: 100%;
}
.queue-card .media-content {
width: 100%;
}
.queue-title {
width: 100%;
}
.queue-actions {
width: 100%;
justify-content: flex-end;
}
}
.progress-bar {
border-radius: 0.75rem 0.75rem 0 0
}
.circle-border {
border: 2px solid;
border-radius: 50%;
padding: 0.5rem;
}
</style>

View file

@ -307,6 +307,7 @@ import { CronExpressionParser } from 'cron-parser'
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue'
import type { AutoCompleteOptions } from '~/types/autocomplete'
import type { exported_task, task_item } from '~/types/tasks'
import {useConfirm} from '~/composables/useConfirm'
const props = defineProps<{
reference?: string | null | undefined
@ -408,7 +409,7 @@ const importItem = async (): Promise<void> => {
}
if (form.url || form.timer) {
if (false === (await box.confirm('Overwrite the current form fields?', true))) {
if (false === (await box.confirm('Overwrite the current form fields?'))) {
return
}
}

View file

@ -1,42 +1,29 @@
import { useStorage } from '@vueuse/core'
import { useDialog } from './useDialog'
import { useDialog, type ConfirmOptions, type AlertOptions, type PromptOptions } from './useDialog'
const dialog = useDialog()
const reduceConfirm = useStorage<boolean>('reduce_confirm', false)
const confirm = async (msg: string, force: boolean = false) => {
if (false === force && true === reduceConfirm.value) {
return true
}
const { status } = await dialog.confirmDialog({
const confirm = async (msg: string, opts: ConfirmOptions = {}) => {
const { status } = await dialog.confirmDialog(Object.assign({
title: 'Please Confirm',
message: msg,
cancelText: 'Cancel',
confirmText: 'OK',
})
} as ConfirmOptions, opts || {}))
return status
}
const alert = async (msg: string) => {
const { status } = await dialog.alertDialog({
const alert = async (msg: string, opts: AlertOptions = {}) => {
const { status } = await dialog.alertDialog(Object.assign({
title: 'Alert',
message: msg,
confirmText: 'OK',
})
} as AlertOptions, opts || {}))
return status
}
const prompt = async (msg: string, defaultValue: string = '') => {
const { status, value } = await dialog.promptDialog({
title: 'Input Required',
message: msg,
initial: defaultValue,
cancelText: 'Cancel',
confirmText: 'OK',
})
const prompt = async (msg: string, opts: PromptOptions = {}) => {
const { status, value } = await dialog.promptDialog(Object.assign({ message: msg } as PromptOptions, opts || {}))
if (status) {
return value
@ -45,6 +32,4 @@ const prompt = async (msg: string, defaultValue: string = '') => {
return null
}
export default function useConfirm() {
return { confirm, alert, prompt, reduceConfirm }
}
export const useConfirm = () => ({ confirm, alert, prompt })

View file

@ -1,169 +1,180 @@
<template>
<Shutdown v-if="app_shutdown" />
<div id="main_container" class="container" v-else>
<NewVersion v-if="newVersionIsAvailable" />
<nav class="navbar is-mobile is-dark">
<div class="navbar-brand pl-5">
<NuxtLink class="navbar-item is-text-overflow" to="/" @click.prevent="(e: MouseEvent) => changeRoute(e)"
v-tooltip="socket.isConnected ? 'Connected' : 'Connecting'">
<span class="is-text-overflow">
<span class="icon"><i class="fas fa-home" /></span>
<span class="has-text-bold" :class="`has-text-${socket.isConnected ? 'success' : 'danger'}`">
YTPTube
</span>
<span class="has-text-bold" v-if="config?.app?.instance_title">: {{ config.app.instance_title }}</span>
</span>
</NuxtLink>
<template v-if="simpleMode">
<Simple @show_settings="() => show_settings = true" />
</template>
<button class="navbar-burger burger" @click="showMenu = !showMenu">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</button>
</div>
<div class="navbar-menu is-unselectable" :class="{ 'is-active': showMenu }">
<div class="navbar-start">
<NuxtLink class="navbar-item" to="/browser" @click.prevent="(e: MouseEvent) => changeRoute(e)">
<span class="icon"><i class="fa-solid fa-folder-tree" /></span>
<span>Files</span>
</NuxtLink>
<NuxtLink class="navbar-item" to="/presets" @click.prevent="(e: MouseEvent) => changeRoute(e)">
<span class="icon"><i class="fa-solid fa-sliders" /></span>
<span>Presets</span>
</NuxtLink>
<div class="navbar-item has-dropdown">
<a class="navbar-link" @click="(e: MouseEvent) => openMenu(e)">
<span class="icon"><i class="fas fa-tasks" /></span>
<span>Tasks</span>
</a>
<div class="navbar-dropdown">
<NuxtLink class="navbar-item" to="/tasks" @click.prevent="(e: MouseEvent) => changeRoute(e)">
<span class="icon"><i class="fa-solid fa-tasks" /></span>
<span>List</span>
</NuxtLink>
<NuxtLink class="navbar-item" to="/task_definitions" @click.prevent="(e: MouseEvent) => changeRoute(e)">
<span class="icon"><i class="fa-solid fa-diagram-project" /></span>
<span>Definitions</span>
</NuxtLink>
</div>
</div>
<NuxtLink class="navbar-item" to="/notifications" @click.prevent="(e: MouseEvent) => changeRoute(e)">
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-paper-plane" /></span>
<span>Notifications</span>
</span>
</NuxtLink>
<NuxtLink class="navbar-item" to="/conditions" @click.prevent="(e: MouseEvent) => changeRoute(e)">
<span class="icon"><i class="fa-solid fa-filter" /></span>
<span>Conditions</span>
</NuxtLink>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown">
<a class="navbar-link" @click="(e: MouseEvent) => openMenu(e)">
<span class="icon"><i class="fas fa-tools" /></span>
<span>Other</span>
</a>
<div class="navbar-dropdown">
<NuxtLink class="navbar-item" to="/logs" @click.prevent="(e: MouseEvent) => changeRoute(e)"
v-if="config.app.file_logging">
<span class="icon"><i class="fa-solid fa-file-lines" /></span>
<span>Logs</span>
</NuxtLink>
<NuxtLink class="navbar-item" to="/console" @click.prevent="(e: MouseEvent) => changeRoute(e)"
v-if="config.app.console_enabled">
<span class="icon"><i class="fa-solid fa-terminal" /></span>
<span>Console</span>
</NuxtLink>
</div>
</div>
<div class="navbar-item" v-if="true === config.app.is_native">
<button class="button is-dark" @click="shutdownApp">
<span class="icon"><i class="fas fa-power-off" /></span>
<span v-if="isMobile">Shutdown</span>
</button>
</div>
<div class="navbar-item">
<button class="button is-dark" @click="reloadPage">
<span class="icon"><i class="fas fa-refresh" /></span>
<span v-if="isMobile">Reload</span>
</button>
</div>
<NotifyDropdown />
<div class="navbar-item" v-if="!isMobile">
<button class="button is-dark has-tooltip-bottom mr-4" v-tooltip.bottom="'WebUI Settings'"
@click="show_settings = !show_settings">
<span class="icon"><i class="fas fa-cog" /></span>
</button>
</div>
<div class="navbar-item" v-if="isMobile">
<button class="button is-dark" @click="show_settings = !show_settings">
<span class="icon"><i class="fas fa-cog" /></span>
<span>WebUI Settings</span>
</button>
</div>
</div>
</div>
</nav>
<div>
<template v-if="show_settings">
<Modal @close="show_settings = false" content-class="modal-content-max is-overflow-visible">
<Settings v-if="show_settings" :isLoading="loadingImage" @reload_bg="() => loadImage(true)" />
<NuxtLoadingIndicator />
<NuxtPage v-if="config.is_loaded" />
<Message v-else class="has-background-info-90 has-text-dark mt-5" title="Loading Configuration"
icon="fas fa-spinner fa-spin">
<p>Loading application configuration. This usually takes less than a second.</p>
<p v-if="!socket.isConnected" class="mt-2">
If this is taking too long, please check that the backend server is running and that the WebSocket
connection is functional.
</p>
</Message>
<Markdown @closeModel="() => doc.file = ''" :file="doc.file" v-if="doc.file" />
<ClientOnly>
<Dialog />
</ClientOnly>
</div>
</Modal>
</template>
<div class="columns mt-3 is-mobile">
<div class="column">
<div class="has-text-left" v-if="config.app?.app_version">
© {{ Year }} - <NuxtLink href="https://github.com/ArabCoders/ytptube" target="_blank">YTPTube</NuxtLink>
<span class="has-tooltip"
v-tooltip="`Build Date: ${config.app?.app_build_date}, Branch: ${config.app?.app_branch}, commit: ${config.app?.app_commit_sha}`">
&nbsp;({{ config?.app?.app_version || 'unknown' }})</span>
- <NuxtLink target="_blank" href="https://github.com/yt-dlp/yt-dlp">yt-dlp</NuxtLink>
<span>&nbsp;({{ config?.app?.ytdlp_version || 'unknown' }})</span>
- <NuxtLink to="/changelog">CHANGELOG</NuxtLink>
- <NuxtLink @click="doc.file = '/api/docs/FAQ.md'">FAQ</NuxtLink>
- <NuxtLink @click="doc.file = '/api/docs/README.md'">README</NuxtLink>
- <NuxtLink @click="doc.file = '/api/docs/API.md'">API</NuxtLink>
<template v-if="!simpleMode">
<Shutdown v-if="app_shutdown" />
<div id="main_container" class="container" v-else>
<NewVersion v-if="newVersionIsAvailable" />
<nav class="navbar is-mobile is-dark">
<div class="navbar-brand pl-5">
<NuxtLink class="navbar-item is-text-overflow" to="/" @click.prevent="(e: MouseEvent) => changeRoute(e)"
v-tooltip="socket.isConnected ? 'Connected' : 'Connecting'">
<span class="is-text-overflow">
<span class="icon"><i class="fas fa-home" /></span>
<span class="has-text-bold" :class="`has-text-${socket.isConnected ? 'success' : 'danger'}`">
YTPTube
</span>
<span class="has-text-bold" v-if="config?.app?.instance_title">: {{ config.app.instance_title }}</span>
</span>
</NuxtLink>
<button class="navbar-burger burger" @click="showMenu = !showMenu">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</button>
</div>
<div class="navbar-menu is-unselectable" :class="{ 'is-active': showMenu }">
<div class="navbar-start">
<NuxtLink class="navbar-item" to="/browser" @click.prevent="(e: MouseEvent) => changeRoute(e)">
<span class="icon"><i class="fa-solid fa-folder-tree" /></span>
<span>Files</span>
</NuxtLink>
<NuxtLink class="navbar-item" to="/presets" @click.prevent="(e: MouseEvent) => changeRoute(e)">
<span class="icon"><i class="fa-solid fa-sliders" /></span>
<span>Presets</span>
</NuxtLink>
<div class="navbar-item has-dropdown">
<a class="navbar-link" @click="(e: MouseEvent) => openMenu(e)">
<span class="icon"><i class="fas fa-tasks" /></span>
<span>Tasks</span>
</a>
<div class="navbar-dropdown">
<NuxtLink class="navbar-item" to="/tasks" @click.prevent="(e: MouseEvent) => changeRoute(e)">
<span class="icon"><i class="fa-solid fa-tasks" /></span>
<span>List</span>
</NuxtLink>
<NuxtLink class="navbar-item" to="/task_definitions" @click.prevent="(e: MouseEvent) => changeRoute(e)">
<span class="icon"><i class="fa-solid fa-diagram-project" /></span>
<span>Definitions</span>
</NuxtLink>
</div>
</div>
<NuxtLink class="navbar-item" to="/notifications" @click.prevent="(e: MouseEvent) => changeRoute(e)">
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-paper-plane" /></span>
<span>Notifications</span>
</span>
</NuxtLink>
<NuxtLink class="navbar-item" to="/conditions" @click.prevent="(e: MouseEvent) => changeRoute(e)">
<span class="icon"><i class="fa-solid fa-filter" /></span>
<span>Conditions</span>
</NuxtLink>
</div>
<div class="navbar-end">
<div class="navbar-item has-dropdown">
<a class="navbar-link" @click="(e: MouseEvent) => openMenu(e)">
<span class="icon"><i class="fas fa-tools" /></span>
<span>Other</span>
</a>
<div class="navbar-dropdown">
<NuxtLink class="navbar-item" to="/logs" @click.prevent="(e: MouseEvent) => changeRoute(e)"
v-if="config.app.file_logging">
<span class="icon"><i class="fa-solid fa-file-lines" /></span>
<span>Logs</span>
</NuxtLink>
<NuxtLink class="navbar-item" to="/console" @click.prevent="(e: MouseEvent) => changeRoute(e)"
v-if="config.app.console_enabled">
<span class="icon"><i class="fa-solid fa-terminal" /></span>
<span>Console</span>
</NuxtLink>
</div>
</div>
<div class="navbar-item" v-if="true === config.app.is_native">
<button class="button is-dark" @click="shutdownApp">
<span class="icon"><i class="fas fa-power-off" /></span>
<span v-if="isMobile">Shutdown</span>
</button>
</div>
<div class="navbar-item">
<button class="button is-dark" @click="reloadPage">
<span class="icon"><i class="fas fa-refresh" /></span>
<span v-if="isMobile">Reload</span>
</button>
</div>
<NotifyDropdown />
<div class="navbar-item" v-if="!isMobile">
<button class="button is-dark has-tooltip-bottom mr-4" v-tooltip.bottom="'WebUI Settings'"
@click="show_settings = !show_settings">
<span class="icon"><i class="fas fa-cog" /></span>
</button>
</div>
<div class="navbar-item" v-if="isMobile">
<button class="button is-dark" @click="show_settings = !show_settings">
<span class="icon"><i class="fas fa-cog" /></span>
<span>WebUI Settings</span>
</button>
</div>
</div>
</div>
</nav>
<div>
<NuxtLoadingIndicator />
<NuxtPage v-if="config.is_loaded" />
<Message v-if="!config.is_loaded" class="has-background-info-90 has-text-dark mt-5"
title="Loading Configuration" icon="fas fa-spinner fa-spin">
<p>Loading application configuration. This usually takes less than a second.</p>
<p v-if="!socket.isConnected" class="mt-2">
If this is taking too long, please check that the backend server is running and that the WebSocket
connection is functional.
</p>
</Message>
<Markdown @closeModel="() => doc.file = ''" :file="doc.file" v-if="doc.file" />
<ClientOnly>
<Dialog />
</ClientOnly>
</div>
<div class="column is-narrow" v-if="config.app?.started">
<div class="has-text-right">
<span class="user-hint"
v-tooltip="'App Started: ' + moment.unix(config.app?.started).format('YYYY-M-DD H:mm Z')">
{{ moment.unix(config.app?.started).fromNow() }}
</span>
<div class="columns mt-3 is-mobile">
<div class="column">
<div class="has-text-left" v-if="config.app?.app_version">
© {{ Year }} - <NuxtLink href="https://github.com/ArabCoders/ytptube" target="_blank">YTPTube</NuxtLink>
<span class="has-tooltip"
v-tooltip="`Build Date: ${config.app?.app_build_date}, Branch: ${config.app?.app_branch}, commit: ${config.app?.app_commit_sha}`">
&nbsp;({{ config?.app?.app_version || 'unknown' }})</span>
- <NuxtLink target="_blank" href="https://github.com/yt-dlp/yt-dlp">yt-dlp</NuxtLink>
<span>&nbsp;({{ config?.app?.ytdlp_version || 'unknown' }})</span>
- <NuxtLink to="/changelog">CHANGELOG</NuxtLink>
- <NuxtLink @click="doc.file = '/api/docs/FAQ.md'">FAQ</NuxtLink>
- <NuxtLink @click="doc.file = '/api/docs/README.md'">README</NuxtLink>
- <NuxtLink @click="doc.file = '/api/docs/API.md'">API</NuxtLink>
</div>
</div>
<div class="column is-narrow" v-if="config.app?.started">
<div class="has-text-right">
<span class="user-hint"
v-tooltip="'App Started: ' + moment.unix(config.app?.started).format('YYYY-M-DD H:mm Z')">
{{ moment.unix(config.app?.started).fromNow() }}
</span>
</div>
</div>
</div>
</div>
</div>
</template>
</template>
<script setup lang="ts">
@ -176,6 +187,7 @@ import moment from 'moment'
import type { YTDLPOption } from '~/types/ytdlp'
import { useDialog } from '~/composables/useDialog'
import Dialog from '~/components/Dialog.vue'
import Simple from '~/components/Simple.vue'
import Shutdown from '~/components/shutdown.vue'
import Markdown from '~/components/Markdown.vue'
@ -191,6 +203,8 @@ const bg_opacity = useStorage('random_bg_opacity', 0.95)
const showMenu = ref(false)
const isMobile = useMediaQuery({ maxWidth: 1024 })
const app_shutdown = ref<boolean>(false)
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false)
const doc = ref<{ file: string }>({ file: '' })
const applyPreferredColorScheme = (scheme: string) => {

View file

@ -129,6 +129,7 @@
<script setup lang="ts">
import type { ConditionItem, ImportedConditionItem } from '~/types/conditions'
import {useConfirm} from '~/composables/useConfirm'
type ConditionItemWithUI = ConditionItem & { raw?: boolean }
@ -226,7 +227,7 @@ const updateItems = async (newItems: ConditionItem[]): Promise<boolean> => {
}
const deleteItem = async (cond: ConditionItem): Promise<void> => {
if (true !== (await box.confirm(`Delete '${cond.name}'?`, true))) {
if (true !== (await box.confirm(`Delete '${cond.name}'?`))) {
return
}

View file

@ -219,6 +219,7 @@
<script setup lang="ts">
import { useStorage } from '@vueuse/core'
import type { notification, notificationImport } from '~/types/notification'
import {useConfirm} from '~/composables/useConfirm'
const toast = useNotification()
const socket = useSocketStore()

View file

@ -204,6 +204,7 @@
<script setup lang="ts">
import { useStorage } from '@vueuse/core'
import type { Preset } from '~/types/presets'
import {useConfirm} from '~/composables/useConfirm'
const toast = useNotification()
const config = useConfigStore()
@ -295,7 +296,7 @@ const updatePresets = async (items: Preset[]): Promise<boolean | undefined> => {
}
const deleteItem = async (item: Preset) => {
if (true !== (await box.confirm(`Delete preset '${item.name}'?`, true))) {
if (true !== (await box.confirm(`Delete preset '${item.name}'?`))) {
return
}

View file

@ -402,6 +402,7 @@ import moment from 'moment'
import { useStorage } from '@vueuse/core'
import { CronExpressionParser } from 'cron-parser'
import Modal from '~/components/Modal.vue'
import { useConfirm } from '~/composables/useConfirm'
import TaskInspect from '~/components/TaskInspect.vue'
import type { task_item, exported_task, error_response } from '~/types/tasks'
@ -595,7 +596,7 @@ const deleteSelected = async () => {
}
const deleteItem = async (item: task_item) => {
if (true !== (await box.confirm(`Delete '${item.name}' task?`, true))) {
if (true !== (await box.confirm(`Delete '${item.name}' task?`))) {
return
}

View file

@ -24,6 +24,7 @@ export const useConfigStore = defineStore('config', () => {
app_branch: '',
started: 0,
app_env: 'production',
simple_mode: false,
},
presets: [
{

View file

@ -26,6 +26,8 @@ type AppConfig = {
browser_control_enabled: boolean
/** Indicates if file logging is enabled */
file_logging: boolean
/** Indicates if the app is in simple mode */
simple_mode: boolean
/** Indicates if the app is running in a native environment (e.g., Electron) */
is_native: boolean
/** App version in format "1.0.0" */

View file

@ -18,6 +18,8 @@ type StoreItem = {
id: string
/** Title of the item */
title: string
/** Description of the item */
description: string
/** URL of the item */
url: string
/** Preset used for the item */

93
uv.lock
View file

@ -875,15 +875,15 @@ wheels = [
[[package]]
name = "pyinstaller-hooks-contrib"
version = "2025.8"
version = "2025.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
{ name = "setuptools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/d6/e5b378b7d4add8c879295c531309b0320e9c07a70458665d091760ffdc87/pyinstaller_hooks_contrib-2025.8.tar.gz", hash = "sha256:3402ad41dfe9b5110af134422e37fc5d421ba342c6cb980bd67cb30b7415641c", size = 164214 }
sdist = { url = "https://files.pythonhosted.org/packages/7d/83/be0f57c0b77b66c33c2283ebd4ea341022b5a743e97c5fb3bebab82b38b9/pyinstaller_hooks_contrib-2025.9.tar.gz", hash = "sha256:56e972bdaad4e9af767ed47d132362d162112260cbe488c9da7fee01f228a5a6", size = 165189 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/34/1d973d0dae849683e53fbcda84443ce016f315e6f4dc7605ede4f56a28c3/pyinstaller_hooks_contrib-2025.8-py3-none-any.whl", hash = "sha256:8d0b8cfa0cb689a619294ae200497374234bd4e3994b3ace2a4442274c899064", size = 442346 },
{ url = "https://files.pythonhosted.org/packages/a2/26/23b4cfc77d7f808c69f59070e1e8293a579ec281a547c61562357160b346/pyinstaller_hooks_contrib-2025.9-py3-none-any.whl", hash = "sha256:ccbfaa49399ef6b18486a165810155e5a8d4c59b41f20dc5da81af7482aaf038", size = 444283 },
]
[[package]]
@ -1051,19 +1051,38 @@ wheels = [
[[package]]
name = "pyyaml"
version = "6.0.2"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 },
{ url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 },
{ url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 },
{ url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 },
{ url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 },
{ url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 },
{ url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 },
{ url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 },
{ url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 },
]
[[package]]
@ -1239,28 +1258,28 @@ wheels = [
[[package]]
name = "ruff"
version = "0.13.1"
version = "0.13.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ab/33/c8e89216845615d14d2d42ba2bee404e7206a8db782f33400754f3799f05/ruff-0.13.1.tar.gz", hash = "sha256:88074c3849087f153d4bb22e92243ad4c1b366d7055f98726bc19aa08dc12d51", size = 5397987 }
sdist = { url = "https://files.pythonhosted.org/packages/02/df/8d7d8c515d33adfc540e2edf6c6021ea1c5a58a678d8cfce9fae59aabcab/ruff-0.13.2.tar.gz", hash = "sha256:cb12fffd32fb16d32cef4ed16d8c7cdc27ed7c944eaa98d99d01ab7ab0b710ff", size = 5416417 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/41/ca37e340938f45cfb8557a97a5c347e718ef34702546b174e5300dbb1f28/ruff-0.13.1-py3-none-linux_armv6l.whl", hash = "sha256:b2abff595cc3cbfa55e509d89439b5a09a6ee3c252d92020bd2de240836cf45b", size = 12304308 },
{ url = "https://files.pythonhosted.org/packages/ff/84/ba378ef4129415066c3e1c80d84e539a0d52feb250685091f874804f28af/ruff-0.13.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4ee9f4249bf7f8bb3984c41bfaf6a658162cdb1b22e3103eabc7dd1dc5579334", size = 12937258 },
{ url = "https://files.pythonhosted.org/packages/8d/b6/ec5e4559ae0ad955515c176910d6d7c93edcbc0ed1a3195a41179c58431d/ruff-0.13.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5c5da4af5f6418c07d75e6f3224e08147441f5d1eac2e6ce10dcce5e616a3bae", size = 12214554 },
{ url = "https://files.pythonhosted.org/packages/70/d6/cb3e3b4f03b9b0c4d4d8f06126d34b3394f6b4d764912fe80a1300696ef6/ruff-0.13.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80524f84a01355a59a93cef98d804e2137639823bcee2931f5028e71134a954e", size = 12448181 },
{ url = "https://files.pythonhosted.org/packages/d2/ea/bf60cb46d7ade706a246cd3fb99e4cfe854efa3dfbe530d049c684da24ff/ruff-0.13.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff7f5ce8d7988767dd46a148192a14d0f48d1baea733f055d9064875c7d50389", size = 12104599 },
{ url = "https://files.pythonhosted.org/packages/2d/3e/05f72f4c3d3a69e65d55a13e1dd1ade76c106d8546e7e54501d31f1dc54a/ruff-0.13.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c55d84715061f8b05469cdc9a446aa6c7294cd4bd55e86a89e572dba14374f8c", size = 13791178 },
{ url = "https://files.pythonhosted.org/packages/81/e7/01b1fc403dd45d6cfe600725270ecc6a8f8a48a55bc6521ad820ed3ceaf8/ruff-0.13.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ac57fed932d90fa1624c946dc67a0a3388d65a7edc7d2d8e4ca7bddaa789b3b0", size = 14814474 },
{ url = "https://files.pythonhosted.org/packages/fa/92/d9e183d4ed6185a8df2ce9faa3f22e80e95b5f88d9cc3d86a6d94331da3f/ruff-0.13.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c366a71d5b4f41f86a008694f7a0d75fe409ec298685ff72dc882f882d532e36", size = 14217531 },
{ url = "https://files.pythonhosted.org/packages/3b/4a/6ddb1b11d60888be224d721e01bdd2d81faaf1720592858ab8bac3600466/ruff-0.13.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4ea9d1b5ad3e7a83ee8ebb1229c33e5fe771e833d6d3dcfca7b77d95b060d38", size = 13265267 },
{ url = "https://files.pythonhosted.org/packages/81/98/3f1d18a8d9ea33ef2ad508f0417fcb182c99b23258ec5e53d15db8289809/ruff-0.13.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0f70202996055b555d3d74b626406476cc692f37b13bac8828acff058c9966a", size = 13243120 },
{ url = "https://files.pythonhosted.org/packages/8d/86/b6ce62ce9c12765fa6c65078d1938d2490b2b1d9273d0de384952b43c490/ruff-0.13.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:f8cff7a105dad631085d9505b491db33848007d6b487c3c1979dd8d9b2963783", size = 13443084 },
{ url = "https://files.pythonhosted.org/packages/a1/6e/af7943466a41338d04503fb5a81b2fd07251bd272f546622e5b1599a7976/ruff-0.13.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9761e84255443316a258dd7dfbd9bfb59c756e52237ed42494917b2577697c6a", size = 12295105 },
{ url = "https://files.pythonhosted.org/packages/3f/97/0249b9a24f0f3ebd12f007e81c87cec6d311de566885e9309fcbac5b24cc/ruff-0.13.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:3d376a88c3102ef228b102211ef4a6d13df330cb0f5ca56fdac04ccec2a99700", size = 12072284 },
{ url = "https://files.pythonhosted.org/packages/f6/85/0b64693b2c99d62ae65236ef74508ba39c3febd01466ef7f354885e5050c/ruff-0.13.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cbefd60082b517a82c6ec8836989775ac05f8991715d228b3c1d86ccc7df7dae", size = 12970314 },
{ url = "https://files.pythonhosted.org/packages/96/fc/342e9f28179915d28b3747b7654f932ca472afbf7090fc0c4011e802f494/ruff-0.13.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:dd16b9a5a499fe73f3c2ef09a7885cb1d97058614d601809d37c422ed1525317", size = 13422360 },
{ url = "https://files.pythonhosted.org/packages/37/54/6177a0dc10bce6f43e392a2192e6018755473283d0cf43cc7e6afc182aea/ruff-0.13.1-py3-none-win32.whl", hash = "sha256:55e9efa692d7cb18580279f1fbb525146adc401f40735edf0aaeabd93099f9a0", size = 12178448 },
{ url = "https://files.pythonhosted.org/packages/64/51/c6a3a33d9938007b8bdc8ca852ecc8d810a407fb513ab08e34af12dc7c24/ruff-0.13.1-py3-none-win_amd64.whl", hash = "sha256:3a3fb595287ee556de947183489f636b9f76a72f0fa9c028bdcabf5bab2cc5e5", size = 13286458 },
{ url = "https://files.pythonhosted.org/packages/fd/04/afc078a12cf68592345b1e2d6ecdff837d286bac023d7a22c54c7a698c5b/ruff-0.13.1-py3-none-win_arm64.whl", hash = "sha256:c0bae9ffd92d54e03c2bf266f466da0a65e145f298ee5b5846ed435f6a00518a", size = 12437893 },
{ url = "https://files.pythonhosted.org/packages/6e/84/5716a7fa4758e41bf70e603e13637c42cfb9dbf7ceb07180211b9bbf75ef/ruff-0.13.2-py3-none-linux_armv6l.whl", hash = "sha256:3796345842b55f033a78285e4f1641078f902020d8450cade03aad01bffd81c3", size = 12343254 },
{ url = "https://files.pythonhosted.org/packages/9b/77/c7042582401bb9ac8eff25360e9335e901d7a1c0749a2b28ba4ecb239991/ruff-0.13.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ff7e4dda12e683e9709ac89e2dd436abf31a4d8a8fc3d89656231ed808e231d2", size = 13040891 },
{ url = "https://files.pythonhosted.org/packages/c6/15/125a7f76eb295cb34d19c6778e3a82ace33730ad4e6f28d3427e134a02e0/ruff-0.13.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c75e9d2a2fafd1fdd895d0e7e24b44355984affdde1c412a6f6d3f6e16b22d46", size = 12243588 },
{ url = "https://files.pythonhosted.org/packages/9e/eb/0093ae04a70f81f8be7fd7ed6456e926b65d238fc122311293d033fdf91e/ruff-0.13.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cceac74e7bbc53ed7d15d1042ffe7b6577bf294611ad90393bf9b2a0f0ec7cb6", size = 12491359 },
{ url = "https://files.pythonhosted.org/packages/43/fe/72b525948a6956f07dad4a6f122336b6a05f2e3fd27471cea612349fedb9/ruff-0.13.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6ae3f469b5465ba6d9721383ae9d49310c19b452a161b57507764d7ef15f4b07", size = 12162486 },
{ url = "https://files.pythonhosted.org/packages/6a/e3/0fac422bbbfb2ea838023e0d9fcf1f30183d83ab2482800e2cb892d02dfe/ruff-0.13.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f8f9e3cd6714358238cd6626b9d43026ed19c0c018376ac1ef3c3a04ffb42d8", size = 13871203 },
{ url = "https://files.pythonhosted.org/packages/6b/82/b721c8e3ec5df6d83ba0e45dcf00892c4f98b325256c42c38ef136496cbf/ruff-0.13.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c6ed79584a8f6cbe2e5d7dbacf7cc1ee29cbdb5df1172e77fbdadc8bb85a1f89", size = 14929635 },
{ url = "https://files.pythonhosted.org/packages/c4/a0/ad56faf6daa507b83079a1ad7a11694b87d61e6bf01c66bd82b466f21821/ruff-0.13.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aed130b2fde049cea2019f55deb939103123cdd191105f97a0599a3e753d61b0", size = 14338783 },
{ url = "https://files.pythonhosted.org/packages/47/77/ad1d9156db8f99cd01ee7e29d74b34050e8075a8438e589121fcd25c4b08/ruff-0.13.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1887c230c2c9d65ed1b4e4cfe4d255577ea28b718ae226c348ae68df958191aa", size = 13355322 },
{ url = "https://files.pythonhosted.org/packages/64/8b/e87cfca2be6f8b9f41f0bb12dc48c6455e2d66df46fe61bb441a226f1089/ruff-0.13.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5bcb10276b69b3cfea3a102ca119ffe5c6ba3901e20e60cf9efb53fa417633c3", size = 13354427 },
{ url = "https://files.pythonhosted.org/packages/7f/df/bf382f3fbead082a575edb860897287f42b1b3c694bafa16bc9904c11ed3/ruff-0.13.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:afa721017aa55a555b2ff7944816587f1cb813c2c0a882d158f59b832da1660d", size = 13537637 },
{ url = "https://files.pythonhosted.org/packages/51/70/1fb7a7c8a6fc8bd15636288a46e209e81913b87988f26e1913d0851e54f4/ruff-0.13.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1dbc875cf3720c64b3990fef8939334e74cb0ca65b8dbc61d1f439201a38101b", size = 12340025 },
{ url = "https://files.pythonhosted.org/packages/4c/27/1e5b3f1c23ca5dd4106d9d580e5c13d9acb70288bff614b3d7b638378cc9/ruff-0.13.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5b939a1b2a960e9742e9a347e5bbc9b3c3d2c716f86c6ae273d9cbd64f193f22", size = 12133449 },
{ url = "https://files.pythonhosted.org/packages/2d/09/b92a5ccee289f11ab128df57d5911224197d8d55ef3bd2043534ff72ca54/ruff-0.13.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:50e2d52acb8de3804fc5f6e2fa3ae9bdc6812410a9e46837e673ad1f90a18736", size = 13051369 },
{ url = "https://files.pythonhosted.org/packages/89/99/26c9d1c7d8150f45e346dc045cc49f23e961efceb4a70c47dea0960dea9a/ruff-0.13.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3196bc13ab2110c176b9a4ae5ff7ab676faaa1964b330a1383ba20e1e19645f2", size = 13523644 },
{ url = "https://files.pythonhosted.org/packages/f7/00/e7f1501e81e8ec290e79527827af1d88f541d8d26151751b46108978dade/ruff-0.13.2-py3-none-win32.whl", hash = "sha256:7c2a0b7c1e87795fec3404a485096bcd790216c7c146a922d121d8b9c8f1aaac", size = 12245990 },
{ url = "https://files.pythonhosted.org/packages/ee/bd/d9f33a73de84fafd0146c6fba4f497c4565fe8fa8b46874b8e438869abc2/ruff-0.13.2-py3-none-win_amd64.whl", hash = "sha256:17d95fb32218357c89355f6f6f9a804133e404fc1f65694372e02a557edf8585", size = 13324004 },
{ url = "https://files.pythonhosted.org/packages/c3/12/28fa2f597a605884deb0f65c1b1ae05111051b2a7030f5d8a4ff7f4599ba/ruff-0.13.2-py3-none-win_arm64.whl", hash = "sha256:da711b14c530412c827219312b7d7fbb4877fb31150083add7e8c5336549cea7", size = 12484437 },
]
[[package]]
@ -1483,11 +1502,11 @@ wheels = [
[[package]]
name = "yt-dlp"
version = "2025.9.23"
version = "2025.9.26"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/90/77/24a13bbd3190849e7e37e6093aa9648f3b26a836d37ba67e3429ee89ad1d/yt_dlp-2025.9.23.tar.gz", hash = "sha256:9282ad1deadb4c90b2e6d3bcf9f360abf88c5f2e4ba836dad7b51387b086d757", size = 3036855 }
sdist = { url = "https://files.pythonhosted.org/packages/58/8f/0daea0feec1ab85e7df85b98ec7cc8c85d706362e80efc5375c7007dc3dc/yt_dlp-2025.9.26.tar.gz", hash = "sha256:c148ae8233ac4ce6c5fbf6f70fcc390f13a00f59da3776d373cf88c5370bda86", size = 3037475 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/b7/723a4061d7efac4bbb86eddef9ceec0fc1e85415c7d665c2e5e1879759bb/yt_dlp-2025.9.23-py3-none-any.whl", hash = "sha256:84e36acf2dfbadb307d734a590dd937923173b13c57cf45a662f40c93e746302", size = 3241497 },
{ url = "https://files.pythonhosted.org/packages/35/94/18210c5e6a9d7e622a3b3f4a73dde205f7adf0c46b42b27d0da8c6e5c872/yt_dlp-2025.9.26-py3-none-any.whl", hash = "sha256:36f5fbc153600f759abd48d257231f0e0a547a115ac7ffb05d5b64e5c7fdf8a2", size = 3241906 },
]
[[package]]