checkpoint

master
voussoir 2016-11-05 21:24:43 -07:00
parent 7ad6160d38
commit 5de1736347
10 changed files with 424 additions and 337 deletions

48
etiquette/constants.py Normal file
View File

@ -0,0 +1,48 @@
import string
# Errors and warnings
ERROR_DATABASE_OUTOFDATE = 'Database is out-of-date. {current} should be {new}'
ERROR_INVALID_ACTION = 'Invalid action'
ERROR_NO_SUCH_TAG = 'Doesn\'t exist'
ERROR_NO_TAG_GIVEN = 'No tag name supplied'
ERROR_SYNONYM_ITSELF = 'Cant apply synonym to itself'
ERROR_TAG_TOO_SHORT = 'Not enough valid chars'
WARNING_MINMAX_INVALID = 'Field "{field}": "{value}" is not a valid request. Ignored.'
WARNING_MINMAX_OOO = 'Field "{field}": minimum "{min}" maximum "{max}" are out of order. Ignored.'
WARNING_NO_SUCH_TAG = 'Tag "{tag}" does not exist. Ignored.'
WARNING_ORDERBY_BADCOL = '"{column}" is not a sorting option. Ignored.'
WARNING_ORDERBY_BADSORTER = 'You can\'t order "{column}" by "{sorter}". Defaulting to descending.'
# Default settings
MIN_TAG_NAME_LENGTH = 1
MAX_TAG_NAME_LENGTH = 32
VALID_TAG_CHARS = string.ascii_lowercase + string.digits + '_'
DEFAULT_ID_LENGTH = 12
DEFAULT_DBNAME = 'phototagger.db'
DEFAULT_THUMBDIR = '_etiquette\\site_thumbnails'
DEFAULT_DIGEST_EXCLUDE_FILES = [
DEFAULT_DBNAME,
'desktop.ini',
'thumbs.db'
]
DEFAULT_DIGEST_EXCLUDE_DIRS = [
'_site_thumbnails',
]
FILE_READ_CHUNK = 2 ** 20
THUMBNAIL_WIDTH = 400
THUMBNAIL_HEIGHT = 400
# Operational info
ADDITIONAL_MIMETYPES = {
'srt': 'text',
'mkv': 'video',
}
EXPRESSION_OPERATORS = {'(', ')', 'OR', 'AND', 'NOT'}
MOTD_STRINGS = [
'Good morning, Paul. What will your first sequence of the day be?',
#'Buckle up, it\'s time to:',
]

View File

@ -1,11 +1,14 @@
import flask
from flask import request from flask import request
import functools
import time
import uuid import uuid
def _generate_session_token(): def _generate_session_token():
token = str(uuid.uuid4()) token = str(uuid.uuid4())
#print('MAKE SESSION', token) #print('MAKE SESSION', token)
return token return token
def give_session_token(function): def give_session_token(function):
@functools.wraps(function) @functools.wraps(function)
def wrapped(*args, **kwargs): def wrapped(*args, **kwargs):
@ -25,3 +28,23 @@ def give_session_token(function):
return ret return ret
return wrapped return wrapped
def not_implemented(function):
'''
Decorator to remember what needs doing.
'''
warnings.warn('%s is not implemented' % function.__name__)
return function
def time_me(function):
'''
Decorator. After the function is run, print the elapsed time.
'''
@functools.wraps(function)
def timed_function(*args, **kwargs):
start = time.time()
result = function(*args, **kwargs)
end = time.time()
print('%s: %0.8f' % (function.__name__, end-start))
return result
return timed_function

View File

