From 387a5e3de35ab1d565f8108789439871e70321bd Mon Sep 17 00:00:00 2001 From: Carey Metcalfe Date: Thu, 7 Oct 2021 09:44:48 -0400 Subject: [PATCH] Switch to using `zipstream-ng` to generate and stream zip files - Fixes zip downloads failing when zipping enough data that Zip64 extensions are required by automatically enabling them if needed. - Fixes zip downloads failing when a file has a datestamp that zipfiles cannot store (pre-1980 or post-2108) by clamping them within the supported range. - Massively speeds up zip downloads by disabling compression (audio files generally don't compress well anyway) - Computes the total size of a generated zip file before streaming it and sets the `Content-Length` header. This allows clients to show a final size and progress bar while downloading, as well as detect if the download fails. - Adds a check to prevent sending an empty zip file to the client if there was no content to download (will error out instead). --- setup.py | 2 +- supysonic/api/media.py | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/setup.py b/setup.py index d5f4363..04a7f0e 100644 --- a/setup.py +++ b/setup.py @@ -18,7 +18,7 @@ reqs = [ "requests>=1.0.0", "mediafile", "watchdog>=0.8.0", - "zipstream", + "zipstream-ng>=1.1.0,<2.0.0", ] setup( diff --git a/supysonic/api/media.py b/supysonic/api/media.py index 3a95ed6..7c2a2ba 100644 --- a/supysonic/api/media.py +++ b/supysonic/api/media.py @@ -22,8 +22,7 @@ from flask import current_app from PIL import Image from pony.orm import ObjectNotFound from xml.etree import ElementTree -from zipfile import ZIP_DEFLATED -from zipstream import ZipFile +from zipstream import ZipStream from ..cache import CacheMiss from ..db import Track, Album, Folder, now @@ -251,16 +250,20 @@ def download_media(): raise NotFound("Folder") # Stream a zip of the tracks + cover art to the client - z = ZipFile(compression=ZIP_DEFLATED) + z = ZipStream(sized=True) for track in rv.tracks: - z.write(track.path, os.path.basename(track.path)) + z.add_path(track.path) cover_path = _cover_from_collection(rv, extract=False) if cover_path: - z.write(cover_path, os.path.basename(cover_path)) + z.add_path(cover_path) + + if not z: + raise GenericError("Nothing to download") resp = Response(z, mimetype="application/zip") resp.headers["Content-Disposition"] = "attachment; filename={}.zip".format(rv.name) + resp.headers["Content-Length"] = len(z) return resp