Added autocomplete for cli options
This commit is contained in:
parent
f31ea55ca0
commit
a0533a0861
19 changed files with 724 additions and 387 deletions
|
|
@ -358,9 +358,11 @@ div.is-centered {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.modal-content-max {
|
.modal-content-max {
|
||||||
width: max-content;
|
width: calc(100% - 20px);
|
||||||
|
max-width: 1344px;
|
||||||
|
min-width: 320px;
|
||||||
|
box-sizing: border-box;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -90,22 +90,20 @@
|
||||||
|
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label is-inline" for="cli_options">
|
<label class="label is-unselectable" for="cli_options">
|
||||||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
||||||
<span>
|
<span>Command options for yt-dlp</span>
|
||||||
Command options for yt-dlp -
|
|
||||||
<NuxtLink @click="showOptions = true" v-text="'View Options'" />
|
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
<div class="control">
|
<TextareaAutocomplete id="cli_options" v-model="form.cli" :options="ytDlpOpt"
|
||||||
<textarea class="textarea is-pre" v-model="form.cli" id="cli_options" :disabled="addInProgress"
|
:disabled="addInProgress" />
|
||||||
placeholder="command options to use, e.g. --proxy 1.2.3.4:3128" />
|
|
||||||
</div>
|
|
||||||
<span class="help is-bold">
|
<span class="help is-bold">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
<span>Not all options are supported <NuxtLink target="_blank"
|
<span>
|
||||||
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some are
|
<NuxtLink @click="showOptions = true" v-text="'View all options'" />. Not all options are
|
||||||
ignored</NuxtLink>. Use with caution.</span>
|
supported <NuxtLink target="_blank"
|
||||||
|
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some
|
||||||
|
are ignored</NuxtLink>. Use with caution.
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -211,6 +209,8 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useStorage } from '@vueuse/core'
|
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 type { ConditionItem, ImportedConditionItem } from '~/types/conditions'
|
||||||
|
|
||||||
const emitter = defineEmits<{
|
const emitter = defineEmits<{
|
||||||
|
|
@ -227,6 +227,7 @@ const props = defineProps<{
|
||||||
const toast = useNotification()
|
const toast = useNotification()
|
||||||
const showImport = useStorage('showImport', false)
|
const showImport = useStorage('showImport', false)
|
||||||
const box = useConfirm()
|
const box = useConfirm()
|
||||||
|
const config = useConfigStore()
|
||||||
|
|
||||||
const form = reactive<ConditionItem>(JSON.parse(JSON.stringify(props.item)))
|
const form = reactive<ConditionItem>(JSON.parse(JSON.stringify(props.item)))
|
||||||
const import_string = ref('')
|
const import_string = ref('')
|
||||||
|
|
@ -238,6 +239,15 @@ const test_data = ref<{
|
||||||
data: { status: boolean | null, data: Record<string, any> }
|
data: { status: boolean | null, data: Record<string, any> }
|
||||||
}>({ show: false, url: '', in_progress: false, changed: false, data: { status: null, data: {} } })
|
}>({ show: false, url: '', in_progress: false, changed: false, data: { status: null, data: {} } })
|
||||||
const showOptions = ref<boolean>(false)
|
const showOptions = ref<boolean>(false)
|
||||||
|
const ytDlpOpt = ref<AutoCompleteOptions>([])
|
||||||
|
|
||||||
|
watch(() => config.ytdlp_options, newOptions => ytDlpOpt.value = newOptions
|
||||||
|
.filter(opt => !opt.ignored)
|
||||||
|
.flatMap(opt => opt.flags
|
||||||
|
.filter(flag => flag.startsWith('--'))
|
||||||
|
.map(flag => ({ value: flag, description: opt.description || '' }))),
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
watch(() => form.filter, () => test_data.value.changed = true)
|
watch(() => form.filter, () => test_data.value.changed = true)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,8 @@
|
||||||
<span class="icon"><i class="fas fa-terminal" /></span>
|
<span class="icon"><i class="fas fa-terminal" /></span>
|
||||||
<span>Associated yt-dlp option</span>
|
<span>Associated yt-dlp option</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="text" v-model="item.field" class="input" :disabled="isLoading" />
|
<InputAutocomplete v-model="item.field" :options="ytDlpOptions" :disabled="isLoading"
|
||||||
|
placeholder="Type or select a yt-dlp option" />
|
||||||
<span class="help is-bold">
|
<span class="help is-bold">
|
||||||
The long form of yt-dlp option name, e.g. <code>--no-overwrites</code> not <code>-w</code>.
|
The long form of yt-dlp option name, e.g. <code>--no-overwrites</code> not <code>-w</code>.
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -164,15 +165,20 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineEmits, ref } from 'vue'
|
import { defineEmits, ref } from 'vue'
|
||||||
import type { DLField } from '~/types/dl_fields'
|
import InputAutocomplete from '~/components/InputAutocomplete.vue'
|
||||||
import { disableOpacity, enableOpacity } from '~/utils'
|
import { disableOpacity, enableOpacity } from '~/utils'
|
||||||
|
|
||||||
|
import type { DLField } from '~/types/dl_fields'
|
||||||
|
import type { AutoCompleteOptions } from '~/types/autocomplete'
|
||||||
|
|
||||||
const emitter = defineEmits<{ (e: 'cancel'): void }>()
|
const emitter = defineEmits<{ (e: 'cancel'): void }>()
|
||||||
|
|
||||||
const toast = useNotification()
|
const toast = useNotification()
|
||||||
|
|
||||||
const isLoading = ref<boolean>(false)
|
const isLoading = ref<boolean>(false)
|
||||||
const items = ref<DLField[]>([])
|
const items = ref<DLField[]>([])
|
||||||
|
const config = useConfigStore()
|
||||||
|
const ytDlpOptions = ref<AutoCompleteOptions>([])
|
||||||
|
|
||||||
const FieldTypes = {
|
const FieldTypes = {
|
||||||
STRING: 'string',
|
STRING: 'string',
|
||||||
|
|
@ -274,6 +280,12 @@ const validateItem = (item: DLField, index: number): boolean => {
|
||||||
|
|
||||||
const sortedDLFields = computed(() => items.value.sort((a, b) => (a.order || 0) - (b.order || 0)))
|
const sortedDLFields = computed(() => items.value.sort((a, b) => (a.order || 0) - (b.order || 0)))
|
||||||
|
|
||||||
|
watch(() => config.ytdlp_options, newOptions => ytDlpOptions.value = newOptions
|
||||||
|
.filter(opt => !opt.ignored).flatMap(opt => opt.flags
|
||||||
|
.filter(flag => flag.startsWith('--'))
|
||||||
|
.map(flag => ({ value: flag, description: opt.description || '' }))),
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
disableOpacity()
|
disableOpacity()
|
||||||
await loadContent()
|
await loadContent()
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,14 @@
|
||||||
code {
|
code {
|
||||||
color: var(--bulma-code) !important
|
color: var(--bulma-code) !important
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="modal is-active" v-if="false === externalModel">
|
<div class="modal is-active" v-if="false === externalModel">
|
||||||
<div class="modal-background" @click="emitter('closeModel')"></div>
|
<div class="modal-background" @click="emitter('closeModel')"></div>
|
||||||
<div class="modal-content" style="width:60vw;">
|
<div class="modal-content modal-content-max">
|
||||||
<div style="font-size:30vh; width: 99%" class="has-text-centered" v-if="isLoading">
|
<div style="font-size:30vh; width: 99%" class="has-text-centered" v-if="isLoading">
|
||||||
<i class="fas fa-circle-notch fa-spin"></i>
|
<i class="fas fa-circle-notch fa-spin"></i>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -25,7 +26,7 @@ code {
|
||||||
</div>
|
</div>
|
||||||
<button class="modal-close is-large" aria-label="close" @click="emitter('closeModel')"></button>
|
<button class="modal-close is-large" aria-label="close" @click="emitter('closeModel')"></button>
|
||||||
</div>
|
</div>
|
||||||
<div style="width:70vw; height: 80vh;" v-else>
|
<div class="modal-content-max" style="height: 80vh;" v-else>
|
||||||
<div class="content p-0 m-0" style="position: relative">
|
<div class="content p-0 m-0" style="position: relative">
|
||||||
<pre><code class="p-4 is-block" v-text="data" /></pre>
|
<pre><code class="p-4 is-block" v-text="data" /></pre>
|
||||||
<button class="button m-4" @click="() => copyText(JSON.stringify(data, null, 4))"
|
<button class="button m-4" @click="() => copyText(JSON.stringify(data, null, 4))"
|
||||||
|
|
|
||||||
133
ui/app/components/InputAutocomplete.vue
Normal file
133
ui/app/components/InputAutocomplete.vue
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
<template>
|
||||||
|
<div class="dropdown" :class="{ 'is-active': showList && filteredOptions.length }" style="width:100%;">
|
||||||
|
<div class="control" style="width:100%;">
|
||||||
|
<input v-model="model" @focus="onFocus" @blur="hideList" @keydown="handleKeydown" @input="onInput" class="input"
|
||||||
|
:placeholder="placeholder" autocomplete="new-password" style="width:100%; position:relative; z-index:2;"
|
||||||
|
:disabled="disabled" />
|
||||||
|
</div>
|
||||||
|
<div class="dropdown-menu" role="menu" style="width:100%; z-index:3;">
|
||||||
|
<div class="dropdown-content" style="width:100%; max-height:10em; overflow-y:auto;">
|
||||||
|
<a v-for="(option, idx) in filteredOptions" :key="option.value" @mousedown.prevent="selectOption(option.value)"
|
||||||
|
:class="['dropdown-item', { 'is-active': idx === highlightedIndex }]"
|
||||||
|
style="display:flex; justify-content:space-between;" :ref="el => setDropdownItemRef(el, idx)">
|
||||||
|
<span class="has-text-weight-bold">{{ option.value }}</span>
|
||||||
|
<abbr class="has-text-grey-light is-text-overflow" :title="option.description" style="margin-left:1em;">
|
||||||
|
{{ option.description }}
|
||||||
|
</abbr>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, computed, defineModel, defineProps, nextTick } from 'vue'
|
||||||
|
import type { AutoCompleteOptions } from '~/types/autocomplete'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
options: AutoCompleteOptions
|
||||||
|
placeholder?: string
|
||||||
|
disabled?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const model = defineModel<string>()
|
||||||
|
|
||||||
|
const onInput = () => {
|
||||||
|
if (false === showList.value) {
|
||||||
|
showList.value = true
|
||||||
|
}
|
||||||
|
highlightedIndex.value = filteredOptions.value.length ? 0 : -1
|
||||||
|
}
|
||||||
|
|
||||||
|
const showList = ref(false)
|
||||||
|
const highlightedIndex = ref(-1)
|
||||||
|
const dropdownItemRefs = ref<(HTMLElement | null)[]>([])
|
||||||
|
|
||||||
|
const filteredOptions = computed(() => {
|
||||||
|
if (!model.value) {
|
||||||
|
return props.options
|
||||||
|
}
|
||||||
|
const val = model.value.toLowerCase()
|
||||||
|
const startsWithFlag = []
|
||||||
|
const includesFlag = []
|
||||||
|
const includesDesc = []
|
||||||
|
for (const opt of props.options) {
|
||||||
|
const flag = opt.value.toLowerCase()
|
||||||
|
const desc = opt.description.toLowerCase()
|
||||||
|
if (flag.startsWith(val)) {
|
||||||
|
startsWithFlag.push(opt)
|
||||||
|
} else if (flag.includes(val)) {
|
||||||
|
includesFlag.push(opt)
|
||||||
|
} else if (desc.includes(val)) {
|
||||||
|
includesDesc.push(opt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...startsWithFlag, ...includesFlag, ...includesDesc]
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectOption = (val: string) => {
|
||||||
|
model.value = val
|
||||||
|
showList.value = false
|
||||||
|
highlightedIndex.value = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
const hideList = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
showList.value = false
|
||||||
|
highlightedIndex.value = -1
|
||||||
|
dropdownItemRefs.value = []
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onFocus = () => {
|
||||||
|
showList.value = true
|
||||||
|
highlightedIndex.value = filteredOptions.value.length ? 0 : -1
|
||||||
|
}
|
||||||
|
|
||||||
|
const setDropdownItemRef = (el: Element | ComponentPublicInstance | null, idx: number) => {
|
||||||
|
dropdownItemRefs.value[idx] = el instanceof HTMLElement ? el : null
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(filteredOptions, () => dropdownItemRefs.value = Array(filteredOptions.value.length).fill(null))
|
||||||
|
|
||||||
|
const handleKeydown = (e: KeyboardEvent) => {
|
||||||
|
if (!filteredOptions.value.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageSize = 5
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
highlightedIndex.value = Math.min(highlightedIndex.value + 1, filteredOptions.value.length - 1)
|
||||||
|
nextTick(() => scrollHighlightedIntoView())
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0)
|
||||||
|
nextTick(() => scrollHighlightedIntoView())
|
||||||
|
} else if (e.key === 'PageDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
highlightedIndex.value = Math.min(highlightedIndex.value + pageSize, filteredOptions.value.length - 1)
|
||||||
|
nextTick(() => scrollHighlightedIntoView())
|
||||||
|
} else if (e.key === 'PageUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
highlightedIndex.value = Math.max(highlightedIndex.value - pageSize, 0)
|
||||||
|
nextTick(() => scrollHighlightedIntoView())
|
||||||
|
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||||
|
const selected = highlightedIndex.value >= 0 && highlightedIndex.value < filteredOptions.value.length ?
|
||||||
|
filteredOptions.value[highlightedIndex.value] : undefined
|
||||||
|
if (selected) {
|
||||||
|
e.preventDefault()
|
||||||
|
selectOption(selected.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollHighlightedIntoView() {
|
||||||
|
const el = dropdownItemRefs.value[highlightedIndex.value]
|
||||||
|
if (!el) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
el.scrollIntoView({ block: 'nearest' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
@ -116,23 +116,22 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-6-tablet is-12-mobile">
|
<div class="column is-6-tablet is-12-mobile">
|
||||||
<DLInput id="cli_options" type="text" label="Command options for yt-dlp" v-model="form.cli"
|
<div class="field">
|
||||||
icon="fa-solid fa-terminal" :disabled="!socket.isConnected || addInProgress">
|
<label class="label is-unselectable" for="cli_options">
|
||||||
<template #title>
|
|
||||||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
||||||
<span>Command options for yt-dlp -
|
<span>Command options for yt-dlp</span>
|
||||||
<NuxtLink @click="showOptions = true" v-text="'View Options'" />
|
</label>
|
||||||
|
<TextareaAutocomplete id="cli_options" v-model="form.cli" :options="ytDlpOpt" />
|
||||||
|
<span class="help is-bold">
|
||||||
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
|
<span>
|
||||||
|
<NuxtLink @click="showOptions = true" v-text="'View all options'" />. Not all options are supported
|
||||||
|
<NuxtLink target="_blank"
|
||||||
|
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some are
|
||||||
|
ignored</NuxtLink>. Use with caution.
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</span>
|
||||||
<template #help>
|
</div>
|
||||||
<span class="help is-bold">
|
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
|
||||||
<span>Not all options are supported <NuxtLink target="_blank"
|
|
||||||
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some are
|
|
||||||
ignored</NuxtLink>. Use with caution.</span>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</DLInput>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-6-tablet is-12-mobile">
|
<div class="column is-6-tablet is-12-mobile">
|
||||||
|
|
@ -241,7 +240,9 @@
|
||||||
<script setup lang="ts">
|
<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 TextareaAutocomplete from '~/components/TextareaAutocomplete.vue'
|
||||||
import type { item_request } from '~/types/item'
|
import type { item_request } from '~/types/item'
|
||||||
|
import type { AutoCompleteOptions } from '~/types/autocomplete';
|
||||||
|
|
||||||
const props = defineProps<{ item?: Partial<item_request> }>()
|
const props = defineProps<{ item?: Partial<item_request> }>()
|
||||||
const emitter = defineEmits<{
|
const emitter = defineEmits<{
|
||||||
|
|
@ -263,6 +264,7 @@ const addInProgress = ref<boolean>(false)
|
||||||
const showFields = ref<boolean>(false)
|
const showFields = ref<boolean>(false)
|
||||||
const showOptions = ref<boolean>(false)
|
const showOptions = ref<boolean>(false)
|
||||||
const dlFieldsExtra = ['--no-download-archive']
|
const dlFieldsExtra = ['--no-download-archive']
|
||||||
|
const ytDlpOpt = ref<AutoCompleteOptions>([])
|
||||||
|
|
||||||
const form = useStorage<item_request>('local_config_v1', {
|
const form = useStorage<item_request>('local_config_v1', {
|
||||||
id: null,
|
id: null,
|
||||||
|
|
@ -445,6 +447,14 @@ onUpdated(async () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(() => config.ytdlp_options, newOptions => ytDlpOpt.value = newOptions
|
||||||
|
.filter(opt => !opt.ignored)
|
||||||
|
.flatMap(opt => opt.flags
|
||||||
|
.filter(flag => flag.startsWith('--'))
|
||||||
|
.map(flag => ({ value: flag, description: opt.description || '' }))),
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -143,22 +143,22 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-6-tablet is-12-mobile">
|
<div class="column is-12">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label is-inline" for="cli_options">
|
<label class="label is-unselectable" for="cli_options">
|
||||||
<span>Command options for yt-dlp -
|
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
||||||
<NuxtLink @click="showOptions = true" v-text="'View Options'" />
|
<span>Command options for yt-dlp</span>
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
<div class="control">
|
<TextareaAutocomplete id="cli_options" v-model="form.cli" :options="ytDlpOpt"
|
||||||
<textarea class="textarea is-pre" v-model="form.cli" id="cli_options" :disabled="addInProgress"
|
:disabled="addInProgress" />
|
||||||
placeholder="command options to use, e.g. --no-embed-metadata --no-embed-thumbnail" />
|
|
||||||
</div>
|
|
||||||
<span class="help is-bold">
|
<span class="help is-bold">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
<span>Not all options are supported <NuxtLink target="_blank"
|
<span>
|
||||||
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some are
|
<NuxtLink @click="showOptions = true" v-text="'View all options'" />. Not all options are
|
||||||
ignored</NuxtLink>. Use with caution.</span>
|
supported <NuxtLink target="_blank"
|
||||||
|
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some
|
||||||
|
are ignored</NuxtLink>. Use with caution.
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -184,7 +184,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column">
|
<div class="column is-6-tablet is-12-mobile">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label is-inline" for="description">
|
<label class="label is-inline" for="description">
|
||||||
<span class="icon"><i class="fa-solid fa-comment" /></span>
|
<span class="icon"><i class="fa-solid fa-comment" /></span>
|
||||||
|
|
@ -235,6 +235,7 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core'
|
||||||
|
import type { AutoCompleteOptions } from '~/types/autocomplete';
|
||||||
import type { Preset, PresetImport } from '~/types/presets'
|
import type { Preset, PresetImport } from '~/types/presets'
|
||||||
|
|
||||||
const emitter = defineEmits<{
|
const emitter = defineEmits<{
|
||||||
|
|
@ -256,6 +257,15 @@ const import_string = ref<string>('')
|
||||||
const showImport = useStorage<boolean>('showImport', false)
|
const showImport = useStorage<boolean>('showImport', false)
|
||||||
const selected_preset = ref<string>('')
|
const selected_preset = ref<string>('')
|
||||||
const showOptions = ref<boolean>(false)
|
const showOptions = ref<boolean>(false)
|
||||||
|
const ytDlpOpt = ref<AutoCompleteOptions>([])
|
||||||
|
|
||||||
|
watch(() => config.ytdlp_options, newOptions => ytDlpOpt.value = newOptions
|
||||||
|
.filter(opt => !opt.ignored)
|
||||||
|
.flatMap(opt => opt.flags
|
||||||
|
.filter(flag => flag.startsWith('--'))
|
||||||
|
.map(flag => ({ value: flag, description: opt.description || '' }))),
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
const checkInfo = async (): Promise<void> => {
|
const checkInfo = async (): Promise<void> => {
|
||||||
for (const key of ['name']) {
|
for (const key of ['name']) {
|
||||||
|
|
|
||||||
|
|
@ -232,22 +232,20 @@
|
||||||
|
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label is-inline" for="cli_options">
|
<label class="label is-unselectable" for="cli_options">
|
||||||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
||||||
<span>Command options for yt-dlp -
|
<span>Command options for yt-dlp</span>
|
||||||
<NuxtLink @click="showOptions = true" v-text="'View Options'" />
|
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
<div class="control">
|
<TextareaAutocomplete id="cli_options" v-model="form.cli" :options="ytDlpOpt"
|
||||||
<textarea type="text" class="textarea is-pre" v-model="form.cli" id="cli_options"
|
:disabled="addInProgress" />
|
||||||
:disabled="addInProgress"
|
|
||||||
placeholder="command options to use, e.g. --no-embed-metadata --no-embed-thumbnail" />
|
|
||||||
</div>
|
|
||||||
<span class="help is-bold">
|
<span class="help is-bold">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
<span>Not all options are supported <NuxtLink target="_blank"
|
<span>
|
||||||
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some are
|
<NuxtLink @click="showOptions = true" v-text="'View all options'" />. Not all options are
|
||||||
ignored</NuxtLink>. Use with caution.</span>
|
supported <NuxtLink target="_blank"
|
||||||
|
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some
|
||||||
|
are ignored</NuxtLink>. Use with caution.
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -307,6 +305,8 @@
|
||||||
import 'assets/css/bulma-switch.css'
|
import 'assets/css/bulma-switch.css'
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core'
|
||||||
import { CronExpressionParser } from 'cron-parser'
|
import { CronExpressionParser } from 'cron-parser'
|
||||||
|
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue'
|
||||||
|
import type { AutoCompleteOptions } from '~/types/autocomplete'
|
||||||
import type { exported_task, task_item } from '~/types/tasks'
|
import type { exported_task, task_item } from '~/types/tasks'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
|
@ -328,11 +328,20 @@ const showImport = useStorage('showImport', false)
|
||||||
const convertInProgress = ref<boolean>(false)
|
const convertInProgress = ref<boolean>(false)
|
||||||
const import_string = ref<string>('')
|
const import_string = ref<string>('')
|
||||||
const showOptions = ref<boolean>(false)
|
const showOptions = ref<boolean>(false)
|
||||||
|
const ytDlpOpt = ref<AutoCompleteOptions>([])
|
||||||
|
|
||||||
const CHANNEL_REGEX = /^https?:\/\/(?:www\.)?youtube\.com\/(?:(?:channel\/(?<channelId>UC[0-9A-Za-z_-]{22}))|(?:c\/(?<customName>[A-Za-z0-9_-]+))|(?:user\/(?<userName>[A-Za-z0-9_-]+))|(?:@(?<handle>[A-Za-z0-9_-]+)))(?<suffix>\/.*)?\/?$/
|
const CHANNEL_REGEX = /^https?:\/\/(?:www\.)?youtube\.com\/(?:(?:channel\/(?<channelId>UC[0-9A-Za-z_-]{22}))|(?:c\/(?<customName>[A-Za-z0-9_-]+))|(?:user\/(?<userName>[A-Za-z0-9_-]+))|(?:@(?<handle>[A-Za-z0-9_-]+)))(?<suffix>\/.*)?\/?$/
|
||||||
|
|
||||||
const form = reactive<task_item>({ ...props.task })
|
const form = reactive<task_item>({ ...props.task })
|
||||||
|
|
||||||
|
watch(() => config.ytdlp_options, newOptions => ytDlpOpt.value = newOptions
|
||||||
|
.filter(opt => !opt.ignored)
|
||||||
|
.flatMap(opt => opt.flags
|
||||||
|
.filter(flag => flag.startsWith('--'))
|
||||||
|
.map(flag => ({ value: flag, description: opt.description || '' }))),
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!props.task?.preset || '' === props.task.preset) {
|
if (!props.task?.preset || '' === props.task.preset) {
|
||||||
form.preset = toRaw(config.app.default_preset)
|
form.preset = toRaw(config.app.default_preset)
|
||||||
|
|
|
||||||
136
ui/app/components/TextareaAutocomplete.vue
Normal file
136
ui/app/components/TextareaAutocomplete.vue
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
<template>
|
||||||
|
<div class="dropdown" :class="{ 'is-active': showList && filteredOptions.length }" style="width:100%;">
|
||||||
|
<div class="control" style="width:100%;">
|
||||||
|
<textarea v-model="localValue" @input="onInput" @focus="showList = true" @blur="hideList" @keydown="onKeydown"
|
||||||
|
class="textarea" :placeholder="placeholder" autocomplete="off" style="width:100%; position:relative; z-index:2;"
|
||||||
|
rows="4" :disabled="disabled" />
|
||||||
|
</div>
|
||||||
|
<div class="dropdown-menu" role="menu" style="width:100%; z-index:3;">
|
||||||
|
<div class="dropdown-content" style="width:100%; max-height:11em; overflow-y:auto;">
|
||||||
|
<a v-for="(option, idx) in filteredOptions" :key="option.value" @mousedown.prevent="appendFlag(option.value)"
|
||||||
|
:class="['dropdown-item', { 'is-active': idx === highlightedIndex }]"
|
||||||
|
style="display:flex; justify-content:space-between;" ref="dropdownItems">
|
||||||
|
<span class="has-text-weight-bold">{{ option.value }}</span>
|
||||||
|
<span class="has-text-grey-light is-text-overflow" style="margin-left:1em;">{{ option.description }}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, defineModel, defineProps, watch, nextTick } from 'vue'
|
||||||
|
import type { AutoCompleteOptions } from '~/types/autocomplete'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
options: AutoCompleteOptions
|
||||||
|
placeholder?: string
|
||||||
|
disabled?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const model = defineModel<string>()
|
||||||
|
const localValue = ref(model.value || '')
|
||||||
|
const showList = ref(false)
|
||||||
|
const highlightedIndex = ref(-1)
|
||||||
|
const dropdownItems = ref<HTMLElement[]>([])
|
||||||
|
|
||||||
|
watch(model, val => localValue.value = val || '')
|
||||||
|
watch(localValue, val => model.value = val)
|
||||||
|
|
||||||
|
const filteredOptions = computed(() => {
|
||||||
|
const lastWord = localValue.value.split(/\s+/).pop() || ''
|
||||||
|
if (!lastWord) {
|
||||||
|
return props.options
|
||||||
|
}
|
||||||
|
const val = lastWord.toLowerCase()
|
||||||
|
const startsWithFlag = []
|
||||||
|
const includesFlag = []
|
||||||
|
const includesDesc = []
|
||||||
|
for (const opt of props.options) {
|
||||||
|
const flag = opt.value.toLowerCase()
|
||||||
|
const desc = opt.description.toLowerCase()
|
||||||
|
if (flag.startsWith(val)) {
|
||||||
|
startsWithFlag.push(opt)
|
||||||
|
} else if (flag.includes(val)) {
|
||||||
|
includesFlag.push(opt)
|
||||||
|
} else if (desc.includes(val)) {
|
||||||
|
includesDesc.push(opt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...startsWithFlag, ...includesFlag, ...includesDesc]
|
||||||
|
})
|
||||||
|
|
||||||
|
const appendFlag = (flag: string) => {
|
||||||
|
// Only replace the last word if the cursor is at the end and the last word is not followed by a space
|
||||||
|
const value = localValue.value
|
||||||
|
const cursorAtEnd = true // textarea autocomplete only works at end
|
||||||
|
const match = value.match(/(\S+)$/)
|
||||||
|
if (match && cursorAtEnd && !value.endsWith(' ')) {
|
||||||
|
// Replace last word
|
||||||
|
localValue.value = value.slice(0, match.index) + flag
|
||||||
|
} else {
|
||||||
|
// Just append
|
||||||
|
localValue.value += (value && !value.endsWith(' ') ? ' ' : '') + flag
|
||||||
|
}
|
||||||
|
showList.value = false
|
||||||
|
highlightedIndex.value = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
const onInput = () => {
|
||||||
|
showList.value = true
|
||||||
|
highlightedIndex.value = filteredOptions.value.length ? 0 : -1
|
||||||
|
}
|
||||||
|
|
||||||
|
const hideList = () => setTimeout(() => { showList.value = false; highlightedIndex.value = -1 }, 100)
|
||||||
|
|
||||||
|
const onKeydown = (e: KeyboardEvent) => {
|
||||||
|
if (!showList.value || !filteredOptions.value.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageSize = 5
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
highlightedIndex.value = Math.min(highlightedIndex.value + 1, filteredOptions.value.length - 1)
|
||||||
|
nextTick(() => {
|
||||||
|
const el = dropdownItems.value[highlightedIndex.value]
|
||||||
|
if (el) el.scrollIntoView({ block: 'nearest' })
|
||||||
|
})
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0)
|
||||||
|
nextTick(() => {
|
||||||
|
const el = dropdownItems.value[highlightedIndex.value]
|
||||||
|
if (el) el.scrollIntoView({ block: 'nearest' })
|
||||||
|
})
|
||||||
|
} else if (e.key === 'PageDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
highlightedIndex.value = Math.min(highlightedIndex.value + pageSize, filteredOptions.value.length - 1)
|
||||||
|
nextTick(() => {
|
||||||
|
const el = dropdownItems.value[highlightedIndex.value]
|
||||||
|
if (el) el.scrollIntoView({ block: 'nearest' })
|
||||||
|
})
|
||||||
|
} else if (e.key === 'PageUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
highlightedIndex.value = Math.max(highlightedIndex.value - pageSize, 0)
|
||||||
|
nextTick(() => {
|
||||||
|
const el = dropdownItems.value[highlightedIndex.value]
|
||||||
|
if (el) el.scrollIntoView({ block: 'nearest' })
|
||||||
|
})
|
||||||
|
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||||
|
const selected = highlightedIndex.value >= 0 && highlightedIndex.value < filteredOptions.value.length ?
|
||||||
|
filteredOptions.value[highlightedIndex.value] : undefined
|
||||||
|
if (selected) {
|
||||||
|
e.preventDefault()
|
||||||
|
appendFlag(selected.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep dropdownItems ref in sync with rendered items
|
||||||
|
watch(filteredOptions, () => nextTick(() => {
|
||||||
|
const items = document.querySelectorAll('.dropdown-item')
|
||||||
|
dropdownItems.value = Array.from(items) as HTMLElement[]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
@ -153,13 +153,7 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core'
|
||||||
|
import type { YTDLPOption } from '~/types/ytdlp'
|
||||||
type YTDLPOption = {
|
|
||||||
flags: string[],
|
|
||||||
description: string | null,
|
|
||||||
group: string | null,
|
|
||||||
ignored: boolean,
|
|
||||||
}
|
|
||||||
|
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const options = ref<YTDLPOption[]>([])
|
const options = ref<YTDLPOption[]>([])
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,7 @@ import 'assets/css/all.css'
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import * as Sentry from '@sentry/nuxt'
|
import * as Sentry from '@sentry/nuxt'
|
||||||
|
import type { YTDLPOption } from '~/types/ytdlp'
|
||||||
|
|
||||||
const Year = new Date().getFullYear()
|
const Year = new Date().getFullYear()
|
||||||
const selectedTheme = useStorage('theme', 'auto')
|
const selectedTheme = useStorage('theme', 'auto')
|
||||||
|
|
@ -235,13 +236,20 @@ watch(() => config.app.sentry_dsn, dsn => {
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
await handleImage(bg_enable.value)
|
await handleImage(bg_enable.value)
|
||||||
} catch (e) {
|
} catch (e) { }
|
||||||
}
|
|
||||||
|
try {
|
||||||
|
const opts = await request('/api/yt-dlp/options')
|
||||||
|
if (!opts.ok) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const data: Array<YTDLPOption> = await opts.json()
|
||||||
|
config.ytdlp_options = data
|
||||||
|
} catch (e) { }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
applyPreferredColorScheme(selectedTheme.value)
|
applyPreferredColorScheme(selectedTheme.value)
|
||||||
} catch (e) {
|
} catch (e) { }
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(selectedTheme, value => {
|
watch(selectedTheme, value => {
|
||||||
|
|
|
||||||
|
|
@ -339,7 +339,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="columns is-multiline" v-if="tasks && tasks.length > 0">
|
<div class="columns is-multiline" v-if="!toggleForm && tasks && tasks.length > 0">
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<Message title="Tips" class="is-info is-background-info-80" icon="fas fa-info-circle">
|
<Message title="Tips" class="is-info is-background-info-80" icon="fas fa-info-circle">
|
||||||
<span>
|
<span>
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ export const useConfigStore = defineStore('config', () => {
|
||||||
],
|
],
|
||||||
dl_fields: [],
|
dl_fields: [],
|
||||||
folders: [],
|
folders: [],
|
||||||
|
ytdlp_options: [],
|
||||||
paused: false,
|
paused: false,
|
||||||
is_loaded: false,
|
is_loaded: false,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
9
ui/app/types/autocomplete.d.ts
vendored
Normal file
9
ui/app/types/autocomplete.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
interface Option {
|
||||||
|
value: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type AutoCompleteOptions = Option[]
|
||||||
|
|
||||||
|
export type { Option, AutoCompleteOptions }
|
||||||
3
ui/app/types/config.d.ts
vendored
3
ui/app/types/config.d.ts
vendored
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { YTDLPOption } from './ytdlp';
|
||||||
import type { DLField } from "./dl_fields"
|
import type { DLField } from "./dl_fields"
|
||||||
|
|
||||||
type AppConfig = {
|
type AppConfig = {
|
||||||
|
|
@ -77,6 +78,8 @@ type ConfigState = {
|
||||||
dl_fields: DLField[]
|
dl_fields: DLField[]
|
||||||
/** List of folders where files can be saved */
|
/** List of folders where files can be saved */
|
||||||
folders: string[]
|
folders: string[]
|
||||||
|
/** List of yt-dlp options */
|
||||||
|
ytdlp_options: YTDLPOption[]
|
||||||
/** Indicates if downloads are currently paused */
|
/** Indicates if downloads are currently paused */
|
||||||
paused: boolean
|
paused: boolean
|
||||||
/** Indicates if the configuration has been loaded */
|
/** Indicates if the configuration has been loaded */
|
||||||
|
|
|
||||||
8
ui/app/types/ytdlp.d.ts
vendored
Normal file
8
ui/app/types/ytdlp.d.ts
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
type YTDLPOption = {
|
||||||
|
flags: string[],
|
||||||
|
description: string | null,
|
||||||
|
group: string | null,
|
||||||
|
ignored: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { YTDLPOption };
|
||||||
|
|
@ -13,8 +13,8 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@pinia/nuxt": "^0.11.2",
|
"@pinia/nuxt": "^0.11.2",
|
||||||
"@sentry/nuxt": "^10.5.0",
|
"@sentry/nuxt": "^10.5.0",
|
||||||
"@vueuse/core": "^13.6.0",
|
"@vueuse/core": "^13.7.0",
|
||||||
"@vueuse/nuxt": "^13.6.0",
|
"@vueuse/nuxt": "^13.7.0",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
"cron-parser": "^5.3.0",
|
"cron-parser": "^5.3.0",
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
12
uv.lock
12
uv.lock
|
|
@ -1363,7 +1363,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "requests"
|
name = "requests"
|
||||||
version = "2.32.4"
|
version = "2.32.5"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "certifi" },
|
{ name = "certifi" },
|
||||||
|
|
@ -1371,9 +1371,9 @@ dependencies = [
|
||||||
{ name = "idna" },
|
{ name = "idna" },
|
||||||
{ name = "urllib3" },
|
{ name = "urllib3" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258 }
|
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847 },
|
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1572,11 +1572,11 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yt-dlp"
|
name = "yt-dlp"
|
||||||
version = "2025.8.11"
|
version = "2025.8.20"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/80/72/de4a7f9bbfef886c7f0790b8246585310f155e4a6589dd38d846efa932e9/yt_dlp-2025.8.11.tar.gz", hash = "sha256:dc7c120a367fe55e0f711613dc80ea29d3a4e0ed8d66104cebfbe3d36e81fdfc", size = 3045769 }
|
sdist = { url = "https://files.pythonhosted.org/packages/c8/af/d3c81af35ae2aef148d0ff78f001650ce5a7ca73fbd3b271eb9aab4c56ee/yt_dlp-2025.8.20.tar.gz", hash = "sha256:da873bcf424177ab5c3b701fa94ea4cdac17bf3aec5ef37b91f530c90def7bcf", size = 3037484 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/d4/dc/4400fb3e8bccdbd0f6ecf36a8a9aea3290ad98a1c9d19664a5c92d7b2e5d/yt_dlp-2025.8.11-py3-none-any.whl", hash = "sha256:f115d2246c1ab5737772bd4845be057eebb91c0d95125a7577d92288351df7d0", size = 3281677 },
|
{ url = "https://files.pythonhosted.org/packages/33/e8/ebd888100684c10799897296e3061c19ba5559b641f8da218bf48229a815/yt_dlp-2025.8.20-py3-none-any.whl", hash = "sha256:073c97e2a3f9cd0fa6a76142c4ef46ca62b2575c37eaf80d8c3718fd6f3277eb", size = 3266841 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue