Add image cache generation

This commit is contained in:
Michał Gdula 2023-03-26 20:58:17 +00:00
parent 2b795e520f
commit de79f5bc54
11 changed files with 126 additions and 96 deletions

View file

@ -13,6 +13,7 @@ USER_DIR = platformdirs.user_config_dir('onlylegs')
DB_PATH = os.path.join(USER_DIR, 'gallery.sqlite') DB_PATH = os.path.join(USER_DIR, 'gallery.sqlite')
# In the future, I want to add support for other databases
# engine = create_engine('postgresql://username:password@host:port/database_name', echo=False) # engine = create_engine('postgresql://username:password@host:port/database_name', echo=False)
# engine = create_engine('mysql://username:password@host:port/database_name', echo=False) # engine = create_engine('mysql://username:password@host:port/database_name', echo=False)
engine = create_engine(f'sqlite:///{DB_PATH}', echo=False) engine = create_engine(f'sqlite:///{DB_PATH}', echo=False)
@ -59,6 +60,7 @@ class Posts (base): # pylint: disable=too-few-public-methods, C0103
post_alt = Column(String, nullable=False) post_alt = Column(String, nullable=False)
junction = relationship('GroupJunction', backref='posts') junction = relationship('GroupJunction', backref='posts')
thumbnail = relationship('Thumbnails', backref='posts')
class Thumbnails (base): # pylint: disable=too-few-public-methods, C0103 class Thumbnails (base): # pylint: disable=too-few-public-methods, C0103
@ -70,7 +72,8 @@ class Thumbnails (base): # pylint: disable=too-few-public-methods, C0103
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
file_name = Column(String, unique=True, nullable=False) file_name = Column(String, unique=True, nullable=False)
file_ext = Column(String, nullable=False) file_ext = Column(String, nullable=False)
data = Column(PickleType, nullable=False) resolution = Column(PickleType, nullable=False)
post_id = Column(Integer, ForeignKey('posts.id'))
class Groups (base): # pylint: disable=too-few-public-methods, C0103 class Groups (base): # pylint: disable=too-few-public-methods, C0103

View file