@ -1,9 +1,6 @@
import distutils.util
import flask import flask
from flask import request from flask import request
import functools
import json import json
import math
import mimetypes import mimetypes
import os import os
import random import random
@ -13,6 +10,10 @@ import sys
import time import time
import warnings import warnings
import constants
import decorators
import helpers
import jsonify
import phototagger import phototagger
try: try:
@ -32,23 +33,10 @@ site.config.update(
TEMPLATES_AUTO_RELOAD=True, TEMPLATES_AUTO_RELOAD=True,
) )
site.jinja_env.add_extension('jinja2.ext.do') site.jinja_env.add_extension('jinja2.ext.do')
#site.debug = True site.debug = True
P = phototagger.PhotoDB() P = phototagger.PhotoDB()
FILE_READ_CHUNK = 2 ** 20
MOTD_STRINGS = [
'Good morning, Paul. What will your first sequence of the day be?',
#'Buckle up, it\'s time to:',
]
THUMBDIR = phototagger.DEFAULT_THUMBDIR
ERROR_INVALID_ACTION = 'Invalid action'
ERROR_NO_TAG_GIVEN = 'No tag name supplied'
ERROR_TAG_TOO_SHORT = 'Not enough valid chars'
ERROR_SYNONYM_ITSELF = 'Cant apply synonym to itself'
ERROR_NO_SUCH_TAG = 'Doesn\'t exist'
#################################################################################################### ####################################################################################################
#################################################################################################### ####################################################################################################
@ -56,15 +44,6 @@ ERROR_NO_SUCH_TAG = 'Doesn\'t exist'
#################################################################################################### ####################################################################################################
def _helper_comma_split(s):
if s is None:
return s
s = s.replace(' ', ',')
s = [x.strip() for x in s.split(',')]
s = [x for x in s if x]
return s
def create_tag(easybake_string): def create_tag(easybake_string):
notes = P.easybake(easybake_string) notes = P.easybake(easybake_string)
notes = [{'action': action, 'tagname': tagname} for (action, tagname) in notes] notes = [{'action': action, 'tagname': tagname} for (action, tagname) in notes]
@ -91,16 +70,6 @@ def delete_synonym(synonym):
master_tag.remove_synonym(synonym) master_tag.remove_synonym(synonym)
return {'action':'delete_synonym', 'synonym': synonym} return {'action':'delete_synonym', 'synonym': synonym}
def edit_params(original, modifications):
new_params = original.to_dict()
new_params.update(modifications)
if not new_params:
return ''
new_params = ['%s=%s' % (k, v) for (k, v) in new_params.items() if v]
new_params = '&'.join(new_params)
new_params = '?' + new_params
return new_params
def make_json_response(j, *args, **kwargs): def make_json_response(j, *args, **kwargs):
dumped = json.dumps(j) dumped = json.dumps(j)
response = flask.Response(dumped, *args, **kwargs) response = flask.Response(dumped, *args, **kwargs)
@ -125,34 +94,6 @@ def P_tag(tagname):
except phototagger.NoSuchTag as e: except phototagger.NoSuchTag as e:
flask.abort(404, 'That tag doesnt exist: %s' % e) flask.abort(404, 'That tag doesnt exist: %s' % e)
def read_filebytes(filepath, range_min, range_max):
range_span = range_max - range_min
#print('read span', range_min, range_max, range_span)
f = open(filepath, 'rb')
f.seek(range_min)
sent_amount = 0
with f:
while sent_amount < range_span:
#print(sent_amount)
chunk = f.read(FILE_READ_CHUNK)
if len(chunk) == 0:
break
yield chunk
sent_amount += len(chunk)
def seconds_to_hms(seconds):
seconds = math.ceil(seconds)
(minutes, seconds) = divmod(seconds, 60)
(hours, minutes) = divmod(minutes, 60)
parts = []
if hours: parts.append(hours)
if minutes: parts.append(minutes)
parts.append(seconds)
hms = ':'.join('%02d' % part for part in parts)
return hms
def send_file(filepath): def send_file(filepath):
''' '''
Range-enabled file sending. Range-enabled file sending.
@ -208,7 +149,7 @@ def send_file(filepath):
if request.method == 'HEAD': if request.method == 'HEAD':
outgoing_data = bytes() outgoing_data = bytes()
else: else:
outgoing_data = read_filebytes(filepath, range_min=range_min, range_max=range_max) outgoing_data = helpers.read_filebytes(filepath, range_min=range_min, range_max=range_max)
response = flask.Response( response = flask.Response(
outgoing_data, outgoing_data,
@ -217,64 +158,6 @@ def send_file(filepath):
) )
return response return response
def truthystring(s):
if isinstance(s, (bool, int)) or s is None:
return s
s = s.lower()
if s in {'1', 'true', 't', 'yes', 'y', 'on'}:
return True
if s in {'null', 'none'}:
return None
return False
####################################################################################################
####################################################################################################
####################################################################################################
####################################################################################################
def jsonify_album(album, minimal=False):
j = {
'id': album.id,
'description': album.description,
'title': album.title,
}
if minimal is False:
j['photos'] = [jsonify_photo(photo) for photo in album.photos()]
j['parent'] = album.parent()
j['sub_albums'] = [child.id for child in album.children()]
return j
def jsonify_photo(photo):
tags = photo.tags()
tags.sort(key=lambda x: x.name)
j = {
'id': photo.id,
'extension': photo.extension,
'width': photo.width,
'height': photo.height,
'ratio': photo.ratio,
'area': photo.area,
'bytes': photo.bytes,
'duration': seconds_to_hms(photo.duration) if photo.duration is not None else None,
'duration_int': photo.duration,
'bytestring': photo.bytestring(),
'has_thumbnail': bool(photo.thumbnail),
'created': photo.created,
'filename': photo.basename,
'mimetype': photo.mimetype(),
'albums': [jsonify_album(album, minimal=True) for album in photo.albums()],
'tags': [jsonify_tag(tag) for tag in tags],
}
return j
def jsonify_tag(tag):
j = {
'id': tag.id,
'name': tag.name,
'qualified_name': tag.qualified_name(),
}
return j
#################################################################################################### ####################################################################################################
#################################################################################################### ####################################################################################################
@ -282,40 +165,45 @@ def jsonify_tag(tag):
#################################################################################################### ####################################################################################################
@site.route('/') @site.route('/')
@give_session_token @decorators.give_session_token
def root(): def root():
motd = random.choice(MOTD_STRINGS) motd = random.choice(constants.MOTD_STRINGS)
return flask.render_template('root.html', motd=motd) return flask.render_template('root.html', motd=motd)
@site.route('/favicon.ico') @site.route('/favicon.ico')
@site.route('/favicon.png') @site.route('/favicon.png')
def favicon(): def favicon():
filename = os.path.join('static', 'favicon.png') filename = os.path.join('static', 'favicon.png')
return flask.send_file(filename) return flask.send_file(filename)
def get_album_core(albumid): def get_album_core(albumid):
album = P_album(albumid) album = P_album(albumid)
album = jsonify_album(album) album = jsonify.album(album)
album['sub_albums'] = [P_album(x) for x in album['sub_albums']]
album['sub_albums'].sort(key=lambda x: (x.title or x.id).lower())
album['sub_albums'] = [jsonify.album(x, minimal=True) for x in album['sub_albums']]
return album return album
@site.route('/album/<albumid>') @site.route('/album/<albumid>')
@give_session_token @decorators.give_session_token
def get_album_html(albumid): def get_album_html(albumid):
album = get_album_core(albumid) album = get_album_core(albumid)
response = flask.render_template( response = flask.render_template(
'album.html', 'album.html',
album=album, album=album,
child_albums=[jsonify_album(P_album(x)) for x in album['sub_albums']],
photos=album['photos'], photos=album['photos'],
) )
return response return response
@site.route('/album/<albumid>.json') @site.route('/album/<albumid>.json')
@give_session_token @decorators.give_session_token
def get_album_json(albumid): def get_album_json(albumid):
album = get_album_core(albumid) album = get_album_core(albumid)
return make_json_response(album) return make_json_response(album)
@site.route('/album/<albumid>.tar') @site.route('/album/<albumid>.tar')
def get_album_tar(albumid): def get_album_tar(albumid):
album = P_album(albumid) album = P_album(albumid)
@ -326,21 +214,26 @@ def get_album_tar(albumid):
outgoing_headers = {'Content-Type': 'application/octet-stream'} outgoing_headers = {'Content-Type': 'application/octet-stream'}
return flask.Response(streamed_zip, headers=outgoing_headers) return flask.Response(streamed_zip, headers=outgoing_headers)
@site.route('/albums')
@give_session_token def get_albums_core():
def get_albums_html():
albums = P.get_albums() albums = P.get_albums()
albums = [a for a in albums if a.parent() is None] albums = [a for a in albums if a.parent() is None]
albums = [jsonify.album(album, minimal=True) for album in albums]
return albums
@site.route('/albums')
@decorators.give_session_token
def get_albums_html():
albums = get_albums_core()
return flask.render_template('albums.html', albums=albums) return flask.render_template('albums.html', albums=albums)
@site.route('/albums.json') @site.route('/albums.json')
@give_session_token @decorators.give_session_token
def get_albums_json(): def get_albums_json():
albums = P.get_albums() albums = get_albums_core()
albums = [a for a in albums if a.parent() is None]
albums = [jsonify_album(album, minimal=True) for album in albums]
return make_json_response(albums) return make_json_response(albums)
@site.route('/file/<photoid>') @site.route('/file/<photoid>')
def get_file(photoid): def get_file(photoid):
requested_photoid = photoid requested_photoid = photoid
@ -348,10 +241,10 @@ def get_file(photoid):
photo = P.get_photo(photoid) photo = P.get_photo(photoid)
do_download = request.args.get('download', False) do_download = request.args.get('download', False)
do_download = truthystring(do_download) do_download = helpers.truthystring(do_download)
use_original_filename = request.args.get('original_filename', False) use_original_filename = request.args.get('original_filename', False)
use_original_filename = truthystring(use_original_filename) use_original_filename = helpers.truthystring(use_original_filename)
if do_download: if do_download:
if use_original_filename: if use_original_filename:
@ -368,25 +261,27 @@ def get_file(photoid):
else: else:
return send_file(photo.real_filepath) return send_file(photo.real_filepath)
def get_photo_core(photoid): def get_photo_core(photoid):
photo = P_photo(photoid) photo = P_photo(photoid)
photo = jsonify_photo(photo) photo = jsonify.photo(photo)
return photo return photo
@site.route('/photo/<photoid>', methods=['GET']) @site.route('/photo/<photoid>', methods=['GET'])
@give_session_token @decorators.give_session_token
def get_photo_html(photoid): def get_photo_html(photoid):
photo = get_photo_core(photoid) photo = get_photo_core(photoid)
photo['tags'].sort(key=lambda x: x['qualified_name']) photo['tags'].sort(key=lambda x: x['qualified_name'])
return flask.render_template('photo.html', photo=photo) return flask.render_template('photo.html', photo=photo)
@site.route('/photo/<photoid>.json', methods=['GET']) @site.route('/photo/<photoid>.json', methods=['GET'])
@give_session_token @decorators.give_session_token
def get_photo_json(photoid): def get_photo_json(photoid):
photo = get_photo_core(photoid) photo = get_photo_core(photoid)
photo = make_json_response(photo) photo = make_json_response(photo)
return photo return photo
def get_search_core(): def get_search_core():
#print(request.args) #print(request.args)
@ -396,9 +291,9 @@ def get_search_core():
extension_not_string = request.args.get('extension_not', None) extension_not_string = request.args.get('extension_not', None)
mimetype_string = request.args.get('mimetype', None) mimetype_string = request.args.get('mimetype', None)
extension_list = _helper_comma_split(extension_string) extension_list = helpers.comma_split(extension_string)
extension_not_list = _helper_comma_split(extension_not_string) extension_not_list = helpers.comma_split(extension_not_string)
mimetype_list = _helper_comma_split(mimetype_string) mimetype_list = helpers.comma_split(mimetype_string)
# LIMIT # LIMIT
limit = request.args.get('limit', '') limit = request.args.get('limit', '')
@ -440,7 +335,7 @@ def get_search_core():
if has_tags == '': if has_tags == '':
has_tags = None has_tags = None
else: else:
has_tags = truthystring(has_tags) has_tags = helpers.truthystring(has_tags)
# MINMAXERS # MINMAXERS
area = request.args.get('area', None) area = request.args.get('area', None)
@ -480,7 +375,7 @@ def get_search_core():
#print(search_kwargs) #print(search_kwargs)
with warnings.catch_warnings(record=True) as catcher: with warnings.catch_warnings(record=True) as catcher:
photos = list(P.search(**search_kwargs)) photos = list(P.search(**search_kwargs))
photos = [jsonify_photo(photo) for photo in photos] photos = [jsonify.photo(photo, include_albums=False) for photo in photos]
warns = [str(warning.message) for warning in catcher] warns = [str(warning.message) for warning in catcher]
#print(warns) #print(warns)
@ -493,13 +388,14 @@ def get_search_core():
# PREV-NEXT PAGE URLS # PREV-NEXT PAGE URLS
offset = offset or 0 offset = offset or 0
original_params = request.args.to_dict()
if len(photos) == limit: if len(photos) == limit:
next_params = edit_params(request.args, {'offset': offset + limit}) next_params = helpers.edit_params(original_params, {'offset': offset + limit})
next_page_url = '/search' + next_params next_page_url = '/search' + next_params
else: else:
next_page_url = None next_page_url = None
if offset > 0: if offset > 0:
prev_params = edit_params(request.args, {'offset': max(0, offset - limit)}) prev_params = helpers.edit_params(original_params, {'offset': max(0, offset - limit)})
prev_page_url = '/search' + prev_params prev_page_url = '/search' + prev_params
else: else:
prev_page_url = None prev_page_url = None
@ -520,7 +416,7 @@ def get_search_core():
return final_results return final_results
@site.route('/search') @site.route('/search')
@give_session_token @decorators.give_session_token
def get_search_html(): def get_search_html():
search_results = get_search_core() search_results = get_search_core()
search_kwargs = search_results['search_kwargs'] search_kwargs = search_results['search_kwargs']
@ -538,16 +434,17 @@ def get_search_html():
return response return response
@site.route('/search.json') @site.route('/search.json')
@give_session_token @decorators.give_session_token
def get_search_json(): def get_search_json():
search_results = get_search_core() search_results = get_search_core()
search_kwargs = search_results['search_kwargs'] search_kwargs = search_results['search_kwargs']
qualname_map = search_results['qualname_map'] qualname_map = search_results['qualname_map']
include_qualname_map = request.args.get('include_map', False) include_qualname_map = request.args.get('include_map', False)
include_qualname_map = truthystring(include_qualname_map) include_qualname_map = helpers.truthystring(include_qualname_map)
if not include_qualname_map: if not include_qualname_map:
search_results.pop('qualname_map') search_results.pop('qualname_map')
return make_json_response(j) return make_json_response(search_results)
@site.route('/static/<filename>') @site.route('/static/<filename>')
def get_static(filename): def get_static(filename):
@ -556,20 +453,33 @@ def get_static(filename):
filename = os.path.join('static', filename) filename = os.path.join('static', filename)
return flask.send_file(filename) return flask.send_file(filename)
@site.route('/tags')
@site.route('/tags/<specific_tag>') def get_tags_core(specific_tag=None):
@give_session_token
def get_tags(specific_tag=None):
try: try:
tags = P.export_tags(phototagger.tag_export_easybake, specific_tag=specific_tag) tags = P.export_tags(phototagger.tag_export_easybake, specific_tag=specific_tag)
except phototagger.NoSuchTag: except phototagger.NoSuchTag:
flask.abort(404, 'That tag doesnt exist') flask.abort(404, 'That tag doesnt exist')
tags = tags.split('\n') tags = tags.split('\n')
tags = [t for t in tags if t != ''] tags = [t for t in tags if t != '']
tags = [(t, t.split('.')[-1].split('+')[0]) for t in tags] tags = [(t, t.split('.')[-1].split('+')[0]) for t in tags]
return tags
@site.route('/tags')
@site.route('/tags/<specific_tag>')
@decorators.give_session_token
def get_tags_html(specific_tag=None):
tags = get_tags_core(specific_tag)
return flask.render_template('tags.html', tags=tags) return flask.render_template('tags.html', tags=tags)
@site.route('/tags.json')
@site.route('/tags/<specific_tag>.json')
@decorators.give_session_token
def get_tags_json(specific_tag=None):
tags = get_tags_core(specific_tag)
tags = [t[0] for t in tags]
return make_json_response(tags)
@site.route('/thumbnail/<photoid>') @site.route('/thumbnail/<photoid>')
def get_thumbnail(photoid): def get_thumbnail(photoid):
photoid = photoid.split('.')[0] photoid = photoid.split('.')[0]
@ -580,9 +490,10 @@ def get_thumbnail(photoid):
flask.abort(404, 'That file doesnt have a thumbnail') flask.abort(404, 'That file doesnt have a thumbnail')
return send_file(path) return send_file(path)
@site.route('/album/<albumid>', methods=['POST']) @site.route('/album/<albumid>', methods=['POST'])
@site.route('/album/<albumid>.json', methods=['POST']) @site.route('/album/<albumid>.json', methods=['POST'])
@give_session_token @decorators.give_session_token
def post_edit_album(albumid): def post_edit_album(albumid):
''' '''
Edit the album's title and description. Edit the album's title and description.
@ -601,15 +512,16 @@ def post_edit_album(albumid):
response = {'error': 'That tag doesnt exist', 'tagname': tag} response = {'error': 'That tag doesnt exist', 'tagname': tag}
return make_json_response(response, status=404) return make_json_response(response, status=404)
recursive = request.form.get('recursive', False) recursive = request.form.get('recursive', False)
recursive = truthystring(recursive) recursive = helpers.truthystring(recursive)
album.add_tag_to_all(tag, nested_children=recursive) album.add_tag_to_all(tag, nested_children=recursive)
response['action'] = action response['action'] = action
response['tagname'] = tag.name response['tagname'] = tag.name
return make_json_response(response) return make_json_response(response)
@site.route('/photo/<photoid>', methods=['POST']) @site.route('/photo/<photoid>', methods=['POST'])
@site.route('/photo/<photoid>.json', methods=['POST']) @site.route('/photo/<photoid>.json', methods=['POST'])
@give_session_token @decorators.give_session_token
def post_edit_photo(photoid): def post_edit_photo(photoid):
''' '''
Add and remove tags from photos. Add and remove tags from photos.
@ -642,8 +554,9 @@ def post_edit_photo(photoid):
response['tagname'] = tag.name response['tagname'] = tag.name
return make_json_response(response) return make_json_response(response)
@site.route('/tags', methods=['POST']) @site.route('/tags', methods=['POST'])
@give_session_token @decorators.give_session_token
def post_edit_tags(): def post_edit_tags():
''' '''
Create and delete tags and synonyms. Create and delete tags and synonyms.
@ -661,12 +574,12 @@ def post_edit_tags():
method = delete_tag method = delete_tag
else: else:
status = 400 status = 400
response = {'error': ERROR_INVALID_ACTION} response = {'error': constants.ERROR_INVALID_ACTION}
if status == 200: if status == 200:
tag = request.form[action].strip() tag = request.form[action].strip()
if tag == '': if tag == '':
response = {'error': ERROR_NO_TAG_GIVEN} response = {'error': constants.ERROR_NO_TAG_GIVEN}
status = 400 status = 400
if status == 200: if status == 200:
@ -675,11 +588,11 @@ def post_edit_tags():
try: try:
response = method(tag) response = method(tag)
except phototagger.TagTooShort: except phototagger.TagTooShort:
response = {'error': ERROR_TAG_TOO_SHORT, 'tagname': tag} response = {'error': constants.ERROR_TAG_TOO_SHORT, 'tagname': tag}
except phototagger.CantSynonymSelf: except phototagger.CantSynonymSelf:
response = {'error': ERROR_SYNONYM_ITSELF, 'tagname': tag} response = {'error': constants.ERROR_SYNONYM_ITSELF, 'tagname': tag}
except phototagger.NoSuchTag as e: except phototagger.NoSuchTag as e:
response = {'error': ERROR_NO_SUCH_TAG, 'tagname': tag} response = {'error': constants.ERROR_NO_SUCH_TAG, 'tagname': tag}
except ValueError as e: except ValueError as e:
response = {'error': e.args[0], 'tagname': tag} response = {'error': e.args[0], 'tagname': tag}
else: else:

