diff --git a/Caddyfile b/Caddyfile index ba5d2e454..0faf9fe7d 100644 --- a/Caddyfile +++ b/Caddyfile @@ -10,8 +10,8 @@ encode gzip uri strip_suffix / - handle_path /api/recipes/image/* { - root * /app/data/img/ + handle_path /api/recipes/media/* { + root * /app/data/recipes/ file_server } diff --git a/frontend/src/api/recipe.js b/frontend/src/api/recipe.js index fe755f6f7..d35c4d1bc 100644 --- a/frontend/src/api/recipe.js +++ b/frontend/src/api/recipe.js @@ -14,9 +14,9 @@ const recipeURLs = { recipe: slug => prefix + slug, update: slug => prefix + slug, delete: slug => prefix + slug, + createAsset: slug => `${prefix}media/${slug}/assets`, recipeImage: slug => `${prefix}${slug}/image`, updateImage: slug => `${prefix}${slug}/image`, - createAsset: slug => `${prefix}${slug}/asset`, }; export const recipeAPI = { @@ -84,7 +84,7 @@ export const recipeAPI = { fd.append("extension", fileObject.name.split(".").pop()); fd.append("name", name); fd.append("icon", icon); - let response = apiReq.post(recipeURLs.createAsset(recipeSlug), fd); + const response = apiReq.post(recipeURLs.createAsset(recipeSlug), fd); return response; }, @@ -135,14 +135,14 @@ export const recipeAPI = { }, recipeImage(recipeSlug) { - return `/api/recipes/image/${recipeSlug}/original.webp`; + return `/api/recipes/media/${recipeSlug}/image/original.webp`; }, recipeSmallImage(recipeSlug) { - return `/api/recipes/image/${recipeSlug}/min-original.webp`; + return `/api/recipes/media/${recipeSlug}/image/min-original.webp`; }, recipeTinyImage(recipeSlug) { - return `/api/recipes/image/${recipeSlug}/tiny-original.webp`; + return `/api/recipes/media/${recipeSlug}/image/tiny-original.webp`; }, }; diff --git a/frontend/src/components/Recipe/Parts/Assets.vue b/frontend/src/components/Recipe/Parts/Assets.vue index d57827683..62119ba21 100644 --- a/frontend/src/components/Recipe/Parts/Assets.vue +++ b/frontend/src/components/Recipe/Parts/Assets.vue @@ -18,7 +18,7 @@ v-if="!edit" color="primary" icon - :href="`/api/recipes/${slug}/asset?file_name=${item.fileName}`" + :href="`/api/recipes/media/${slug}/assets/${item.fileName}`" target="_blank" top > @@ -135,7 +135,7 @@ export default { this.value.splice(index, 1); }, copyLink(name, fileName) { - const copyText = `![${name}](${this.baseURL}/api/recipes/${this.slug}/assets/${fileName})`; + const copyText = `![${name}](${this.baseURL}/api/recipes/media/${this.slug}/assets/${fileName})`; navigator.clipboard.writeText(copyText).then( () => console.log("Copied", copyText), () => console.log("Copied Failed", copyText) diff --git a/mealie/routes/backup_routes.py b/mealie/routes/backup_routes.py index 864a1380a..c6f0b0f59 100644 --- a/mealie/routes/backup_routes.py +++ b/mealie/routes/backup_routes.py @@ -3,6 +3,7 @@ import shutil from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status from mealie.core.config import app_dirs +from mealie.core.root_logger import get_logger from mealie.core.security import create_file_token from mealie.db.db_setup import generate_session from mealie.routes.deps import get_current_user @@ -12,6 +13,7 @@ from mealie.services.backups.exports import backup_all from sqlalchemy.orm.session import Session router = APIRouter(prefix="/api/backups", tags=["Backups"], dependencies=[Depends(get_current_user)]) +logger = get_logger() @router.get("/available", response_model=Imports) @@ -44,7 +46,8 @@ def export_database(data: BackupJob, session: Session = Depends(generate_session export_groups=data.options.groups, ) return {"export_path": export_path} - except Exception: + except Exception as e: + logger.error(e) raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/mealie/routes/mealplans/crud.py b/mealie/routes/mealplans/crud.py index 7b58853d7..bb2f17672 100644 --- a/mealie/routes/mealplans/crud.py +++ b/mealie/routes/mealplans/crud.py @@ -88,7 +88,7 @@ def get_todays_image(session: Session = Depends(generate_session), group_name: s recipe = get_todays_meal(session, group_in_db) if recipe: - recipe_image = image.read_image(recipe.slug, image_type=image.IMG_OPTIONS.ORIGINAL_IMAGE) + recipe_image = recipe.image_dir.joinpath(image.ImageOptions.ORIGINAL_IMAGE) else: raise HTTPException(status.HTTP_404_NOT_FOUND) if recipe_image: diff --git a/mealie/routes/recipe/__init__.py b/mealie/routes/recipe/__init__.py index 8af92586a..1d54034f0 100644 --- a/mealie/routes/recipe/__init__.py +++ b/mealie/routes/recipe/__init__.py @@ -1,10 +1,10 @@ from fastapi import APIRouter -from mealie.routes.recipe import all_recipe_routes, category_routes, recipe_assets, recipe_crud_routes, tag_routes +from mealie.routes.recipe import all_recipe_routes, category_routes, recipe_crud_routes, recipe_media, tag_routes router = APIRouter() router.include_router(all_recipe_routes.router) router.include_router(recipe_crud_routes.router) -router.include_router(recipe_assets.router) +router.include_router(recipe_media.router) router.include_router(category_routes.router) router.include_router(tag_routes.router) diff --git a/mealie/routes/recipe/recipe_crud_routes.py b/mealie/routes/recipe/recipe_crud_routes.py index 8547aa09f..01a05005c 100644 --- a/mealie/routes/recipe/recipe_crud_routes.py +++ b/mealie/routes/recipe/recipe_crud_routes.py @@ -4,7 +4,8 @@ from mealie.db.database import db 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.services.image.image import delete_image, rename_image, scrape_image, write_image +from mealie.services.image.image import scrape_image, write_image +from mealie.services.recipe.asset import check_asset from mealie.services.scraper.scraper import create_from_url from sqlalchemy.orm.session import Session @@ -58,7 +59,7 @@ def update_recipe( print(recipe.assets) if recipe_slug != recipe.slug: - rename_image(original_slug=recipe_slug, new_slug=recipe.slug) + check_asset(original_slug=recipe_slug, recipe=recipe) return recipe @@ -76,7 +77,7 @@ def patch_recipe( session, recipe_slug, new_data=data.dict(exclude_unset=True, exclude_defaults=True) ) if recipe_slug != recipe.slug: - rename_image(original_slug=recipe_slug, new_slug=recipe.slug) + check_asset(original_slug=recipe_slug, recipe=recipe) return recipe @@ -91,7 +92,6 @@ def delete_recipe( try: delete_data = db.recipes.delete(session, recipe_slug) - delete_image(recipe_slug) return delete_data except Exception: diff --git a/mealie/routes/recipe/recipe_assets.py b/mealie/routes/recipe/recipe_media.py similarity index 75% rename from mealie/routes/recipe/recipe_assets.py rename to mealie/routes/recipe/recipe_media.py index ace33077a..b89605c72 100644 --- a/mealie/routes/recipe/recipe_assets.py +++ b/mealie/routes/recipe/recipe_media.py @@ -3,7 +3,6 @@ from enum import Enum from fastapi import APIRouter, Depends, File, Form, HTTPException, status from fastapi.datastructures import UploadFile -from mealie.core.config import app_dirs from mealie.db.database import db from mealie.db.db_setup import generate_session from mealie.routes.deps import get_current_user @@ -12,7 +11,7 @@ from slugify import slugify from sqlalchemy.orm.session import Session from starlette.responses import FileResponse -router = APIRouter(prefix="/api/recipes", tags=["Recipe Media"]) +router = APIRouter(prefix="/api/recipes/media", tags=["Recipe Media"]) class ImageType(str, Enum): @@ -21,11 +20,12 @@ class ImageType(str, Enum): tiny = "tiny-original.webp" -@router.get("/image/{recipe_slug}/{file_name}") +@router.get("/{recipe_slug}/image/{file_name}") async def get_recipe_img(recipe_slug: str, file_name: ImageType = ImageType.original): """Takes in a recipe slug, returns the static image. This route is proxied in the docker image and should not hit the API in production""" - recipe_image = app_dirs.IMG_DIR.joinpath(recipe_slug, file_name.value) + recipe_image = Recipe(slug=recipe_slug).image_dir.joinpath(file_name.value) + if recipe_image: return FileResponse(recipe_image) else: @@ -33,13 +33,17 @@ async def get_recipe_img(recipe_slug: str, file_name: ImageType = ImageType.orig @router.get("/{recipe_slug}/assets/{file_name}") -async def get_recipe_asset(recipe_slug, file_name: str): +async def get_recipe_asset(recipe_slug: str, file_name: str): """ Returns a recipe asset """ - file = app_dirs.RECIPE_DATA_DIR.joinpath(recipe_slug, file_name) - return FileResponse(file) + file = Recipe(slug=recipe_slug).asset_dir.joinpath(file_name) + + try: + return FileResponse(file) + except Exception: + raise HTTPException(status.HTTP_404_NOT_FOUND) -@router.post("/{recipe_slug}/asset", response_model=RecipeAsset) +@router.post("/{recipe_slug}/assets", response_model=RecipeAsset) def upload_recipe_asset( recipe_slug: str, name: str = Form(...), @@ -52,8 +56,7 @@ def upload_recipe_asset( """ Upload a file to store as a recipe asset """ file_name = slugify(name) + "." + extension asset_in = RecipeAsset(name=name, icon=icon, file_name=file_name) - dest = app_dirs.RECIPE_DATA_DIR.joinpath(recipe_slug, file_name) - dest.parent.mkdir(exist_ok=True, parents=True) + dest = Recipe(slug=recipe_slug).asset_dir.joinpath(file_name) with dest.open("wb") as buffer: shutil.copyfileobj(file.file, buffer) diff --git a/mealie/schema/recipe.py b/mealie/schema/recipe.py index 1ff2aabf7..debdec1d5 100644 --- a/mealie/schema/recipe.py +++ b/mealie/schema/recipe.py @@ -1,7 +1,9 @@ import datetime +from pathlib import Path from typing import Any, Optional from fastapi_camelcase import CamelModel +from mealie.core.config import app_dirs from mealie.db.models.recipe.recipe import RecipeModel from pydantic import BaseModel, Field, validator from pydantic.utils import GetterDict @@ -58,8 +60,8 @@ class Nutrition(CamelModel): class RecipeSummary(CamelModel): id: Optional[int] - name: str - slug: Optional[str] = "" + name: Optional[str] + slug: str = "" image: Optional[Any] description: Optional[str] @@ -98,6 +100,28 @@ class Recipe(RecipeSummary): org_url: Optional[str] = Field(None, alias="orgURL") extras: Optional[dict] = {} + @staticmethod + def directory_from_slug(slug) -> Path: + return app_dirs.RECIPE_DATA_DIR.joinpath(slug) + + @property + def directory(self) -> Path: + dir = app_dirs.RECIPE_DATA_DIR.joinpath(self.slug) + dir.mkdir(exist_ok=True, parents=True) + return dir + + @property + def asset_dir(self) -> Path: + dir = self.directory.joinpath("assets") + dir.mkdir(exist_ok=True, parents=True) + return dir + + @property + def image_dir(self) -> Path: + dir = self.directory.joinpath("images") + dir.mkdir(exist_ok=True, parents=True) + return dir + class Config: orm_mode = True @@ -140,6 +164,8 @@ class Recipe(RecipeSummary): @validator("slug", always=True, pre=True) def validate_slug(slug: str, values): + if not values["name"]: + return slug name: str = values["name"] calc_slug: str = slugify(name) diff --git a/mealie/services/backups/exports.py b/mealie/services/backups/exports.py index afd9a735e..a81be77d5 100644 --- a/mealie/services/backups/exports.py +++ b/mealie/services/backups/exports.py @@ -32,7 +32,7 @@ class ExportDatabase: export_tag = datetime.now().strftime("%Y-%b-%d") self.main_dir = app_dirs.TEMP_DIR.joinpath(export_tag) - self.img_dir = self.main_dir.joinpath("images") + self.recipes = self.main_dir.joinpath("recipes") self.templates_dir = self.main_dir.joinpath("templates") try: @@ -43,7 +43,7 @@ class ExportDatabase: required_dirs = [ self.main_dir, - self.img_dir, + self.recipes, self.templates_dir, ] @@ -67,10 +67,10 @@ class ExportDatabase: with open(out_file, "w") as f: f.write(content) - def export_images(self): - shutil.copytree(app_dirs.IMG_DIR, self.img_dir, dirs_exist_ok=True) + def export_recipe_dirs(self): + shutil.copytree(app_dirs.RECIPE_DATA_DIR, self.recipes, dirs_exist_ok=True) - def export_items(self, items: list[BaseModel], folder_name: str, export_list=True): + def export_items(self, items: list[BaseModel], folder_name: str, export_list=True, slug_folder=False): items = [x.dict() for x in items] out_dir = self.main_dir.joinpath(folder_name) out_dir.mkdir(parents=True, exist_ok=True) @@ -79,8 +79,9 @@ class ExportDatabase: ExportDatabase._write_json_file(items, out_dir.joinpath(f"{folder_name}.json")) else: for item in items: - filename = sanitize_filename(f"{item.get('name')}.json") - ExportDatabase._write_json_file(item, out_dir.joinpath(filename)) + final_dest = out_dir if not slug_folder else out_dir.joinpath(item.get("slug")) + filename = sanitize_filename(f"{item.get('slug')}.json") + ExportDatabase._write_json_file(item, final_dest.joinpath(filename)) @staticmethod def _write_json_file(data: Union[dict, list], out_file: Path): @@ -121,9 +122,9 @@ def backup_all( if export_recipes: all_recipes = db.recipes.get_all(session) - db_export.export_items(all_recipes, "recipes", export_list=False) + db_export.export_recipe_dirs() + db_export.export_items(all_recipes, "recipes", export_list=False, slug_folder=True) db_export.export_templates(all_recipes) - db_export.export_images() if export_settings: all_settings = db.settings.get_all(session) diff --git a/mealie/services/backups/imports.py b/mealie/services/backups/imports.py index b561752bd..4b637b741 100644 --- a/mealie/services/backups/imports.py +++ b/mealie/services/backups/imports.py @@ -2,7 +2,7 @@ import json import shutil import zipfile from pathlib import Path -from typing import Callable, List +from typing import Callable from mealie.core.config import app_dirs from mealie.db.database import db @@ -49,7 +49,7 @@ class ImportDatabase: def import_recipes(self): recipe_dir: Path = self.import_dir.joinpath("recipes") imports = [] - successful_imports = [] + successful_imports = {} recipes = ImportDatabase.read_models_file( file_path=recipe_dir, model=Recipe, single_file=False, migrate=ImportDatabase._recipe_migration @@ -68,7 +68,7 @@ class ImportDatabase: ) if import_status.status: - successful_imports.append(recipe.slug) + successful_imports.update({recipe.slug: recipe}) imports.append(import_status) @@ -105,15 +105,21 @@ class ImportDatabase: return recipe_dict - def _import_images(self, successful_imports: List[str]): + def _import_images(self, successful_imports: list[Recipe]): image_dir = self.import_dir.joinpath("images") - for image in image_dir.iterdir(): - if image.stem in successful_imports: - 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) + + if image_dir.exists(): + for image in image_dir.iterdir(): + item: Recipe = successful_imports.get(image.stem) + + if item: + dest_dir = item.image_dir + + if image.is_dir(): + shutil.copytree(image, dest_dir, dirs_exist_ok=True) + + if image.is_file(): + shutil.copy(image, dest_dir) minify.migrate_images() @@ -227,7 +233,7 @@ class ImportDatabase: return [model(**g) for g in file_data] all_models = [] - for file in file_path.glob("*.json"): + for file in file_path.glob("**/*.json"): with open(file, "r") as f: file_data = json.loads(f.read()) diff --git a/mealie/services/image/image.py b/mealie/services/image/image.py index ed5f90ab0..229b2d08d 100644 --- a/mealie/services/image/image.py +++ b/mealie/services/image/image.py @@ -4,7 +4,7 @@ from pathlib import Path import requests from mealie.core import root_logger -from mealie.core.config import app_dirs +from mealie.schema.recipe import Recipe from mealie.services.image import minify logger = root_logger.get_logger() @@ -20,47 +20,11 @@ class ImageOptions: 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] - """ - 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 rename_image(original_slug, new_slug) -> Path: - current_path = app_dirs.IMG_DIR.joinpath(original_slug) - new_path = app_dirs.IMG_DIR.joinpath(new_slug) - - try: - new_path = current_path.rename(new_path) - except FileNotFoundError: - logger.error(f"Image Directory {original_slug} Doesn't Exist") - - return new_path - - def write_image(recipe_slug: str, file_data: bytes, extension: str) -> Path: - try: - delete_image(recipe_slug) - except Exception: - pass - - image_dir = Path(app_dirs.IMG_DIR.joinpath(f"{recipe_slug}")) - image_dir.mkdir(exist_ok=True, parents=True) + image_dir = Recipe(slug=recipe_slug).image_dir extension = extension.replace(".", "") image_path = image_dir.joinpath(f"original.{extension}") + image_path.unlink(missing_ok=True) if isinstance(file_data, Path): shutil.copy2(file_data, image_path) @@ -77,12 +41,6 @@ def write_image(recipe_slug: str, file_data: bytes, extension: str) -> Path: 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 @@ -96,7 +54,7 @@ def scrape_image(image_url: str, slug: str) -> Path: image_url = image_url.get("url") filename = slug + "." + image_url.split(".")[-1] - filename = app_dirs.IMG_DIR.joinpath(filename) + filename = Recipe(slug=slug).image_dir.joinpath(filename) try: r = requests.get(image_url, stream=True) diff --git a/mealie/services/image/minify.py b/mealie/services/image/minify.py index e33bbb38e..9dddcb63b 100644 --- a/mealie/services/image/minify.py +++ b/mealie/services/image/minify.py @@ -4,10 +4,8 @@ from pathlib import Path from mealie.core import root_logger from mealie.core.config import app_dirs -from mealie.db.database import db -from mealie.db.db_setup import create_session +from mealie.schema.recipe import Recipe from PIL import Image -from sqlalchemy.orm.session import Session logger = root_logger.get_logger() @@ -20,11 +18,7 @@ class ImageSizes: def get_image_sizes(org_img: Path, min_img: Path, tiny_img: Path) -> ImageSizes: - return ImageSizes( - org=sizeof_fmt(org_img), - min=sizeof_fmt(min_img), - tiny=sizeof_fmt(tiny_img), - ) + return ImageSizes(org=sizeof_fmt(org_img), min=sizeof_fmt(min_img), tiny=sizeof_fmt(tiny_img)) def minify_image(image_file: Path) -> ImageSizes: @@ -110,28 +104,9 @@ def move_all_images(): if new_file.is_file(): new_file.unlink() image_file.rename(new_file) - - -def validate_slugs_in_database(session: Session = None): - def check_image_path(image_name: str, slug_path: str) -> bool: - existing_path: Path = app_dirs.IMG_DIR.joinpath(image_name) - slug_path: Path = app_dirs.IMG_DIR.joinpath(slug_path) - - if existing_path.is_dir(): - slug_path.rename(existing_path) - else: - logger.info("No Image Found") - - session = session or create_session() - all_recipes = db.recipes.get_all(session) - - slugs_and_images = [(x.slug, x.image) for x in all_recipes] - - for slug, image in slugs_and_images: - image_slug = image.split(".")[0] # Remove Extension - if slug != image_slug: - logger.info(f"{slug}, Doesn't Match '{image_slug}'") - check_image_path(image, slug) + if image_file.is_dir(): + slug = image_file.name + image_file.rename(Recipe(slug=slug).image_dir) def migrate_images(): @@ -139,7 +114,7 @@ def migrate_images(): move_all_images() - for image in app_dirs.IMG_DIR.glob("*/original.*"): + for image in app_dirs.RECIPE_DATA_DIR.glob("**/original.*"): minify_image(image) @@ -148,4 +123,3 @@ def migrate_images(): if __name__ == "__main__": migrate_images() - validate_slugs_in_database() diff --git a/mealie/services/recipe/__init__.py b/mealie/services/recipe/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/mealie/services/recipe/asset.py b/mealie/services/recipe/asset.py new file mode 100644 index 000000000..7d5b80fdb --- /dev/null +++ b/mealie/services/recipe/asset.py @@ -0,0 +1,22 @@ +from pathlib import Path + +from mealie.core.config import app_dirs +from mealie.core.root_logger import get_logger +from mealie.schema.recipe import Recipe + +logger = get_logger() + + +def check_asset(original_slug, recipe: Recipe) -> Path: + if original_slug == recipe.slug: + return recipe.assets + + current_dir = app_dirs.RECIPE_DATA_DIR.joinpath(original_slug) + + try: + current_dir.rename(recipe.directory) + except FileNotFoundError: + logger.error(f"Recipe Directory not Found: {original_slug}") + logger.info(f"Renaming Recipe Directory: {original_slug} -> {recipe.slug}") + + return current_dir.absolute()