@ -1,26 +1,24 @@
""" """
Onlylegs - API endpoints Onlylegs - API endpoints
Used internally by the frontend and possibly by other applications
""" """
from uuid import uuid4 from uuid import uuid4
import os import os
import pathlib import pathlib
import io import platformdirs
import logging import logging
from datetime import datetime as dt from datetime import datetime as dt
from flask import (Blueprint, send_from_directory, send_file, from flask import Blueprint, send_from_directory, abort, flash, jsonify, request, g, current_app
abort, flash, jsonify, request, g, current_app)
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from colorthief import ColorThief from colorthief import ColorThief
from PIL import Image, ImageOps, ImageFilter
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from gallery.auth import login_required from gallery.auth import login_required
from gallery import db from gallery import db
from gallery.utils import metadata as mt from gallery.utils import metadata as mt
from gallery.utils.generate_image import ImageGenerator
blueprint = Blueprint('api', __name__, url_prefix='/api') blueprint = Blueprint('api', __name__, url_prefix='/api')
@ -33,14 +31,8 @@ def file(file_name):
""" """
Returns a file from the uploads folder Returns a file from the uploads folder
r for resolution, 400x400 or thumb for thumbnail r for resolution, 400x400 or thumb for thumbnail
f is whether to apply filters to the image, such as blurring NSFW images
b is whether to force blur the image, even if it's not NSFW
""" """
# Get args
res = request.args.get('r', default=None, type=str) # Type of file (thumb, etc) res = request.args.get('r', default=None, type=str) # Type of file (thumb, etc)
filtered = request.args.get('f', default=False, type=bool) # Whether to apply filters # pylint: disable=W0612
blur = request.args.get('b', default=False, type=bool) # Whether to force blur
file_name = secure_filename(file_name) # Sanitize file name file_name = secure_filename(file_name) # Sanitize file name
# if no args are passed, return the raw file # if no args are passed, return the raw file
@ -50,64 +42,12 @@ def file(file_name):
return send_from_directory(current_app.config['UPLOAD_FOLDER'], file_name) return send_from_directory(current_app.config['UPLOAD_FOLDER'], file_name)
buff = io.BytesIO() thumb = ImageGenerator.thumbnail(file_name, res)
img = None # Image object to be set
try: # Open image and set extension if not thumb:
img = Image.open(os.path.join(current_app.config['UPLOAD_FOLDER'], file_name))
except FileNotFoundError: # FileNotFound is raised if the file doesn't exist
logging.error('File not found: %s', file_name)
abort(404) abort(404)
except OSError as err: # OSError is raised if the file is broken or corrupted
logging.error('Possibly broken image %s, error: %s', file_name, err)
abort(500)
img_ext = pathlib.Path(file_name).suffix.replace('.', '').lower() # Get file extension return send_from_directory(os.path.dirname(thumb), os.path.basename(thumb))
img_ext = current_app.config['ALLOWED_EXTENSIONS'][img_ext] # Convert to MIME type
img_icc = img.info.get("icc_profile") # Get ICC profile
img = ImageOps.exif_transpose(img) # Rotate image based on EXIF data
# Todo: If type is thumb(nail), return from database instead of file system pylint: disable=W0511
# as it's faster than generating a new thumbnail on every request
if res:
if res in ['thumb', 'thumbnail']:
width, height = 400, 400
elif res in ['prev', 'preview']:
width, height = 1920, 1080
else:
try:
width, height = res.split('x')
width = int(width)
height = int(height)
except ValueError:
abort(400)
img.thumbnail((width, height), Image.LANCZOS)
# Todo: If the image has a NSFW tag, blur image for example pylint: disable=W0511
# if filtered:
# pass
# If forced to blur, blur image
if blur:
img = img.filter(ImageFilter.GaussianBlur(20))
try:
img.save(buff, img_ext, icc_profile=img_icc)
except OSError:
# This usually happens when saving a JPEG with an ICC profile,
# so we convert to RGB and try again
img = img.convert('RGB')
img.save(buff, img_ext, icc_profile=img_icc)
except Exception as err:
logging.error('Could not resize image %s, error: %s', file_name, err)
abort(500)
img.close() # Close image to free memory, learned the hard way
buff.seek(0) # Reset buffer to start
return send_file(buff, mimetype='image/' + img_ext)
@blueprint.route('/upload', methods=['POST']) @blueprint.route('/upload', methods=['POST'])
@ -171,34 +111,37 @@ def delete_image(image_id):
""" """
img = db_session.query(db.Posts).filter_by(id=image_id).first() img = db_session.query(db.Posts).filter_by(id=image_id).first()
# Check if image exists and if user is allowed to delete it (author)
if img is None: if img is None:
abort(404) abort(404)
if img.author_id != g.user.id: if img.author_id != g.user.id:
abort(403) abort(403)
# Delete file
try: try:
os.remove(os.path.join(current_app.config['UPLOAD_FOLDER'],img.file_name)) os.remove(os.path.join(current_app.config['UPLOAD_FOLDER'],img.file_name))
except FileNotFoundError: except FileNotFoundError:
# File was already deleted or doesn't exist
logging.warning('File not found: %s, already deleted or never existed', img.file_name) logging.warning('File not found: %s, already deleted or never existed', img.file_name)
except Exception as err:
logging.error('Could not remove file: %s', err)
abort(500)
try: # Delete cached files
cache_path = os.path.join(platformdirs.user_config_dir('onlylegs'), 'cache')
cache_name = img.file_name.rsplit('.')[0]
for file in pathlib.Path(cache_path).glob(cache_name + '*'):
os.remove(file)
# Delete from database
db_session.query(db.Posts).filter_by(id=image_id).delete() db_session.query(db.Posts).filter_by(id=image_id).delete()
# Remove all entries in junction table
groups = db_session.query(db.GroupJunction).filter_by(post_id=image_id).all() groups = db_session.query(db.GroupJunction).filter_by(post_id=image_id).all()
for group in groups: for group in groups:
db_session.delete(group) db_session.delete(group)
# Commit all changes
db_session.commit() db_session.commit()
except Exception as err:
logging.error('Could not remove from database: %s', err)
abort(500)
logging.info('Removed image (%s) %s', image_id, img.file_name) logging.info('Removed image (%s) %s', image_id, img.file_name)
flash(['Image was all in Le Head!', 1]) flash(['Image was all in Le Head!', '1'])
return 'Gwa Gwa' return 'Gwa Gwa'

View file

@ -30,7 +30,7 @@
</div> </div>
{% else %} {% else %}
<img <img
src="/api/file/{{ images.0.file_name }}?r=1920x1080" src="/api/file/{{ images.0.file_name }}?r=prev"
onload="imgFade(this)" onload="imgFade(this)"
style="opacity:0;" style="opacity:0;"
/> />
@ -58,7 +58,7 @@
<p class="image-subtitle"></p> <p class="image-subtitle"></p>
<p class="image-title"><span class="time">{{ image.created_at }}</span></p> <p class="image-title"><span class="time">{{ image.created_at }}</span></p>
</div> </div>
<img alt="{{ image.post_alt }}" data-src="{{ image.file_name }}" onload="imgFade(this)" style="opacity:0;" id="lazy-load"/> <img alt="{{ image.post_alt }}" data-src="{{ image.file_name }}" onload="this.classList.add('loaded');" id="lazy-load"/>
</a> </a>
{% endfor %} {% endfor %}
</div> </div>

