Merge pull request #483 from arabcoders/dev

Feat: add drop file support for cookies field.
This commit is contained in:
Abdulmohsen 2025-11-12 18:59:49 +03:00 committed by GitHub
commit c1b9034ef8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 985 additions and 746 deletions

View file

@ -136,19 +136,23 @@
</div>
<div class="column is-6-tablet is-12-mobile">
<DLInput id="ytdlpCookies" type="text" label="Cookies" v-model="form.cookies" icon="fa-solid fa-cookie"
:disabled="!socket.isConnected || addInProgress" :placeholder="getDefault('cookies', '')">
<template #help>
<span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Use the <NuxtLink target="_blank"
to="https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp">
Recommended addon</NuxtLink> by yt-dlp to export cookies. <span class="has-text-danger">The
cookies MUST be in Netscape HTTP Cookie format.</span>
</span>
<div class="field">
<label class="label is-unselectable" for="ytdlpCookies">
<span class="icon"><i class="fa-solid fa-cookie" /></span>
<span>Cookies</span>
</label>
<TextDropzone id="ytdlpCookies" v-model="form.cookies" :disabled="!socket.isConnected || addInProgress"
@error="(msg: string) => toast.error(msg)"
:placeholder="getDefault('cookies', 'Leave empty to use default cookies. Or drag & drop a cookie file here.')" />
<span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Use the <NuxtLink target="_blank"
to="https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp">
Recommended addon</NuxtLink> by yt-dlp to export cookies. <span class="has-text-danger">The
cookies MUST be in Netscape HTTP Cookie format.</span>
</span>
</template>
</DLInput>
</span>
</div>
</div>
<template v-if="config.dl_fields.length > 0">
<div class="column is-6-tablet is-12-mobile" v-for="(fi, index) in sortedDLFields"

View file

@ -134,10 +134,8 @@
<span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Use this output template if non are given with URL. if not set, it will defaults to
<code>{{ config.app.output_template }}</code>.
For more information visit <NuxtLink href="https://github.com/yt-dlp/yt-dlp#output-template"
target="_blank">
this page</NuxtLink>.
<code>{{ config.app.output_template }}</code>. For more information visit <NuxtLink
href="https://github.com/yt-dlp/yt-dlp#output-template" target="_blank">this page</NuxtLink>.
</span>
</span>
</div>
@ -170,8 +168,9 @@
Cookies
</label>
<div class="control">
<textarea class="textarea is-pre" id="cookies" v-model="form.cookies" :disabled="addInProgress"
placeholder="Leave empty to use default cookies" />
<TextDropzone id="cookies" v-model="form.cookies" :disabled="addInProgress"
@error="(msg: string) => toast.error(msg)"
placeholder="Leave empty to use default cookies. Or drag & drop a cookie file here." />
</div>
<span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span>

View file

