From 1ecd1f979ebbb63e3858fd16a759041ff69c2bd8 Mon Sep 17 00:00:00 2001
From: Ethan Dalool <git@voussoir.net>
Date: Mon, 12 Dec 2016 19:49:36 -0800
Subject: [PATCH] create exceptions.py and move more constants

---
 README.md           |  21 +++
 constants.py        |  15 +-
 etiquette.py        |  35 ++---
 etiquette_launch.py |   2 +-
 exceptions.py       |  46 ++++++
 helpers.py          | 151 ++++++++++++++++++
 phototagger.py      | 369 +++++++++++---------------------------------
 requirements.txt    |   4 +-
 8 files changed, 343 insertions(+), 300 deletions(-)
 create mode 100644 README.md
 create mode 100644 exceptions.py

diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ab01146
--- /dev/null
+++ b/README.md
@@ -0,0 +1,21 @@
+Etiquette
+=========
+
+This is the readme file.
+
+### Changelog
+
+- **[addition]** A new feature was added.
+- **[bugfix]** Incorrect behavior was fixed.
+- **[change]** An existing feature was slightly modified or parameters were renamed.
+- **[cleanup]** Code was improved, comments were added, or other changes with minor impact on the interface.
+- **[removal]** An old feature was removed.
+
+&nbsp;
+
+- 2016 11 28
+    - **[addition]** Added `etiquette_upgrader.py`. When an update causes the anatomy of the etiquette database to change, I will increment the `phototagger.DATABASE_VERSION` variable, and add a new function to this script that should automatically make all the necessary changes. Until the database is upgraded, phototagger will not start. Don't forget to make backups just in case.
+
+- 2016 11 05
+    - **[addition]** Added the ability to download an album as a `.tar` file. No compression is used. I still need to do more experiments to make sure this is working perfectly.
+
diff --git a/constants.py b/constants.py
index b8156c2..326a92c 100644
--- a/constants.py
+++ b/constants.py
@@ -1,5 +1,18 @@
 import string
 
+ALLOWED_ORDERBY_COLUMNS = [
+    'extension',
+    'width',
+    'height',
+    'ratio',
+    'area',
+    'duration',
+    'bytes',
+    'created',
+    'tagged_at',
+    'random',
+]
+
 # Errors and warnings
 ERROR_DATABASE_OUTOFDATE = 'Database is out-of-date. {current} should be {new}. Please use etiquette_upgrader.py'
 ERROR_INVALID_ACTION = 'Invalid action'
@@ -21,7 +34,7 @@ VALID_TAG_CHARS = string.ascii_lowercase + string.digits + '_'
 
 DEFAULT_ID_LENGTH = 12
 DEFAULT_DBNAME = 'phototagger.db'
