diff --git a/Caddyfile b/Caddyfile index 19c18a3b0faf..ba5d2e454488 100644 --- a/Caddyfile +++ b/Caddyfile @@ -5,11 +5,16 @@ :80 { @proxied path /api/* /docs /openapi.json - + root * /app/dist encode gzip uri strip_suffix / + handle_path /api/recipes/image/* { + root * /app/data/img/ + file_server + } + handle @proxied { reverse_proxy http://127.0.0.1:9000 } diff --git a/Dockerfile b/Dockerfile index e1321727d013..37e08b71c3c0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,4 @@ +# build FROM node:lts-alpine as build-stage WORKDIR /app COPY ./frontend/package*.json ./ @@ -5,50 +6,53 @@ RUN npm install COPY ./frontend/ . RUN npm run build -FROM python:3.9-alpine - - -RUN apk add --no-cache libxml2-dev \ - libxslt-dev \ - libxml2 caddy \ - libffi-dev \ - python3 \ - python3-dev \ - jpeg-dev \ - lcms2-dev \ - openjpeg-dev \ - zlib-dev +FROM python:3.9-slim-buster ENV PRODUCTION true -EXPOSE 80 -WORKDIR /app/ +ENV POETRY_VERSION 1.1.6 -COPY ./pyproject.toml /app/ -RUN apk add --update --no-cache --virtual .build-deps \ +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc g++ \ curl \ - g++ \ - python3-dev \ - musl-dev \ - gcc \ - build-base && \ - curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | POETRY_HOME=/opt/poetry python && \ - cd /usr/local/bin && \ - ln -s /opt/poetry/bin/poetry && \ - poetry config virtualenvs.create false && \ - cd /app/ && poetry install --no-root --no-dev && \ - apk --purge del .build-deps + gnupg gnupg2 gnupg1 \ + apt-transport-https \ + debian-archive-keyring \ + debian-keyring \ + libwebp-dev \ + && curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | apt-key add - \ + && curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee -a /etc/apt/sources.list.d/caddy-stable.list \ + && apt-get update && apt-get install -y --no-install-recommends \ + caddy \ + && apt autoremove \ + && rm -rf /var/lib/apt/lists/* \ + && apt-get remove -y curl apt-transport-https debian-keyring g++ gnupg gnupg2 gnupg1 + +RUN pip install --no-cache-dir "poetry==$POETRY_VERSION" + +#! Future +# pip install --no-cache-dir "psycopg2-binary==2.8.6" + +WORKDIR /app +COPY pyproject.toml /app/ COPY ./mealie /app/mealie -RUN poetry install --no-dev +RUN poetry config virtualenvs.create false \ + && poetry install --no-dev + +#! Future +# COPY ./alembic /app +# COPY alembic.ini /app COPY ./Caddyfile /app COPY ./dev/data/templates /app/data/templates + +# frontend build COPY --from=build-stage /app/dist /app/dist VOLUME [ "/app/data/" ] -RUN chmod +x /app/mealie/run.sh -CMD /app/mealie/run.sh +EXPOSE 80 +CMD /app/mealie/run.sh \ No newline at end of file diff --git a/Dockerfile.dev b/Dockerfile.dev index 2c5519117dae..20e0b4817b43 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,24 +1,33 @@ -FROM python:3 +FROM python:3.9-slim-buster + + +ENV PRODUCTION false +ENV POETRY_VERSION 1.1.6 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc g++ \ + curl \ + gnupg gnupg2 gnupg1 \ + apt-transport-https \ + debian-archive-keyring \ + debian-keyring \ + libwebp-dev \ + && curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | apt-key add - \ + && curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee -a /etc/apt/sources.list.d/caddy-stable.list \ + && apt-get update && apt-get install -y --no-install-recommends \ + && apt autoremove \ + && rm -rf /var/lib/apt/lists/* \ + && apt-get remove -y curl apt-transport-https debian-keyring g++ gnupg gnupg2 gnupg1 + +RUN pip install --no-cache-dir "poetry==$POETRY_VERSION" WORKDIR /app/ -ENV PRODUCTION false - -RUN apt-get update -y && \ - apt-get install -y python-pip python-dev - -# Install Poetry -RUN curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | POETRY_HOME=/opt/poetry python && \ - cd /usr/local/bin && \ - ln -s /opt/poetry/bin/poetry && \ - poetry config virtualenvs.create false - # Copy poetry.lock* in case it doesn't exist in the repo COPY ./pyproject.toml /app/ - COPY ./mealie /app/mealie - -RUN poetry install +RUN poetry config virtualenvs.create false \ + && poetry install RUN chmod +x /app/mealie/run.sh CMD ["/app/mealie/run.sh", "reload"] diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 6e1aac0a9a11..850435e362b7 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -3,6 +3,7 @@ version: "3.1" services: # Vue Frontend mealie-frontend: + container_name: mealie-frontend image: mealie-frontend:dev build: context: ./frontend @@ -18,6 +19,7 @@ services: # Fast API mealie-api: + container_name: mealie-api image: mealie-api:dev build: context: ./ @@ -34,6 +36,7 @@ services: # Mkdocs mealie-docs: + container_name: mealie-docs image: squidfunk/mkdocs-material restart: always ports: diff --git a/docs/docs/changelog/v0.5.0.md b/docs/docs/changelog/v0.5.0.md index 5db08118ba78..3eaa6e88d33a 100644 --- a/docs/docs/changelog/v0.5.0.md +++ b/docs/docs/changelog/v0.5.0.md @@ -20,6 +20,11 @@ - 'Dinner this week' shows a warning when no meal is planned yet - 'Dinner today' shows a warning when no meal is planned yet +### Performance +- Images are now served up by the Caddy increase performance and offloading some loads from the API server +- Requesting all recipes from the server has been rewritten to refresh less often and manage client side data better. +- All images are now converted to .webp for better compression + ### General - New Toolbox Page! - Bulk assign categories and tags by keyword search @@ -38,6 +43,7 @@ ### Behind the Scenes +- New debian based docker image - Unified Sidebar Components - Refactor UI components to fit Vue best practices (WIP) - The API returns more consistent status codes diff --git a/frontend/src/api/recipe.js b/frontend/src/api/recipe.js index b11a73ddb3b7..1666e7554185 100644 --- a/frontend/src/api/recipe.js +++ b/frontend/src/api/recipe.js @@ -134,14 +134,14 @@ export const recipeAPI = { }, recipeImage(recipeSlug) { - return `/api/recipes/${recipeSlug}/image?image_type=original`; + return `/api/recipes/image/${recipeSlug}/original.webp`; }, recipeSmallImage(recipeSlug) { - return `/api/recipes/${recipeSlug}/image?image_type=small`; + return `/api/recipes/image/${recipeSlug}/min-original.webp`; }, recipeTinyImage(recipeSlug) { - return `/api/recipes/${recipeSlug}/image?image_type=tiny`; + return `/api/recipes/image/${recipeSlug}/tiny-original.webp`; }, }; diff --git a/frontend/src/pages/Recipe/ViewRecipe.vue b/frontend/src/pages/Recipe/ViewRecipe.vue index 56fdbd7e6c73..2f2c19e05129 100644 --- a/frontend/src/pages/Recipe/ViewRecipe.vue +++ b/frontend/src/pages/Recipe/ViewRecipe.vue @@ -165,7 +165,7 @@ export default { }, getImage(image) { if (image) { - return api.recipes.recipeImage(image) + "&rnd=" + this.imageKey; + return api.recipes.recipeImage(image) + "?&rnd=" + this.imageKey; } }, async deleteRecipe() { diff --git a/makefile b/makefile index 595fe19996ec..4978d4bbf0a1 100644 --- a/makefile +++ b/makefile @@ -74,7 +74,7 @@ docker-dev: ## Build and Start Docker Development Stack docker-compose -f docker-compose.dev.yml -p dev-mealie up --build docker-prod: ## Build and Start Docker Production Stack - docker-compose -p mealie up --build -d + docker-compose -f docker-compose.yml -p mealie up --build -d code-gen: ## Run Code-Gen Scripts poetry run python dev/scripts/app_routes_gen.py diff --git a/mealie/db/models/recipe/recipe.py b/mealie/db/models/recipe/recipe.py index b9ffbedf3d64..44701369442f 100644 --- a/mealie/db/models/recipe/recipe.py +++ b/mealie/db/models/recipe/recipe.py @@ -118,7 +118,6 @@ class RecipeModel(SqlAlchemyBase, BaseMixins): # Mealie Specific self.settings = RecipeSettings(**settings) if settings else RecipeSettings() - print(self.settings) self.tags = [Tag.create_if_not_exist(session=session, name=tag) for tag in tags] self.slug = slug self.date_added = date_added diff --git a/mealie/routes/recipe/recipe_crud_routes.py b/mealie/routes/recipe/recipe_crud_routes.py index 1c04bd94e68b..cdeaf3f71c9f 100644 --- a/mealie/routes/recipe/recipe_crud_routes.py +++ b/mealie/routes/recipe/recipe_crud_routes.py @@ -2,6 +2,8 @@ from enum import Enum from fastapi import APIRouter, Depends, File, Form, HTTPException, status from fastapi.responses import FileResponse +from mealie.core.config import app_dirs +from mealie.core.root_logger import get_logger from mealie.db.database import db from mealie.db.db_setup import generate_session from mealie.routes.deps import get_current_user @@ -11,6 +13,7 @@ from mealie.services.scraper.scraper import create_from_url from sqlalchemy.orm.session import Session router = APIRouter(prefix="/api/recipes", tags=["Recipe CRUD"]) +logger = get_logger() @router.post("/create", status_code=201, response_model=str) @@ -104,22 +107,15 @@ def delete_recipe( class ImageType(str, Enum): - original = "original" - small = "small" - tiny = "tiny" + original = "original.webp" + small = "min-original.webp" + tiny = "tiny-original.webp" -@router.get("/{recipe_slug}/image") -async def get_recipe_img(recipe_slug: str, image_type: ImageType = ImageType.original): +@router.get("/image/{recipe_slug}/{file_name}") +async def get_recipe_img(recipe_slug: str, file_name: ImageType = ImageType.original): """ Takes in a recipe slug, returns the static image """ - 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) + recipe_image = app_dirs.IMG_DIR.joinpath(recipe_slug, file_name.value) if recipe_image: return FileResponse(recipe_image) else: diff --git a/mealie/run.sh b/mealie/run.sh index 56ce31532cd6..74da3d66fd31 100755 --- a/mealie/run.sh +++ b/mealie/run.sh @@ -1,11 +1,8 @@ -#!/bin/sh +#!/bin/bash # Get Reload Arg `run.sh reload` for dev server ARG1=${1:-production} -# Set Script Directory - Used for running the script from a different directory. -# DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" - # # Initialize Database Prerun poetry run python /app/mealie/db/init_db.py poetry run python /app/mealie/services/image/minify.py @@ -15,12 +12,12 @@ poetry run python /app/mealie/services/image/minify.py # Migrations # Set Port from ENV Variable -if [[ "$ARG1" = "reload" ]] +if [ "$ARG1" == "reload" ] then echo "Hot Reload!" # Start API - uvicorn mealie.app:app --host 0.0.0.0 --port 9000 --reload + python /app/mealie/app.py else echo "Production" # Web Server diff --git a/mealie/services/image/minify.py b/mealie/services/image/minify.py index 78d3180238d4..52344628427c 100644 --- a/mealie/services/image/minify.py +++ b/mealie/services/image/minify.py @@ -35,21 +35,31 @@ def minify_image(image_file: Path) -> ImageSizes: min_dest (Path): FULL Destination File Path tiny_dest (Path): FULL Destination File Path """ - min_dest = image_file.parent.joinpath(f"min-original{image_file.suffix}") - tiny_dest = image_file.parent.joinpath(f"tiny-original{image_file.suffix}") + def cleanup(dir: Path) -> None: + for file in dir.glob("*.*"): + if file.suffix != ".webp": + file.unlink() - if min_dest.exists() and tiny_dest.exists(): + org_dest = image_file.parent.joinpath(f"original.webp") + min_dest = image_file.parent.joinpath(f"min-original.webp") + tiny_dest = image_file.parent.joinpath(f"tiny-original.webp") + + if min_dest.exists() and tiny_dest.exists() and org_dest.exists(): return try: img = Image.open(image_file) + + img.save(org_dest, "WEBP") 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) + img.save(min_dest, "WEBP", quality=70) tiny_image = crop_center(img) - tiny_image.save(tiny_dest, quality=70) + tiny_image.save(tiny_dest, "WEBP", quality=70) + + cleanup_images = True except Exception: shutil.copy(image_file, min_dest) @@ -58,7 +68,10 @@ def minify_image(image_file: Path) -> ImageSizes: image_sizes = get_image_sizes(image_file, min_dest, tiny_dest) logger.info(f"{image_file.name} Minified: {image_sizes.org} -> {image_sizes.min} -> {image_sizes.tiny}") - + + if cleanup_images: + cleanup(image_file.parent) + return image_sizes