Skip to content

Commit

Permalink
Merge pull request #80 from interactionresearchstudio/feature/downloa…
Browse files Browse the repository at this point in the history
…d-all

Download all / selected content with buttons
  • Loading branch information
mikevanis authored Apr 8, 2021
2 parents 3bbd694 + b7742dd commit 7bf5b0a
Show file tree
Hide file tree
Showing 8 changed files with 233 additions and 38 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -248,4 +248,5 @@ dist

package-lock.json
yarn-lock.json
yarn.lock
yarn.lock
*.zip
1 change: 0 additions & 1 deletion naturewatch_camera_server/FileSaver.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
from subprocess import call
import zipfile


try:
import picamera
import picamera.array
Expand Down
68 changes: 68 additions & 0 deletions naturewatch_camera_server/ZipfileGenerator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
from io import RawIOBase
from zipfile import ZipFile, ZipInfo, ZIP_DEFLATED

import os

class ZipfileGenerator():

class UnseekableStream(RawIOBase):
def __init__(self):
self._buffer = b''

def writable(self):
return True

def write(self, b):
if self.closed:
raise ValueError('Stream was closed!')
self._buffer += b
return len(b)

def get(self):
chunk = self._buffer
self._buffer = b''
return chunk

# Constructor
def __init__(self,
paths = [], # { 'filename':'', 'arcname':'' }
chunk_size = 0x8000):

self.paths = paths
self.chunk_size = chunk_size

# Generator
def get(self):

output = ZipfileGenerator.UnseekableStream()

with ZipFile(output, mode='w') as zf:

for path in self.paths:

try:
if len(path['arcname']) == 0:
path['arcname'] = path['filename']

z_info = ZipInfo.from_file(path['filename'], path['arcname'])

# it's not worth the resources, achieves max 0.1% on JPEGs...
#z_info.compress_type = ZIP_DEFLATED

# should we try to fix the disk timestamps?
# or should it be solved by setting the system time with the browser time?

with open(path['filename'], 'rb') as entry, zf.open(z_info, mode='w') as dest:

for chunk in iter(lambda: entry.read(self.chunk_size), b''):
dest.write(chunk)
# yield chunk of the zip file stream in bytes.
yield output.get()

except FileNotFoundError:
# this should probably be logged, but how?
pass

# ZipFile was closed: get the final bytes
yield output.get()

59 changes: 37 additions & 22 deletions naturewatch_camera_server/data.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from flask import Blueprint, Response, request, json, send_from_directory
from flask import current_app
import time
import json
import os

from .ZipfileGenerator import ZipfileGenerator

data = Blueprint('data', __name__)


Expand All @@ -28,22 +29,6 @@ def get_photo(filename):
return Response("{'NOT_FOUND':'" + filename + "'}", status=404, mimetype='application/json')


@data.route('/download/<filename>')
def get_download(filename):
file_path = current_app.user_config["videos_path"] + filename + '.mp4'
if os.path.isfile(os.path.join(file_path)):
output = current_app.file_saver.download_zip(filename + '.mp4')
return send_from_directory(os.path.join('static/data/videos'), filename + ".mp4" + ".zip", mimetype="application/zip")
else:
return Response("{'NOT_FOUND':'" + filename + "'}", status=404, mimetype='application/json')


@data.route('/download/video')
def download_all():
current_app.file_saver.download_all_video()
return Response("{'NOT_FOUND':'" "'}", status=404, mimetype='application/json')


@data.route('/photos/<filename>', methods=["DELETE"])
def delete_photo(filename):
file_path = current_app.user_config["photos_path"] + filename
Expand Down Expand Up @@ -83,15 +68,45 @@ def delete_video(filename):
return Response('{"ERROR": "' + filename + '"}', status=500, mimetype='application/json')


def construct_directory_list(current_app, path):
def get_all_files(app, src_path):
# just for now... we should take an array of file names
src_list = construct_directory_list(app, src_path)
paths = list(map(lambda fn: {'filename': os.path.join(src_path, fn), 'arcname': fn}, src_list))
return paths


@data.route('/download/videos.zip', methods=["POST", "GET"])
def download_videos():
videos_path = current_app.user_config["videos_path"]
if request.is_json:
body = request.get_json()
paths = list(map(lambda fn: {'filename': os.path.join(videos_path, fn), 'arcname': fn}, body["paths"]))
else:
paths = get_all_files(current_app, videos_path)
return Response(ZipfileGenerator(paths).get(), mimetype='application/zip')


