mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-12-16 07:15:26 -05:00
feat: Reprocess image user script (#6704)
This commit is contained in:
@@ -9,6 +9,23 @@
|
|||||||
- Create a Backup and Download from the UI
|
- Create a Backup and Download from the UI
|
||||||
- Upgrade
|
- Upgrade
|
||||||
|
|
||||||
|
!!! info "Improved Image Processing"
|
||||||
|
Starting with :octicons-tag-24: v3.7.0, we updated our image processing algorithm to improve image quality and compression. New image processing can be up to 40%-50% smaller on disk while providing higher resolution thumbnails. To take advantage of these improvements on older recipes, you can run our image-processing script:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
docker exec -it mealie bash
|
||||||
|
python /opt/mealie/lib64/python3.12/site-packages/mealie/scripts/reprocess_images.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Options
|
||||||
|
- `--workers N`: Number of worker threads (default: 2, safe for low-powered devices)
|
||||||
|
- `--force-all`: Reprocess all recipes regardless of current image state
|
||||||
|
|
||||||
|
### Example
|
||||||
|
```shell
|
||||||
|
python /opt/mealie/lib64/python3.12/site-packages/mealie/scripts/reprocess_images.py --workers 8
|
||||||
|
```
|
||||||
|
|
||||||
## Upgrading to Mealie v1 or later
|
## Upgrading to Mealie v1 or later
|
||||||
If you are upgrading from pre-v1.0.0 to v1.0.0 or later (v2.0.0, etc.), make sure you read [Migrating to Mealie v1](./migrating-to-mealie-v1.md)!
|
If you are upgrading from pre-v1.0.0 to v1.0.0 or later (v2.0.0, etc.), make sure you read [Migrating to Mealie v1](./migrating-to-mealie-v1.md)!
|
||||||
|
|
||||||
|
|||||||
@@ -44,10 +44,10 @@ class ABCMinifier(ABC):
|
|||||||
def __init__(self, purge=False, opts: MinifierOptions | None = None, logger: Logger | None = None):
|
def __init__(self, purge=False, opts: MinifierOptions | None = None, logger: Logger | None = None):
|
||||||
self._purge = purge
|
self._purge = purge
|
||||||
self._opts = opts or MinifierOptions()
|
self._opts = opts or MinifierOptions()
|
||||||
self._logger = logger or Logger("Minifier")
|
self.logger = logger or Logger("Minifier")
|
||||||
|
|
||||||
def get_image_sizes(self, org_img: Path, min_img: Path, tiny_img: Path):
|
def get_image_sizes(self, org_img: Path, min_img: Path, tiny_img: Path):
|
||||||
self._logger.info(
|
self.logger.info(
|
||||||
f"{org_img.name} Minified: {sizeof_fmt(org_img)} -> {sizeof_fmt(min_img)} -> {sizeof_fmt(tiny_img)}"
|
f"{org_img.name} Minified: {sizeof_fmt(org_img)} -> {sizeof_fmt(min_img)} -> {sizeof_fmt(tiny_img)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -162,7 +162,7 @@ class PillowMinifier(ABCMinifier):
|
|||||||
tiny_dest = image_path.parent.joinpath("tiny-original.webp")
|
tiny_dest = image_path.parent.joinpath("tiny-original.webp")
|
||||||
|
|
||||||
if not force and min_dest.exists() and tiny_dest.exists() and org_dest.exists():
|
if not force and min_dest.exists() and tiny_dest.exists() and org_dest.exists():
|
||||||
self._logger.info(f"{image_path.name} already exists in all formats")
|
self.logger.info(f"{image_path.name} already exists in all formats")
|
||||||
return
|
return
|
||||||
|
|
||||||
success = False
|
success = False
|
||||||
@@ -171,35 +171,35 @@ class PillowMinifier(ABCMinifier):
|
|||||||
with Image.open(image_path) as img:
|
with Image.open(image_path) as img:
|
||||||
if self._opts.original:
|
if self._opts.original:
|
||||||
if not force and org_dest.exists():
|
if not force and org_dest.exists():
|
||||||
self._logger.info(f"{org_dest} already exists")
|
self.logger.info(f"{org_dest} already exists")
|
||||||
else:
|
else:
|
||||||
original = img.copy()
|
original = img.copy()
|
||||||
original.thumbnail((2048, 2048), Image.LANCZOS)
|
original.thumbnail((2048, 2048), Image.LANCZOS)
|
||||||
result_path = PillowMinifier.to_webp(dest=org_dest, quality=80, img=original)
|
result_path = PillowMinifier.to_webp(dest=org_dest, quality=80, img=original)
|
||||||
self._logger.info(f"{result_path} created")
|
self.logger.info(f"{result_path} created")
|
||||||
success = True
|
success = True
|
||||||
|
|
||||||
if self._opts.miniature:
|
if self._opts.miniature:
|
||||||
if not force and min_dest.exists():
|
if not force and min_dest.exists():
|
||||||
self._logger.info(f"{min_dest} already exists")
|
self.logger.info(f"{min_dest} already exists")
|
||||||
else:
|
else:
|
||||||
mini = img.copy()
|
mini = img.copy()
|
||||||
mini.thumbnail((1024, 1024), Image.LANCZOS)
|
mini.thumbnail((1024, 1024), Image.LANCZOS)
|
||||||
result_path = PillowMinifier.to_webp(dest=min_dest, quality=80, img=mini)
|
result_path = PillowMinifier.to_webp(dest=min_dest, quality=80, img=mini)
|
||||||
self._logger.info(f"{result_path} created")
|
self.logger.info(f"{result_path} created")
|
||||||
success = True
|
success = True
|
||||||
|
|
||||||
if self._opts.tiny:
|
if self._opts.tiny:
|
||||||
if not force and tiny_dest.exists():
|
if not force and tiny_dest.exists():
|
||||||
self._logger.info(f"{tiny_dest} already exists")
|
self.logger.info(f"{tiny_dest} already exists")
|
||||||
else:
|
else:
|
||||||
tiny = PillowMinifier.crop_center(img.copy(), size=(300, 300))
|
tiny = PillowMinifier.crop_center(img.copy(), size=(300, 300))
|
||||||
result_path = PillowMinifier.to_webp(dest=tiny_dest, quality=80, img=tiny)
|
result_path = PillowMinifier.to_webp(dest=tiny_dest, quality=80, img=tiny)
|
||||||
self._logger.info(f"{result_path} created")
|
self.logger.info(f"{result_path} created")
|
||||||
success = True
|
success = True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._logger.error(f"[ERROR] Failed to minify {image_path.name}. Error: {e}")
|
self.logger.error(f"[ERROR] Failed to minify {image_path.name}. Error: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
if self._purge and success:
|
if self._purge and success:
|
||||||
|
|||||||
190
mealie/scripts/reprocess_images.py
Normal file
190
mealie/scripts/reprocess_images.py
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import argparse
|
||||||
|
import threading
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from PIL import Image
|
||||||
|
from pydantic import UUID4
|
||||||
|
|
||||||
|
from mealie.core import root_logger
|
||||||
|
from mealie.db.db_setup import session_context
|
||||||
|
from mealie.db.models.recipe import RecipeModel
|
||||||
|
from mealie.services.recipe.recipe_data_service import RecipeDataService
|
||||||
|
|
||||||
|
logger = root_logger.get_logger()
|
||||||
|
minifier_logger = root_logger.get_logger("minifier")
|
||||||
|
minifier_logger.setLevel("WARNING")
|
||||||
|
|
||||||
|
NON_ORIGINAL_FILENAMES = {"min-original.webp", "tiny-original.webp"}
|
||||||
|
|
||||||
|
|
||||||
|
def check_if_tiny_image_is_old(image_path: Path) -> bool:
|
||||||
|
with Image.open(image_path) as img:
|
||||||
|
# This will miss images which were originally smaller than 300x300,
|
||||||
|
# but we probably don't care about those anyway
|
||||||
|
return img.width == 300 and img.height == 300
|
||||||
|
|
||||||
|
|
||||||
|
def check_needs_reprocess(recipe_id: UUID4) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a recipe's images need reprocessing by examining tiny image dimensions.
|
||||||
|
New processing creates 600x600 tiny images, old processing created 300x300.
|
||||||
|
|
||||||
|
Returns True if needs reprocessing (has old 300x300 tiny image or missing images).
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
service = RecipeDataService(recipe_id)
|
||||||
|
tiny_path = service.dir_image / "tiny-original.webp"
|
||||||
|
original_path = service.dir_image / "original.webp"
|
||||||
|
|
||||||
|
if not original_path.exists():
|
||||||
|
return False # Cannot reprocess without original image
|
||||||
|
|
||||||
|
if not tiny_path.exists():
|
||||||
|
return True # Needs reprocessing if tiny image is missing
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.error(f"Failed to access recipe {recipe_id} images for reprocessing check; skipping")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
return check_if_tiny_image_is_old(tiny_path)
|
||||||
|
except Exception:
|
||||||
|
logger.error(f"Failed to open tiny image for recipe {recipe_id}; assuming reprocessing needed")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_recipe_ids(force_all: bool = False) -> set[UUID4]:
|
||||||
|
logger.info("Fetching recipes for image reprocessing")
|
||||||
|
|
||||||
|
with session_context() as session:
|
||||||
|
result = session.execute(sa.text(f"SELECT id FROM {RecipeModel.__tablename__}"))
|
||||||
|
|
||||||
|
recipe_ids = {UUID4(row[0]) for row in result}
|
||||||
|
if force_all:
|
||||||
|
logger.info("!!Force processing all recipes regardless of current image state")
|
||||||
|
return recipe_ids
|
||||||
|
|
||||||
|
else:
|
||||||
|
return {recipe_id for recipe_id in recipe_ids if check_needs_reprocess(recipe_id)}
|
||||||
|
|
||||||
|
|
||||||
|
def reprocess_recipe_images(recipe_id: UUID4, force_all: bool = False) -> None:
|
||||||
|
service = RecipeDataService(recipe_id, logger=minifier_logger)
|
||||||
|
original_image = service.dir_image / "original.webp"
|
||||||
|
if not original_image.exists():
|
||||||
|
# Double-check that original image exists. We may have skipped this if we're using force_all
|
||||||
|
logger.error(f"Original image missing for recipe {recipe_id}; cannot reprocess")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Reprocess recipe images
|
||||||
|
for image_filename in NON_ORIGINAL_FILENAMES:
|
||||||
|
image_file = service.dir_image / image_filename
|
||||||
|
image_file.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
service.minifier.minify(original_image, force=True)
|
||||||
|
|
||||||
|
# Reprocess timeline event images
|
||||||
|
timeline_dir = service.dir_image_timeline
|
||||||
|
if not timeline_dir.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
for event_dir in timeline_dir.iterdir():
|
||||||
|
try:
|
||||||
|
if not event_dir.is_dir():
|
||||||
|
continue
|
||||||
|
|
||||||
|
event_original = event_dir / "original.webp"
|
||||||
|
if not event_original.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
event_tiny = event_dir / "tiny-original.webp"
|
||||||
|
if not force_all and (event_tiny.exists() and not check_if_tiny_image_is_old(event_tiny)):
|
||||||
|
continue
|
||||||
|
|
||||||
|
for image_filename in NON_ORIGINAL_FILENAMES:
|
||||||
|
image_file = event_dir / image_filename
|
||||||
|
image_file.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
service.minifier.minify(event_original, force=True)
|
||||||
|
except Exception:
|
||||||
|
# Silently skip these; they're not as important and there could be a lot of them which could spam logs
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
def process_recipe(recipe_id: UUID4, force_all: bool = False) -> tuple[UUID4, bool]:
|
||||||
|
"""Process a single recipe's images, returning (recipe_id, success)"""
|
||||||
|
try:
|
||||||
|
reprocess_recipe_images(recipe_id, force_all)
|
||||||
|
return recipe_id, True
|
||||||
|
except Exception:
|
||||||
|
logger.exception(f"Failed to reprocess images for recipe {recipe_id}")
|
||||||
|
return recipe_id, False
|
||||||
|
|
||||||
|
|
||||||
|
def process_all_recipes(recipe_ids: set[UUID4], force_all: bool = False, max_workers: int = 2) -> set[UUID4]:
|
||||||
|
"""Process all given recipe IDs concurrently, returning set of failed recipe IDs."""
|
||||||
|
failed_recipe_ids: set[UUID4] = set()
|
||||||
|
progress_freq = 20 if len(recipe_ids) <= 1000 else 100
|
||||||
|
progress_lock = threading.Lock()
|
||||||
|
completed_count = 0
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||||
|
future_to_recipe = {
|
||||||
|
executor.submit(process_recipe, recipe_id, force_all): recipe_id for recipe_id in recipe_ids
|
||||||
|
}
|
||||||
|
|
||||||
|
for future in as_completed(future_to_recipe):
|
||||||
|
recipe_id, success = future.result()
|
||||||
|
if not success:
|
||||||
|
failed_recipe_ids.add(recipe_id)
|
||||||
|
|
||||||
|
# Progress reporting
|
||||||
|
with progress_lock:
|
||||||
|
completed_count += 1
|
||||||
|
if completed_count % progress_freq == 0:
|
||||||
|
perc = (completed_count / len(recipe_ids)) * 100
|
||||||
|
logger.info(f"{perc:.2f}% complete ({completed_count}/{len(recipe_ids)})")
|
||||||
|
|
||||||
|
return failed_recipe_ids
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Reprocess old recipe images to improve compression and upgrade quality"
|
||||||
|
)
|
||||||
|
parser.add_argument("--workers", type=int, default=2, help="Number of worker threads (default: 2)")
|
||||||
|
parser.add_argument(
|
||||||
|
"--force-all", action="store_true", help="Reprocess all recipes regardless of current image state"
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
workers: int = max(1, args.workers)
|
||||||
|
force_all: bool = args.force_all
|
||||||
|
|
||||||
|
recipe_ids = fetch_recipe_ids(force_all=force_all)
|
||||||
|
if not recipe_ids:
|
||||||
|
logger.info("No recipes need image reprocessing. Exiting...")
|
||||||
|
exit(0)
|
||||||
|
|
||||||
|
confirmed = input(
|
||||||
|
f"Found {len(recipe_ids)} {'recipe' if len(recipe_ids) == 1 else 'recipes'} "
|
||||||
|
f"needing image reprocessing (using {workers} {'worker' if workers == 1 else 'workers'}). Proceed? (y/n) "
|
||||||
|
)
|
||||||
|
if confirmed.lower() != "y":
|
||||||
|
print("aborting") # noqa
|
||||||
|
exit(0)
|
||||||
|
|
||||||
|
logger.info("Starting image reprocessing...")
|
||||||
|
failed_recipe_ids = process_all_recipes(recipe_ids, force_all, max_workers=workers)
|
||||||
|
|
||||||
|
logger.info(f"Image reprocessing complete. {len(recipe_ids) - len(failed_recipe_ids)} successfully processed")
|
||||||
|
if failed_recipe_ids:
|
||||||
|
logger.error(f"Failed recipes: {', '.join(str(rid) for rid in failed_recipe_ids)}")
|
||||||
|
|
||||||
|
exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import shutil
|
import shutil
|
||||||
|
from logging import Logger
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from httpx import AsyncClient, Response
|
from httpx import AsyncClient, Response
|
||||||
@@ -60,7 +61,7 @@ class InvalidDomainError(Exception):
|
|||||||
class RecipeDataService(BaseService):
|
class RecipeDataService(BaseService):
|
||||||
minifier: img.ABCMinifier
|
minifier: img.ABCMinifier
|
||||||
|
|
||||||
def __init__(self, recipe_id: UUID4) -> None:
|
def __init__(self, recipe_id: UUID4, logger: Logger | None = None) -> None:
|
||||||
"""
|
"""
|
||||||
RecipeDataService is a service that consolidates the reading/writing actions related
|
RecipeDataService is a service that consolidates the reading/writing actions related
|
||||||
to assets, and images for a recipe.
|
to assets, and images for a recipe.
|
||||||
@@ -68,6 +69,7 @@ class RecipeDataService(BaseService):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
self.recipe_id = recipe_id
|
self.recipe_id = recipe_id
|
||||||
|
self.logger = logger or self.logger
|
||||||
self.minifier = img.PillowMinifier(purge=True, logger=self.logger)
|
self.minifier = img.PillowMinifier(purge=True, logger=self.logger)
|
||||||
|
|
||||||
self.dir_data = Recipe.directory_from_id(self.recipe_id)
|
self.dir_data = Recipe.directory_from_id(self.recipe_id)
|
||||||
|
|||||||
Reference in New Issue
Block a user