Merge pull request #322 from arabcoders/dev

Download as zipfile when downloading multiple files
This commit is contained in:
Abdulmohsen 2025-07-04 19:23:33 +03:00 committed by GitHub
commit b3bd02afc8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 151 additions and 31 deletions

View file

@ -1,10 +1,13 @@
import asyncio
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Any
from urllib.parse import unquote_plus from urllib.parse import unquote_plus
from aiohttp import web from aiohttp import web
from aiohttp.web import Request, Response from aiohttp.web import Request, Response
from app.library.cache import Cache
from app.library.config import Config from app.library.config import Config
from app.library.encoder import Encoder from app.library.encoder import Encoder
from app.library.ffprobe import ffprobe from app.library.ffprobe import ffprobe
@ -309,3 +312,88 @@ async def path_action(request: Request, config: Config) -> Response:
) )
return web.Response(status=web.HTTPOk.status_code) return web.Response(status=web.HTTPOk.status_code)
@route("POST", "api/file/download", "browser.download.prepare")
async def prepare_zip_file(request: Request, config: Config, cache: Cache):
json = await request.json()
if not json or not isinstance(json, list):
return web.json_response({"error": "Invalid parameters."}, status=400)
files: list[str] = []
for f in json:
if not isinstance(f, str):
continue
ref, status = get_file(download_path=config.download_path, file=f)
if status == web.HTTPNotFound.status_code:
continue
files.append(ref)
sc: list[dict] = get_file_sidecar(ref)
if sc:
for side in sc:
for scf in sc[side]:
if isinstance(scf, dict) and "file" in scf:
files.append(scf["file"]) # noqa: PERF401
if not files:
return web.json_response({"error": "No valid files."}, status=400)
import uuid
token = str(uuid.uuid4())
cache.set(f"download:{token}", files, ttl=600)
return web.json_response(
data={"token": token, "files": [str(f.relative_to(config.download_path)) for f in files]},
status=web.HTTPOk.status_code,
)
@route("GET", "api/file/download/{token}", "browser.download.stream")
async def stream_zip_download(request: Request, config: Config, cache: Cache) -> Response | web.StreamResponse:
token: str | None = request.match_info.get("token")
if not token:
return web.json_response({"error": "Download token is required."}, status=web.HTTPBadRequest.status_code)
files: Any | None = cache.get(f"download:{token}")
if not files or not isinstance(files, list):
return web.json_response({"error": "Invalid or expired download token."}, status=web.HTTPBadRequest.status_code)
files: list[Path] = [p for p in files if p.is_file() and p.exists()]
if len(files) < 1:
return web.json_response({"error": "No valid files."}, status=web.HTTPBadRequest.status_code)
from zipstream import ZipStream
rootPath = Path(config.download_path).resolve()
zs = ZipStream()
for file in files:
zs.add_path(str(file), str(file.relative_to(rootPath)))
response = web.StreamResponse(
status=200,
headers={
"Content-Type": "application/zip",
"Content-Disposition": f'attachment; filename="{token}.zip"',
},
)
await response.prepare(request)
try:
LOG.info(f"Streaming zip download for token: '{token}', files: {len(files)}")
for chunk in zs:
if request.transport is None or request.transport.is_closing():
LOG.info("Client disconnected, aborting zip download.")
break
await response.write(chunk)
await response.write_eof()
except asyncio.CancelledError:
LOG.info("Download cancelled by client.")
except Exception as e:
LOG.error(f"Streaming zip download error. {type(e).__name__}: {e}")
finally:
return response # noqa: B012

View file

@ -36,6 +36,7 @@ dependencies = [
"multidict==6.5.1", "multidict==6.5.1",
"dateparser>=1.2.1", "dateparser>=1.2.1",
"defusedxml>=0.7.1", "defusedxml>=0.7.1",
"zipstream-ng>=1.8.0",
] ]
[tool.ruff] [tool.ruff]

View file

