mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-12-29 21:37:15 -05:00
Feature/image minify (#256)
* fix settings * app info cleanup * bottom-bar experiment * remove dup key * type hints * add dependency * updated image with query parameters * read image options * add image minification * add image minification step * alt image routes * add image minification * set mobile bar to top Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
@@ -45,19 +45,19 @@ LOGGER_FILE = DATA_DIR.joinpath("mealie.log")
|
||||
|
||||
class AppDirectories:
|
||||
def __init__(self, cwd, data_dir) -> None:
|
||||
self.DATA_DIR = data_dir
|
||||
self.WEB_PATH = cwd.joinpath("dist")
|
||||
self.IMG_DIR = data_dir.joinpath("img")
|
||||
self.BACKUP_DIR = data_dir.joinpath("backups")
|
||||
self.DEBUG_DIR = data_dir.joinpath("debug")
|
||||
self.MIGRATION_DIR = data_dir.joinpath("migration")
|
||||
self.NEXTCLOUD_DIR = self.MIGRATION_DIR.joinpath("nextcloud")
|
||||
self.CHOWDOWN_DIR = self.MIGRATION_DIR.joinpath("chowdown")
|
||||
self.TEMPLATE_DIR = data_dir.joinpath("templates")
|
||||
self.USER_DIR = data_dir.joinpath("users")
|
||||
self.SQLITE_DIR = data_dir.joinpath("db")
|
||||
self.RECIPE_DATA_DIR = data_dir.joinpath("recipes")
|
||||
self.TEMP_DIR = data_dir.joinpath(".temp")
|
||||
self.DATA_DIR: Path = data_dir
|
||||
self.WEB_PATH: Path = cwd.joinpath("dist")
|
||||
self.IMG_DIR: Path = data_dir.joinpath("img")
|
||||
self.BACKUP_DIR: Path = data_dir.joinpath("backups")
|
||||
self.DEBUG_DIR: Path = data_dir.joinpath("debug")
|
||||
self.MIGRATION_DIR: Path = data_dir.joinpath("migration")
|
||||
self.NEXTCLOUD_DIR: Path = self.MIGRATION_DIR.joinpath("nextcloud")
|
||||
self.CHOWDOWN_DIR: Path = self.MIGRATION_DIR.joinpath("chowdown")
|
||||
self.TEMPLATE_DIR: Path = data_dir.joinpath("templates")
|
||||
self.USER_DIR: Path = data_dir.joinpath("users")
|
||||
self.SQLITE_DIR: Path = data_dir.joinpath("db")
|
||||
self.RECIPE_DATA_DIR: Path = data_dir.joinpath("recipes")
|
||||
self.TEMP_DIR: Path = data_dir.joinpath(".temp")
|
||||
|
||||
self.ensure_directories()
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from enum import Enum
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from mealie.db.database import db
|
||||
@@ -5,7 +7,7 @@ from mealie.db.db_setup import generate_session
|
||||
from mealie.routes.deps import get_current_user
|
||||
from mealie.schema.recipe import Recipe, RecipeURLIn
|
||||
from mealie.schema.snackbar import SnackResponse
|
||||
from mealie.services.image_services import read_image, write_image
|
||||
from mealie.services.image.image import IMG_OPTIONS, delete_image, read_image, write_image
|
||||
from mealie.services.scraper.scraper import create_from_url
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
@@ -72,20 +74,35 @@ def delete_recipe(
|
||||
|
||||
try:
|
||||
db.recipes.delete(session, recipe_slug)
|
||||
delete_image(recipe_slug)
|
||||
except:
|
||||
raise HTTPException(status_code=404, detail=SnackResponse.error("Unable to Delete Recipe"))
|
||||
|
||||
return SnackResponse.error(f"Recipe {recipe_slug} Deleted")
|
||||
|
||||
|
||||
class ImageType(str, Enum):
|
||||
original = "original"
|
||||
small = "small"
|
||||
tiny = "tiny"
|
||||
|
||||
|
||||
@router.get("/{recipe_slug}/image")
|
||||
async def get_recipe_img(recipe_slug: str):
|
||||
async def get_recipe_img(recipe_slug: str, image_type: ImageType = ImageType.original):
|
||||
""" Takes in a recipe slug, returns the static image """
|
||||
recipe_image = read_image(recipe_slug)
|
||||
if image_type == ImageType.original:
|
||||
which_image = IMG_OPTIONS.ORIGINAL_IMAGE
|
||||
elif image_type == ImageType.small:
|
||||
which_image = IMG_OPTIONS.MINIFIED_IMAGE
|
||||
elif image_type == ImageType.tiny:
|
||||
which_image = IMG_OPTIONS.TINY_IMAGE
|
||||
|
||||
recipe_image = read_image(recipe_slug, image_type=which_image)
|
||||
print(recipe_image)
|
||||
if recipe_image:
|
||||
return FileResponse(recipe_image)
|
||||
else:
|
||||
return
|
||||
raise HTTPException(404, "file not found")
|
||||
|
||||
|
||||
@router.put("/{recipe_slug}/image")
|
||||
|
||||
@@ -65,8 +65,7 @@ class ExportDatabase:
|
||||
f.write(content)
|
||||
|
||||
def export_images(self):
|
||||
for file in app_dirs.IMG_DIR.iterdir():
|
||||
shutil.copy(file, self.img_dir.joinpath(file.name))
|
||||
shutil.copytree(app_dirs.IMG_DIR, self.img_dir, dirs_exist_ok=True)
|
||||
|
||||
def export_items(self, items: list[BaseModel], folder_name: str, export_list=True):
|
||||
items = [x.dict() for x in items]
|
||||
|
||||
@@ -11,6 +11,7 @@ from mealie.schema.restore import CustomPageImport, GroupImport, RecipeImport, S
|
||||
from mealie.schema.settings import CustomPageOut, SiteSettings
|
||||
from mealie.schema.theme import SiteTheme
|
||||
from mealie.schema.user import UpdateGroup, UserInDB
|
||||
from mealie.services.image import minify
|
||||
from pydantic.main import BaseModel
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
@@ -108,7 +109,13 @@ class ImportDatabase:
|
||||
image_dir = self.import_dir.joinpath("images")
|
||||
for image in image_dir.iterdir():
|
||||
if image.stem in successful_imports:
|
||||
shutil.copy(image, app_dirs.IMG_DIR)
|
||||
if image.is_dir():
|
||||
dest = app_dirs.IMG_DIR.joinpath(image.stem)
|
||||
shutil.copytree(image, dest, dirs_exist_ok=True)
|
||||
if image.is_file():
|
||||
shutil.copy(image, app_dirs.IMG_DIR)
|
||||
|
||||
minify.migrate_images()
|
||||
|
||||
def import_themes(self):
|
||||
themes_file = self.import_dir.joinpath("themes", "themes.json")
|
||||
|
||||
0
mealie/services/image/__init__.py
Normal file
0
mealie/services/image/__init__.py
Normal file
101
mealie/services/image/image.py
Normal file
101
mealie/services/image/image.py
Normal file
@@ -0,0 +1,101 @@
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
import requests
|
||||
from fastapi.logger import logger
|
||||
from mealie.core.config import app_dirs
|
||||
from mealie.services.image import minify
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageOptions:
|
||||
ORIGINAL_IMAGE: str = "original*"
|
||||
MINIFIED_IMAGE: str = "min-original*"
|
||||
TINY_IMAGE: str = "tiny-original*"
|
||||
|
||||
|
||||
IMG_OPTIONS = ImageOptions()
|
||||
|
||||
|
||||
def read_image(recipe_slug: str, image_type: str = "original") -> Path:
|
||||
"""returns the path to the image file for the recipe base of image_type
|
||||
|
||||
Args:
|
||||
recipe_slug (str): Recipe Slug
|
||||
image_type (str, optional): Glob Style Matcher "original*" | "min-original* | "tiny-original*"
|
||||
|
||||
Returns:
|
||||
Path: [description]
|
||||
"""
|
||||
print(image_type)
|
||||
recipe_slug = recipe_slug.split(".")[0] # Incase of File Name
|
||||
recipe_image_dir = app_dirs.IMG_DIR.joinpath(recipe_slug)
|
||||
|
||||
for file in recipe_image_dir.glob(image_type):
|
||||
return file
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def write_image(recipe_slug: str, file_data: bytes, extension: str) -> Path.name:
|
||||
try:
|
||||
delete_image(recipe_slug)
|
||||
except:
|
||||
pass
|
||||
|
||||
image_dir = Path(app_dirs.IMG_DIR.joinpath(f"{recipe_slug}"))
|
||||
image_dir.mkdir()
|
||||
extension = extension.replace(".", "")
|
||||
image_path = image_dir.joinpath(f"original.{extension}")
|
||||
|
||||
if isinstance(file_data, bytes):
|
||||
with open(image_path, "ab") as f:
|
||||
f.write(file_data)
|
||||
else:
|
||||
with open(image_path, "ab") as f:
|
||||
shutil.copyfileobj(file_data, f)
|
||||
|
||||
minify.migrate_images()
|
||||
|
||||
return image_path
|
||||
|
||||
|
||||
def delete_image(recipe_slug: str) -> str:
|
||||
recipe_slug = recipe_slug.split(".")[0]
|
||||
for file in app_dirs.IMG_DIR.glob(f"{recipe_slug}*"):
|
||||
return shutil.rmtree(file)
|
||||
|
||||
|
||||
def scrape_image(image_url: str, slug: str) -> Path:
|
||||
if isinstance(image_url, str): # Handles String Types
|
||||
image_url = image_url
|
||||
|
||||
if isinstance(image_url, list): # Handles List Types
|
||||
image_url = image_url[0]
|
||||
|
||||
if isinstance(image_url, dict): # Handles Dictionary Types
|
||||
for key in image_url:
|
||||
if key == "url":
|
||||
image_url = image_url.get("url")
|
||||
|
||||
filename = slug + "." + image_url.split(".")[-1]
|
||||
filename = app_dirs.IMG_DIR.joinpath(filename)
|
||||
|
||||
try:
|
||||
r = requests.get(image_url, stream=True)
|
||||
except:
|
||||
logger.exception("Fatal Image Request Exception")
|
||||
return None
|
||||
|
||||
if r.status_code == 200:
|
||||
r.raw.decode_content = True
|
||||
|
||||
write_image(slug, r.raw, filename.suffix)
|
||||
|
||||
filename.unlink()
|
||||
|
||||
return filename
|
||||
|
||||
return None
|
||||
84
mealie/services/image/minify.py
Normal file
84
mealie/services/image/minify.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from pathlib import Path
|
||||
|
||||
from mealie.core.config import app_dirs
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
|
||||
def minify_image(image_file: Path, min_dest: Path, tiny_dest: Path):
|
||||
"""Minifies an image in it's original file format. Quality is lost
|
||||
|
||||
Args:
|
||||
my_path (Path): Source Files
|
||||
min_dest (Path): FULL Destination File Path
|
||||
tiny_dest (Path): FULL Destination File Path
|
||||
"""
|
||||
try:
|
||||
img = Image.open(image_file)
|
||||
basewidth = 720
|
||||
wpercent = basewidth / float(img.size[0])
|
||||
hsize = int((float(img.size[1]) * float(wpercent)))
|
||||
img = img.resize((basewidth, hsize), Image.ANTIALIAS)
|
||||
img.save(min_dest, quality=70)
|
||||
|
||||
tiny_image = crop_center(img)
|
||||
tiny_image.save(tiny_dest, quality=70)
|
||||
|
||||
except UnidentifiedImageError:
|
||||
pass
|
||||
|
||||
|
||||
def crop_center(pil_img, crop_width=300, crop_height=300):
|
||||
img_width, img_height = pil_img.size
|
||||
return pil_img.crop(
|
||||
(
|
||||
(img_width - crop_width) // 2,
|
||||
(img_height - crop_height) // 2,
|
||||
(img_width + crop_width) // 2,
|
||||
(img_height + crop_height) // 2,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def sizeof_fmt(size, decimal_places=2):
|
||||
for unit in ["B", "kB", "MB", "GB", "TB", "PB"]:
|
||||
if size < 1024.0 or unit == "PiB":
|
||||
break
|
||||
size /= 1024.0
|
||||
return f"{size:.{decimal_places}f} {unit}"
|
||||
|
||||
|
||||
def move_all_images():
|
||||
for image_file in app_dirs.IMG_DIR.iterdir():
|
||||
if image_file.is_file():
|
||||
if image_file.name == ".DS_Store":
|
||||
continue
|
||||
new_folder = app_dirs.IMG_DIR.joinpath(image_file.stem)
|
||||
new_folder.mkdir(parents=True, exist_ok=True)
|
||||
image_file.rename(new_folder.joinpath(f"original{image_file.suffix}"))
|
||||
|
||||
|
||||
def migrate_images():
|
||||
print("Checking for Images to Minify...")
|
||||
|
||||
move_all_images()
|
||||
|
||||
# Minify Loop
|
||||
for image in app_dirs.IMG_DIR.glob("*/original.*"):
|
||||
min_dest = image.parent.joinpath(f"min-original{image.suffix}")
|
||||
tiny_dest = image.parent.joinpath(f"tiny-original{image.suffix}")
|
||||
|
||||
if min_dest.exists() and tiny_dest.exists():
|
||||
continue
|
||||
|
||||
minify_image(image, min_dest, tiny_dest)
|
||||
|
||||
org_size = sizeof_fmt(image.stat().st_size)
|
||||
dest_size = sizeof_fmt(min_dest.stat().st_size)
|
||||
tiny_size = sizeof_fmt(tiny_dest.stat().st_size)
|
||||
print(f"{image.name} Minified: {org_size} -> {dest_size} -> {tiny_size}")
|
||||
|
||||
print("Finished Minification Check")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate_images()
|
||||
@@ -1,63 +0,0 @@
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from fastapi.logger import logger
|
||||
from mealie.core.config import app_dirs
|
||||
|
||||
|
||||
def read_image(recipe_slug: str) -> Path:
|
||||
if app_dirs.IMG_DIR.joinpath(recipe_slug).is_file():
|
||||
return app_dirs.IMG_DIR.joinpath(recipe_slug)
|
||||
|
||||
recipe_slug = recipe_slug.split(".")[0]
|
||||
for file in app_dirs.IMG_DIR.glob(f"{recipe_slug}*"):
|
||||
return file
|
||||
|
||||
|
||||
def write_image(recipe_slug: str, file_data: bytes, extension: str) -> Path.name:
|
||||
delete_image(recipe_slug)
|
||||
|
||||
image_path = Path(app_dirs.IMG_DIR.joinpath(f"{recipe_slug}.{extension}"))
|
||||
with open(image_path, "ab") as f:
|
||||
f.write(file_data)
|
||||
|
||||
return image_path
|
||||
|
||||
|
||||
def delete_image(recipe_slug: str) -> str:
|
||||
recipe_slug = recipe_slug.split(".")[0]
|
||||
for file in app_dirs.IMG_DIR.glob(f"{recipe_slug}*"):
|
||||
return file.unlink()
|
||||
|
||||
|
||||
def scrape_image(image_url: str, slug: str) -> Path:
|
||||
if isinstance(image_url, str): # Handles String Types
|
||||
image_url = image_url
|
||||
|
||||
if isinstance(image_url, list): # Handles List Types
|
||||
image_url = image_url[0]
|
||||
|
||||
if isinstance(image_url, dict): # Handles Dictionary Types
|
||||
for key in image_url:
|
||||
if key == "url":
|
||||
image_url = image_url.get("url")
|
||||
|
||||
filename = slug + "." + image_url.split(".")[-1]
|
||||
filename = app_dirs.IMG_DIR.joinpath(filename)
|
||||
|
||||
try:
|
||||
r = requests.get(image_url, stream=True)
|
||||
except:
|
||||
logger.exception("Fatal Image Request Exception")
|
||||
return None
|
||||
|
||||
if r.status_code == 200:
|
||||
r.raw.decode_content = True
|
||||
|
||||
with open(filename, "wb") as f:
|
||||
shutil.copyfileobj(r.raw, f)
|
||||
|
||||
return filename
|
||||
|
||||
return None
|
||||
@@ -52,7 +52,7 @@ def get_todays_meal(session: Session, group: Union[int, GroupInDB]) -> Recipe:
|
||||
Returns:
|
||||
Recipe: Pydantic Recipe Object
|
||||
"""
|
||||
session = session if session else create_session()
|
||||
session = session or create_session()
|
||||
|
||||
if isinstance(group, int):
|
||||
group: GroupInDB = db.groups.get(session, group)
|
||||
|
||||
@@ -5,7 +5,7 @@ import requests
|
||||
import scrape_schema_recipe
|
||||
from mealie.core.config import app_dirs
|
||||
from fastapi.logger import logger
|
||||
from mealie.services.image_services import scrape_image
|
||||
from mealie.services.image.image import scrape_image
|
||||
from mealie.schema.recipe import Recipe
|
||||
from mealie.services.scraper import open_graph
|
||||
from mealie.services.scraper.cleaner import Cleaner
|
||||
|
||||
Reference in New Issue
Block a user