View file

@ -29,7 +29,7 @@
<p class="image-subtitle"></p> <p class="image-subtitle"></p>
<p class="image-title">{{ group.name }}</p> <p class="image-title">{{ group.name }}</p>
</div> </div>
<img data-src="{{ group.thumbnail.file_name }}" onload="imgFade(this)" style="opacity:0;" id="lazy-load"/> <img data-src="{{ group.thumbnail.file_name }}" onload="this.classList.add('loaded');" id="lazy-load"/>
</a> </a>
{% else %} {% else %}
<a id="group-{{ group.id }}" class="gallery-item" href="{{ url_for('group.group', group_id=group.id) }}"> <a id="group-{{ group.id }}" class="gallery-item" href="{{ url_for('group.group', group_id=group.id) }}">
@ -37,7 +37,7 @@
<p class="image-subtitle"></p> <p class="image-subtitle"></p>
<p class="image-title">{{ group.name }}</p> <p class="image-title">{{ group.name }}</p>
</div> </div>
<img src="{{ url_for('static', filename='error.png') }}" onload="imgFade(this)" style="opacity:0;"/> <img src="{{ url_for('static', filename='error.png') }}" onload="this.classList.add('loaded');"/>
</a> </a>
{% endif %} {% endif %}
{% endfor %} {% endfor %}

View file

@ -91,6 +91,10 @@
alt="{{ image.post_alt }}" alt="{{ image.post_alt }}"
onload="imgFade(this)" style="opacity:0;" onload="imgFade(this)" style="opacity:0;"
onerror="this.src='{{ url_for('static', filename='error.png')}}'" onerror="this.src='{{ url_for('static', filename='error.png')}}'"
{% if "File" in image.image_exif %}
width="{{ image.image_exif.File.Width.raw }}"
height="{{ image.image_exif.File.Height.raw }}"
{% endif %}
/> />
</div> </div>

View file

@ -41,7 +41,7 @@
<p class="image-subtitle"></p> <p class="image-subtitle"></p>
<p class="image-title"><span class="time">{{ image.created_at }}</span></p> <p class="image-title"><span class="time">{{ image.created_at }}</span></p>
</div> </div>
<img alt="{{ image.post_alt }}" data-src="{{ image.file_name }}" onload="imgFade(this)" style="opacity:0;" id="lazy-load"/> <img alt="{{ image.post_alt }}" data-src="{{ image.file_name }}" onload="this.classList.add('loaded');" id="lazy-load"/>
</a> </a>
{% endfor %} {% endfor %}
</div> </div>

View file

@ -61,7 +61,7 @@
<div class="wrapper"> <div class="wrapper">
<div class="navigation"> <div class="navigation">
<img src="{{url_for('static', filename='icon.png')}}" alt="Logo" class="logo" onload="imgFade(this)" style="opacity:0;"> <img src="{{url_for('static', filename='icon.png')}}" alt="Logo" class="logo" onload="this.style.opacity=1;" style="opacity:0">
<a href="{{url_for('gallery.index')}}" class="navigation-item {% block nav_home %}{% endblock %}"> <a href="{{url_for('gallery.index')}}" class="navigation-item {% block nav_home %}{% endblock %}">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M208,32H80A16,16,0,0,0,64,48V64H48A16,16,0,0,0,32,80V208a16,16,0,0,0,16,16H176a16,16,0,0,0,16-16V192h16a16,16,0,0,0,16-16V48A16,16,0,0,0,208,32ZM80,48H208v69.38l-16.7-16.7a16,16,0,0,0-22.62,0L93.37,176H80Zm96,160H48V80H64v96a16,16,0,0,0,16,16h96ZM104,88a16,16,0,1,1,16,16A16,16,0,0,1,104,88Z"></path></svg> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M208,32H80A16,16,0,0,0,64,48V64H48A16,16,0,0,0,32,80V208a16,16,0,0,0,16,16H176a16,16,0,0,0,16-16V192h16a16,16,0,0,0,16-16V48A16,16,0,0,0,208,32ZM80,48H208v69.38l-16.7-16.7a16,16,0,0,0-22.62,0L93.37,176H80Zm96,160H48V80H64v96a16,16,0,0,0,16,16h96ZM104,88a16,16,0,1,1,16,16A16,16,0,0,1,104,88Z"></path></svg>
@ -150,7 +150,7 @@
<script> <script>
{% for message in get_flashed_messages() %} {% for message in get_flashed_messages() %}
// Show notifications on page load // Show notifications on page load
addNotification('{{ message[0] }}', '{{ message[1] }}'); addNotification('{{ message[0] }}', {{ message[1] }});
{% endfor %} {% endfor %}
</script> </script>
{% block script %}{% endblock %} {% block script %}{% endblock %}