@ -4,7 +4,10 @@
<span class="icon"> <span class="icon">
<i :class="showCompleted ? 'fa-solid fa-arrow-up' : 'fa-solid fa-arrow-down'" /> <i :class="showCompleted ? 'fa-solid fa-arrow-up' : 'fa-solid fa-arrow-down'" />
</span> </span>
<span>History <span v-if="hasItems">({{ stateStore.count('history') }})</span></span> <span>
History <span v-if="hasItems">({{ stateStore.count('history') }})</span>
<span v-if="selectedElms.length > 0">&nbsp;- Selected: {{ selectedElms.length }}</span>
</span>
</span> </span>
</h1> </h1>
@ -22,16 +25,19 @@
</span> </span>
</button> </button>
</div> </div>
<div class="column is-half-mobile" v-if="hasDownloaded && hasSelected"> <div class="column is-half-mobile">
<button type="button" class="button is-fullwidth is-link" @click="downloadSelected"> <button type="button" class="button is-fullwidth is-link" @click="downloadSelected"
:disabled="!hasDownloaded || !hasSelected"
v-tooltip="!hasSelected || !hasDownloaded ? '' : 'Download items as zip'">
<span class="icon-text is-block"> <span class="icon-text is-block">
<span class="icon"><i class="fa-solid fa-download" /></span> <span class="icon"><i class="fa-solid fa-compress-alt" /></span>
<span>Download</span> <span>Download</span>
</span> </span>
</button> </button>
</div> </div>
<div class="column is-half-mobile" v-if="hasSelected"> <div class="column is-half-mobile">
<button type="button" class="button is-fullwidth is-danger" @click="deleteSelectedItems"> <button type="button" class="button is-fullwidth is-danger" @click="deleteSelectedItems"
:disabled="!hasSelected">
<span class="icon-text is-block"> <span class="icon-text is-block">
<span class="icon"><i class="fa-solid fa-trash-can" /></span> <span class="icon"><i class="fa-solid fa-trash-can" /></span>
<span>{{ config.app.remove_files ? 'Remove' : 'Clear' }}</span> <span>{{ config.app.remove_files ? 'Remove' : 'Clear' }}</span>
@ -829,33 +835,49 @@ watch(embed_url, v => {
document.querySelector('body').setAttribute("style", `opacity: ${v ? 1 : bg_opacity.value}`) document.querySelector('body').setAttribute("style", `opacity: ${v ? 1 : bg_opacity.value}`)
}) })
const downloadSelected = () => { const downloadSelected = async () => {
if (selectedElms.value.length < 1) { if (selectedElms.value.length < 1) {
toast.error('No items selected.') toast.error('No items selected.')
return return
} }
const body = document.querySelector('body') let files_list = []
for (const key in selectedElms.value) { for (const key in selectedElms.value) {
const item = stateStore.history[selectedElms.value[key]] const item = stateStore.history[selectedElms.value[key]]
if ('finished' !== item.status) { if ('finished' !== item.status || !item.filename) {
continue; continue
} }
files_list.push(item.folder ? item.folder + '/' + item.filename : item.filename)
}
try {
const response = await request('/api/file/download', {
method: 'POST',
credentials: 'include',
body: JSON.stringify(files_list),
});
const json = await response.json();
if (!response.ok) {
toast.error(json.error || 'Failed to start download.');
return
}
const token = json.token
const body = document.querySelector('body')
const link = document.createElement('a'); const link = document.createElement('a');
link.href = makeDownload(config, item); link.href = uri(`/api/file/download/${token}`);
link.setAttribute('target', '_blank');
let name = item?.filename
if (name) {
link.setAttribute('download', name.split('/').reverse()[0]);
}
link.setAttribute('target', '_self');
body.appendChild(link); body.appendChild(link);
link.click(); link.click();
body.removeChild(link); body.removeChild(link);
} catch (e) {
console.error(e);
toast.error(`Error: ${e.message}`);
return;
} }
} }

View file