@ -0,0 +1,246 @@
<template>
<div class="file-dropzone is-relative" :class="{ 'is-dragging': isDragging }" @drop.prevent="handleDrop"
@dragover.prevent="handleDragOver" @dragenter.prevent="handleDragEnter" @dragleave.prevent="handleDragLeave"
@click="handleClick">
<textarea ref="textareaRef" class="control textarea is-fullwidth" :class="{ 'is-focused': isDragging }"
:value="model" @input="handleInput" :disabled="disabled" :placeholder="placeholder" :id="id" />
<div v-if="isDragging"
class="dropzone-overlay has-background-success-90 is-flex is-align-items-center is-justify-content-center">
<div class="has-text-centered has-text-dark">
<span class="icon is-large"><i class="fa-solid fa-file-arrow-down fa-3x" /></span>
<p class="mt-3 is-size-5 has-text-weight-bold">Drop file here</p>
</div>
</div>
<input ref="fileInputRef" type="file" :accept="accept" @change="handleFileSelect" style="display: none" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
interface Props {
disabled?: boolean
placeholder?: string
id?: string
accept?: string
maxSize?: number
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
placeholder: '',
id: '',
accept: '',
maxSize: 2 * 1024 * 1024
})
const model = defineModel<string>({ default: '' })
const emit = defineEmits<{ error: [message: string] }>()
const textareaRef = ref<HTMLTextAreaElement | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const isDragging = ref<boolean>(false)
const dragCounter = ref<number>(0)
const handleInput = (event: Event): void => {
const target = event.target as HTMLTextAreaElement
model.value = target.value
}
const handleDragEnter = (event: DragEvent): void => {
if (props.disabled) {
return
}
dragCounter.value++
if (event.dataTransfer?.types.includes('Files')) {
isDragging.value = true
}
}
const handleDragLeave = (_event: DragEvent): void => {
if (props.disabled) {
return
}
dragCounter.value--
if (0 === dragCounter.value) {
isDragging.value = false
}
}
const handleDragOver = (event: DragEvent): void => {
if (props.disabled) {
return
}
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'copy'
}
}
const handleDrop = async (event: DragEvent): Promise<void> => {
if (props.disabled) {
return
}
isDragging.value = false
dragCounter.value = 0
const files = event.dataTransfer?.files
if (!files || 0 === files.length) {
return
}
const file = files[0]
if (file) {
await processFile(file)
}
}
const handleClick = (event: MouseEvent): void => {
if (props.disabled) {
return
}
const selection = window.getSelection()
if (selection && selection.toString().length > 0) {
return
}
if (event && event.target === textareaRef.value) {
return
}
}
const handleFileSelect = async (event: Event): Promise<void> => {
const target = event.target as HTMLInputElement
const files = target.files
if (!files || 0 === files.length) {
return
}
const file = files[0]
if (file) {
await processFile(file)
}
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
const processFile = async (file: File): Promise<void> => {
try {
if (file.size > props.maxSize) {
const sizeMB = (file.size / 1024 / 1024).toFixed(2)
const maxSizeMB = (props.maxSize / 1024 / 1024).toFixed(2)
const errorMsg = `File too large: ${sizeMB}MB. Maximum allowed size is ${maxSizeMB}MB.`
emit('error', errorMsg)
console.error(errorMsg)
return
}
const isBinary = await checkIfBinary(file)
if (isBinary) {
const errorMsg = 'File appears to be binary. Please provide a text file.'
emit('error', errorMsg)
console.error(errorMsg)
return
}
const text = await readFileAsText(file)
model.value = text
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Failed to read file'
emit('error', errorMsg)
console.error('Failed to read file:', error)
}
}
const checkIfBinary = async (file: File): Promise<boolean> => {
const chunkSize = 8192
const blob = file.slice(0, chunkSize)
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (e: ProgressEvent<FileReader>) => {
if (!e.target?.result) {
resolve(false)
return
}
const bytes = new Uint8Array(e.target.result as ArrayBuffer)
let nullBytes = 0
let nonPrintable = 0
for (let i = 0; i < bytes.length; i++) {
const byte = bytes[i]
if (undefined === byte) {
continue
}
if (0 === byte) {
nullBytes++
}
if (9 !== byte && 10 !== byte && 13 !== byte && (byte < 32 || byte > 126)) {
nonPrintable++
}
}
resolve(nullBytes > 0 || (nonPrintable / bytes.length) > 0.3)
}
reader.onerror = () => reject(new Error('Failed to read file for binary check'))
reader.readAsArrayBuffer(blob)
})
}
const readFileAsText = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (e: ProgressEvent<FileReader>) => {
if (e.target?.result) {
resolve(e.target.result as string)
} else {
reject(new Error('Failed to read file'))
}
}
reader.onerror = () => reject(new Error('File reading error'))
reader.readAsText(file)
})
}
</script>
<style>
.file-dropzone {
cursor: text;
}
.dropzone-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 4px;
pointer-events: none;
z-index: 10;
}
.file-dropzone.is-dragging textarea {
box-shadow: --var(--bulma-card-shadow) !important;
border-color: --var(--bulma-success);
}
</style>

View file

@ -3,7 +3,7 @@
<div class="control" style="width:100%;">
<textarea :id="id" ref="textareaRef" v-model="localValue" @input="onInput" @focus="onFocus" @blur="hideList"
@keydown="onKeydown" @keyup="updateCaret" @click="updateCaret" @mouseup="updateCaret"
class="textarea" :placeholder="placeholder" autocomplete="off"
class="control is-fullwidth 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;">

View file