View file

@ -103,7 +103,7 @@
align-items: center align-items: center
h1 h1
font-size: 3.5rem font-size: 3rem
text-align: center text-align: center
p p

View file

@ -80,11 +80,17 @@
object-fit: cover object-fit: cover
object-position: center object-position: center
background-color: RGB($fg-white)
transform: scale(1.05) transform: scale(1.05)
transition: all 0.3s cubic-bezier(.79, .14, .15, .86) background-color: RGB($fg-white)
filter: blur(0.5rem)
opacity: 0
transition: all 0.2s cubic-bezier(.79, .14, .15, .86)
&.loaded
filter: blur(0)
opacity: 1
&:after &:after
content: "" content: ""

View file

@ -60,7 +60,7 @@ $breakpoint: 800px
--blue: 141, 163, 185 --blue: 141, 163, 185
--purple: 169, 136, 176 --purple: 169, 136, 176
--primary: 183, 169, 151 --primary: var(--green) // 183, 169, 151
--warning: var(--orange) --warning: var(--orange)
--critical: var(--red) --critical: var(--red)
--success: var(--green) --success: var(--green)

View file

@ -0,0 +1,74 @@
import os
import platformdirs
from PIL import Image, ImageOps #, ImageFilter
from werkzeug.utils import secure_filename
CACHE_PATH = platformdirs.user_config_dir('onlylegs') + '/cache'
UPLOAD_PATH = platformdirs.user_config_dir('onlylegs') + '/uploads'
class ImageGenerator:
def thumbnail(name, resolution, ext=None):
"""
Image thumbnail generator
Uses PIL to generate a thumbnail of the image and saves it to the cache directory
Name is the filename
resolution: 400x400 or thumb, or any other resolution
ext is the file extension of the image
"""
# Make image cache directory if it doesn't exist
if not os.path.exists(CACHE_PATH):
os.makedirs(CACHE_PATH)
# no sussy business
name, name_ext = secure_filename(name).rsplit('.')
if not ext:
ext = name_ext.strip('.')
# PIL doesnt like jpg so we convert it to jpeg
if ext.lower() == "jpg":
ext = "jpeg"
# Set resolution based on preset resolutions
if resolution in ['thumb', 'thumbnail']:
res_x, res_y = (400, 400)
elif resolution in ['prev', 'preview']:
res_x, res_y = (1920, 1080)
elif len(resolution.split('x')) == 2:
res_x, res_y = resolution.split('x')
else:
return None
# If image has been already generated, return it from the cache
if os.path.exists(os.path.join(CACHE_PATH, f'{name}_{res_x}x{res_y}.{ext}')):
return os.path.join(CACHE_PATH, f'{name}_{res_x}x{res_y}.{ext}')
# Check if image exists in the uploads directory
if not os.path.exists(os.path.join(UPLOAD_PATH, f'{name}.{name_ext}')):
return None
# Open image and rotate it based on EXIF data and get ICC profile so colors are correct
image = Image.open(os.path.join(UPLOAD_PATH, f'{name}.{name_ext}'))
image_icc = image.info.get("icc_profile")
img_x, img_y = image.size
# Resize image to fit the resolution
image = ImageOps.exif_transpose(image)
image.thumbnail((min(img_x, int(res_x)), min(img_y, int(res_y))), Image.ANTIALIAS)
# Save image to cache directory
try:
image.save(os.path.join(CACHE_PATH,f'{name}_{res_x}x{res_y}.{ext}'),
icc_profile=image_icc)
except OSError:
# This usually happens when saving a JPEG with an ICC profile,
# so we convert to RGB and try again
image = image.convert('RGB')
image.save(os.path.join(CACHE_PATH, f'{name}_{res_x}x{res_y}.{ext}'),
icc_profile=image_icc)
# No need to keep the image in memory, learned the hard way
image.close()
return os.path.join(CACHE_PATH, f'{name}_{res_x}x{res_y}.{ext}')