photolib/photoapp/thumbserver.py

258 lines
8.5 KiB
Python

import os
import logging
import cherrypy
import shutil
import tempfile
import traceback
import requests
from threading import Thread
from contextlib import closing
from queue import Queue, Empty
from shutil import copyfileobj
from subprocess import check_call
from photoapp.dbutils import SAEnginePlugin, SATool, db, get_db_engine, create_db_sessionmaker
from photoapp.storage import uri_to_storage
from photoapp.api import PhotosApi, LibraryManager
from photoapp.types import User, Photo
from photoapp.utils import require_auth
from photoapp.common import pwhash
from photoapp.dbsession import DatabaseSession
from photoapp.thumb import thumb_path, image_file_style
def get_video_thumb(srcpath, outpath):
#TODO limit execution time
cmd = [
"ffmpeg",
"-i", srcpath,
"-vframes", "1", # Output one frame
"-an", # Disable audio
# "-s", "400x222" # Output size
"-ss", "1", # grab the frame from 1 second into the video
outpath
]
#TODO capture output and only log on error
check_call(cmd)
def setup_thumb_user(engine):
#TODO create the internal User used to talk to this service
# if user doesnt exist
# create
# log the password
pass
class ThumbWorker(Thread):
def __init__(self, engine, library, cache):
super().__init__()
self.daemon = True
self.queue = Queue()
self.engine = engine
self.library = library
self.cache = cache
def run(self):
logged_empty = False
while True:
try:
image_uuid, style_name = self.queue.get(block=True, timeout=5.0)
logged_empty = False
except Empty:
if not logged_empty:
logging.info("queue empty")
logged_empty = True
continue
try:
with (
closing(create_db_sessionmaker(self.engine)()) as s,
tempfile.TemporaryDirectory() as d,
):
self.do_thumb(image_uuid, style_name, s, d)
except:
traceback.print_exc() #TODO something like _failed_thumbs_cache
#TODO handle errors differently, like
# db error -> kill program
# filesystem error -> kill program
# PIL error -> ignore
def do_thumb(self, image_uuid, style_name, session, tmpdir):
"""
Generate a thumbnail for the given image identified by uuid
"""
# find the image
image = session.query(Photo).filter(Photo.uuid == image_uuid).first()
if not image:
logging.info("attempted invalid uuid: %s", image_uuid)
return
# Bail if it exists in storage already
cache_path = thumb_path(style_name, image_uuid)
if self.cache.exists(cache_path):
return
# download the image
local_src_path = os.path.join(tmpdir, image.fname) # TODO fname isn't sanitized?
thumb_tmp_path = os.path.join(tmpdir, "thumb.jpg")
with (
self.library.open(image.path, "rb") as src,
open(local_src_path, "wb") as dest,
):
shutil.copyfileobj(src, dest)
# generate a still from the image
get_video_thumb(local_src_path, thumb_tmp_path)
logging.info("generated %s: %sb", thumb_tmp_path, str(os.path.getsize(thumb_tmp_path)))
# Do normal cropping of the thumb
thumb_cropped_path = os.path.join(tmpdir, "thumb_cropped.jpg")
image_file_style(thumb_tmp_path, thumb_cropped_path, style_name, image.orientation)
# copy thumbnail to cache storage
with (
open(thumb_cropped_path, 'rb') as fsrc,
closing(self.cache.open(cache_path, 'wb')) as fdest
):
copyfileobj(fsrc, fdest)
class ThumbServiceWeb(object):
def __init__(self, queue_thumbnail):
self.queue_thumbnail = queue_thumbnail
@cherrypy.expose
def index(self):
yield "photoapp thumbnail service OK"
@cherrypy.expose
# @require_auth
def thumb(self, uuid, style):
"""
Generate a thumbnail for the file identified. Calling this endpoint adds the image to the queue. Duplicate
requests are OK and are ignored later
"""
self.queue_thumbnail((uuid, style, ))
yield "ok"
class ThumbClient(object):
"""
Client for interacting with the thumbserver api
"""
def __init__(self, server_url):
self.server_url = server_url
self.session = requests.Session()
a = requests.adapters.HTTPAdapter(max_retries=0)
self.session.mount('http://', a)
def request_thumb(self, photo_uuid, style_name):
self.session.get(self.server_url, params=dict(uuid=photo_uuid, style=style_name))
# TODO dedupe me
def validate_password(realm, username, password):
if db.query(User).filter(User.name == username, User.password == pwhash(password)).first():
return True
return False
def main():
# this is a slimmed down version of daemon.py TODO dedupe me
import argparse
import signal
parser = argparse.ArgumentParser(description="Photod photo server")
parser.add_argument('-p', '--port', help="tcp port to listen on",
default=int(os.environ.get("THUMB_SERVICE_PORT", 8081)), type=int)
parser.add_argument('-l', '--library', default=os.environ.get("STORAGE_URL"), help="library path")
parser.add_argument('-c', '--cache', default=os.environ.get("CACHE_URL"), help="cache url")
# https://docs.sqlalchemy.org/en/13/core/engines.html
parser.add_argument('-s', '--database', help="sqlalchemy database connection uri",
default=os.environ.get("DATABASE_URL")),
parser.add_argument('--debug', action="store_true", help="enable development options")
args = parser.parse_args()
if not args.database:
parser.error("--database or DATABASE_URL is required")
if not args.library:
parser.error("--library or STORAGE_URL is required")
if not args.cache:
parser.error("--cache or CACHE_URL is required")
logging.basicConfig(level=logging.INFO if args.debug else logging.WARNING,
format="%(asctime)-15s %(levelname)-8s %(filename)s:%(lineno)d %(message)s")
# Get database connection
engine = get_db_engine(args.database)
setup_thumb_user(engine)
# Setup database in web framework
cherrypy.tools.db = SATool()
SAEnginePlugin(cherrypy.engine, engine).subscribe()
# Create various internal tools
library_storage = uri_to_storage(args.library)
library_manager = LibraryManager(library_storage)
cache_storage = uri_to_storage(args.cache)
thumbnail_worker = ThumbWorker(engine, library_storage, cache_storage)
thumbnail_worker.start()
# Setup and mount web ui
web = ThumbServiceWeb(thumbnail_worker.queue.put)
cherrypy.tree.mount(web, '/', {'/': {'tools.trailing_slash.on': False,
'tools.db.on': True, }})
# Setup and mount API
api = PhotosApi(library_manager)
cherrypy.tree.mount(api, '/api', {'/': {'tools.sessions.on': False,
'tools.trailing_slash.on': False,
'tools.auth_basic.on': True,
'tools.auth_basic.realm': 'photolib',
'tools.auth_basic.checkpassword': validate_password,
'tools.db.on': True}})
# General config options
cherrypy.config.update({
'tools.sessions.storage_class': DatabaseSession,
'tools.sessions.on': True,
'tools.sessions.locking': 'explicit',
'tools.sessions.timeout': 525600,
'request.show_tracebacks': True,
'server.socket_port': args.port,
'server.thread_pool': 5,
'server.socket_host': '0.0.0.0',
'server.show_tracebacks': True,
'log.screen': False,
'engine.autoreload.on': args.debug,
})
# Setup signal handling and run it.
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()