Initial Development Commit

main 0.1.0
Andrew 4 years ago
parent 1ece5958b8
commit 21264da348
  1. 184
      __init__.py
  2. 61
      config.py
  3. 100
      database.py
  4. 47
      forms.py
  5. 38
      tasks.py
  6. 180
      templates/thumbnailer_settings.html
  7. 6
      templates/thumbnailer_tep_settings_menu.html
  8. 153
      thumbnailer.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',
)
)

@ -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()

@ -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()

@ -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',
)

@ -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()

@ -0,0 +1,180 @@
{% extends "settings_page.html" %}
{% block header_script_files %}
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
{% endblock %}
{% block settings_pane %}
<div id="app">
<settings ref="settings" v-bind:settings="settings"></settings>
<modals ref="modal" v-bind:settings="settings"></modals>
</div>
{% endblock %}
{% block script %}
Vue.component('settings', {
props: ['settings'],
template: `
<div class="row r-10 m-2">
<div class="col col-12">
<div class="row">
<div class="col-sm-12 col-md-6 text-center text-md-start">
<h2>Thumbnailer</h2>
</div>
</div>
<hr />
<div class="row">
<ul class="nav nav-tabs" id="myTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="settings-tab" data-bs-toggle="tab" data-bs-target="#settings" type="button" role="tab" aria-controls="settings" aria-selected="true">Settings</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="actions-tab" data-bs-toggle="tab" data-bs-target="#actions" type="button" role="tab" aria-controls="actions" aria-selected="false">Actions</button>
</li>
</ul>
<div class="tab-content py-2" id="myTabContent">
<div class="tab-pane fade show active" id="settings" role="tabpanel" aria-labelledby="settings-tab">
<div class="row">
<table class="table table-striped">
<tbody>
<tr>
<th scope="row">{{ settings_form.max_width.label.text }}</th><td>[[ settings.THUMBNAILER.max_width ]]</td>
</tr>
<tr>
<th scope="row">{{ settings_form.quality.label.text }}</th><td>[[ settings.THUMBNAILER.quality ]]</td>
</tr>
<tr>
<th scope="row">{{ settings_form.save_original_file.label.text }}</th><td>[[ settings.THUMBNAILER.save_original_file ]]</td>
</tr>
</tbody>
</table>
</div>
<div class="row w-100">
<div class="col-12 text-end my-2">
<button type="button" class="btn btn-info" data-bs-toggle="modal" data-bs-target="#settingsModal">Modify Settings</button>
</div>
</div>
</div>
<div class="tab-pane fade" id="actions" role="tabpanel" aria-labelledby="actions-tab">
<table class="table table-striped">
<thead>
<tr>
<th scope="col">Actions</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Scan New Images</th><td><a onclick="scanImages()"><i class="fas fa-play"></i></a></td>
</tr>
<tr>
<th scope="row">Process All Images</th><td><a onclick="processImages()"><i class="fas fa-play"></i></a></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
`,
methods: {},
delimiters: ["[[","]]"]
})
Vue.component('modals',{
props: ['settings'],
template: `
<div>
<div class="modal" id="settingsModal" role="dialog" aria-labelledby="settingsModal" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="notesModalTitle">
Modify App Settings
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="POST">
<div class="modal-body">
{{ settings_form.csrf_token }}
<div class="mb-3">
{{ settings_form.max_width.label }}
{{ settings_form.max_width(class_='form-control', placeholder=settings_form.max_width.label.text) }}
</div>
<div class="mb-3">
{{ settings_form.quality.label }}
{{ settings_form.quality(class_='form-control', placeholder=settings_form.quality.label.text) }}
</div>
<div class="mb-3">
{{ settings_form.save_original_file }}
{{ settings_form.save_original_file.label }}
</div>
</div>
<div class="modal-footer">
{{ settings_form.settings_button(class_='btn btn-success') }}
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
</form>
</div>
</div>
</div>
</div>
`,
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 %}

@ -0,0 +1,6 @@
<li class="nav-item">
<a class="nav-link{% if request.path == url_for('thumbnailer.thumbnailer_settings_page') %} active{% endif %}" href="{{ url_for('thumbnailer.thumbnailer_settings_page') }}">
<i class="far fa-images"></i>
Thumbnailer
</a>
</li>

@ -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)
Loading…
Cancel
Save