Download selected as archive when downloading multiple files. Implements #321, Update #310

This commit is contained in:
arabcoders 2025-07-04 19:18:40 +03:00
parent 27476a39eb
commit a5812a7d61
5 changed files with 149 additions and 29 deletions

View file

@ -1,10 +1,13 @@
import asyncio
import logging
from pathlib import Path
from typing import Any
from urllib.parse import unquote_plus
from aiohttp import web
from aiohttp.web import Request, Response
from app.library.cache import Cache
from app.library.config import Config
from app.library.encoder import Encoder
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)
@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",
"dateparser>=1.2.1",
"defusedxml>=0.7.1",
"zipstream-ng>=1.8.0",
]
[tool.ruff]

View file

@ -4,7 +4,10 @@
<span class="icon">
<i :class="showCompleted ? 'fa-solid fa-arrow-up' : 'fa-solid fa-arrow-down'" />
</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>
</h1>
@ -22,16 +25,19 @@
</span>
</button>
</div>
<div class="column is-half-mobile" v-if="hasDownloaded && hasSelected">
<button type="button" class="button is-fullwidth is-link" @click="downloadSelected">
<div class="column is-half-mobile">
<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"><i class="fa-solid fa-download" /></span>
<span class="icon"><i class="fa-solid fa-compress-alt" /></span>
<span>Download</span>
</span>
</button>
</div>
<div class="column is-half-mobile" v-if="hasSelected">
<button type="button" class="button is-fullwidth is-danger" @click="deleteSelectedItems">
<div class="column is-half-mobile">
<button type="button" class="button is-fullwidth is-danger" @click="deleteSelectedItems"
:disabled="!hasSelected">
<span class="icon-text is-block">
<span class="icon"><i class="fa-solid fa-trash-can" /></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}`)
})
const downloadSelected = () => {
const downloadSelected = async () => {
if (selectedElms.value.length < 1) {
toast.error('No items selected.')
return
}
const body = document.querySelector('body')
let files_list = []
for (const key in selectedElms.value) {
const item = stateStore.history[selectedElms.value[key]]
if ('finished' !== item.status) {
continue;
if ('finished' !== item.status || !item.filename) {
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');
link.href = makeDownload(config, item);
let name = item?.filename
if (name) {
link.setAttribute('download', name.split('/').reverse()[0]);
}
link.setAttribute('target', '_self');
link.href = uri(`/api/file/download/${token}`);
link.setAttribute('target', '_blank');
body.appendChild(link);
link.click();
body.removeChild(link);
} catch (e) {
console.error(e);
toast.error(`Error: ${e.message}`);
return;
}
}

View file

@ -4,7 +4,9 @@
<span class="icon">
<i class="fas" :class="showQueue ? 'fa-solid fa-arrow-up' : 'fa-solid fa-arrow-down'" />
</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>
</h1>
@ -22,13 +24,9 @@
</button>
</div>
<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">
<span class="icon-text is-block">
<span class="icon">
<i class="fa-solid fa-trash-can" />
</span>
<span>Cancel Selected</span>
</span>
<button type="button" class="button is-fullwidth is-warning" :disabled="!hasSelected" @click="cancelSelected">
<span class="icon"><i class="fa-solid fa-eject" /></span>
<span>Cancel</span>
</button>
</div>
</div>
@ -228,7 +226,7 @@
<span class="icon"><i class="fa-solid fa-play" /></span>
<span>Play video</span>
</NuxtLink>
<hr class="dropdown-divider" v-if="!config.app.basic_mode"/>
<hr class="dropdown-divider" v-if="!config.app.basic_mode" />
</template>
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset)"

11
uv.lock
View file

@ -1408,6 +1408,7 @@ dependencies = [
{ name = "regex" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
{ name = "yt-dlp" },
{ name = "zipstream-ng" },
]
[package.optional-dependencies]
@ -1450,5 +1451,15 @@ requires-dist = [
{ name = "regex" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
{ name = "yt-dlp" },
{ name = "zipstream-ng", specifier = ">=1.8.0" },
]
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 },
]