photolib/photoapp/daemon.py

416 lines
16 KiB
Python
Raw Normal View History

2018-09-09 12:05:13 -07:00
import os
import cherrypy
import logging
2018-09-11 20:35:04 -07:00
from datetime import datetime, timedelta
2018-09-09 12:05:13 -07:00
from photoapp.library import PhotoLibrary
2018-09-09 16:44:10 -07:00
from photoapp.types import Photo, PhotoSet, Tag, TagItem
2018-09-09 12:05:13 -07:00
from jinja2 import Environment, FileSystemLoader, select_autoescape
2018-09-10 21:19:02 -07:00
from sqlalchemy import desc
2018-09-09 16:44:10 -07:00
from sqlalchemy.exc import IntegrityError
2018-09-11 20:35:04 -07:00
from sqlalchemy import func, and_
2018-09-09 23:43:17 -07:00
import math
2018-09-09 12:05:13 -07:00
2018-09-09 16:44:10 -07:00
APPROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../"))
2018-09-22 15:12:01 -07:00
def auth():
return cherrypy.session.get('authed', None)
def mime2ext(mime):
return {"image/png": "png",
"image/jpeg": "jpg",
"image/gif": "gif",
"application/octet-stream-xmp": "xmp",
"image/x-canon-cr2": "cr2",
"video/mp4": "mp4",
"video/quicktime": "mov"}[mime]
2018-09-09 12:05:13 -07:00
class PhotosWeb(object):
2018-09-09 16:44:10 -07:00
def __init__(self, library, template_dir):
2018-09-09 12:05:13 -07:00
self.library = library
2018-09-22 15:12:01 -07:00
2018-09-09 16:44:10 -07:00
self.tpl = Environment(loader=FileSystemLoader(template_dir),
2018-09-09 12:05:13 -07:00
autoescape=select_autoescape(['html', 'xml']))
2018-09-22 15:12:01 -07:00
self.tpl.filters['mime2ext'] = mime2ext
2018-09-09 13:45:26 -07:00
self.tpl.filters['basename'] = os.path.basename
2018-09-09 23:43:17 -07:00
self.tpl.filters['ceil'] = math.ceil
2018-09-22 15:12:01 -07:00
self.tpl.filters['statusstr'] = lambda x: str(x).split(".")[-1]
2018-09-09 12:05:13 -07:00
self.thumb = ThumbnailView(self)
self.photo = PhotoView(self)
self.download = DownloadView(self)
2018-09-11 20:35:04 -07:00
self.date = DateView(self)
2018-09-13 22:59:37 -07:00
self.tag = TagView(self)
self.album = self.tag
2018-09-09 12:05:13 -07:00
2018-09-09 17:21:23 -07:00
def render(self, template, **kwargs):
2018-09-22 15:12:01 -07:00
"""
Render a template
"""
2018-09-09 17:21:23 -07:00
return self.tpl.get_template(template).render(**kwargs, **self.get_default_vars())
def get_default_vars(self):
2018-09-22 15:12:01 -07:00
"""
Return a dict containing variables expected to be on every page
"""
2018-09-09 17:21:23 -07:00
s = self.session()
ret = {
2018-09-13 22:59:37 -07:00
"all_tags": s.query(Tag).order_by(Tag.title).all(),
"all_albums": s.query(Tag).filter(Tag.is_album == True).order_by(Tag.title).all(),
2018-09-22 15:12:23 -07:00
"path": cherrypy.request.path_info,
"auth": auth()
2018-09-09 17:21:23 -07:00
}
2018-09-22 15:12:01 -07:00
s.close()
2018-09-09 17:21:23 -07:00
return ret
2018-09-09 12:05:13 -07:00
def session(self):
2018-09-22 15:12:01 -07:00
"""
Get a database session
"""
2018-09-09 12:05:13 -07:00
return self.library.session()
@cherrypy.expose
def index(self):
2018-09-22 15:12:01 -07:00
"""
Home page - redirect to the photo feed
"""
2018-09-09 12:05:13 -07:00
raise cherrypy.HTTPRedirect('feed', 302)
@cherrypy.expose
def feed(self, page=0, pgsize=25):
2018-09-22 15:12:01 -07:00
"""
/feed - main photo feed - show photos sorted by date, newest first
"""
2018-09-09 12:05:13 -07:00
s = self.session()
page, pgsize = int(page), int(pgsize)
2018-09-09 23:43:17 -07:00
total_sets = s.query(func.count(PhotoSet.id)).first()[0]
2018-09-09 12:05:13 -07:00
images = s.query(PhotoSet).order_by(PhotoSet.date.desc()).offset(pgsize * page).limit(pgsize).all()
2018-09-13 22:59:37 -07:00
yield self.render("feed.html", images=[i for i in images], page=page, pgsize=int(pgsize), total_sets=total_sets)
2018-09-09 12:05:13 -07:00
@cherrypy.expose
2018-09-22 15:12:01 -07:00
def stats(self):
"""
/stats - show server statistics
"""
2018-09-09 12:05:13 -07:00
s = self.session()
images = s.query(func.count(PhotoSet.uuid),
func.strftime('%Y', PhotoSet.date).label('year'),
func.strftime('%m', PhotoSet.date).label('month')). \
2018-09-10 21:19:02 -07:00
group_by('year', 'month').order_by(desc('year'), desc('month')).all()
tsize = s.query(func.sum(Photo.size)).scalar()
yield self.render("monthly.html", images=images, tsize=tsize)
2018-09-09 12:05:13 -07:00
2018-09-09 13:45:26 -07:00
@cherrypy.expose
def map(self, i=None, zoom=3):
2018-09-22 15:12:01 -07:00
"""
/map - show all photos on the a map, or a single point if $i is passed
TODO using so many coordinates is slow in the browser. dedupe them somehow.
"""
2018-09-09 13:45:26 -07:00
s = self.session()
query = s.query(PhotoSet).filter(PhotoSet.lat != 0, PhotoSet.lon != 0)
if i:
query = query.filter(PhotoSet.uuid == i)
2018-09-09 17:21:23 -07:00
yield self.render("map.html", images=query.all(), zoom=int(zoom))
2018-09-09 13:45:26 -07:00
2018-09-13 22:59:22 -07:00
@cherrypy.expose
2018-09-22 15:12:23 -07:00
def create_tags(self, fromdate=None, uuid=None, tag=None, newtag=None, remove=None):
2018-09-13 22:59:22 -07:00
"""
2018-09-22 15:12:23 -07:00
/create_tags - tag multiple items selected by day of photo
:param fromdate: act upon photos taken on this day
:param uuid: act upon a single photo with this uuid
:param tag: target photos will have a tag specified by this uuid added
:param remove: target photos will have the tag specified by this uuid removed
:param newtag: new tag name to create
2018-09-13 22:59:22 -07:00
"""
s = self.session()
2018-09-22 15:12:23 -07:00
def get_photos():
if fromdate:
dt = datetime.strptime(fromdate, "%Y-%m-%d")
dt_end = dt + timedelta(days=1)
photos = s.query(PhotoSet).filter(and_(PhotoSet.date >= dt,
PhotoSet.date < dt_end)).order_by(PhotoSet.date)
num_photos = s.query(func.count(PhotoSet.id)). \
filter(and_(PhotoSet.date >= dt, PhotoSet.date < dt_end)).order_by(PhotoSet.date).scalar()
if uuid:
photos = s.query(PhotoSet).filter(PhotoSet.uuid == uuid)
num_photos = s.query(func.count(PhotoSet.id)).filter(PhotoSet.uuid == uuid).scalar()
return photos, num_photos
if remove:
rmtag = s.query(Tag).filter(Tag.uuid == remove).first()
photoq, _ = get_photos()
for photo in photoq:
s.query(TagItem).filter(TagItem.tag_id == rmtag.id, TagItem.set_id == photo.id).delete()
s.commit()
2018-09-13 22:59:22 -07:00
if newtag:
# TODO validate uuid ?
s.add(Tag(title=newtag)) # TODO slug
# TODO generate slug now or in model?
s.commit()
# raise cherrypy.HTTPRedirect('/photo/{}/tag'.format(uuid), 302)
2018-09-22 15:12:23 -07:00
photos, num_photos = get_photos()
2018-09-13 22:59:22 -07:00
if tag: # Create the tag on all the photos
tag = s.query(Tag).filter(Tag.uuid == tag).first()
for photo in photos.all():
if 0 == s.query(func.count(TagItem.id)).filter(TagItem.tag_id == tag.id,
TagItem.set_id == photo.id).scalar():
s.add(TagItem(tag_id=tag.id, set_id=photo.id))
s.commit()
alltags = s.query(Tag).order_by(Tag.title).all()
2018-09-22 15:12:23 -07:00
yield self.render("create_tags.html", images=photos, alltags=alltags,
num_photos=num_photos, fromdate=fromdate, uuid=uuid)
@cherrypy.expose
def login(self):
"""
/login - enable super features by logging into the app
"""
cherrypy.session['authed'] = cherrypy.request.login
raise cherrypy.HTTPRedirect('/feed', 302)
@cherrypy.expose
def sess(self):
"""
/sess - TODO DELETE ME dump session contents for debugging purposes
"""
yield cherrypy.session['authed']
2018-09-13 22:59:22 -07:00
2018-09-09 12:05:13 -07:00
2018-09-11 20:35:04 -07:00
@cherrypy.popargs('date')
class DateView(object):
2018-09-13 22:59:22 -07:00
"""
View all the photos shot on a given date
"""
2018-09-11 20:35:04 -07:00
def __init__(self, master):
self.master = master
@cherrypy.expose
def index(self, date=None, page=0):
s = self.master.session()
if date:
page = int(page)
pgsize = 100
dt = datetime.strptime(date, "%Y-%m-%d")
dt_end = dt + timedelta(days=1)
total_sets = s.query(func.count(PhotoSet.id)). \
filter(and_(PhotoSet.date >= dt, PhotoSet.date < dt_end)).first()[0]
images = s.query(PhotoSet).filter(and_(PhotoSet.date >= dt,
PhotoSet.date < dt_end)).order_by(PhotoSet.date). \
offset(page * pgsize).limit(pgsize).all()
2018-09-13 22:59:37 -07:00
yield self.master.render("date.html", page=page, pgsize=pgsize, total_sets=total_sets,
images=[i for i in images], date=dt)
2018-09-11 20:35:04 -07:00
return
images = s.query(PhotoSet, func.strftime('%Y-%m-%d',
PhotoSet.date).label('gdate'),
func.count('photos.id'),
func.strftime('%Y', PhotoSet.date).label('year'),
func.strftime('%m', PhotoSet.date).label('month'),
func.strftime('%d', PhotoSet.date).label('day')). \
group_by('gdate').order_by(desc('year'), 'month', 'day').all()
yield self.master.render("dates.html", images=images)
2018-09-09 12:05:13 -07:00
@cherrypy.popargs('item_type', 'thumb_size', 'uuid')
class ThumbnailView(object):
2018-09-22 15:12:01 -07:00
"""
Generate and serve thumbnails on-demand
"""
2018-09-09 12:05:13 -07:00
def __init__(self, master):
self.master = master
self._cp_config = {"tools.trailing_slash.on": False}
@cherrypy.expose
def index(self, item_type, thumb_size, uuid):
uuid = uuid.split(".")[0]
s = self.master.session()
query = s.query(Photo).filter(Photo.set.has(uuid=uuid)) if item_type == "set" \
else s.query(Photo).filter(Photo.uuid == uuid) if item_type == "one" \
else None
# prefer making thumbs from jpeg to avoid loading large raws
first = None
best = None
for photo in query.all():
if first is None:
first = photo
if photo.format == "image/jpeg":
best = photo
break
thumb_from = best or first
if not thumb_from:
raise Exception("404") # TODO it right
# TODO some lock around calls to this based on uuid
thumb_path = self.master.library.make_thumb(thumb_from, thumb_size)
if thumb_path:
2018-09-09 13:45:26 -07:00
return cherrypy.lib.static.serve_file(thumb_path, "image/jpeg")
2018-09-09 12:05:13 -07:00
else:
2018-09-09 16:44:10 -07:00
return cherrypy.lib.static.serve_file(os.path.join(APPROOT, "styles/dist/unknown.svg"), "image/svg+xml")
2018-09-09 12:05:13 -07:00
@cherrypy.popargs('item_type', 'uuid')
class DownloadView(object):
2018-09-22 15:12:01 -07:00
"""
View original files or force-download them
"""
2018-09-09 12:05:13 -07:00
def __init__(self, master):
self.master = master
self._cp_config = {"tools.trailing_slash.on": False}
@cherrypy.expose
def index(self, item_type, uuid, preview=False):
uuid = uuid.split(".")[0]
s = self.master.session()
query = None if item_type == "set" \
else s.query(Photo).filter(Photo.uuid == uuid) if item_type == "one" \
else None # TODO set download query
item = query.first()
extra = {}
if not preview:
extra.update(disposition="attachement", name=os.path.basename(item.path))
return cherrypy.lib.static.serve_file(os.path.abspath(os.path.join(self.master.library.path, item.path)),
content_type=item.format, **extra)
@cherrypy.popargs('uuid')
class PhotoView(object):
2018-09-22 15:12:01 -07:00
"""
View a single photo
"""
2018-09-09 12:05:13 -07:00
def __init__(self, master):
self.master = master
self._cp_config = {"tools.trailing_slash.on": False}
@cherrypy.expose
def index(self, uuid):
uuid = uuid.split(".")[0]
s = self.master.session()
photo = s.query(PhotoSet).filter(PhotoSet.uuid == uuid).first()
2018-09-09 17:21:23 -07:00
yield self.master.render("photo.html", image=photo)
2018-09-09 12:05:13 -07:00
2018-09-13 22:59:22 -07:00
@cherrypy.popargs('uuid')
class TagView(object):
"""
View the photos associated with a single tag
"""
def __init__(self, master):
self.master = master
# self._cp_config = {"tools.trailing_slash.on": False}
@cherrypy.expose
def index(self, uuid, page=0):
page = int(page)
pgsize = 100
s = self.master.session()
2018-09-22 15:12:23 -07:00
if uuid == "untagged":
numphotos = s.query(func.count(PhotoSet.id)).filter(~PhotoSet.id.in_(s.query(TagItem.set_id))).scalar()
photos = s.query(PhotoSet).filter(~PhotoSet.id.in_(s.query(TagItem.set_id))).\
offset(page * pgsize).limit(pgsize).all()
yield self.master.render("untagged.html", images=photos, total_items=numphotos, pgsize=pgsize, page=page)
else:
tag = s.query(Tag).filter(Tag.uuid == uuid).first()
numphotos = s.query(func.count(Tag.id)).join(TagItem).join(PhotoSet).filter(Tag.uuid == uuid).scalar()
photos = s.query(PhotoSet).join(TagItem).join(Tag).filter(Tag.uuid == uuid). \
order_by(PhotoSet.date.desc()).offset(page * pgsize).limit(pgsize).all()
yield self.master.render("album.html", tag=tag, images=photos,
total_items=numphotos, pgsize=pgsize, page=page)
2018-09-13 22:59:22 -07:00
@cherrypy.expose
def op(self, uuid, op):
s = self.master.session()
tag = s.query(Tag).filter(Tag.uuid == uuid).first()
if op == "Demote to tag":
tag.is_album = 0
elif op == "Promote to album":
tag.is_album = 1
2018-09-22 15:12:35 -07:00
elif op == "Delete tag":
s.query(TagItem).filter(TagItem.tag_id == tag.id).delete()
s.delete(tag)
s.commit()
raise cherrypy.HTTPRedirect('/', 302)
2018-09-13 22:59:22 -07:00
else:
2018-09-22 15:12:35 -07:00
raise Exception("Invalid op: '{}'".format(op))
2018-09-13 22:59:22 -07:00
s.commit()
raise cherrypy.HTTPRedirect('/tag/{}'.format(tag.uuid), 302)
2018-09-09 12:05:13 -07:00
def main():
import argparse
import signal
parser = argparse.ArgumentParser(description="Photod photo server")
parser.add_argument('-p', '--port', default=8080, type=int, help="tcp port to listen on")
parser.add_argument('-l', '--library', default="./library", help="library path")
2018-09-11 22:25:09 -07:00
parser.add_argument('-c', '--cache', default="./cache", help="cache path")
parser.add_argument('-s', '--database', default="./photos.db", help="path to persistent sqlite database")
2018-09-09 12:05:13 -07:00
parser.add_argument('--debug', action="store_true", help="enable development options")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO if args.debug else logging.WARNING,
format="%(asctime)-15s %(levelname)-8s %(filename)s:%(lineno)d %(message)s")
2018-09-11 22:25:09 -07:00
library = PhotoLibrary(args.database, args.library, args.cache)
2018-09-09 12:05:13 -07:00
2018-09-09 16:44:10 -07:00
tpl_dir = os.path.join(APPROOT, "templates") if not args.debug else "templates"
web = PhotosWeb(library, tpl_dir)
2018-09-09 12:05:13 -07:00
web_config = {}
2018-09-22 15:12:23 -07:00
def validate_password(realm, username, password):
print("I JUST VALIDATED {}:{} ({})".format(username, password, realm))
return True
2018-09-09 12:05:13 -07:00
cherrypy.tree.mount(web, '/', {'/': web_config,
'/static': {"tools.staticdir.on": True,
2018-09-22 15:12:23 -07:00
"tools.staticdir.dir": os.path.join(APPROOT, "styles/dist")
if not args.debug else os.path.abspath("styles/dist")},
'/login': {'tools.auth_basic.on': True,
'tools.auth_basic.realm': 'photolib',
'tools.auth_basic.checkpassword': validate_password}})
2018-09-09 12:05:13 -07:00
cherrypy.config.update({
2018-09-22 15:12:23 -07:00
'tools.sessions.on': True,
'tools.sessions.locking': 'explicit',
'tools.sessions.timeout': 525600,
2018-09-09 12:05:13 -07:00
'request.show_tracebacks': True,
'server.socket_port': args.port,
'server.thread_pool': 25,
'server.socket_host': '0.0.0.0',
'server.show_tracebacks': True,
'log.screen': False,
'engine.autoreload.on': args.debug
})
def signal_handler(signum, stack):
logging.critical('Got sig {}, exiting...'.format(signum))
cherrypy.engine.exit()
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
try:
cherrypy.engine.start()
cherrypy.engine.block()
finally:
logging.info("API has shut down")
cherrypy.engine.exit()
if __name__ == '__main__':
main()