@ -389,7 +389,7 @@ onUpdated(async () => {
form.value.preset = config.app.default_preset form.value.preset = config.app.default_preset
} }
if (form.value?.preset && !config.presets.some(p => p.name === form.value.preset)) { if (config.isLoaded() && form.value?.preset && !config.presets.some(p => p.name === form.value.preset)) {
form.value.preset = config.app.default_preset form.value.preset = config.app.default_preset
} }
}) })
@ -401,7 +401,7 @@ onMounted(async () => {
form.value.preset = config.app.default_preset form.value.preset = config.app.default_preset
} }
if (form.value?.preset && !config.presets.some(p => p.name === form.value.preset)) { if (config.isLoaded() && form.value?.preset && !config.presets.some(p => p.name === form.value.preset)) {
form.value.preset = config.app.default_preset form.value.preset = config.app.default_preset
} }

View file

@ -4,7 +4,9 @@
<span class="icon"> <span class="icon">
<i class="fas" :class="showQueue ? 'fa-solid fa-arrow-up' : 'fa-solid fa-arrow-down'" /> <i class="fas" :class="showQueue ? 'fa-solid fa-arrow-up' : 'fa-solid fa-arrow-down'" />
</span> </span>
<span>Queue <span v-if="hasQueuedItems">({{ filteredItems.length }})</span></span> <span>Queue <span v-if="hasQueuedItems">({{ filteredItems.length }})</span>
<span v-if="selectedElms.length > 0">&nbsp;- Selected: {{ selectedElms.length }}</span>
</span>
</span> </span>
</h1> </h1>
@ -22,13 +24,9 @@
</button> </button>
</div> </div>
<div class="column is-half-mobile" v-if="('cards' === display_style || hasSelected)"> <div class="column is-half-mobile" v-if="('cards' === display_style || hasSelected)">
<button type="button" class="button is-fullwidth is-danger" :disabled="!hasSelected" @click="cancelSelected"> <button type="button" class="button is-fullwidth is-warning" :disabled="!hasSelected" @click="cancelSelected">
<span class="icon-text is-block"> <span class="icon"><i class="fa-solid fa-eject" /></span>
<span class="icon"> <span>Cancel</span>
<i class="fa-solid fa-trash-can" />
</span>
<span>Cancel Selected</span>
</span>
</button> </button>
</div> </div>
</div> </div>
@ -228,7 +226,7 @@
<span class="icon"><i class="fa-solid fa-play" /></span> <span class="icon"><i class="fa-solid fa-play" /></span>
<span>Play video</span> <span>Play video</span>
</NuxtLink> </NuxtLink>
<hr class="dropdown-divider" v-if="!config.app.basic_mode"/> <hr class="dropdown-divider" v-if="!config.app.basic_mode" />
</template> </template>
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset)" <NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset)"

11
uv.lock
View file

@ -1408,6 +1408,7 @@ dependencies = [
{ name = "regex" }, { name = "regex" },
{ name = "tzdata", marker = "sys_platform == 'win32'" }, { name = "tzdata", marker = "sys_platform == 'win32'" },
{ name = "yt-dlp" }, { name = "yt-dlp" },
{ name = "zipstream-ng" },
] ]
[package.optional-dependencies] [package.optional-dependencies]
@ -1450,5 +1451,15 @@ requires-dist = [
{ name = "regex" }, { name = "regex" },
{ name = "tzdata", marker = "sys_platform == 'win32'" }, { name = "tzdata", marker = "sys_platform == 'win32'" },
{ name = "yt-dlp" }, { name = "yt-dlp" },
{ name = "zipstream-ng", specifier = ">=1.8.0" },
] ]
provides-extras = ["webview"] provides-extras = ["webview"]
[[package]]
name = "zipstream-ng"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ac/16/5d9224baf640214255c34a0a0e9528c8403d2b89e2ba7df9d7cada58beb1/zipstream_ng-1.8.0.tar.gz", hash = "sha256:b7129d2c15d26934b3e1cb22256593b6bdbd03c553c26f4199a5bf05110642bc", size = 35887 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cd/81/11ecdfd5370d6c383f0188a6f2fa2842499e1be617e678d1845f972c6821/zipstream_ng-1.8.0-py3-none-any.whl", hash = "sha256:e7196cb845cf924ed12e7a3b38404ef9e82a5a699801295f5f4cf601449e2bf6", size = 23082 },
]