134
etiquette/helpers.py Normal file
View File

@ -0,0 +1,134 @@
import math
import constants
def chunk_sequence(sequence, chunk_length, allow_incomplete=True):
'''
Given a sequence, divide it into sequences of length `chunk_length`.
allow_incomplete:
If True, allow the final chunk to be shorter if the
given sequence is not an exact multiple of `chunk_length`.
If False, the incomplete chunk will be discarded.
'''
(complete, leftover) = divmod(len(sequence), chunk_length)
if not allow_incomplete:
leftover = 0
chunk_count = complete + min(leftover, 1)
chunks = []
for x in range(chunk_count):
left = chunk_length * x
right = left + chunk_length
chunks.append(sequence[left:right])
return chunks
def comma_split(s):
'''
Split the string apart by commas, discarding all extra whitespace and
blank phrases.
'''
if s is None:
return s
s = s.replace(' ', ',')
s = [x.strip() for x in s.split(',')]
s = [x for x in s if x]
return s
def edit_params(original, modifications):
'''
Given a dictionary representing URL parameters,
apply the modifications and return a URL parameter string.
{'a':1, 'b':2}, {'b':3} => ?a=1&b=3
'''
new_params = original.copy()
new_params.update(modifications)
if not new_params:
return ''
new_params = ['%s=%s' % (k, v) for (k, v) in new_params.items() if v]
new_params = '&'.join(new_params)
new_params = '?' + new_params
return new_params
def fit_into_bounds(image_width, image_height, frame_width, frame_height):
'''
Given the w+h of the image and the w+h of the frame,
return new w+h that fits the image into the frame
while maintaining the aspect ratio.
'''
ratio = min(frame_width/image_width, frame_height/image_height)
new_width = int(image_width * ratio)
new_height = int(image_height * ratio)
return (new_width, new_height)
def hms_to_seconds(hms):
'''
Convert hh:mm:ss string to an integer seconds.
'''
hms = hms.split(':')
seconds = 0
if len(hms) == 3:
seconds += int(hms[0])*3600
hms.pop(0)
if len(hms) == 2:
seconds += int(hms[0])*60
hms.pop(0)
if len(hms) == 1:
seconds += int(hms[0])
return seconds
def is_xor(*args):
'''
Return True if and only if one arg is truthy.
'''
return [bool(a) for a in args].count(True) == 1
def read_filebytes(filepath, range_min, range_max):
'''
Yield chunks of bytes from the file between the endpoints.
'''
range_span = range_max - range_min
#print('read span', range_min, range_max, range_span)
f = open(filepath, 'rb')
f.seek(range_min)
sent_amount = 0
with f:
while sent_amount < range_span:
#print(sent_amount)
chunk = f.read(constants.FILE_READ_CHUNK)
if len(chunk) == 0:
break
yield chunk
sent_amount += len(chunk)
def seconds_to_hms(seconds):
'''
Convert integer number of seconds to an hh:mm:ss string.
Only the necessary fields are used.
'''
seconds = math.ceil(seconds)
(minutes, seconds) = divmod(seconds, 60)
(hours, minutes) = divmod(minutes, 60)
parts = []
if hours: parts.append(hours)
if minutes: parts.append(minutes)
parts.append(seconds)
hms = ':'.join('%02d' % part for part in parts)
return hms
def truthystring(s):
if isinstance(s, (bool, int)) or s is None:
return s
s = s.lower()
if s in {'1', 'true', 't', 'yes', 'y', 'on'}:
return True
if s in {'null', 'none'}:
return None
return False

