photolib/photoapp/thumbserver.py

239 lines
7.7 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 ThumbGenerator
def get_video_thumb(srcpath, outpath):
#TODO
# download the video file
# use `ffmpeg -i filename.flv` to inspect the video, parse output for duration
# take a thumb halfway through the video
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
]
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
self.thumbtool = thumbtool
def run(self):
while True:
try:
image_uuid, style_name = self.queue.get(block=True, timeout=5.0)
except Empty:
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
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
# download the image
local_src_path = os.path.join(tmpdir, image.fname) # TODO fname isn't sanitized?
thumb_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_path)
# copy thumbnail to cache storage
# TODO write thumb.jpg to thumb storage
os.system(f"cp {thumb_path} /Users/dave/Desktop/")
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()