diff --git a/.gitignore b/.gitignore index 780ebb4..5734146 100644 --- a/.gitignore +++ b/.gitignore @@ -248,4 +248,5 @@ dist package-lock.json yarn-lock.json -yarn.lock \ No newline at end of file +yarn.lock +*.zip diff --git a/naturewatch_camera_server/FileSaver.py b/naturewatch_camera_server/FileSaver.py index d35ec44..0d9e0dd 100755 --- a/naturewatch_camera_server/FileSaver.py +++ b/naturewatch_camera_server/FileSaver.py @@ -7,7 +7,6 @@ from subprocess import call import zipfile - try: import picamera import picamera.array diff --git a/naturewatch_camera_server/ZipfileGenerator.py b/naturewatch_camera_server/ZipfileGenerator.py new file mode 100644 index 0000000..d0d1687 --- /dev/null +++ b/naturewatch_camera_server/ZipfileGenerator.py @@ -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() + diff --git a/naturewatch_camera_server/data.py b/naturewatch_camera_server/data.py index cbf5128..c6d32ba 100755 --- a/naturewatch_camera_server/data.py +++ b/naturewatch_camera_server/data.py @@ -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__) @@ -28,22 +29,6 @@ def get_photo(filename): return Response("{'NOT_FOUND':'" + filename + "'}", status=404, mimetype='application/json') -@data.route('/download/') -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/', methods=["DELETE"]) def delete_photo(filename): file_path = current_app.user_config["photos_path"] + filename @@ -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) diff --git a/naturewatch_camera_server/static/client/package.json b/naturewatch_camera_server/static/client/package.json index dfa1a56..acac223 100755 --- a/naturewatch_camera_server/static/client/package.json +++ b/naturewatch_camera_server/static/client/package.json @@ -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", diff --git a/naturewatch_camera_server/static/client/src/gallery/ContentSelect.js b/naturewatch_camera_server/static/client/src/gallery/ContentDelete.js similarity index 90% rename from naturewatch_camera_server/static/client/src/gallery/ContentSelect.js rename to naturewatch_camera_server/static/client/src/gallery/ContentDelete.js index 035575f..fcc6022 100755 --- a/naturewatch_camera_server/static/client/src/gallery/ContentSelect.js +++ b/naturewatch_camera_server/static/client/src/gallery/ContentDelete.js @@ -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 = { @@ -25,7 +25,7 @@ class ContentSelect extends React.Component { renderSelectButton() { return( - + ); } @@ -63,7 +63,7 @@ class ContentSelect extends React.Component { return( - + ); @@ -78,7 +78,7 @@ class ContentSelect extends React.Component { } } -ContentSelect.propTypes = { +ContentDelete.propTypes = { isSelectActive: PropTypes.bool.isRequired, onSelectStart: PropTypes.func.isRequired, onDelete: PropTypes.func.isRequired, @@ -86,4 +86,4 @@ ContentSelect.propTypes = { onClearSelection: PropTypes.func.isRequired }; -export default ContentSelect; \ No newline at end of file +export default ContentDelete; \ No newline at end of file diff --git a/naturewatch_camera_server/static/client/src/gallery/ContentDownload.js b/naturewatch_camera_server/static/client/src/gallery/ContentDownload.js new file mode 100755 index 0000000..d130ffe --- /dev/null +++ b/naturewatch_camera_server/static/client/src/gallery/ContentDownload.js @@ -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( + + ); + } + + renderDownloadButtons() { + return( + + + + + + ); + } + + render() { + return( +
+ {this.renderButtons()} +
+ ); + } +} + +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; \ No newline at end of file diff --git a/naturewatch_camera_server/static/client/src/gallery/GalleryComponent.js b/naturewatch_camera_server/static/client/src/gallery/GalleryComponent.js index dbfdb5b..0c14a9f 100755 --- a/naturewatch_camera_server/static/client/src/gallery/GalleryComponent.js +++ b/naturewatch_camera_server/static/client/src/gallery/GalleryComponent.js @@ -1,12 +1,13 @@ import React from 'react'; import {Container, Row, Col} from 'react-bootstrap'; import { Link } from 'react-router-dom'; -//import Gallery from 'react-grid-gallery'; import axios from 'axios'; +import FileDownload from 'js-file-download'; import Header from '../common/Header'; import ContentTypeSelector from './ContentTypeSelector'; -import ContentSelect from './ContentSelect'; +import ContentDelete from './ContentDelete'; import GalleryGrid from './GalleryGrid'; +import ContentDownload from "./ContentDownload"; class GalleryComponent extends React.Component { constructor(props, context) { @@ -17,13 +18,18 @@ class GalleryComponent extends React.Component { this.onSelectStart = this.onSelectStart.bind(this); this.onDelete = this.onDelete.bind(this); this.onDeleteAll = this.onDeleteAll.bind(this); + this.onDownload = this.onDownload.bind(this); + this.onDownloadAll = this.onDownloadAll.bind(this); this.onClearSelection = this.onClearSelection.bind(this); this.onContentSelect = this.onContentSelect.bind(this); + this.downloadPaths = this.downloadPaths.bind(this); this.state = { content: [], showingVideos: false, - isSelectActive: false + isSelectActive: false, + selectType: "none", + isDownloading: false, } } @@ -94,9 +100,9 @@ class GalleryComponent extends React.Component { } } - onSelectStart() { + onSelectStart(type) { this.setState({ - isSelectActive: true + selectType: type }); } @@ -133,9 +139,58 @@ class GalleryComponent extends React.Component { } + onDownloadAll() { + this.setState({isDownloading: true}); + axios({ + url: this.state.showingVideos ? '/data/download/videos.zip' : '/data/download/photos.zip', + method: 'GET', + responseType: 'blob' + }).then((res) => { + FileDownload(res.data, this.state.showingVideos ? 'videos.zip' : 'photos.zip'); + console.log("Downloaded all content."); + this.setState({isDownloading: false}); + }).catch((err) => { + console.error(err); + this.setState({isDownloading: false}); + }); + } + + onDownload() { + this.setState({isDownloading: true}); + let tempContent = this.state.content; + let paths = []; + tempContent.forEach((c) => { + if (c.selected) + paths.push(c.src.substr(c.src.lastIndexOf('/') + 1)); + }); + console.log(paths); + this.downloadPaths(paths); + } + + downloadPaths(paths) { + axios({ + url: this.state.showingVideos ? '/data/download/videos.zip' : '/data/download/photos.zip', + method: 'POST', + responseType: 'blob', + headers: { + 'Content-Type': 'application/json' + }, + data: { + paths: paths + } + }).then((res) => { + FileDownload(res.data, this.state.showingVideos ? 'videos.zip' : 'photos.zip'); + console.log("Downloaded content."); + this.setState({isDownloading: false}); + }).catch((err) => { + console.error(err); + this.setState({isDownloading: false}); + }); + } + onClearSelection() { this.setState({ - isSelectActive: false + selectType: 'none' }, () => { if (this.state.showingVideos) { this.getVideos(); @@ -179,12 +234,20 @@ class GalleryComponent extends React.Component { Back - + @@ -193,7 +256,7 @@ class GalleryComponent extends React.Component {