49
etiquette/jsonify.py Normal file
View File

@ -0,0 +1,49 @@
import helpers
def album(a, minimal=False):
j = {
'id': a.id,
'description': a.description,
'title': a.title,
}
if not minimal:
j['photos'] = [photo(p) for p in a.photos()]
j['parent'] = a.parent()
j['sub_albums'] = [child.id for child in a.children()]
return j
def photo(p, include_albums=True, include_tags=True):
tags = p.tags()
tags.sort(key=lambda x: x.name)
j = {
'id': p.id,
'extension': p.extension,
'width': p.width,
'height': p.height,
'ratio': p.ratio,
'area': p.area,
'bytes': p.bytes,
'duration_str': helpers.seconds_to_hms(p.duration) if p.duration is not None else None,
'duration': p.duration,
'bytestring': p.bytestring(),
'has_thumbnail': bool(p.thumbnail),
'created': p.created,
'filename': p.basename,
'mimetype': p.mimetype(),
}
if include_albums:
j['albums'] = [album(a, minimal=True) for a in p.albums()]
if include_tags:
j['tags'] = [tag(t) for t in tags]
return j
def tag(t):
j = {
'id': t.id,
'name': t.name,
'qualified_name': t.qualified_name(),
}
return j

View File

@ -15,17 +15,20 @@ import time
import traceback import traceback
import warnings import warnings
sys.path.append('C:\\git\\else\\Bytestring'); import bytestring import constants
sys.path.append('C:\\git\\else\\SpinalTap'); import spinal import decorators
import helpers
VALID_TAG_CHARS = string.ascii_lowercase + string.digits + '_' try:
MIN_TAG_NAME_LENGTH = 1 sys.path.append('C:\\git\\else\\Bytestring')
MAX_TAG_NAME_LENGTH = 32 sys.path.append('C:\\git\\else\\SpinalTap')
DEFAULT_ID_LENGTH = 12 import bytestring
DEFAULT_DBNAME = 'phototagger.db' import spinal
DEFAULT_THUMBDIR = '_etiquette\\site_thumbnails' except ImportError:
THUMBNAIL_WIDTH = 400 # pip install
THUMBNAIL_HEIGHT = 400 # https://raw.githubusercontent.com/voussoir/else/master/_voussoirkit/voussoirkit.zip
from vousoirkit import bytestring
from vousoirkit import spinal
try: try:
ffmpeg = converter.Converter( ffmpeg = converter.Converter(
@ -40,18 +43,6 @@ logging.basicConfig(level=logging.DEBUG)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
logging.getLogger("PIL.PngImagePlugin").setLevel(logging.WARNING) logging.getLogger("PIL.PngImagePlugin").setLevel(logging.WARNING)
ADDITIONAL_MIMETYPES = {
'srt': 'text',
'mkv': 'video',
}
WARNING_MINMAX_INVALID = 'Field "{field}": "{value}" is not a valid request. Ignored.'
WARNING_MINMAX_OOO = 'Field "{field}": minimum "{min}" maximum "{max}" are out of order. Ignored.'
WARNING_NO_SUCH_TAG = 'Tag "{tag}" does not exist. Ignored.'
WARNING_ORDERBY_BADCOL = '"{column}" is not a sorting option. Ignored.'
WARNING_ORDERBY_BADSORTER = 'You can\'t order "{column}" by "{sorter}". Defaulting to descending.'
OPERATORS = {'(', ')', 'OR', 'AND', 'NOT'}
SQL_LASTID_COLUMNS = [ SQL_LASTID_COLUMNS = [
'table', 'table',
'last_id', 'last_id',
@ -107,10 +98,11 @@ SQL_SYN = {key:index for (index, key) in enumerate(SQL_SYN_COLUMNS)}
SQL_TAG = {key:index for (index, key) in enumerate(SQL_TAG_COLUMNS)} SQL_TAG = {key:index for (index, key) in enumerate(SQL_TAG_COLUMNS)}
SQL_TAGGROUP = {key:index for (index, key) in enumerate(SQL_TAGGROUP_COLUMNS)} SQL_TAGGROUP = {key:index for (index, key) in enumerate(SQL_TAGGROUP_COLUMNS)}
DATABASE_VERSION = 1
DB_INIT = ''' DB_INIT = '''
PRAGMA count_changes = OFF; PRAGMA count_changes = OFF;
PRAGMA cache_size = 10000; PRAGMA cache_size = 10000;
PRAGMA user_version = {user_version};
CREATE TABLE IF NOT EXISTS albums( CREATE TABLE IF NOT EXISTS albums(
id TEXT, id TEXT,
title TEXT, title TEXT,
@ -183,27 +175,8 @@ CREATE INDEX IF NOT EXISTS index_tagsyn_name on tag_synonyms(name);
-- Tag-group relation -- Tag-group relation
CREATE INDEX IF NOT EXISTS index_grouprel_parentid on tag_group_rel(parentid); 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); CREATE INDEX IF NOT EXISTS index_grouprel_memberid on tag_group_rel(memberid);
''' '''.format(user_version=DATABASE_VERSION)
def not_implemented(function):
'''
Decorator to remember what needs doing.
'''
warnings.warn('%s is not implemented' % function.__name__)
return function
def time_me(function):
'''
Decorator. After the function is run, print the elapsed time.
'''
@functools.wraps(function)
def timed_function(*args, **kwargs):
start = time.time()
result = function(*args, **kwargs)
end = time.time()
print('%s: %0.8f' % (function.__name__, end-start))
return result
return timed_function
def _helper_extension(ext): def _helper_extension(ext):
''' '''
@ -220,8 +193,6 @@ def _helper_extension(ext):
def _helper_filenamefilter(subject, terms): def _helper_filenamefilter(subject, terms):
basename = subject.lower() basename = subject.lower()
#print(basename)
#print(terms)
return all(term in basename for term in terms) return all(term in basename for term in terms)
def _helper_minmax(key, value, minimums, maximums): def _helper_minmax(key, value, minimums, maximums):
@ -237,10 +208,10 @@ def _helper_minmax(key, value, minimums, maximums):
try: try:
(low, high) = hyphen_range(value) (low, high) = hyphen_range(value)
except ValueError: except ValueError:
warnings.warn(WARNING_MINMAX_INVALID.format(field=key, value=value)) warnings.warn(constants.WARNING_MINMAX_INVALID.format(field=key, value=value))
return return
except OutOfOrder as e: except OutOfOrder as e:
warnings.warn(WARNING_MINMAX_OOO.format(field=key, min=e.args[1], max=e.args[2])) warnings.warn(constants.WARNING_MINMAX_OOO.format(field=key, min=e.args[1], max=e.args[2]))
return return
if low is not None: if low is not None:
minimums[key] = low minimums[key] = low
@ -278,13 +249,13 @@ def _helper_orderby(orderby):
'random', 'random',
] ]
if not sortable: if not sortable:
warnings.warn(WARNING_ORDERBY_BADCOL.format(column=column)) warnings.warn(constants.WARNING_ORDERBY_BADCOL.format(column=column))
return None return None
if column == 'random': if column == 'random':
column = 'RANDOM()' column = 'RANDOM()'
if sorter not in ['desc', 'asc']: if sorter not in ['desc', 'asc']:
warnings.warn(WARNING_ORDERBY_BADSORTER.format(column=column, sorter=sorter)) warnings.warn(constants.WARNING_ORDERBY_BADSORTER.format(column=column, sorter=sorter))
sorter = 'desc' sorter = 'desc'
return (column, sorter) return (column, sorter)
@ -307,7 +278,7 @@ def _helper_setify(photodb, l, warn_bad_tags=False):
except NoSuchTag: except NoSuchTag:
if not warn_bad_tags: if not warn_bad_tags:
raise raise
warnings.warn(WARNING_NO_SUCH_TAG.format(tag=tag)) warnings.warn(constants.WARNING_NO_SUCH_TAG.format(tag=tag))
continue continue
else: else:
s.add(tag) s.add(tag)
@ -321,51 +292,12 @@ def _helper_unitconvert(value):
if value is None: if value is None:
return None return None
if ':' in value: if ':' in value:
return hms_to_seconds(value) return helpers.hms_to_seconds(value)
elif all(c in '0123456789.' for c in value): elif all(c in '0123456789.' for c in value):
return float(value) return float(value)
else: else:
return bytestring.parsebytes(value) return bytestring.parsebytes(value)
def chunk_sequence(sequence, chunk_length, allow_incomplete=True):
'''
Given a sequence, divide it into sequences of length `chunk_length`.
allow_incomplete:
If True, allow the final chunk to be shorter if the
given sequence is not an exact multiple of `chunk_length`.
If False, the incomplete chunk will be discarded.
'''
(complete, leftover) = divmod(len(sequence), chunk_length)
if not allow_incomplete:
leftover = 0
chunk_count = complete + min(leftover, 1)
chunks = []
for x in range(chunk_count):
left = chunk_length * x
right = left + chunk_length
chunks.append(sequence[left:right])
return chunks
def hms_to_seconds(hms):
'''
Convert hh:mm:ss string to an integer seconds.
'''
hms = hms.split(':')
seconds = 0
if len(hms) == 3:
seconds += int(hms[0])*3600
hms.pop(0)
if len(hms) == 2:
seconds += int(hms[0])*60
hms.pop(0)
if len(hms) == 1:
seconds += int(hms[0])
return seconds
def hyphen_range(s): def hyphen_range(s):
''' '''
Given a string like '1-3', return ints (1, 3) representing lower Given a string like '1-3', return ints (1, 3) representing lower
@ -393,23 +325,10 @@ def hyphen_range(s):
raise OutOfOrder(s, low, high) raise OutOfOrder(s, low, high)
return low, high return low, high
def fit_into_bounds(image_width, image_height, frame_width, frame_height):
'''
Given the w+h of the image and the w+h of the frame,
return new w+h that fits the image into the frame
while maintaining the aspect ratio.
'''
ratio = min(frame_width/image_width, frame_height/image_height)
new_width = int(image_width * ratio)
new_height = int(image_height * ratio)
return (new_width, new_height)
def get_mimetype(filepath): def get_mimetype(filepath):
extension = os.path.splitext(filepath)[1].replace('.', '') extension = os.path.splitext(filepath)[1].replace('.', '')
if extension in ADDITIONAL_MIMETYPES: if extension in constants.ADDITIONAL_MIMETYPES:
return ADDITIONAL_MIMETYPES[extension] return constants.ADDITIONAL_MIMETYPES[extension]
mimetype = mimetypes.guess_type(filepath)[0] mimetype = mimetypes.guess_type(filepath)[0]
if mimetype is not None: if mimetype is not None:
mimetype = mimetype.split('/')[0] mimetype = mimetype.split('/')[0]
@ -424,12 +343,6 @@ def getnow(timestamp=True):
return now.timestamp() return now.timestamp()
return now return now
def is_xor(*args):
'''
Return True if and only if one arg is truthy.
'''
return [bool(a) for a in args].count(True) == 1
def normalize_filepath(filepath): def normalize_filepath(filepath):
''' '''
Remove some bad characters. Remove some bad characters.
@ -450,12 +363,12 @@ def normalize_tagname(tagname):
tagname = tagname.lower() tagname = tagname.lower()
tagname = tagname.replace('-', '_') tagname = tagname.replace('-', '_')
tagname = tagname.replace(' ', '_') tagname = tagname.replace(' ', '_')
tagname = (c for c in tagname if c in VALID_TAG_CHARS) tagname = (c for c in tagname if c in constants.VALID_TAG_CHARS)
tagname = ''.join(tagname) tagname = ''.join(tagname)
if len(tagname) < MIN_TAG_NAME_LENGTH: if len(tagname) < constants.MIN_TAG_NAME_LENGTH:
raise TagTooShort(tagname) raise TagTooShort(tagname)
if len(tagname) > MAX_TAG_NAME_LENGTH: if len(tagname) > constants.MAX_TAG_NAME_LENGTH:
raise TagTooLong(tagname) raise TagTooLong(tagname)
return tagname return tagname
@ -509,13 +422,13 @@ def searchfilter_expression(photo_tags, expression, frozen_children, warn_bad_ta
if can_shortcircuit and token != ')': if can_shortcircuit and token != ')':
continue continue
if token not in OPERATORS: if token not in constants.EXPRESSION_OPERATORS:
try: try:
token = normalize_tagname(token) token = normalize_tagname(token)
value = any(option in photo_tags for option in frozen_children[token]) value = any(option in photo_tags for option in frozen_children[token])
except KeyError: except KeyError:
if warn_bad_tags: if warn_bad_tags:
warnings.warn(WARNING_NO_SUCH_TAG.format(tag=token)) warnings.warn(constants.NO_SUCH_TAG.format(tag=token))
else: else:
raise NoSuchTag(token) raise NoSuchTag(token)
return False return False
@ -536,13 +449,17 @@ def searchfilter_expression(photo_tags, expression, frozen_children, warn_bad_ta
has_operand = True has_operand = True
continue continue
if has_operand and ((operand_stack[-1] == 0 and token == 'AND') or (operand_stack[-1] == 1 and token == 'OR')): can_shortcircuit = (
can_shortcircuit = True has_operand and
(
(operand_stack[-1] == 0 and token == 'AND') or
(operand_stack[-1] == 1 and token == 'OR')
)
)
if can_shortcircuit:
if operator_stack and operator_stack[-1] == '(': if operator_stack and operator_stack[-1] == '(':
operator_stack.pop() operator_stack.pop()
continue continue
else:
can_shortcircuit = False
operator_stack.append(token) operator_stack.append(token)
#time.sleep(.3) #time.sleep(.3)
@ -636,7 +553,7 @@ def tag_export_stdout(tags, depth=0):
if tag.parent() is None: if tag.parent() is None:
print() print()
@time_me @decorators.time_me
def tag_export_totally_flat(tags): def tag_export_totally_flat(tags):
result = {} result = {}
for tag in tags: for tag in tags:
@ -1097,7 +1014,7 @@ class PDBTagMixin:
''' '''
Redirect to get_tag_by_id or get_tag_by_name after xor-checking the parameters. Redirect to get_tag_by_id or get_tag_by_name after xor-checking the parameters.
''' '''
if not is_xor(id, name): if not helpers.is_xor(id, name):
raise XORException('One and only one of `id`, `name` can be passed.') raise XORException('One and only one of `id`, `name` can be passed.')
if id is not None: if id is not None:
@ -1192,19 +1109,35 @@ class PhotoDB(PDBAlbumMixin, PDBPhotoMixin, PDBTagMixin):
The `rename` method of Tag objects includes a parameter The `rename` method of Tag objects includes a parameter
`apply_to_synonyms` if you do want them to follow. `apply_to_synonyms` if you do want them to follow.
''' '''
def __init__(self, databasename=DEFAULT_DBNAME, thumbnail_folder=DEFAULT_THUMBDIR, id_length=None): def __init__(
if id_length is None: self,
self.id_length = DEFAULT_ID_LENGTH databasename=constants.DEFAULT_DBNAME,
thumbnail_folder=constants.DEFAULT_THUMBDIR,
id_length=constants.DEFAULT_ID_LENGTH,
):
self.databasename = databasename self.databasename = databasename
self.database_abspath = os.path.abspath(databasename) self.database_abspath = os.path.abspath(databasename)
self.thumbnail_folder = os.path.abspath(thumbnail_folder) existing_database = os.path.exists(databasename)
os.makedirs(thumbnail_folder, exist_ok=True)
self.sql = sqlite3.connect(databasename) self.sql = sqlite3.connect(databasename)
self.cur = self.sql.cursor() self.cur = self.sql.cursor()
if existing_database:
self.cur.execute('PRAGMA user_version')
existing_version = self.cur.fetchone()[0]
if existing_version != DATABASE_VERSION:
message = constants.ERROR_DATABASE_OUTOFDATE
message = message.format(current=existing_version, new=DATABASE_VERSION)
log.critical(message)
raise SystemExit
statements = DB_INIT.split(';') statements = DB_INIT.split(';')
for statement in statements: for statement in statements:
self.cur.execute(statement) self.cur.execute(statement)
self.thumbnail_folder = os.path.abspath(thumbnail_folder)
os.makedirs(thumbnail_folder, exist_ok=True)
self.id_length = id_length
self.on_commit_queue = [] self.on_commit_queue = []
self._cached_frozen_children = None self._cached_frozen_children = None
@ -1232,15 +1165,9 @@ class PhotoDB(PDBAlbumMixin, PDBPhotoMixin, PDBTagMixin):
if not os.path.isdir(directory): if not os.path.isdir(directory):
raise ValueError('Not a directory: %s' % directory) raise ValueError('Not a directory: %s' % directory)
if exclude_directories is None: if exclude_directories is None:
exclude_directories = [ exclude_directories = constants.DEFAULT_DIGEST_EXCLUDE_DIRS
'_site_thumbnails',
]
if exclude_filenames is None: if exclude_filenames is None:
exclude_filenames = [ exclude_filenames = constants.DEFAULT_DIGEST_EXCLUDE_FILES
DEFAULT_DBNAME,
'desktop.ini',
'thumbs.db'
]
directory = spinal.str_to_fp(directory) directory = spinal.str_to_fp(directory)
directory.correct_case() directory.correct_case()
@ -1306,15 +1233,9 @@ class PhotoDB(PDBAlbumMixin, PDBPhotoMixin, PDBTagMixin):
if not os.path.isdir(directory): if not os.path.isdir(directory):
raise ValueError('Not a directory: %s' % directory) raise ValueError('Not a directory: %s' % directory)
if exclude_directories is None: if exclude_directories is None:
exclude_directories = [ exclude_directories = constants.DEFAULT_DIGEST_EXCLUDE_DIRS
'_site_thumbnails',
]
if exclude_filenames is None: if exclude_filenames is None:
exclude_filenames = [ exclude_filenames = constants.DEFAULT_DIGEST_EXCLUDE_FILES
DEFAULT_DBNAME,
'desktop.ini',
'thumbs.db'
]
directory = spinal.str_to_fp(directory) directory = spinal.str_to_fp(directory)
generator = spinal.walk_generator( generator = spinal.walk_generator(
@ -1806,7 +1727,7 @@ class Photo(ObjectBase):
log.debug('Committing - delete photo') log.debug('Committing - delete photo')
self.photodb.commit() self.photodb.commit()
@time_me @decorators.time_me
def generate_thumbnail(self, commit=True, **special): def generate_thumbnail(self, commit=True, **special):
''' '''
special: special:
@ -1825,11 +1746,11 @@ class Photo(ObjectBase):
pass pass
else: else:
(width, height) = image.size (width, height) = image.size
(new_width, new_height) = fit_into_bounds( (new_width, new_height) = helpers.fit_into_bounds(
image_width=width, image_width=width,
image_height=height, image_height=height,
frame_width=THUMBNAIL_WIDTH, frame_width=constants.THUMBNAIL_WIDTH,
frame_height=THUMBNAIL_HEIGHT, frame_height=constants.THUMBNAIL_HEIGHT,
) )
if new_width < width: if new_width < width:
image = image.resize((new_width, new_height)) image = image.resize((new_width, new_height))
@ -1841,11 +1762,11 @@ class Photo(ObjectBase):
probe = ffmpeg.probe(self.real_filepath) probe = ffmpeg.probe(self.real_filepath)
try: try:
if probe.video: if probe.video:
size = fit_into_bounds( size = helpers.fit_into_bounds(
image_width=probe.video.video_width, image_width=probe.video.video_width,
image_height=probe.video.video_height, image_height=probe.video.video_height,
frame_width=THUMBNAIL_WIDTH, frame_width=constants.THUMBNAIL_WIDTH,
frame_height=THUMBNAIL_HEIGHT, frame_height=constants.THUMBNAIL_HEIGHT,
) )
size = '%dx%d' % size size = '%dx%d' % size
duration = probe.video.duration duration = probe.video.duration
@ -1898,7 +1819,7 @@ class Photo(ObjectBase):
return False return False
def make_thumbnail_filepath(self): def make_thumbnail_filepath(self):
chunked_id = chunk_sequence(self.id, 3) chunked_id = helpers.chunk_sequence(self.id, 3)
basename = chunked_id[-1] basename = chunked_id[-1]
folder = chunked_id[:-1] folder = chunked_id[:-1]
folder = os.sep.join(folder) folder = os.sep.join(folder)
@ -1911,7 +1832,7 @@ class Photo(ObjectBase):
def mimetype(self): def mimetype(self):
return get_mimetype(self.real_filepath) return get_mimetype(self.real_filepath)
@time_me @decorators.time_me
def reload_metadata(self, commit=True): def reload_metadata(self, commit=True):
''' '''
Load the file's height, width, etc as appropriate for this type of file. Load the file's height, width, etc as appropriate for this type of file.

View File

@ -25,15 +25,15 @@
{% else %} {% else %}
<h3>Parent: <a href="/albums">Albums</a></h3> <h3>Parent: <a href="/albums">Albums</a></h3>
{% endif %} {% endif %}
{% if child_albums %} {% if album["sub_albums"] %}
<h3>Sub-albums</h3> <h3>Sub-albums</h3>
<ul> <ul>
{% for album in child_albums %} {% for sub_album in album["sub_albums"] %}
<li><a href="/album/{{album["id"]}}"> <li><a href="/album/{{sub_album["id"]}}">
{% if album["title"] %} {% if sub_album["title"] %}
{{album["title"]}} {{sub_album["title"]}}
{% else %} {% else %}
{{album["id"]}} {{sub_album["id"]}}
{% endif %}</a> {% endif %}</a>
</li> </li>
{% endfor %} {% endfor %}

View File

@ -17,12 +17,12 @@
{{header.make_header()}} {{header.make_header()}}
<div id="content_body"> <div id="content_body">
{% for album in albums %} {% for album in albums %}
{% if album.title %} {% if album["title"] %}
{% set title=album.id + " " + album.title %} {% set title=album["id"] + " " + album["title"] %}
{% else %} {% else %}
{% set title=album.id %} {% set title=album["id"] %}
{% endif %} {% endif %}
<a href="/album/{{album.id}}">{{title}}</a> <a href="/album/{{album["id"]}}">{{title}}</a>
{% endfor %} {% endfor %}
</div> </div>
</body> </body>

View File

@ -115,7 +115,7 @@
<li>Size: {{photo["bytestring"]}}</li> <li>Size: {{photo["bytestring"]}}</li>
{% endif %} {% endif %}
{% if photo["duration"] %} {% if photo["duration"] %}
<li>Duration: {{photo["duration"]}}</li> <li>Duration: {{photo["duration_str"]}}</li>
{% endif %} {% endif %}
<li><a href="/file/{{photo["id"]}}.{{photo["extension"]}}?download=1">Download as {{photo["id"]}}.{{photo["extension"]}}</a></li> <li><a href="/file/{{photo["id"]}}.{{photo["extension"]}}?download=1">Download as {{photo["id"]}}.{{photo["extension"]}}</a></li>
<li><a href="/file/{{photo["id"]}}.{{photo["extension"]}}?download=1&original_filename=1">Download as "{{photo["filename"]}}"</a></li> <li><a href="/file/{{photo["id"]}}.{{photo["extension"]}}?download=1&original_filename=1">Download as "{{photo["filename"]}}"</a></li>

View File

@ -1,4 +1,4 @@
{% set basics = {% set thumbnails =
{ {
"audio": "audio", "audio": "audio",
"txt": "txt", "txt": "txt",
@ -15,12 +15,11 @@
src="/thumbnail/{{photo["id"]}}.jpg" src="/thumbnail/{{photo["id"]}}.jpg"
{% else %} {% else %}
{% set choice = {% set choice =
photo['extension'] if photo['extension'] in basics else thumbnails.get(photo["extension"],
photo['mimetype'] if photo['mimetype'] in basics else thumbnails.get(photo["mimetype"],
'other' 'other'))
%} %}
src="/static/basic_thumbnails/{{choice}}.png" src="/static/basic_thumbnails/{{choice}}.png"
{% endif %} {% endif %}
</a> </a>
</div> </div>
@ -31,7 +30,7 @@
{{photo["width"]}}x{{photo["height"]}}, {{photo["width"]}}x{{photo["height"]}},
{% endif %} {% endif %}
{% if photo["duration"] %} {% if photo["duration"] %}
{{photo["duration"]}}, {{photo["duration_str"]}},
{% endif %} {% endif %}
{{photo["bytestring"]}} {{photo["bytestring"]}}
</span> </span>