@data.route('/download/photos.zip', methods=["POST", "GET"])
def download_photos():
photos_path = current_app.user_config["photos_path"]
if request.is_json:
body = request.get_json()
paths = list(map(lambda fn: {'filename': os.path.join(photos_path, fn), 'arcname': fn}, body["paths"]))
else:
paths = get_all_files(current_app, photos_path)
return Response(ZipfileGenerator(paths).get(), mimetype='application/zip')


def construct_directory_list(app, path):
files = [f for f in os.listdir(path) if os.path.isfile(os.path.join(path, f))]
files = [f for f in files if f.lower().endswith(('.jpg', '.mp4'))]
files = [f for f in files if not f.lower().startswith('thumb_')]
files.sort(key=lambda f: os.path.getmtime(os.path.join(get_correct_filepath(current_app,f))), reverse=True)
files.sort(key=lambda f: os.path.getmtime(os.path.join(get_correct_filepath(app, f))), reverse=True)
return files

def get_correct_filepath(current_app, path):

def get_correct_filepath(app, path):
if path.lower().endswith('.jpg'):
return os.path.join(current_app.user_config["photos_path"], path)
return os.path.join(app.user_config["photos_path"], path)
elif path.lower().endswith('.mp4'):
return os.path.join(current_app.user_config["videos_path"], path)
return os.path.join(app.user_config["videos_path"], path)
1 change: 1 addition & 0 deletions naturewatch_camera_server/static/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"@material-ui/icons": "^4.2.1",
"axios": "^0.19.0",
"bootstrap": "^4.3.1",
"js-file-download": "^0.4.12",
"prop-types": "^15.7.2",
"react": "^16.8.6",
"react-bootstrap": "^1.0.0-beta.9",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Button, ButtonGroup } from 'react-bootstrap';
import {isBrowser} from 'react-device-detect';
import PropTypes from 'prop-types';

class ContentSelect extends React.Component {
class ContentDelete extends React.Component {
constructor(props) {
super(props);
this.state = {
Expand All @@ -25,7 +25,7 @@ class ContentSelect extends React.Component {

renderSelectButton() {
return(
<Button variant="primary" onClick={this.props.onSelectStart}>Select</Button>
<Button variant="primary" onClick={() => {this.props.onSelectStart('delete')}}>Delete</Button>
);
}

Expand Down Expand Up @@ -63,7 +63,7 @@ class ContentSelect extends React.Component {
return(
<ButtonGroup aria-label="delete">
<Button variant="primary" onClick={this.onDeleteAll}>{this.state.deleteAllText}</Button>
<Button variant="primary" onClick={this.onDelete}>Delete</Button>
<Button variant="primary" onClick={this.onDelete}>Delete Selected</Button>
<Button variant="primary" onClick={this.onClearSelection}>Cancel</Button>
</ButtonGroup>
);
Expand All @@ -78,12 +78,12 @@ class ContentSelect extends React.Component {
}
}

ContentSelect.propTypes = {
ContentDelete.propTypes = {
isSelectActive: PropTypes.bool.isRequired,
onSelectStart: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onDeleteAll: PropTypes.func.isRequired,
onClearSelection: PropTypes.func.isRequired
};

export default ContentSelect;
export default ContentDelete;
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import React from 'react';
import { Button, ButtonGroup } from 'react-bootstrap';
import PropTypes from 'prop-types';

class ContentDownload extends React.Component {
renderButtons() {
if (this.props.isSelectActive) {
return this.renderDownloadButtons();
}
else {
return this.renderDownloadButton();
}
}

renderDownloadButton() {
return(
<Button variant="primary" onClick={() => {this.props.onSelectStart('download')}}>Download</Button>
);
}

renderDownloadButtons() {
return(
<ButtonGroup aria-label="download">
<Button variant="primary" disabled={this.props.isDownloading} onClick={this.props.onDownloadAll}>Download All</Button>
<Button variant="primary" disabled={this.props.isDownloading} onClick={this.props.onDownload}>Download Selected</Button>
<Button variant="primary" onClick={this.props.onClearSelection}>Cancel</Button>
</ButtonGroup>
);
}

render() {
return(
<div className="content-select">
{this.renderButtons()}
</div>
);
}
}

ContentDownload.propTypes = {
isSelectActive: PropTypes.bool.isRequired,
onSelectStart: PropTypes.func.isRequired,
onDownload: PropTypes.func.isRequired,
onDownloadAll: PropTypes.func.isRequired,
onClearSelection: PropTypes.func.isRequired
};

export default ContentDownload;
Loading

0 comments on commit 7bf5b0a

Please sign in to comment.