@ -84,8 +84,8 @@
<template>
<div v-if="infoLoaded">
<div style="position: relative;">
<video class="player" ref="video" :poster="uri(thumbnail)" playsinline controls crossorigin="anonymous"
preload="auto" autoplay>
<video class="player" ref="video" :poster="uri(thumbnail)" :width="videoWidth" :height="videoHeight"
playsinline controls crossorigin="anonymous" preload="auto" autoplay>
<source v-for="source in sources" :key="source.src" :src="source.src" @error="source.onerror"
:type="source.type" />
<track v-for="(track, i) in tracks" :key="track.file" :kind="track.kind" :label="track.label"
@ -275,6 +275,8 @@ const artist = ref('')
const title = ref('')
const isAudio = ref(false)
const hasVideoStream = ref(false)
const videoWidth = ref<number | undefined>(undefined)
const videoHeight = ref<number | undefined>(undefined)
const volume = useStorage('player_volume', 1)
const notFirefox = !navigator.userAgent.toLowerCase().includes('firefox')
const infoLoaded = ref(false)
@ -504,6 +506,15 @@ onMounted(async () => {
hasVideoStream.value = Array.isArray(response.ffprobe?.video)
&& response.ffprobe.video.some(s => 'video' === s.codec_type)
// Extract video dimensions to prevent layout reflow
if (hasVideoStream.value && response.ffprobe?.video) {
const videoStream = response.ffprobe.video.find(s => 'video' === s.codec_type)
if (videoStream?.width && videoStream?.height) {
videoWidth.value = videoStream.width
videoHeight.value = videoStream.height
}
}
if (!props.item.extras?.is_video && props.item.extras?.is_audio) {
isAudio.value = true
} else {

View file

@ -154,13 +154,19 @@
<div>
<NuxtLoadingIndicator />
<NuxtPage v-if="config.is_loaded" :isLoading="loadingImage" @reload_bg="() => loadImage(true)" />
<Message v-if="!config.is_loaded" class="has-background-info-90 has-text-dark mt-5"
<Message v-if="!config.is_loaded" class="mt-5" :newStyle="true"
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>
<p v-if="socket.error" class="has-text-danger">
<span class="icon-text">
<span class="icon"><i class="fas fa-triangle-exclamation" /></span>
{{ socket.error }}
</span>
</p>
</Message>
<Markdown @closeModel="() => doc.file = ''" :file="doc.file" v-if="doc.file" />
<ClientOnly>
@ -182,8 +188,8 @@
- <NuxtLink @click="doc.file = '/api/docs/README.md'">README</NuxtLink>
- <NuxtLink @click="doc.file = '/api/docs/API.md'">API</NuxtLink>
- <NuxtLink @click="scrollToTop">
<span class="icon"><i class="fas fa-arrow-up" /></span>
<span>Top</span>
<span class="icon"><i class="fas fa-arrow-up" /></span>
<span>Top</span>
</NuxtLink>
</div>
</div>

View file

@ -13,6 +13,7 @@ export const useSocketStore = defineStore('socket', () => {
const socket = ref<IOSocket | null>(null)
const isConnected = ref<boolean>(false)
const connectionStatus = ref<connectionStatus>('disconnected')
const error = ref<string | null>(null)
const emit = (event: string, data?: any): any => socket.value?.emit(event, data)
const on = (event: string | string[], callback: (...args: any[]) => void, withEvent: boolean = false) => {
@ -72,16 +73,26 @@ export const useSocketStore = defineStore('socket', () => {
connectionStatus.value = 'connecting';
socket.value = io(url, opts)
on("connect_error", (e: any) => console.error("Socket connection error:", e));
on("connect_error", (e: any) => {
isConnected.value = false
if (null === e || undefined === e) {
error.value = 'Connection error: Unknown error';
return;
}
error.value = `Connection error: ${e.type || 'Unknown'}: ${e.message || 'Unknown error'}`;
});
on('connect', () => {
isConnected.value = true
connectionStatus.value = 'connected';
error.value = null;
});
on('disconnect', () => {
isConnected.value = false
connectionStatus.value = 'disconnected';
error.value = 'Disconnected from server.';
});
on('configuration', stream => {
@ -100,6 +111,7 @@ export const useSocketStore = defineStore('socket', () => {
config.add('folders', json.data.folders)
stateStore.addAll('queue', json.data.queue || {})
stateStore.addAll('history', json.data.done || {})
error.value = null;
})
on('item_added', stream => {
@ -215,5 +227,6 @@ export const useSocketStore = defineStore('socket', () => {
socket, isConnected,
getSessionId,
connectionStatus: readonly(connectionStatus) as Readonly<Ref<connectionStatus>>,
error: readonly(error) as Readonly<Ref<string | null>>,
};
});

View file

@ -27,15 +27,15 @@
"cronstrue": "^3.9.0",
"floating-vue": "^5.2.2",
"hls.js": "^1.6.14",
"marked": "^16.4.1",
"marked": "^17.0.0",
"marked-alert": "^2.1.2",
"marked-base-url": "^1.1.7",
"marked-gfm-heading-id": "^4.1.2",
"marked-base-url": "^1.1.8",
"marked-gfm-heading-id": "^4.1.3",
"moment": "^2.30.1",
"nuxt": "^4.2.0",
"nuxt": "^4.2.1",
"pinia": "^3.0.4",
"socket.io-client": "^4.8.1",
"vue": "^3.5.23",
"vue": "^3.5.24",
"vue-router": "^4.6.3",
"vue-toastification": "2.0.0-rc.5"
},
@ -57,7 +57,7 @@
"@typescript-eslint/parser": "^8.46.3",
"eslint": "^9.39.1",
"typescript": "^5.9.3",
"vitest": "^4.0.7",
"vitest": "^4.0.8",
"vue-eslint-parser": "^10.2.0",
"vue-tsc": "^3.1.3"
}

File diff suppressed because it is too large Load diff

30
uv.lock
View file

@ -237,11 +237,11 @@ wheels = [
[[package]]
name = "certifi"
version = "2025.10.5"
version = "2025.11.12"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519 }
sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286 },
{ url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438 },
]
[[package]]
@ -1139,7 +1139,7 @@ wheels = [
[[package]]
name = "pytest"
version = "8.4.2"
version = "9.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@ -1148,21 +1148,21 @@ dependencies = [
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618 }
sdist = { url = "https://files.pythonhosted.org/packages/07/56/f013048ac4bc4c1d9be45afd4ab209ea62822fb1598f40687e6bf45dcea4/pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8", size = 1564125 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750 },
{ url = "https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668 },
]
[[package]]
name = "pytest-asyncio"
version = "1.2.0"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119 }
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095 },
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075 },
]
[[package]]
@ -1649,14 +1649,14 @@ wheels = [
[[package]]
name = "wsproto"
version = "1.2.0"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/4a/44d3c295350d776427904d73c189e10aeae66d7f555bb2feee16d1e4ba5a/wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065", size = 53425 }
sdist = { url = "https://files.pythonhosted.org/packages/91/8d/48e227460422d3f78f52618d8ef7d7a0474c6fcdaddf7f2d1aa25854ea75/wsproto-1.3.1.tar.gz", hash = "sha256:81529992325c28f0d9b86ca66fc973da96eb80ab53410249ce2e502749c7723c", size = 50083 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/58/e860788190eba3bcce367f74d29c4675466ce8dddfba85f7827588416f01/wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736", size = 24226 },
{ url = "https://files.pythonhosted.org/packages/8f/da/539c2d24b13025e54a86ce3215eb9b6297b023937a087db9ef2a436cc7b4/wsproto-1.3.1-py3-none-any.whl", hash = "sha256:297ce79322989c0d286cc158681641cd18bc7632dfb38cf4054696a89179b993", size = 24402 },
]
[[package]]
@ -1739,11 +1739,11 @@ wheels = [
[[package]]
name = "yt-dlp"
version = "2025.10.22"
version = "2025.11.12"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/08/70/cf4bd6c837ab0a709040888caa70d166aa2dfbb5018d1d5c983bf0b50254/yt_dlp-2025.10.22.tar.gz", hash = "sha256:db2d48133222b1d9508c6de757859c24b5cefb9568cf68ccad85dac20b07f77b", size = 3046863 }
sdist = { url = "https://files.pythonhosted.org/packages/cf/41/53ad8c6e74d6627bd598dfbb8ad7c19d5405e438210ad0bbaf1b288387e7/yt_dlp-2025.11.12.tar.gz", hash = "sha256:5f0795a6b8fc57a5c23332d67d6c6acf819a0b46b91a6324bae29414fa97f052", size = 3076928 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/2a/fd184bf97d570841aa86b4aeb84aee93e7957a34059dafd4982157c10bff/yt_dlp-2025.10.22-py3-none-any.whl", hash = "sha256:9c803a9598859f91d0d5bd3337f1506ecb40bbe97f6efbe93bc4461fed344fb2", size = 3248983 },
{ url = "https://files.pythonhosted.org/packages/5f/16/fdebbee6473473a1c0576bd165a50e4a70762484d638c1d59fa9074e175b/yt_dlp-2025.11.12-py3-none-any.whl", hash = "sha256:b47af37bbb16b08efebb36825a280ea25a507c051f93bf413a6e4a0e586c6e79", size = 3279151 },
]
[[package]]