This commit is contained in:
parent
27476a39eb
commit
a5812a7d61
5 changed files with 149 additions and 29 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ dependencies = [
|
|||
"multidict==6.5.1",
|
||||
"dateparser>=1.2.1",
|
||||
"defusedxml>=0.7.1",
|
||||
"zipstream-ng>=1.8.0",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
|
|
|
|||
|
|
@ -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"> - 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"> - 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
11
uv.lock
|
|
@ -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 },
|
||||
]
|
||||
|
|
|
|||
Loading…
Reference in a new issue