-DEFAULT_THUMBDIR = '_etiquette\\site_thumbnails'
+DEFAULT_DATADIR = '.\\_etiquette'
 DEFAULT_DIGEST_EXCLUDE_FILES = [
     DEFAULT_DBNAME,
     'desktop.ini',
diff --git a/etiquette.py b/etiquette.py
index 56430a8..1b7a778 100644
--- a/etiquette.py
+++ b/etiquette.py
@@ -12,20 +12,15 @@ import warnings
 
 import constants
 import decorators
+import exceptions
 import helpers
 import jsonify
 import phototagger
 
-try:
-    sys.path.append('C:\\git\\else\\Bytestring')
-    sys.path.append('C:\\git\\else\\WebstreamZip')
-    import bytestring
-    import webstreamzip
-except ImportError:
-    # pip install
-    # https://raw.githubusercontent.com/voussoir/else/master/_voussoirkit/voussoirkit.zip
-    from vousoirkit import bytestring
-    from vousoirkit import webstreamzip
+# pip install
+# https://raw.githubusercontent.com/voussoir/else/master/_voussoirkit/voussoirkit.zip
+from voussoirkit import bytestring
+from voussoirkit import webstreamzip
 
 site = flask.Flask(__name__)
 site.config.update(
@@ -61,7 +56,7 @@ def delete_synonym(synonym):
     synonym = phototagger.normalize_tagname(synonym)
     try:
         master_tag = P.get_tag(synonym)
-    except phototagger.NoSuchTag:
+    except exceptions.NoSuchTag:
         flask.abort(404, 'That synonym doesnt exist')
 
     if synonym not in master_tag.synonyms():
@@ -79,19 +74,19 @@ def make_json_response(j, *args, **kwargs):
 def P_album(albumid):
     try:
         return P.get_album(albumid)
-    except phototagger.NoSuchAlbum:
+    except exceptions.NoSuchAlbum:
         flask.abort(404, 'That album doesnt exist')
 
 def P_photo(photoid):
     try:
         return P.get_photo(photoid)
-    except phototagger.NoSuchPhoto:
+    except exceptions.NoSuchPhoto:
         flask.abort(404, 'That photo doesnt exist')
 
 def P_tag(tagname):
     try:
         return P.get_tag(tagname)
-    except phototagger.NoSuchTag as e:
+    except exceptions.NoSuchTag as e:
         flask.abort(404, 'That tag doesnt exist: %s' % e)
 
 def send_file(filepath):
@@ -465,7 +460,7 @@ def get_static(filename):
 def get_tags_core(specific_tag=None):
     try:
         tags = P.export_tags(phototagger.tag_export_easybake, specific_tag=specific_tag)
-    except phototagger.NoSuchTag:
+    except exceptions.NoSuchTag:
         flask.abort(404, 'That tag doesnt exist')
     tags = tags.split('\n')
     tags = [t for t in tags if t != '']
@@ -516,7 +511,7 @@ def post_edit_album(albumid):
         tag = request.form[action].strip()
         try:
             tag = P_tag(tag)
-        except phototagger.NoSuchTag:
+        except exceptions.NoSuchTag:
             response = {'error': 'That tag doesnt exist', 'tagname': tag}
             return make_json_response(response, status=404)
         recursive = request.form.get('recursive', False)
@@ -552,7 +547,7 @@ def post_edit_photo(photoid):
 
     try:
         tag = P.get_tag(tag)
-    except phototagger.NoSuchTag:
+    except exceptions.NoSuchTag:
         response = {'error': 'That tag doesnt exist', 'tagname': tag}
         return make_json_response(response, status=404)
 
@@ -595,11 +590,11 @@ def post_edit_tags():
         status = 400
         try:
             response = method(tag)
-        except phototagger.TagTooShort:
+        except exceptions.TagTooShort:
             response = {'error': constants.ERROR_TAG_TOO_SHORT, 'tagname': tag}
-        except phototagger.CantSynonymSelf:
+        except exceptions.CantSynonymSelf:
             response = {'error': constants.ERROR_SYNONYM_ITSELF, 'tagname': tag}
-        except phototagger.NoSuchTag as e:
+        except exceptions.NoSuchTag as e:
             response = {'error': constants.ERROR_NO_SUCH_TAG, 'tagname': tag}
         except ValueError as e:
             response = {'error': e.args[0], 'tagname': tag}
diff --git a/etiquette_launch.py b/etiquette_launch.py
index 2f71625..97f7308 100644
--- a/etiquette_launch.py
+++ b/etiquette_launch.py
@@ -25,5 +25,5 @@ else:
     )
 
 
-print('Starting server')
+print('Starting server on port %d' % port)
 http.serve_forever()
\ No newline at end of file
diff --git a/exceptions.py b/exceptions.py
new file mode 100644
index 0000000..8103941
--- /dev/null
+++ b/exceptions.py
@@ -0,0 +1,46 @@
+class CantSynonymSelf(Exception):
+    pass
+
+class NoSuchAlbum(Exception):
+    pass
+
+class NoSuchGroup(Exception):
+    pass
+
+class NoSuchPhoto(Exception):
+    pass
+
+class NoSuchSynonym(Exception):
+    pass
+
+class NoSuchTag(Exception):
+    pass
+
+
+class PhotoExists(Exception):
+    pass
+
+class TagExists(Exception):
+    pass
+
+class GroupExists(Exception):
+    pass
+
+
+class TagTooLong(Exception):
+    pass
+
+class TagTooShort(Exception):
+    pass
+
+class NotExclusive(Exception):
+    '''
+    For when two or more mutually exclusive actions have been requested.
+    '''
+    pass
+
+class OutOfOrder(Exception):
+    '''
+    For when a requested range (a, b) has b > a
+    '''
+    pass
\ No newline at end of file
diff --git a/helpers.py b/helpers.py
index bfc77dc..37d9f22 100644
--- a/helpers.py
+++ b/helpers.py
@@ -1,6 +1,10 @@
 import math
+import mimetypes
+import os
 
+import exceptions
 import constants
+import warnings
 
 def chunk_sequence(sequence, chunk_length, allow_incomplete=True):
     '''
@@ -67,6 +71,42 @@ def fit_into_bounds(image_width, image_height, frame_width, frame_height):
 
     return (new_width, new_height)
 
+def get_mimetype(filepath):
+    extension = os.path.splitext(filepath)[1].replace('.', '')
+    if extension in constants.ADDITIONAL_MIMETYPES:
+        return constants.ADDITIONAL_MIMETYPES[extension]
+    mimetype = mimetypes.guess_type(filepath)[0]
+    if mimetype is not None:
+        mimetype = mimetype.split('/')[0]
+    return mimetype
+
+def hyphen_range(s):
+    '''
+    Given a string like '1-3', return ints (1, 3) representing lower
+    and upper bounds.
+
+    Supports bytestring.parsebytes and hh:mm:ss format.
+    '''
+    s = s.strip()
+    s = s.replace(' ', '')
+    if not s:
+        return (None, None)
+    parts = s.split('-')
+    parts = [part.strip() or None for part in parts]
+    if len(parts) == 1:
+        low = parts[0]
+        high = None
+    elif len(parts) == 2:
+        (low, high) = parts
+    else:
+        raise ValueError('Too many hyphens')
+
+    low = _unitconvert(low)
+    high = _unitconvert(high)
+    if low is not None and high is not None and low > high:
+        raise exceptions.OutOfOrder(s, low, high)
+    return low, high
+
 def hms_to_seconds(hms):
     '''
     Convert hh:mm:ss string to an integer seconds.
@@ -133,3 +173,114 @@ def truthystring(s):
     if s in {'null', 'none'}:
         return None
     return False
+
+#===============================================================================
+
+def _minmax(key, value, minimums, maximums):
+    '''
+    When searching, this function dissects a hyphenated range string
+    and inserts the correct k:v pair into both minimums and maximums.
+    ('area', '100-200', {}, {}) --> {'area': 100}, {'area': 200} (MODIFIED IN PLACE)
+    '''
+    if value is None:
+        return
+    if isinstance(value, (int, float)):
+        minimums[key] = value
+        return
+    try:
+        (low, high) = hyphen_range(value)
+    except ValueError:
+        warnings.warn(constants.WARNING_MINMAX_INVALID.format(field=key, value=value))
+        return
+    except exceptions.OutOfOrder as e:
+        warnings.warn(constants.WARNING_MINMAX_OOO.format(field=key, min=e.args[1], max=e.args[2]))
+        return
+    if low is not None:
+        minimums[key] = low
+    if high is not None:
+        maximums[key] = high
+
+def _normalize_extensions(extensions):
+    '''
+    When searching, this function normalizes the list of inputted extensions.
+    '''
+    if isinstance(extensions, str):
+        extensions = extensions.split()
+    if extensions is None:
+        return set()
+    extensions = [e.lower().strip('.').strip() for e in extensions]
+    extensions = set(e for e in extensions if e)
+    return extensions
+
+def _orderby(orderby):
+    '''
+    When searching, this function ensures that the user has entered a valid orderby
+    query, and normalizes the query text.
+
+    'random asc' --> ('random', 'asc')
+    'area' --> ('area', 'desc')
+    '''
+    orderby = orderby.lower().strip()
+    if orderby == '':
+        return None
+
+    orderby = orderby.split(' ')
+    if len(orderby) == 2:
+        (column, sorter) = orderby
+    elif len(orderby) == 1:
+        column = orderby[0]
+        sorter = 'desc'
+    else:
+        return None
+
+    #print(column, sorter)
+    if column not in constants.ALLOWED_ORDERBY_COLUMNS:
+        warnings.warn(constants.WARNING_ORDERBY_BADCOL.format(column=column))
+        return None
+    if column == 'random':
+        column = 'RANDOM()'
+
+    if sorter not in ['desc', 'asc']:
+        warnings.warn(constants.WARNING_ORDERBY_BADSORTER.format(column=column, sorter=sorter))
+        sorter = 'desc'
+    return (column, sorter)
+
+def _setify_tags(photodb, tags, warn_bad_tags=False):
+    '''
+    When searching, this function converts the list of tag strings that the user
+    requested into Tag objects. If a tag doesn't exist we'll either raise an exception
+    or just issue a warning.
+    '''
+    if tags is None:
+        return set()
+
+    tagset = set()
+    for tag in tags:
+        tag = tag.strip()
+        if tag == '':
+            continue
+        try:
+            tag = photodb.get_tag(tag)
+            tagset.add(tag)
+        except NoSuchTag:
+            if warn_bad_tags:
+                warnings.warn(constants.WARNING_NO_SUCH_TAG.format(tag=tag))
+                continue
+            else:
+                raise
+
+    return tagset
+
+def _unitconvert(value):
+    '''
+    When parsing hyphenated ranges, this function is used to convert
+    strings like "1k" to 1024 and "1:00" to 60.
+    '''
+    if value is None:
+        return None
+    if ':' in value:
+        return helpers.hms_to_seconds(value)
+    elif all(c in '0123456789.' for c in value):
+        return float(value)
+    else:
+        return bytestring.parsebytes(value)
diff --git a/phototagger.py b/phototagger.py
index 7df3e13..798e17c 100644
--- a/phototagger.py
+++ b/phototagger.py
@@ -17,21 +17,14 @@ import warnings
 
 import constants
 import decorators
+import exceptions
 import helpers
 
-try:
-    sys.path.append('C:\\git\\else\\Bytestring')
-    sys.path.append('C:\\git\\else\\Pathclass')
-    sys.path.append('C:\\git\\else\\SpinalTap')
-    import bytestring
-    import pathclass
-    import spinal
-except ImportError:
-    # pip install
-    # https://raw.githubusercontent.com/voussoir/else/master/_voussoirkit/voussoirkit.zip
-    from voussoirkit import bytestring
-    from voussoirkit import pathclass
-    from voussoirkit import spinal
+# pip install
+# https://raw.githubusercontent.com/voussoir/else/master/_voussoirkit/voussoirkit.zip
+from voussoirkit import bytestring
+from voussoirkit import pathclass
+from voussoirkit import spinal
 
 try:
     ffmpeg = converter.Converter(
@@ -185,164 +178,11 @@ CREATE INDEX IF NOT EXISTS index_grouprel_parentid on tag_group_rel(parentid);
 CREATE INDEX IF NOT EXISTS index_grouprel_memberid on tag_group_rel(memberid);
 '''.format(user_version=DATABASE_VERSION)
 
-ALLOWED_ORDERBY_COLUMNS = [
-    'extension',
-    'width',
-    'height',
-    'ratio',
-    'area',
-    'duration',
-    'bytes',
-    'created',
-    'tagged_at',
-    'random',
-]
-
-def _helper_extension(ext):
-    '''
-    When searching, this function normalizes the list of permissible extensions.
-    '''
-    if isinstance(ext, str):
-        ext = [ext]
-    if ext is None:
-        return set()
-    ext = [e.lower().strip('.') for e in ext]
-    ext = [e for e in ext if e]
-    ext = set(ext)
-    return ext
 
 def _helper_filenamefilter(subject, terms):
     basename = subject.lower()
     return all(term in basename for term in terms)
 
-def _helper_minmax(key, value, minimums, maximums):
-    '''
-    When searching, this function dissects a hyphenated range string
-    and inserts the correct k:v pair into both minimums and maximums.
-    '''
-    if value is None:
-        return
-    if isinstance(value, (int, float)):
-        minimums[key] = value
-        return
-    try:
-        (low, high) = hyphen_range(value)
-    except ValueError:
-        warnings.warn(constants.WARNING_MINMAX_INVALID.format(field=key, value=value))
-        return
-    except OutOfOrder as e:
-        warnings.warn(constants.WARNING_MINMAX_OOO.format(field=key, min=e.args[1], max=e.args[2]))
-        return
-    if low is not None:
-        minimums[key] = low
-    if high is not None:
-        maximums[key] = high
-
-def _helper_orderby(orderby):
-    '''
-    When searching, this function ensures that the user has entered a valid orderby
-    query, and normalizes the query text.
-    '''
-    orderby = orderby.lower().strip()
-    if orderby == '':
-        return None
-
-    orderby = orderby.split(' ')
-    if len(orderby) == 2:
-        (column, sorter) = orderby
-    elif len(orderby) == 1:
-        column = orderby[0]
-        sorter = 'desc'
-    else:
-        return None
-
-    #print(column, sorter)
-    if column not in ALLOWED_ORDERBY_COLUMNS:
-        warnings.warn(constants.WARNING_ORDERBY_BADCOL.format(column=column))
-        return None
-    if column == 'random':
-        column = 'RANDOM()'
-
-    if sorter not in ['desc', 'asc']:
-        warnings.warn(constants.WARNING_ORDERBY_BADSORTER.format(column=column, sorter=sorter))
-        sorter = 'desc'
-    return (column, sorter)
-
-def _helper_setify(photodb, l, warn_bad_tags=False):
-    '''
-    When searching, this function converts the list of tag strings that the user
-    requested into Tag objects. If a tag doesn't exist we'll either raise an exception
-    or just issue a warning.
-    '''
-    if l is None:
-        return set()
-
-    s = set()
-    for tag in l:
-        tag = tag.strip()
-        if tag == '':
-            continue
-        try:
-            tag = photodb.get_tag(tag)
-        except NoSuchTag:
-            if not warn_bad_tags:
-                raise
-            warnings.warn(constants.WARNING_NO_SUCH_TAG.format(tag=tag))
-            continue
-        else:
-            s.add(tag)
-    return s
-
-def _helper_unitconvert(value):
-    '''
-    When parsing hyphenated ranges, this function is used to convert
-    strings like "1k" to 1024 and "1:00" to 60.
-    '''
-    if value is None:
-        return None
-    if ':' in value:
-        return helpers.hms_to_seconds(value)
-    elif all(c in '0123456789.' for c in value):
-        return float(value)
-    else:
-        return bytestring.parsebytes(value)
-
-def hyphen_range(s):
-    '''
-    Given a string like '1-3', return ints (1, 3) representing lower
-    and upper bounds.
-
-    Supports bytestring.parsebytes and hh:mm:ss format.
-    '''
-    s = s.strip()
-    s = s.replace(' ', '')
-    if not s:
-        return (None, None)
-    parts = s.split('-')
-    parts = [part.strip() or None for part in parts]
-    if len(parts) == 1:
-        low = parts[0]
-        high = None
-    elif len(parts) == 2:
-        (low, high) = parts
-    else:
-        raise ValueError('Too many hyphens')
-
-    low = _helper_unitconvert(low)
-    high = _helper_unitconvert(high)
-    if low is not None and high is not None and low > high:
-        raise OutOfOrder(s, low, high)
-    return low, high
-
-def get_mimetype(filepath):
-    extension = os.path.splitext(filepath)[1].replace('.', '')
-    if extension in constants.ADDITIONAL_MIMETYPES:
-        return constants.ADDITIONAL_MIMETYPES[extension]
-    mimetype = mimetypes.guess_type(filepath)[0]
-    if mimetype is not None:
-        mimetype = mimetype.split('/')[0]
-    return mimetype
-
 def getnow(timestamp=True):
     '''
     Return the current UTC timestamp or datetime object.
@@ -376,9 +216,9 @@ def normalize_tagname(tagname):
     tagname = ''.join(tagname)
 
     if len(tagname) < constants.MIN_TAG_NAME_LENGTH:
-        raise TagTooShort(tagname)
+        raise exceptions.TagTooShort(tagname)
     if len(tagname) > constants.MAX_TAG_NAME_LENGTH:
-        raise TagTooLong(tagname)
+        raise exceptions.TagTooLong(tagname)
 
     return tagname
 
@@ -437,9 +277,9 @@ def searchfilter_expression(photo_tags, expression, frozen_children, warn_bad_ta
                 value = any(option in photo_tags for option in frozen_children[token])
             except KeyError:
                 if warn_bad_tags:
-                    warnings.warn(constants.NO_SUCH_TAG.format(tag=token))
+                    warnings.warn(constants.WARNING_NO_SUCH_TAG.format(tag=token))
                 else:
-                    raise NoSuchTag(token)
+                    raise exceptions.NoSuchTag(token)
                 return False
             operand_stack.append(value)
             if has_operand:
@@ -573,50 +413,6 @@ def tag_export_totally_flat(tags):
                 result[synonym] = children
     return result
 
-####################################################################################################
-####################################################################################################
-
-class CantSynonymSelf(Exception):
-    pass
-
-class NoSuchAlbum(Exception):
-    pass
-
-class NoSuchGroup(Exception):
-    pass
-
-class NoSuchPhoto(Exception):
-    pass
-
-class NoSuchSynonym(Exception):
-    pass
-
-class NoSuchTag(Exception):
-    pass
-
-
-class PhotoExists(Exception):
-    pass
-
-class TagExists(Exception):
-    pass
-
-class GroupExists(Exception):
-    pass
-
-
-class TagTooLong(Exception):
-    pass
-
-class TagTooShort(Exception):
-    pass
-
-class XORException(Exception):
-    pass
-
-class OutOfOrder(Exception):
-    pass
-
 
 ####################################################################################################
 ####################################################################################################
@@ -632,15 +428,16 @@ class PDBAlbumMixin:
         '''
         filepath = os.path.abspath(filepath)
         self.cur.execute('SELECT * FROM albums WHERE associated_directory == ?', [filepath])
-        f = self.cur.fetchone()
-        if f is None:
-            raise NoSuchAlbum(filepath)
-        return self.get_album(f[SQL_ALBUM['id']])
+        fetch = self.cur.fetchone()
+        if fetch is None:
+            raise exceptions.NoSuchAlbum(filepath)
+        return self.get_album(fetch[SQL_ALBUM['id']])
 
     def get_albums(self):
         yield from self.get_things(thing_type='album')
 
-    def new_album(self,
+    def new_album(
+            self,
             associated_directory=None,
             commit=True,
             description=None,
@@ -691,7 +488,7 @@ class PDBPhotoMixin:
         self.cur.execute('SELECT * FROM photos WHERE filepath == ?', [filepath])
         fetch = self.cur.fetchone()
         if fetch is None:
-            raise_no_such_thing(NoSuchPhoto, thing_name=filepath)
+            raise_no_such_thing(exceptions.NoSuchPhoto, thing_name=filepath)
         photo = Photo(self, fetch)
         return photo
 
@@ -706,10 +503,10 @@ class PDBPhotoMixin:
         temp_cur = self.sql.cursor()
         temp_cur.execute('SELECT * FROM photos ORDER BY created DESC')
         while True:
-            f = temp_cur.fetchone()
-            if f is None:
+            fetch = temp_cur.fetchone()
+            if fetch is None:
                 break
-            photo = Photo(self, f)
+            photo = Photo(self, fetch)
 
             yield photo
 
@@ -750,7 +547,7 @@ class PDBPhotoMixin:
         database. Tags may be applied now or later.
 
         If `allow_duplicates` is False, we will first check the database for any files
-        with the same path and raise PhotoExists if found.
+        with the same path and raise exceptions.PhotoExists if found.
 
         Returns the Photo object.
         '''
@@ -759,10 +556,10 @@ class PDBPhotoMixin:
         if not allow_duplicates:
             try:
                 existing = self.get_photo_by_path(filename)
-            except NoSuchPhoto:
+            except exceptions.NoSuchPhoto:
                 pass
             else:
-                exc = PhotoExists(filename, existing)
+                exc = exceptions.PhotoExists(filename, existing)
                 exc.photo = existing
                 raise exc
 
@@ -874,7 +671,7 @@ class PDBPhotoMixin:
         QUERY OPTIONS
         warn_bad_tags:
             If a tag is not found, issue a warning but continue the search.
-            Otherwise, a NoSuchTag exception would be raised.
+            Otherwise, a exceptions.NoSuchTag exception would be raised.
 
         limit:
             The maximum number of *successful* results to yield.
@@ -890,18 +687,18 @@ class PDBPhotoMixin:
         start_time = time.time()
         maximums = {}
         minimums = {}
-        _helper_minmax('area', area, minimums, maximums)
-        _helper_minmax('created', created, minimums, maximums)
-        _helper_minmax('width', width, minimums, maximums)
-        _helper_minmax('height', height, minimums, maximums)
-        _helper_minmax('ratio', ratio, minimums, maximums)
-        _helper_minmax('bytes', bytes, minimums, maximums)
-        _helper_minmax('duration', duration, minimums, maximums)
+        helpers._minmax('area', area, minimums, maximums)
+        helpers._minmax('created', created, minimums, maximums)
+        helpers._minmax('width', width, minimums, maximums)
+        helpers._minmax('height', height, minimums, maximums)
+        helpers._minmax('ratio', ratio, minimums, maximums)
+        helpers._minmax('bytes', bytes, minimums, maximums)
+        helpers._minmax('duration', duration, minimums, maximums)
         orderby = orderby or []
 
-        extension = _helper_extension(extension)
-        extension_not = _helper_extension(extension_not)
-        mimetype = _helper_extension(mimetype)
+        extension = helpers._normalize_extensions(extension)
+        extension_not = helpers._normalize_extensions(extension_not)
+        mimetype = helpers._normalize_extensions(mimetype)
 
         if filename is not None:
             if not isinstance(filename, str):
@@ -909,14 +706,14 @@ class PDBPhotoMixin:
             filename = set(term.lower() for term in filename.strip().split(' '))
 
         if (tag_musts or tag_mays or tag_forbids) and tag_expression:
-            raise XORException('Expression filter cannot be used with musts, mays, forbids')
+            raise exceptions.NotExclusive('Expression filter cannot be used with musts, mays, forbids')
 
-        tag_musts = _helper_setify(self, tag_musts, warn_bad_tags=warn_bad_tags)
-        tag_mays = _helper_setify(self, tag_mays, warn_bad_tags=warn_bad_tags)
-        tag_forbids = _helper_setify(self, tag_forbids, warn_bad_tags=warn_bad_tags)
+        tag_musts = helpers._setify_tags(photodb=self, tags=tag_musts, warn_bad_tags=warn_bad_tags)
+        tag_mays = helpers._setify_tags(photodb=self, tags=tag_mays, warn_bad_tags=warn_bad_tags)
+        tag_forbids = helpers._setify_tags(photodb=self, tags=tag_forbids, warn_bad_tags=warn_bad_tags)
 
         query = 'SELECT * FROM photos'
-        orderby = [_helper_orderby(o) for o in orderby]
+        orderby = [helpers._orderby(o) for o in orderby]
         orderby = [o for o in orderby if o]
         if orderby:
             whereable_columns = [o[0] for o in orderby if o[0] != 'RANDOM()']
@@ -1025,14 +822,14 @@ class PDBTagMixin:
         Redirect to get_tag_by_id or get_tag_by_name after xor-checking the parameters.
         '''
         if not helpers.is_xor(id, name):
-            raise XORException('One and only one of `id`, `name` can be passed.')
+            raise exceptions.NotExclusive('One and only one of `id`, `name` can be passed.')
 
         if id is not None:
             return self.get_tag_by_id(id)
         elif name is not None:
             return self.get_tag_by_name(name)
         else:
-            raise_no_such_thing(NoSuchTag, thing_id=id, thing_name=name)
+            raise_no_such_thing(exceptions.NoSuchTag, thing_id=id, thing_name=name)
 
     def get_tag_by_id(self, id):
         return self.get_thing_by_id('tag', thing_id=id)
@@ -1055,7 +852,7 @@ class PDBTagMixin:
             fetch = self.cur.fetchone()
             if fetch is None:
                 # was not a top tag or synonym
-                raise_no_such_thing(NoSuchTag, thing_name=tagname)
+                raise_no_such_thing(exceptions.NoSuchTag, thing_name=tagname)
             tagname = fetch[SQL_SYN['master']]
 
     def get_tags(self):
@@ -1068,10 +865,10 @@ class PDBTagMixin:
         tagname = normalize_tagname(tagname)
         try:
             self.get_tag_by_name(tagname)
-        except NoSuchTag:
+        except exceptions.NoSuchTag:
             pass
         else:
-            raise TagExists(tagname)
+            raise exceptions.TagExists(tagname)
 
         tagid = self.generate_id('tags')
         self._cached_frozen_children = None
@@ -1121,10 +918,17 @@ class PhotoDB(PDBAlbumMixin, PDBPhotoMixin, PDBTagMixin):
     '''
     def __init__(
             self,
-            databasename=constants.DEFAULT_DBNAME,
-            thumbnail_folder=constants.DEFAULT_THUMBDIR,
-            id_length=constants.DEFAULT_ID_LENGTH,
+            databasename=None,
+            data_directory=None,
+            id_length=None,
         ):
+        if databasename is None:
+            databasename = constants.DEFAULT_DBNAME
+        if data_directory is None:
+            data_directory = constants.DEFAULT_DATADIR
+        if id_length is None:
+            id_length = constants.DEFAULT_ID_LENGTH
+
         self.databasename = databasename
         self.database_abspath = os.path.abspath(databasename)
         existing_database = os.path.exists(databasename)
@@ -1143,8 +947,11 @@ class PhotoDB(PDBAlbumMixin, PDBPhotoMixin, PDBTagMixin):
         for statement in statements:
             self.cur.execute(statement)
 
-        self.thumbnail_folder = os.path.abspath(thumbnail_folder)
-        os.makedirs(thumbnail_folder, exist_ok=True)
+
+        self.data_directory = data_directory
+        self.thumbnail_folder = os.path.join(data_directory, 'site_thumbnails')
+        self.thumbnail_folder = os.path.abspath(self.thumbnail_folder)
+        os.makedirs(self.thumbnail_folder, exist_ok=True)
 
         self.id_length = id_length
 
@@ -1189,7 +996,7 @@ class PhotoDB(PDBAlbumMixin, PDBPhotoMixin, PDBTagMixin):
         )
         try:
             album = self.get_album_by_path(directory.absolute_path)
-        except NoSuchAlbum:
+        except exceptions.NoSuchAlbum:
             album = self.new_album(
                 associated_directory=directory.absolute_path,
                 commit=False,
@@ -1202,7 +1009,7 @@ class PhotoDB(PDBAlbumMixin, PDBPhotoMixin, PDBTagMixin):
             if current_album is None:
                 try:
                     current_album = self.get_album_by_path(current_location.absolute_path)
-                except NoSuchAlbum:
+                except exceptions.NoSuchAlbum:
                     current_album = self.new_album(
                         associated_directory=current_location.absolute_path,
                         commit=False,
@@ -1213,13 +1020,13 @@ class PhotoDB(PDBAlbumMixin, PDBPhotoMixin, PDBTagMixin):
                 parent = albums[current_location.parent.absolute_path]
                 try:
                     parent.add(current_album, commit=False)
-                except GroupExists:
+                except exceptions.GroupExists:
                     pass
                 #print('Added to %s' % parent.title)
             for filepath in files:
                 try:
                     photo = self.new_photo(filepath.absolute_path, commit=False)
-                except PhotoExists as e:
+                except exceptions.PhotoExists as e:
                     photo = e.photo
                 current_album.add_photo(photo, commit=False)
 
@@ -1259,7 +1066,7 @@ class PhotoDB(PDBAlbumMixin, PDBPhotoMixin, PDBTagMixin):
             filepath = filepath.absolute_path
             try:
                 photo = self.get_photo_by_path(filepath)
-            except NoSuchPhoto:
+            except exceptions.NoSuchPhoto:
                 pass
             else:
                 continue
@@ -1282,7 +1089,7 @@ class PhotoDB(PDBAlbumMixin, PDBPhotoMixin, PDBTagMixin):
             try:
                 item = self.get_tag(name)
                 note = ('existing_tag', item.qualified_name())
-            except NoSuchTag:
+            except exceptions.NoSuchTag:
                 item = self.new_tag(name)
                 note = ('new_tag', item.qualified_name())
             output_notes.append(note)
@@ -1330,7 +1137,7 @@ class PhotoDB(PDBAlbumMixin, PDBPhotoMixin, PDBTagMixin):
                     lower.join_group(higher)
                     note = ('join_group', '%s.%s' % (higher.name, lower.name))
                     output_notes.append(note)
-                except GroupExists:
+                except exceptions.GroupExists:
                     pass
             tag = tags[-1]
 
@@ -1340,7 +1147,7 @@ class PhotoDB(PDBAlbumMixin, PDBPhotoMixin, PDBTagMixin):
                 note = ('new_synonym', '%s+%s' % (tag.name, synonym))
                 output_notes.append(note)
                 print('New syn %s' % synonym)
-            except TagExists:
+            except exceptions.TagExists:
                 pass
         return output_notes
 
@@ -1405,19 +1212,19 @@ class PhotoDB(PDBAlbumMixin, PDBPhotoMixin, PDBTagMixin):
             'album':
             {
                 'class': Album,
-                'exception': NoSuchAlbum,
+                'exception': exceptions.NoSuchAlbum,
                 'table': 'albums',
             },
             'tag':
             {
                 'class': Tag,
-                'exception': NoSuchTag,
+                'exception': exceptions.NoSuchTag,
                 'table': 'tags',
             },
             'photo':
             {
                 'class': Photo,
-                'exception': NoSuchPhoto,
+                'exception': exceptions.NoSuchPhoto,
                 'table': 'photos',
             },
         }[thing_type]
@@ -1447,7 +1254,7 @@ class GroupableMixin:
         '''
         Add a Tag object to this group.
 
-        If that object is already a member of another group, a GroupExists is raised.
+        If that object is already a member of another group, a exceptions.GroupExists is raised.
         '''
         if not isinstance(member, type(self)):
             raise TypeError('Member must be of type %s' % type(self))
@@ -1459,7 +1266,7 @@ class GroupableMixin:
                 that_group = self
             else:
                 that_group = self.group_getter(id=fetch[SQL_TAGGROUP['parentid']])
-            raise GroupExists('%s already in group %s' % (member.name, that_group.name))
+            raise exceptions.GroupExists('%s already in group %s' % (member.name, that_group.name))
 
         self.photodb._cached_frozen_children = None
         self.photodb.cur.execute('INSERT INTO tag_group_rel VALUES(?, ?)', [self.id, member.id])
@@ -1740,7 +1547,7 @@ class Photo(ObjectBase):
         for tag in other_photo.tags():
             self.add_tag(tag)
 
-    def delete(self, commit=True):
+    def delete(self, delete_file=False, commit=True):
         '''
         Delete the Photo and its relation to any tags and albums.
         '''
@@ -1748,6 +1555,14 @@ class Photo(ObjectBase):
         self.photodb.cur.execute('DELETE FROM photos WHERE id == ?', [self.id])
         self.photodb.cur.execute('DELETE FROM photo_tag_rel WHERE photoid == ?', [self.id])
         self.photodb.cur.execute('DELETE FROM album_photo_rel WHERE photoid == ?', [self.id])
+
+        if delete_file:
+            path = self.real_path.absolute_path
+            if commit:
+                os.remove(path)
+            else:
+                queue_action = {'action': os.remove, 'args': [path]}
+                self.photodb.on_commit_queue.append(queue_action)
         if commit:
             log.debug('Committing - delete photo')
             self.photodb.commit()
@@ -1856,7 +1671,7 @@ class Photo(ObjectBase):
         return hopeful_filepath
 
     def mimetype(self):
-        return get_mimetype(self.real_filepath)
+        return helpers.get_mimetype(self.real_filepath)
 
     @decorators.time_me
     def reload_metadata(self, commit=True):
@@ -1979,7 +1794,7 @@ class Photo(ObjectBase):
         else:
             queue_action = {'action': os.remove, 'args': [old_path.absolute_path]}
             self.photodb.on_commit_queue.append(queue_action)
-        
+
         self.__reinit__()
 
     def tags(self):
@@ -2036,11 +1851,11 @@ class Tag(ObjectBase, GroupableMixin):
             raise ValueError('Cannot assign synonym to itself.')
 
         try:
-            tag = self.photodb.get_tag_by_name(synname)
-        except NoSuchTag:
+            self.photodb.get_tag_by_name(synname)
+        except exceptions.NoSuchTag:
             pass
         else:
-            raise TagExists(synname)
+            raise exceptions.TagExists(synname)
 
         self.photodb._cached_frozen_children = None
         self.photodb.cur.execute('INSERT INTO tag_synonyms VALUES(?, ?)', [synname, self.name])
@@ -2103,11 +1918,11 @@ class Tag(ObjectBase, GroupableMixin):
         '''
         if self._cached_qualified_name:
             return self._cached_qualified_name
-        string = self.name
+        qualname = self.name
         for parent in self.walk_parents():
-            string = parent.name + '.' + string
-        self._cached_qualified_name = string
-        return string
+            qualname = parent.name + '.' + qualname
+        self._cached_qualified_name = qualname
+        return qualname
 
     def remove_synonym(self, synname, commit=True):
         '''
@@ -2137,10 +1952,10 @@ class Tag(ObjectBase, GroupableMixin):
 
         try:
             self.photodb.get_tag(new_name)
-        except NoSuchTag:
+        except exceptions.NoSuchTag:
             pass
         else:
-            raise TagExists(new_name)
+            raise exceptions.TagExists(new_name)
 
         self._cached_qualified_name = None
         self.photodb._cached_frozen_children = None
diff --git a/requirements.txt b/requirements.txt
index 247bdb1..f039759 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1,5 @@
 flask
 gevent
-pillow
\ No newline at end of file
+pillow
+https://raw.githubusercontent.com/voussoir/else/master/_voussoirkit/voussoirkit.zip
+git+https://github.com/senko/python-video-converter.git
\ No newline at end of file