From 21264da3484d9b8f4fb6e768cb8eae8445d7d14d Mon Sep 17 00:00:00 2001 From: Andrew Date: Tue, 23 Mar 2021 01:42:48 -0500 Subject: [PATCH] Initial Development Commit --- __init__.py | 184 +++++++++++++++++++ config.py | 61 ++++++ database.py | 100 ++++++++++ forms.py | 47 +++++ tasks.py | 38 ++++ templates/thumbnailer_settings.html | 180 ++++++++++++++++++ templates/thumbnailer_tep_settings_menu.html | 6 + thumbnailer.py | 153 +++++++++++++++ 8 files changed, 769 insertions(+) create mode 100644 __init__.py create mode 100644 config.py create mode 100644 database.py create mode 100644 forms.py create mode 100644 tasks.py create mode 100644 templates/thumbnailer_settings.html create mode 100644 templates/thumbnailer_tep_settings_menu.html create mode 100644 thumbnailer.py diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..d8e9240 --- /dev/null +++ b/__init__.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""------------------------------------------------------------------------------------------- +-- IMPORTS +-------------------------------------------------------------------------------------------""" + +""" --- PYTHON IMPORTS --- """ +import os, pathlib +from os.path import dirname, abspath + +""" --- STASHR CORE IMPORTS --- """ +from stashr import database as stashr_database +from stashr.stashr import stashr_image_downloaded +from stashr.api import create_json_return, api + +""" --- STASHR PLUGIN IMPORTS --- """ +from . import thumbnailer, tasks, forms +from .config import thumbconfig + +""" --- FLASK EXTENSION IMPORTS --- """ +from flask_login import current_user + +""" --- STASHR DEPENDENCY IMPORTS --- """ +from flask import Blueprint, render_template, request, flash, redirect, url_for +from flask_login import login_required, current_user + +"""------------------------------------------------------------------------------------------- +-- PLUGIN +-------------------------------------------------------------------------------------------""" + +# PLUGIN DETAILS +__plugin_name__ = "Image Thumbnailer" +__version__ = "0.1.0" +__author__ = "Stashr" +__description__ = "Shrink image filesize" + +"""------------------------------ +- PLUGIN ROUTES +------------------------------""" + +""" --- DEFINE BLUEPRINT --- """ +bp = Blueprint('thumbnailer', __name__, root_path=dirname(abspath(__file__)), template_folder='templates', static_folder='static') + +""" --- PAGES --- """ + +@bp.route('/settings', methods=['GET', 'POST']) +@login_required +def thumbnailer_settings_page(): + + if current_user.role != 'admin': + flash('Permission Denied', 'error') + return redirect(url_for('index_page')) + + settings_form = forms.settings_form() + + if request.method == 'POST': + if settings_form.validate(): + thumbconfig['THUMBNAILER']['max_width'] = settings_form.max_width.data + thumbconfig['THUMBNAILER']['quality'] = settings_form.quality.data + thumbconfig['THUMBNAILER']['save_original_file'] = settings_form.save_original_file.data + thumbconfig.write() + flash('Thumbnailer Settings Updated', 'success') + else: + for error in settings_form.errors.items(): + flash(f'{error[0]}: {error[1]}', 'error') + + return render_template( + 'thumbnailer_settings.html', + settings_form=settings_form, + title='Thumbnailer Settings' + ) + + +""" --- API --- """ + +# SCAN IMAGES +@bp.route('/api/scan', methods=['POST']) +def api_post_scan_images(): + + user = current_user + + if not user.is_authenticated: + if not request.json: + return create_json_return('400') + if "api_key" not in request.json: + return create_json_return('400') + if request.json['api_key'] == "": + return create_json_return('100') + + user = stashr_database.session \ + .query(stashr_database.Users) \ + .filter(stashr_database.Users.api_key == request.json['api_key']) \ + .first() + + if user is None: + return create_json_return('100') + + if user.role.lower() != 'admin': + return create_json_return('401') + + tasks.scan_images_task() + + return create_json_return('200') + + +# PROCESS IMAGES +@bp.route('/api/process/images', methods=['POST']) +def api_post_process_images(): + + user = current_user + + if not user.is_authenticated: + if not request.json: + return create_json_return('400') + if "api_key" not in request.json: + return create_json_return('400') + if request.json['api_key'] == "": + return create_json_return('100') + + user = stashr_database.session \ + .query(stashr_database.Users) \ + .filter(stashr_database.Users.api_key == request.json['api_key']) \ + .first() + + if user is None: + return create_json_return('100') + + if user.role.lower() != 'admin': + return create_json_return('401') + + tasks.process_images_task() + + return create_json_return('200') + + +@bp.route('/api/settings', methods=['GET']) +def api_get_settings(): + + user = current_user + + if not user.is_authenticated: + api_key = request.args.get('api_key') + if api_key == "": + return create_json_return('100') + user = stashr_database.session \ + .query(stashr_database.Users) \ + .filter(stashr_database.Users.api_key == api_key) \ + .first() + + if user is None: + return create_json_return('100') + + if user.role.lower() != 'admin': + return create_json_return('401') + + settings = thumbconfig + + return create_json_return('200', results=settings) + + +"""------------------------------ +- PLUGIN FUNCTIONS +------------------------------""" + +@stashr_image_downloaded.connect +def do_something(*args, **kwargs): + pass + + +"""------------------------------ +- REGISTER PLUGIN +------------------------------""" + +def register(): + return dict( + bep=dict( + blueprint=bp, + prefix='/thumbnailer' + ), + tep = dict( + settings_menu='thumbnailer_tep_settings_menu.html', + ) + ) \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..9b6e14b --- /dev/null +++ b/config.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""------------------------------------------------------------------------------------------- +-- IMPORTS +-------------------------------------------------------------------------------------------""" + +""" --- PYTHON IMPORTS --- """ +import os, io, shutil +from os.path import dirname, abspath + +from configobj import ConfigObj +from validate import Validator + +"""------------------------------------------------------------------------------------------- +-- THUMBNAILER CONFIG +-------------------------------------------------------------------------------------------""" + +class ThumbConfig(ConfigObj): + + configspec = u""" +[THUMBNAILER] +max_width = integer(default=160) +quality = integer(default=50) +save_original_file = boolean(default=False) + """ + + def __init__(self): + super(ThumbConfig, self).__init__() + + configspecfile = os.path.join( + dirname(abspath(__file__)), + 'configspec.ini' + ) + + if not os.path.exists(configspecfile): + with open(configspecfile, 'w') as fd: + shutil.copyfileobj(io.StringIO(ThumbConfig.configspec), fd) + + self.filename = os.path.join( + dirname(abspath(__file__)), + 'config.ini' + ) + self.configspec = configspecfile + self.encoding = "UTF8" + + tmp = ConfigObj(self.filename, configspec=self.configspec, encoding=self.encoding) + validator = Validator() + tmp.validate(validator, copy=True) + + self.merge(tmp) + + if not os.path.exists(self.filename): + self.write() + + +"""------------------------------------------------------------------------------------------- +-- CONFIGURATION DEFINITION +-------------------------------------------------------------------------------------------""" + +thumbconfig = ThumbConfig() \ No newline at end of file diff --git a/database.py b/database.py new file mode 100644 index 0000000..8491cca --- /dev/null +++ b/database.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""------------------------------------------------------------------------------------------- +-- IMPORTS +-------------------------------------------------------------------------------------------""" + +""" --- HUEY IMPORT --- """ + +""" --- PYTHON IMPORTS --- """ +import os +from os.path import dirname, abspath + +""" --- STASHR DEPENDENCY IMPORTS --- """ + +""" --- STASHR CORE IMPORTS --- """ +from stashr import log +from stashr import database as stashrdb + +""" --- FLASK EXTENSION IMPORTS --- """ + +""" --- SQLALCHEMY IMPORTS --- """ +from sqlalchemy import * +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import * + +""" --- CREATE LOGGER --- """ + +""" --- STASHR PLUGIN IMPORTS --- """ + +""" --- CREATE LOGGER --- """ +logger = log.stashr_logger(__name__) + +""" --- CREATE SQLITE ENGINE --- """ +db_path = os.path.join(dirname(abspath(__file__)), 'database.db') +engine = create_engine('sqlite:///{0}'.format(db_path), + connect_args={'check_same_thread': False, 'timeout': 15}, + echo=False) +Base = declarative_base() + +from marshmallow_sqlalchemy import ModelSchema, SQLAlchemyAutoSchema, SQLAlchemySchema, auto_field +from marshmallow import fields +from marshmallow_sqlalchemy.fields import Nested + + +"""------------------------------------------------------------------------------------------- +-- DATABASE +-------------------------------------------------------------------------------------------""" + +class Images(Base): + __tablename__ = 'images' + + image_id = Column(Integer, primary_key=True) + + image_type = Column(String) + image_type_id = Column(Integer) + + image_filename = Column(String) + image_path = Column(String) + + image_original_size = Column(Integer) + image_processed_size = Column(Integer) + + image_original_width = Column(Integer) + image_original_height = Column(Integer) + + image_processed = Column(Boolean, server_default='0') + +"""------------------------------------------------------------------------------------------- +-- DATABASE SCHEMAS +-------------------------------------------------------------------------------------------""" + +class ImagesSchema(SQLAlchemyAutoSchema): + class Meta: + model = Images + +"""------------------------------------------------------------------------------------------- +-- DATABASE DEFINITION +-------------------------------------------------------------------------------------------""" + +Session = sessionmaker(bind=engine) +session = Session() + +"""------------------------------------------------------------------------------------------- +-- DATABASE FUNCTIONS +-------------------------------------------------------------------------------------------""" + +""" --- DATABASE MIGRATION --- """ +def migrate_database(): + # Check for new database tables + # Check for new table columns + pass + +""" --- CHECK DATABASE --- """ +if not os.path.exists(db_path): + logger.info('Creating Thumbnailer Database') + Base.metadata.create_all(engine) +else: + logger.info('Thumbnailer Database Exists') + migrate_database() \ No newline at end of file diff --git a/forms.py b/forms.py new file mode 100644 index 0000000..ce42e8f --- /dev/null +++ b/forms.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""------------------------------------------------------------------------------------------- +-- IMPORTS +-------------------------------------------------------------------------------------------""" + +""" --- HUEY IMPORT --- """ +""" --- PYTHON IMPORTS --- """ +""" --- STASHR DEPENDENCY IMPORTS --- """ +""" --- STASHR CORE IMPORTS --- """ +from stashr import log, database + +""" --- FLASK EXTENSION IMPORTS --- """ +from flask_wtf import FlaskForm + +from wtforms import StringField, BooleanField, SelectField, IntegerField, HiddenField, TextAreaField, SubmitField +from wtforms.validators import DataRequired, Length, Email, EqualTo, ValidationError, NumberRange + +""" --- CREATE LOGGER --- """ +logger = log.stashr_logger(__name__) + +"""------------------------------------------------------------------------------------------- +-- FORMS +-------------------------------------------------------------------------------------------""" + +class settings_form(FlaskForm): + + max_width = IntegerField( + 'Max Image Width', + validators = [ + ] + ) + quality = IntegerField( + 'Image Quality', + validators = [ + NumberRange(min=0, max=100, message='Enter a number between 0 and 100') + ] + ) + save_original_file = BooleanField( + 'Backup Original Image Files', + validators = [ + ] + ) + settings_button = SubmitField( + 'Save', + ) \ No newline at end of file diff --git a/tasks.py b/tasks.py new file mode 100644 index 0000000..b9ca8d9 --- /dev/null +++ b/tasks.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""------------------------------------------------------------------------------------------- +-- IMPORTS +-------------------------------------------------------------------------------------------""" + +""" --- HUEY IMPORT --- """ +from stashr.stashr import huey +from huey import crontab + +""" --- PYTHON IMPORTS --- """ + +""" --- STASHR DEPENDENCY IMPORTS --- """ + +""" --- STASHR CORE IMPORTS --- """ +from stashr import log + +""" --- STASHR PLUGIN IMPORTS --- """ +from . import thumbnailer + +""" --- CREATE LOGGER --- """ +logger = log.stashr_logger(__name__) + +"""------------------------------------------------------------------------------------------- +-- THUMBNAILER TASKS +-------------------------------------------------------------------------------------------""" + + +@huey.task() +def scan_images_task(): + thumbnailer.get_images() + thumbnailer.get_image_stats() + + +@huey.task() +def process_images_task(): + thumbnailer.process_images() \ No newline at end of file diff --git a/templates/thumbnailer_settings.html b/templates/thumbnailer_settings.html new file mode 100644 index 0000000..001a569 --- /dev/null +++ b/templates/thumbnailer_settings.html @@ -0,0 +1,180 @@ +{% extends "settings_page.html" %} + +{% block header_script_files %} + +{% endblock %} + +{% block settings_pane %} + +
+ + +
+ +{% endblock %} + +{% block script %} + +Vue.component('settings', { + props: ['settings'], + template: ` +
+
+
+
+

Thumbnailer

+
+
+
+
+ +
+
+
+ + + + + + + + + + + + +
{{ settings_form.max_width.label.text }}[[ settings.THUMBNAILER.max_width ]]
{{ settings_form.quality.label.text }}[[ settings.THUMBNAILER.quality ]]
{{ settings_form.save_original_file.label.text }}[[ settings.THUMBNAILER.save_original_file ]]
+
+
+
+ +
+
+
+
+ + + + + + + + + + + + + + + +
Actions
Scan New Images
Process All Images
+
+
+
+
+
+ `, + methods: {}, + delimiters: ["[[","]]"] +}) + +Vue.component('modals',{ + props: ['settings'], + template: ` +
+ +
+ `, + methods: {}, + delimiters: ["[[","]]"] +}) + +var app = new Vue({ + el: '#app', + data: { + settings: [] + }, + created() { + this.getSettings() + }, + methods: { + getSettings() { + axios.get('{{ url_for('thumbnailer.api_get_settings') }}') + .then(res => { + this.settings = res.data.results; + document.getElementById('max_width').value = res.data.results.THUMBNAILER.max_width; + document.getElementById('quality').value = res.data.results.THUMBNAILER.quality; + document.getElementById('quality').checked = res.data.results.THUMBNAILER.settings_button; + }) + .catch(err => console.log(err)) + } + }, + delimiters: ["[[","]]"] +}) + +function scanImages() { + axios.post('{{ url_for('thumbnailer.api_post_scan_images') }}') + .then(res => { + if(res.data.status_code == 200) { + stashrToast('Scanning Images', 'success') + } else { + stashrToast('Error', 'error') + } + }) +} + +function processImages() { + axios.post('{{ url_for('thumbnailer.api_post_process_images') }}') + .then(res => { + if(res.data.status_code == 200) { + stashrToast('Processing Images', 'success') + } else { + stashrToast('Error', 'error') + } + }) +} + +{% endblock %} \ No newline at end of file diff --git a/templates/thumbnailer_tep_settings_menu.html b/templates/thumbnailer_tep_settings_menu.html new file mode 100644 index 0000000..9bcade8 --- /dev/null +++ b/templates/thumbnailer_tep_settings_menu.html @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/thumbnailer.py b/thumbnailer.py new file mode 100644 index 0000000..7814116 --- /dev/null +++ b/thumbnailer.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""------------------------------------------------------------------------------------------- +-- IMPORTS +-------------------------------------------------------------------------------------------""" + +""" --- PYTHON IMPORTS --- """ +import os, pathlib, shutil + +""" --- STASHR CORE IMPORTS --- """ +from stashr import log, folders, utils + +""" --- STASHR PLUGIN IMPORTS --- """ +from . import database +from .config import thumbconfig + +""" --- STASHR PLUGIN DEPENDENCY IMPORTS --- """ +try: + from PIL import Image +except: + utils.install_package('pillow') + from PIL import Image + +""" --- CREATE LOGGER --- """ +logger = log.stashr_logger(__name__) + +"""------------------------------------------------------------------------------------------- +-- THUMBNAILER FUNCTIONS +-------------------------------------------------------------------------------------------""" + + +def get_images(): + + stored_images = database.session \ + .query(database.Images) \ + .all() + + stored_images = database.ImagesSchema(many=True).dump(stored_images) + + new_images = [] + existing_images = [] + + images_folder = folders.StashrFolders().images_folder() + + subdirs = os.scandir(images_folder) + for subdir in subdirs: + files = os.scandir(os.path.join(images_folder, subdir)) + for image in files: + existing_images.append(image) + for item in existing_images: + if not any(path['image_path'] == item.path for path in stored_images): + new_image = database.Images( + image_type = pathlib.Path(item.path).parent.name, + image_type_id = pathlib.Path(item.path).stem, + image_filename = item.name, + image_path = item.path, + ) + new_images.append(new_image) + + database.session.bulk_save_objects(new_images) + database.session.commit() + + +def get_image_stats(): + + missing_stats = database.session \ + .query(database.Images) \ + .filter(database.Images.image_original_size == None) \ + .all() + + try: + for image in missing_stats: + with Image.open(image.image_path) as img: + width, height = img.size + + image.image_original_width = width + image.image_original_height = height + image.image_original_size = pathlib.Path(image.image_path).stat().st_size + + database.session.merge(image) + except Exception as e: + logger.error(e) + + database.session.commit() + + +def process_images(): + + non_processed = database.session \ + .query(database.Images) \ + .filter(database.Images.image_processed == False) \ + .all() + + for image in non_processed: + + thumbnail = pathlib.Path(new_process_image(image)) + + if thumbnail.stat().st_size == 0: + thumbnail.unlink() + continue + + if thumbconfig['THUMBNAILER']['save_original_file']: + backup_original_image(image.image_path) + + thumbnail.replace(image.image_path) + + image.image_processed = True + image.image_processed_size = pathlib.Path(image.image_path).stat().st_size + database.session.merge(image) + + database.session.commit() + + +def new_process_image(image): + + max_width = thumbconfig['THUMBNAILER']['max_width'] + quality = thumbconfig['THUMBNAILER']['quality'] + + ratio = image.image_original_height/image.image_original_width + size = (max_width, int(max_width*ratio)) + + thumbnail_file = os.path.join( + pathlib.Path(image.image_path).parent, + f'{pathlib.Path(image.image_path).stem}-thumbnail.jpg' + ) + try: + with Image.open(image.image_path) as img: + img.thumbnail(size) + img.save(thumbnail_file, quality=quality) + finally: + return thumbnail_file + + +def backup_original_image(original_file_path): + + original_file_path = pathlib.Path(original_file_path) + + backup_folder = os.path.join( + folders.StashrFolders().backup_folder(), + 'images', + original_file_path.parent.name + ) + + if not os.path.isdir(backup_folder): + os.makedirs(backup_folder) + + backup_file = pathlib.Path(os.path.join( + backup_folder, + original_file_path.name + )) + + shutil.copy(original_file_path, backup_file)