Repository: mealie-recipes/mealie (python)

Issue:
English plural handling grammatically incorrect with ingredient amounts enabled

### First Check

- [x] This is not a feature request.
- [x] I added a very descriptive title to this issue (title field is above this).
- [x] I used the GitHub search to find a similar issue and didn't find it.
- [x] I searched the Mealie documentation, with the integrated search.
- [x] I already read the docs and didn't find an answer.
- [x] This issue can be replicated on the demo site (https://demo.mealie.io/).

### What is the issue you are experiencing?

When using ingredient amounts, the plural handling could be improved. I can't speak for other languages, but in English if I have a unit of an ingredient other than whole ingredients (slice of onion vs onion), only the unit should be pluralized (i.e. 2 slices onion vs. 2 onions). I cannot think of a case where this isn't true.

You may introduce an example like bunches of scallions, but for cases like this, the singular still has the mass noun (i.e. 1 bunch of scallions is correct), so scallions has to be a separate ingredient entry anyway.

Again, I don't know how this works in other languages, but I don't think it should be hard to at least add a setting that pluralizes the unit if a unit is present, and otherwise pluralizes the ingredient. Otherwise we're left with the default behavior that yields 2 cups sugars.

### Steps to Reproduce

N/A

### Please provide relevant logs

N/A

### Mealie Version

_No response_

### Deployment

Docker (Linux)

### Additional Deployment Details

_No response_


Patch:
diff --git a/dev/code-generation/gen_ts_locales.py b/dev/code-generation/gen_ts_locales.py
index 4208ca84441..b2d3ab3e40e 100644
--- a/dev/code-generation/gen_ts_locales.py
+++ b/dev/code-generation/gen_ts_locales.py
@@ -1,6 +1,7 @@
+import json
 import os
 import pathlib
-from dataclasses import dataclass
+import re
 from pathlib import Path
 
 import dotenv
@@ -10,6 +11,7 @@
 from requests import Response
 from utils import CodeDest, CodeKeys, inject_inline, log
 
+from mealie.lang.locale_config import LOCALE_CONFIG, LocalePluralFoodHandling, LocaleTextDirection
 from mealie.schema._mealie import MealieModel
 
 BASE = pathlib.Path(__file__).parent.parent.parent
@@ -17,57 +19,6 @@
 API_KEY = dotenv.get_key(BASE / ".env", "CROWDIN_API_KEY") or os.environ.get("CROWDIN_API_KEY", "")
 
 
-@dataclass
-class LocaleData:
-    name: str
-    dir: str = "ltr"
-
-
-LOCALE_DATA: dict[str, LocaleData] = {
-    "af-ZA": LocaleData(name="Afrikaans (Afrikaans)"),
-    "ar-SA": LocaleData(name="العربية (Arabic)", dir="rtl"),
-    "bg-BG": LocaleData(name="Български (Bulgarian)"),
-    "ca-ES": LocaleData(name="Català (Catalan)"),
-    "cs-CZ": LocaleData(name="Čeština (Czech)"),
-    "da-DK": LocaleData(name="Dansk (Danish)"),
-    "de-DE": LocaleData(name="Deutsch (German)"),
-    "el-GR": LocaleData(name="Ελληνικά (Greek)"),
-    "en-GB": LocaleData(name="British English"),
-    "en-US": LocaleData(name="American English"),
-    "es-ES": LocaleData(name="Español (Spanish)"),
-    "et-EE": LocaleData(name="Eesti (Estonian)"),
-    "fi-FI": LocaleData(name="Suomi (Finnish)"),
-    "fr-BE": LocaleData(name="Belge (Belgian)"),
-    "fr-CA": LocaleData(name="Français canadien (Canadian French)"),
-    "fr-FR": LocaleData(name="Français (French)"),
-    "gl-ES": LocaleData(name="Galego (Galician)"),
-    "he-IL": LocaleData(name="עברית (Hebrew)", dir="rtl"),
-    "hr-HR": LocaleData(name="Hrvatski (Croatian)"),
-    "hu-HU": LocaleData(name="Magyar (Hungarian)"),
-    "is-IS": LocaleData(name="Íslenska (Icelandic)"),
-    "it-IT": LocaleData(name="Italiano (Italian)"),
-    "ja-JP": LocaleData(name="日本語 (Japanese)"),
-    "ko-KR": LocaleData(name="한국어 (Korean)"),
-    "lt-LT": LocaleData(name="Lietuvių (Lithuanian)"),
-    "lv-LV": LocaleData(name="Latviešu (Latvian)"),
-    "nl-NL": LocaleData(name="Nederlands (Dutch)"),
-    "no-NO": LocaleData(name="Norsk (Norwegian)"),
-    "pl-PL": LocaleData(name="Polski (Polish)"),
-    "pt-BR": LocaleData(name="Português do Brasil (Brazilian Portuguese)"),
-    "pt-PT": LocaleData(name="Português (Portuguese)"),
-    "ro-RO": LocaleData(name="Română (Romanian)"),
-    "ru-RU": LocaleData(name="Pусский (Russian)"),
-    "sk-SK": LocaleData(name="Slovenčina (Slovak)"),
-    "sl-SI": LocaleData(name="Slovenščina (Slovenian)"),
-    "sr-SP": LocaleData(name="српски (Serbian)"),
-    "sv-SE": LocaleData(name="Svenska (Swedish)"),
-    "tr-TR": LocaleData(name="Türkçe (Turkish)"),
-    "uk-UA": LocaleData(name="Українська (Ukrainian)"),
-    "vi-VN": LocaleData(name="Tiếng Việt (Vietnamese)"),
-    "zh-CN": LocaleData(name="简体中文 (Chinese simplified)"),
-    "zh-TW": LocaleData(name="繁體中文 (Chinese traditional)"),
-}
-
 LOCALE_TEMPLATE = """// This Code is auto generated by gen_ts_locales.py
 export const LOCALES = [{% for locale in locales %}
   {
@@ -75,6 +26,7 @@ class LocaleData:
     value: "{{ locale.locale }}",
     progress: {{ locale.progress }},
     dir: "{{ locale.dir }}",
+    pluralFoodHandling: "{{ locale.plural_food_handling }}",
   },{% endfor %}
 ];
 
@@ -87,10 +39,11 @@ class TargetLanguage(MealieModel):
     id: str
     name: str
     locale: str
-    dir: str = "ltr"
+    dir: LocaleTextDirection = LocaleTextDirection.LTR
+    plural_food_handling: LocalePluralFoodHandling = LocalePluralFoodHandling.ALWAYS
     threeLettersCode: str
     twoLettersCode: str
-    progress: float = 0.0
+    progress: int = 0
 
 
 class CrowdinApi:
@@ -117,43 +70,15 @@ def get_project(self) -> Response:
     def get_languages(self) -> list[TargetLanguage]:
         response = self.get_project()
         tls = response.json()["data"]["targetLanguages"]
+        return [TargetLanguage(**t) for t in tls]
 
-        models = [TargetLanguage(**t) for t in tls]
-
-        models.insert(
-            0,
-            TargetLanguage(
-                id="en-US",
-                name="English",
-                locale="en-US",
-                dir="ltr",
-                threeLettersCode="en",
-                twoLettersCode="en",
-                progress=100,
-            ),
-        )
-
-        progress: list[dict] = self.get_progress()["data"]
-
-        for model in models:
-            if model.locale in LOCALE_DATA:
-                locale_data = LOCALE_DATA[model.locale]
-                model.name = locale_data.name
-                model.dir = locale_data.dir
-
-            for p in progress:
-                if p["data"]["languageId"] == model.id:
-                    model.progress = p["data"]["translationProgress"]
-
-        models.sort(key=lambda x: x.locale, reverse=True)
-        return models
-
-    def get_progress(self) -> dict:
+    def get_progress(self) -> dict[str, int]:
         response = requests.get(
             f"https://api.crowdin.com/api/v2/projects/{self.project_id}/languages/progress?limit=500",
             headers=self.headers,
         )
-        return response.json()
+        data = response.json()["data"]
+        return {p["data"]["languageId"]: p["translationProgress"] for p in data}
 
 
 PROJECT_DIR = Path(__file__).parent.parent.parent
@@ -195,8 +120,8 @@ def inject_nuxt_values():
 
     all_langs = []
     for match in locales_dir.glob("*.json"):
-        match_data = LOCALE_DATA.get(match.stem)
-        match_dir = match_data.dir if match_data else "ltr"
+        match_data = LOCALE_CONFIG.get(match.stem)
+        match_dir = match_data.dir if match_data else LocaleTextDirection.LTR
 
         lang_string = f'{{ code: "{match.stem}", file: "{match.name.replace(".json", ".ts")}", dir: "{match_dir}" }},'
         all_langs.append(lang_string)
@@ -221,9 +146,82 @@ def inject_registration_validation_values():
     inject_inline(reg_valid, CodeKeys.nuxt_local_messages, all_langs)
 
 
+def _get_local_models() -> list[TargetLanguage]:
+    return [
+        TargetLanguage(
+            id=locale,
+            name=data.name,
+            locale=locale,
+            threeLettersCode=locale.split("-")[-1],
+            twoLettersCode=locale.split("-")[-1],
+        )
+        for locale, data in LOCALE_CONFIG.items()
+        if locale != "en-US"  # Crowdin doesn't include this, so we manually inject it later
+    ]
+
+
+def _get_local_progress() -> dict[str, int]:
+    with open(CodeDest.use_locales) as f:
+        content = f.read()
+
+    # Extract the array content between [ and ]
+    match = re.search(r"export const LOCALES = (\[.*?\]);", content, re.DOTALL)
+    if not match:
+        raise ValueError("Could not find LOCALES array in file")
+
+    # Convert JS to JSON
+    array_content = match.group(1)
+
+    # Replace unquoted keys with quoted keys for valid JSON
+    # This converts: { name: "value" } to { "name": "value" }
+    json_str = re.sub(r"([,\{\s])([a-zA-Z_][a-zA-Z0-9_]*)\s*:", r'\1"\2":', array_content)
+
+    # Remove trailing commas before } and ]
+    json_str = re.sub(r",(\s*[}\]])", r"\1", json_str)
+
+    locales = json.loads(json_str)
+    return {locale["value"]: locale["progress"] for locale in locales}
+
+
+def get_languages() -> list[TargetLanguage]:
+    if API_KEY:
+        api = CrowdinApi(None)
+        models = api.get_languages()
+        progress = api.get_progress()
+    else:
+        log.warning("CROWDIN_API_KEY is not set, using local lanugages instead")
+        log.warning("DOUBLE CHECK the output!!! Do not overwrite with bad local locale data!")
+        models = _get_local_models()
+        progress = _get_local_progress()
+
+    models.insert(
+        0,
+        TargetLanguage(
+            id="en-US",
+            name="English",
+            locale="en-US",
+            dir=LocaleTextDirection.LTR,
+            plural_food_handling=LocalePluralFoodHandling.WITHOUT_UNIT,
+            threeLettersCode="en",
+            twoLettersCode="en",
+            progress=100,
+        ),
+    )
+
+    for model in models:
+        if model.locale in LOCALE_CONFIG:
+            locale_data = LOCALE_CONFIG[model.locale]
+            model.name = locale_data.name
+            model.dir = locale_data.dir
+            model.plural_food_handling = locale_data.plural_food_handling
+            model.progress = progress.get(model.id, model.progress)
+
+    models.sort(key=lambda x: x.locale, reverse=True)
+    return models
+
+
 def generate_locales_ts_file():
-    api = CrowdinApi(None)
-    models = api.get_languages()
+    models = get_languages()
     tmpl = Template(LOCALE_TEMPLATE)
     rendered = tmpl.render(locales=models)
 
@@ -233,10 +231,6 @@ def generate_locales_ts_file():
 
 
 def main():
-    if API_KEY is None or API_KEY == "":
-        log.error("CROWDIN_API_KEY is not set")
-        return
-
     generate_locales_ts_file()
     inject_nuxt_values()
     inject_registration_validation_values()
diff --git a/frontend/composables/recipes/use-recipe-ingredients.ts b/frontend/composables/recipes/use-recipe-ingredients.ts
index e6557654ee9..bae49b5389b 100644
--- a/frontend/composables/recipes/use-recipe-ingredients.ts
+++ b/frontend/composables/recipes/use-recipe-ingredients.ts
@@ -1,5 +1,6 @@
 import DOMPurify from "isomorphic-dompurify";
 import { useFraction } from "./use-fraction";
+import { useLocales } from "../use-locales";
 import type { CreateIngredientFood, CreateIngredientUnit, IngredientFood, IngredientUnit, Recipe, RecipeIngredient } from "~/lib/api/types/recipe";
 
 const { frac } = useFraction();
@@ -56,10 +57,33 @@ type ParsedIngredientText = {
   recipeLink?: string;
 };
 
+function shouldUsePluralFood(quantity: number, hasUnit: boolean, pluralFoodHandling: string): boolean {
+  if (quantity && quantity <= 1) {
+    return false;
+  }
+
+  switch (pluralFoodHandling) {
+    case "always":
+      return true;
+    case "without-unit":
+      return !(quantity && hasUnit);
+    case "never":
+      return false;
+
+    default:
+      // same as without-unit
+      return !(quantity && hasUnit);
+  }
+}
+
 export function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true, groupSlug?: string): ParsedIngredientText {
+  const { locales, locale } = useLocales();
+  const filteredLocales = locales.filter(lc => lc.value === locale.value);
+  const pluralFoodHandling = filteredLocales.length ? filteredLocales[0].pluralFoodHandling : "without-unit";
+
   const { quantity, food, unit, note, referencedRecipe } = ingredient;
   const usePluralUnit = quantity !== undefined && ((quantity || 0) * scale > 1 || (quantity || 0) * scale === 0);
-  const usePluralFood = (!quantity) || quantity * scale > 1;
+  const usePluralFood = shouldUsePluralFood((quantity || 0) * scale, !!unit, pluralFoodHandling);
 
   let returnQty = "";
 
diff --git a/frontend/composables/use-locales/available-locales.ts b/frontend/composables/use-locales/available-locales.ts
index 74878307472..54cd01d4845 100644
--- a/frontend/composables/use-locales/available-locales.ts
+++ b/frontend/composables/use-locales/available-locales.ts
@@ -5,251 +5,293 @@ export const LOCALES = [
     value: "zh-TW",
     progress: 9,
     dir: "ltr",
+    pluralFoodHandling: "never",
   },
   {
     name: "简体中文 (Chinese simplified)",
     value: "zh-CN",
     progress: 38,
     dir: "ltr",
+    pluralFoodHandling: "never",
   },
   {
     name: "Tiếng Việt (Vietnamese)",
     value: "vi-VN",
     progress: 2,
     dir: "ltr",
+    pluralFoodHandling: "never",
   },
   {
     name: "Українська (Ukrainian)",
     value: "uk-UA",
     progress: 83,
     dir: "ltr",
+    pluralFoodHandling: "always",
   },
   {
     name: "Türkçe (Turkish)",
     value: "tr-TR",
     progress: 40,
     dir: "ltr",
+    pluralFoodHandling: "never",
   },
   {
     name: "Svenska (Swedish)",
     value: "sv-SE",
     progress: 61,
     dir: "ltr",
+    pluralFoodHandling: "always",
   },
   {
     name: "српски (Serbian)",
     value: "sr-SP",
     progress: 16,
     dir: "ltr",
+    pluralFoodHandling: "always",
   },
   {
     name: "Slovenščina (Slovenian)",
     value: "sl-SI",
     progress: 40,
     dir: "ltr",
+    pluralFoodHandling: "always",
   },
   {
     name: "Slovenčina (Slovak)",
     value: "sk-SK",
     progress: 47,
     dir: "ltr",
+    pluralFoodHandling: "always",
   },
   {
     name: "Pусский (Russian)",
     value: "ru-RU",
     progress: 44,
     dir: "ltr",
+    pluralFoodHandling: "always",
   },
   {
     name: "Română (Romanian)",
     value: "ro-RO",
     progress: 44,
     dir: "ltr",
+    pluralFoodHandling: "always",
   },
   {
     name: "Português (Portuguese)",
     value: "pt-PT",
     progress: 39,
     dir: "ltr",
+    pluralFoodHandling: "always",
   },
   {
     name: "Português do Brasil (Brazilian Portuguese)",
     value: "pt-BR",
     progress: 46,
     dir: "ltr",
+    pluralFoodHandling: "always",
   },
   {
     name: "Polski (Polish)",
     value: "pl-PL",
     progress: 49,
     dir: "ltr",
+    pluralFoodHandling: "always",
   },
   {
     name: "Norsk (Norwegian)",
     value: "no-NO",
     progress: 42,
     dir: "ltr",
+    pluralFoodHandling: "always",
   },
   {
     name: "Nederlands (Dutch)",
     value: "nl-NL",
     progress: 60,
     dir: "ltr",
+    pluralFoodHandling: "always",
   },
   {
     name: "Latviešu (Latvian)",
     value: "lv-LV",
     progress: 35,
     dir: "ltr",
+    pluralFoodHandling: "always",
   },
   {
     name: "Lietuvių (Lithuanian)",
     value: "lt-LT",
     progress: 30,
     dir: "ltr",
+    pluralFoodHandling: "always",
   },
   {
     name: "한국어 (Korean)",
     value: "ko-KR",
     progress: 38,
     dir: "ltr",
+    pluralFoodHandling: "never",
   },
   {
     name: "日本語 (Japanese)",
     value: "ja-JP",
     progress: 36,
     dir: "ltr",
+    pluralFoodHandling: "never",
   },
   {
     name: "Italiano (Italian)",
     value: "it-IT",
     progress: 52,
     dir: "ltr",
+    pluralFoodHandling: "always",
   },
   {
     name: "Íslenska (Icelandic)",
     value: "is-IS",
     progress: 43,
     dir: "ltr",
+    pluralFoodHandling: "always",
   },
   {
     name: "Magyar (Hungarian)",
     value: "hu-HU",
     progress: 46,
     dir: "ltr",
+    pluralFoodHandling: "always",
   },
   {
     name: "Hrvatski (Croatian)",
     value: "hr-HR",
     progress: 30,
     dir: "ltr",
+    pluralFoodHandling: "always",
   },
   {
     name: "עברית (Hebrew)",
     value: "he-IL",
     progress: 64,
     dir: "rtl",
+    pluralFoodHandling: "always",
   },
   {
     name: "Galego (Galician)",
     value: "gl-ES",
     progress: 38,
     dir: "ltr",
+    pluralFoodHandling: "always",
   },
   {
     name: "Français (French)",
     value: "fr-FR",
     progress: 67,
     dir: "ltr",
+    pluralFoodHandling: "always",
   },
   {
     name: "Français canadien (Canadian French)",
     value: "fr-CA",
     progress: 83,
     dir: "ltr",
+    pluralFoodHandling: "always",
   },
   {
     name: "Belge (Belgian)",
     value: "fr-BE",
     progress: 39,
     dir: "ltr",
+    pluralFoodHandling: "always",
   },
   {
     name: "Suomi (Finnish)",
     value: "fi-FI",
     progress: 40,
     dir: "ltr",
+    pluralFoodHandling: "always",
   },
   {
     name: "Eesti (Estonian)",
     value: "et-EE",
     progress: 45,
     dir: "ltr",
+    pluralFoodHandling: "always",
   },
   {
     name: "Español (Spanish)",
     value: "es-ES",
     progress: 46,
     dir: "ltr",
+    pluralFoodHandling: "always",
   },
   {
     name: "American English",
     value: "en-US",
-    progress: 100.0,
+    progress: 100,
     dir: "ltr",
+    pluralFoodHandling: "without-unit",
   },
   {
     name: "British English",
     value: "en-GB",
     progress: 42,
     dir: "ltr",
+    pluralFoodHandling: "without-unit",
   },
   {
     name: "Ελληνικά (Greek)",
     value: "el-GR",
     progress: 41,
     dir: "ltr",
+    pluralFoodHandling: "always",
   },
   {
     name: "Deutsch (German)",
     value: "de-DE",
     progress: 85,
     dir: "ltr",
+    pluralFoodHandling: "always",
   },
   {
     name: "Dansk (Danish)",
     value: "da-DK",
     progress: 65,
     dir: "ltr",
+    pluralFoodHandling: "always",
   },
   {
     name: "Čeština (Czech)",
     value: "cs-CZ",
     progress: 43,
     dir: "ltr",
+    pluralFoodHandling: "always",
   },
   {
     name: "Català (Catalan)",
     value: "ca-ES",
     progress: 40,
     dir: "ltr",
+    pluralFoodHandling: "always",
   },
   {
     name: "Български (Bulgarian)",
     value: "bg-BG",
     progress: 49,
     dir: "ltr",
+    pluralFoodHandling: "always",
   },
   {
     name: "العربية (Arabic)",
     value: "ar-SA",
     progress: 25,
     dir: "rtl",
+    pluralFoodHandling: "always",
   },
   {
     name: "Afrikaans (Afrikaans)",
     value: "af-ZA",
     progress: 26,
     dir: "ltr",
+    pluralFoodHandling: "always",
   },
 ];
diff --git a/frontend/composables/use-locales/use-locales.ts b/frontend/composables/use-locales/use-locales.ts
index 1287bf04755..124a136df21 100644
--- a/frontend/composables/use-locales/use-locales.ts
+++ b/frontend/composables/use-locales/use-locales.ts
@@ -1,8 +1,9 @@
 import type { LocaleObject } from "@nuxtjs/i18n";
 import { LOCALES } from "./available-locales";
+import { useGlobalI18n } from "../use-global-i18n";
 
 export const useLocales = () => {
-  const i18n = useI18n();
+  const i18n = useGlobalI18n();
   const { current: vuetifyLocale } = useLocale();
 
   const locale = computed<LocaleObject["code"]>({
diff --git a/mealie/app.py b/mealie/app.py
index 8ea6811a676..4309050a65a 100644
--- a/mealie/app.py
+++ b/mealie/app.py
@@ -20,6 +20,7 @@
 from mealie.core.config import get_app_settings
 from mealie.core.root_logger import get_logger
 from mealie.core.settings.static import APP_VERSION
+from mealie.middleware.locale_context import LocaleContextMiddleware
 from mealie.routes import router, spa, utility_routes
 from mealie.routes.handlers import register_debug_handler
 from mealie.routes.media import media_router
@@ -107,6 +108,7 @@ async def lifespan_fn(_: FastAPI) -> AsyncGenerator[None, None]:
 
 app.add_middleware(GZipMiddleware, minimum_size=1000)
 app.add_middleware(SessionMiddleware, secret_key=settings.SESSION_SECRET)
+app.add_middleware(LocaleContextMiddleware)
 
 if not settings.PRODUCTION:
     allowed_origins = ["http://localhost:3000"]
diff --git a/mealie/lang/locale_config.py b/mealie/lang/locale_config.py
new file mode 100644
index 00000000000..9ebef497fef
--- /dev/null
+++ b/mealie/lang/locale_config.py
@@ -0,0 +1,66 @@
+from dataclasses import dataclass
+from enum import StrEnum
+
+
+class LocaleTextDirection(StrEnum):
+    LTR = "ltr"
+    RTL = "rtl"
+
+
+class LocalePluralFoodHandling(StrEnum):
+    ALWAYS = "always"
+    WITHOUT_UNIT = "without-unit"
+    NEVER = "never"
+
+
+@dataclass
+class LocaleConfig:
+    name: str
+    dir: LocaleTextDirection = LocaleTextDirection.LTR
+    plural_food_handling: LocalePluralFoodHandling = LocalePluralFoodHandling.ALWAYS
+
+
+LOCALE_CONFIG: dict[str, LocaleConfig] = {
+    "af-ZA": LocaleConfig(name="Afrikaans (Afrikaans)"),
+    "ar-SA": LocaleConfig(name="العربية (Arabic)", dir=LocaleTextDirection.RTL),
+    "bg-BG": LocaleConfig(name="Български (Bulgarian)"),
+    "ca-ES": LocaleConfig(name="Català (Catalan)"),
+    "cs-CZ": LocaleConfig(name="Čeština (Czech)"),
+    "da-DK": LocaleConfig(name="Dansk (Danish)"),
+    "de-DE": LocaleConfig(name="Deutsch (German)"),
+    "el-GR": LocaleConfig(name="Ελληνικά (Greek)"),
+    "en-GB": LocaleConfig(name="British English", plural_food_handling=LocalePluralFoodHandling.WITHOUT_UNIT),
+    "en-US": LocaleConfig(name="American English", plural_food_handling=LocalePluralFoodHandling.WITHOUT_UNIT),
+    "es-ES": LocaleConfig(name="Español (Spanish)"),
+    "et-EE": LocaleConfig(name="Eesti (Estonian)"),
+    "fi-FI": LocaleConfig(name="Suomi (Finnish)"),
+    "fr-BE": LocaleConfig(name="Belge (Belgian)"),
+    "fr-CA": LocaleConfig(name="Français canadien (Canadian French)"),
+    "fr-FR": LocaleConfig(name="Français (French)"),
+    "gl-ES": LocaleConfig(name="Galego (Galician)"),
+    "he-IL": LocaleConfig(name="עברית (Hebrew)", dir=LocaleTextDirection.RTL),
+    "hr-HR": LocaleConfig(name="Hrvatski (Croatian)"),
+    "hu-HU": LocaleConfig(name="Magyar (Hungarian)"),
+    "is-IS": LocaleConfig(name="Íslenska (Icelandic)"),
+    "it-IT": LocaleConfig(name="Italiano (Italian)"),
+    "ja-JP": LocaleConfig(name="日本語 (Japanese)", plural_food_handling=LocalePluralFoodHandling.NEVER),
+    "ko-KR": LocaleConfig(name="한국어 (Korean)", plural_food_handling=LocalePluralFoodHandling.NEVER),
+    "lt-LT": LocaleConfig(name="Lietuvių (Lithuanian)"),
+    "lv-LV": LocaleConfig(name="Latviešu (Latvian)"),
+    "nl-NL": LocaleConfig(name="Nederlands (Dutch)"),
+    "no-NO": LocaleConfig(name="Norsk (Norwegian)"),
+    "pl-PL": LocaleConfig(name="Polski (Polish)"),
+    "pt-BR": LocaleConfig(name="Português do Brasil (Brazilian Portuguese)"),
+    "pt-PT": LocaleConfig(name="Português (Portuguese)"),
+    "ro-RO": LocaleConfig(name="Română (Romanian)"),
+    "ru-RU": LocaleConfig(name="Pусский (Russian)"),
+    "sk-SK": LocaleConfig(name="Slovenčina (Slovak)"),
+    "sl-SI": LocaleConfig(name="Slovenščina (Slovenian)"),
+    "sr-SP": LocaleConfig(name="српски (Serbian)"),
+    "sv-SE": LocaleConfig(name="Svenska (Swedish)"),
+    "tr-TR": LocaleConfig(name="Türkçe (Turkish)", plural_food_handling=LocalePluralFoodHandling.NEVER),
+    "uk-UA": LocaleConfig(name="Українська (Ukrainian)"),
+    "vi-VN": LocaleConfig(name="Tiếng Việt (Vietnamese)", plural_food_handling=LocalePluralFoodHandling.NEVER),
+    "zh-CN": LocaleConfig(name="简体中文 (Chinese simplified)", plural_food_handling=LocalePluralFoodHandling.NEVER),
+    "zh-TW": LocaleConfig(name="繁體中文 (Chinese traditional)", plural_food_handling=LocalePluralFoodHandling.NEVER),
+}
diff --git a/mealie/lang/providers.py b/mealie/lang/providers.py
index 67d1471f735..ddd2f44a5c0 100644
--- a/mealie/lang/providers.py
+++ b/mealie/lang/providers.py
@@ -1,10 +1,12 @@
 from abc import abstractmethod
+from contextvars import ContextVar
 from functools import lru_cache
 from pathlib import Path
 from typing import Protocol
 
 from fastapi import Header
 
+from mealie.lang.locale_config import LOCALE_CONFIG, LocaleConfig
 from mealie.pkgs import i18n
 
 CWD = Path(__file__).parent
@@ -17,6 +19,19 @@ def t(self, key, default=None, **kwargs) -> str:
         pass
 
 
+_locale_context: ContextVar[tuple[Translator, LocaleConfig] | None] = ContextVar("locale_context", default=None)
+
+
+def set_locale_context(translator: Translator, locale_config: LocaleConfig) -> None:
+    """Set the locale context for the current request"""
+    _locale_context.set((translator, locale_config))
+
+
+def get_locale_context() -> tuple[Translator, LocaleConfig] | None:
+    """Get the current locale context"""
+    return _locale_context.get()
+
+
 @lru_cache
 def _load_factory() -> i18n.ProviderFactory:
     return i18n.ProviderFactory(
@@ -25,12 +40,19 @@ def _load_factory() -> i18n.ProviderFactory:
     )
 
 
-def local_provider(accept_language: str | None = Header(None)) -> Translator:
+def get_locale_provider(accept_language: str | None = Header(None)) -> Translator:
     factory = _load_factory()
     accept_language = accept_language or "en-US"
     return factory.get(accept_language)
 
 
+def get_locale_config(accept_language: str | None = Header(None)) -> LocaleConfig:
+    if accept_language and accept_language in LOCALE_CONFIG:
+        return LOCALE_CONFIG[accept_language]
+    else:
+        return LOCALE_CONFIG["en-US"]
+
+
 @lru_cache
 def get_all_translations(key: str) -> dict[str, str]:
     factory = _load_factory()
diff --git a/mealie/middleware/__init__.py b/mealie/middleware/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/mealie/middleware/locale_context.py b/mealie/middleware/locale_context.py
new file mode 100644
index 00000000000..35772dcbf18
--- /dev/null
+++ b/mealie/middleware/locale_context.py
@@ -0,0 +1,22 @@
+from fastapi import Request
+from starlette.middleware.base import BaseHTTPMiddleware
+
+from mealie.lang.providers import get_locale_config, get_locale_provider, set_locale_context
+
+
+class LocaleContextMiddleware(BaseHTTPMiddleware):
+    """
+    Inject translator and locale config into context var.
+    This allows any part of the app to call get_locale_context, as long as it's within an HTTP request context.
+    """
+
+    async def dispatch(self, request: Request, call_next):
+        accept_language = request.headers.get("accept-language")
+        translator = get_locale_provider(accept_language)
+        locale_config = get_locale_config(accept_language)
+
+        # Set context for this request
+        set_locale_context(translator, locale_config)
+
+        response = await call_next(request)
+        return response
diff --git a/mealie/routes/_base/base_controllers.py b/mealie/routes/_base/base_controllers.py
index e7d4bd5e28b..5476e740720 100644
--- a/mealie/routes/_base/base_controllers.py
+++ b/mealie/routes/_base/base_controllers.py
@@ -17,7 +17,8 @@
 from mealie.core.settings.directories import AppDirectories
 from mealie.core.settings.settings import AppSettings
 from mealie.db.db_setup import generate_session
-from mealie.lang import local_provider
+from mealie.lang import get_locale_config, get_locale_provider
+from mealie.lang.locale_config import LocaleConfig
 from mealie.lang.providers import Translator
 from mealie.repos._utils import NOT_SET, NotSet
 from mealie.repos.all_repositories import AllRepositories, get_repositories
@@ -30,7 +31,8 @@
 
 class _BaseController(ABC):  # noqa: B024
     session: Session = Depends(generate_session)
-    translator: Translator = Depends(local_provider)
+    translator: Translator = Depends(get_locale_provider)
+    locale_config: LocaleConfig = Depends(get_locale_config)
 
     _repos: AllRepositories | None = None
     _logger: Logger | None = None
@@ -39,7 +41,7 @@ class _BaseController(ABC):  # noqa: B024
 
     @property
     def t(self):
-        return self.translator.t if self.translator else local_provider().t
+        return self.translator.t if self.translator else get_locale_provider().t
 
     @property
     def repos(self):
@@ -136,7 +138,7 @@ class BaseUserController(_BaseController):
 
     user: PrivateUser = Depends(get_current_user)
     integration_id: str = Depends(get_integration_id)
-    translator: Translator = Depends(local_provider)
+    translator: Translator = Depends(get_locale_provider)
 
     # Manual Cache
     _checks: OperationChecks
diff --git a/mealie/routes/auth/auth.py b/mealie/routes/auth/auth.py
index 91ab5897652..d5d961ef239 100644
--- a/mealie/routes/auth/auth.py
+++ b/mealie/routes/auth/auth.py
@@ -15,7 +15,7 @@
 from mealie.core.security.providers.openid_provider import OpenIDProvider
 from mealie.core.security.security import get_auth_provider
 from mealie.db.db_setup import generate_session
-from mealie.lang import local_provider
+from mealie.lang import get_locale_provider
 from mealie.routes._base.routers import UserAPIRouter
 from mealie.schema.user import PrivateUser
 from mealie.schema.user.auth import CredentialsRequestForm
@@ -155,5 +155,5 @@ async def logout(
 ):
     response.delete_cookie("mealie.access_token")
 
-    translator = local_provider(accept_language)
+    translator = get_locale_provider(accept_language)
     return {"message": translator.t("notifications.logged-out")}
diff --git a/mealie/schema/recipe/recipe_ingredient.py b/mealie/schema/recipe/recipe_ingredient.py
index aab12f88cab..cafdd58d801 100644
--- a/mealie/schema/recipe/recipe_ingredient.py
+++ b/mealie/schema/recipe/recipe_ingredient.py
@@ -11,6 +11,8 @@
 from sqlalchemy.orm.interfaces import LoaderOption
 
 from mealie.db.models.recipe import IngredientFoodModel
+from mealie.lang.locale_config import LocalePluralFoodHandling
+from mealie.lang.providers import get_locale_context
 from mealie.schema._mealie import MealieModel
 from mealie.schema._mealie.mealie_model import UpdatedAtField
 from mealie.schema._mealie.types import NoneFloat
@@ -239,18 +241,38 @@ def _format_unit_for_display(self) -> str:
 
         return unit_val
 
-    def _format_food_for_display(self) -> str:
+    def _format_food_for_display(self, plural_handling: LocalePluralFoodHandling) -> str:
         if not self.food:
             return ""
 
-        use_plural = (not self.quantity) or self.quantity > 1
+        if self.quantity and self.quantity <= 1:
+            use_plural = False
+        else:
+            match plural_handling:
+                case LocalePluralFoodHandling.NEVER:
+                    use_plural = False
+                case LocalePluralFoodHandling.WITHOUT_UNIT:
+                    # if quantity is zero then unit is not shown even if it's set
+                    use_plural = not (self.quantity and self.unit)
+                case LocalePluralFoodHandling.ALWAYS:
+                    use_plural = True
+                case _:
+                    use_plural = False
+
         if use_plural:
             return self.food.plural_name or self.food.name
         else:
             return self.food.name
 
     def _format_display(self) -> str:
-        components = []
+        locale_context = get_locale_context()
+        if locale_context:
+            _, locale_cfg = locale_context
+            plural_food_handling = locale_cfg.plural_food_handling
+        else:
+            plural_food_handling = LocalePluralFoodHandling.WITHOUT_UNIT
+
+        components: list[str] = []
 
         if self.quantity:
             components.append(self._format_quantity_for_display())
@@ -259,7 +281,7 @@ def _format_display(self) -> str:
             components.append(self._format_unit_for_display())
 
         if self.food:
-            components.append(self._format_food_for_display())
+            components.append(self._format_food_for_display(plural_food_handling))
 
         if self.note:
             components.append(self.note)
diff --git a/mealie/services/email/email_service.py b/mealie/services/email/email_service.py
index a66b3369cc6..fb42c96e4ef 100644
--- a/mealie/services/email/email_service.py
+++ b/mealie/services/email/email_service.py
@@ -4,7 +4,7 @@
 from pydantic import BaseModel
 
 from mealie.core.root_logger import get_logger
-from mealie.lang import local_provider
+from mealie.lang import get_locale_provider
 from mealie.lang.providers import Translator
 from mealie.services._base_service import BaseService
 
@@ -34,7 +34,7 @@ def __init__(self, sender: ABCEmailSender | None = None, locale: str | None = No
         self.templates_dir = CWD / "templates"
         self.default_template = self.templates_dir / "default.html"
         self.sender: ABCEmailSender = sender or DefaultEmailSender()
-        self.translator: Translator = local_provider(locale)
+        self.translator: Translator = get_locale_provider(locale)
 
         super().__init__()
 


Test patch:
diff --git a/frontend/composables/recipe-page/use-extract-ingredient-references.test.ts b/frontend/composables/recipe-page/use-extract-ingredient-references.test.ts
index 9721348cecd..fa2640bb4de 100644
--- a/frontend/composables/recipe-page/use-extract-ingredient-references.test.ts
+++ b/frontend/composables/recipe-page/use-extract-ingredient-references.test.ts
@@ -1,60 +1,71 @@
-import { describe, expect, test } from "vitest";
+import { describe, expect, test, vi, beforeEach } from "vitest";
 import { useExtractIngredientReferences } from "./use-extract-ingredient-references";
+import { useLocales } from "../use-locales";
+
+vi.mock("../use-locales");
 
 const punctuationMarks = ["*", "?", "/", "!", "**", "&", "."];
 
 describe("test use extract ingredient references", () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    vi.mocked(useLocales).mockReturnValue({
+      locales: [{ value: "en-US", pluralFoodHandling: "without-unit" }],
+      locale: { value: "en-US", pluralFoodHandling: "without-unit" },
+    } as any);
+  });
+
   test("when text empty return empty", () => {
-    const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "", true);
+    const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "");
     expect(result).toStrictEqual(new Set());
   });
 
   test("when and ingredient matches exactly and has a reference id, return the referenceId", () => {
-    const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onion", true);
+    const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onion");
 
     expect(result).toEqual(new Set(["123"]));
   });
 
   test.each(punctuationMarks)("when ingredient is suffixed by punctuation, return the referenceId", (suffix) => {
-    const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onion" + suffix, true);
+    const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onion" + suffix);
 
     expect(result).toEqual(new Set(["123"]));
   });
 
   test.each(punctuationMarks)("when ingredient is prefixed by punctuation, return the referenceId", (prefix) => {
-    const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing " + prefix + "Onion", true);
+    const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing " + prefix + "Onion");
     expect(result).toEqual(new Set(["123"]));
   });
 
   test("when ingredient is first on a multiline, return the referenceId", () => {
     const multilineSting = "lksjdlk\nOnion";
-    const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], multilineSting, true);
+    const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], multilineSting);
     expect(result).toEqual(new Set(["123"]));
   });
 
   test("when the ingredient matches partially exactly and has a reference id, return the referenceId", () => {
-    const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onions", true);
+    const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing Onions");
     expect(result).toEqual(new Set(["123"]));
   });
 
   test("when the ingredient matches with different casing and has a reference id, return the referenceId", () => {
-    const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing oNions", true);
+    const result = useExtractIngredientReferences([{ note: "Onions", referenceId: "123" }], [], "A sentence containing oNions");
     expect(result).toEqual(new Set(["123"]));
   });
 
   test("when no ingredients, return empty", () => {
-    const result = useExtractIngredientReferences([], [], "A sentence containing oNions", true);
+    const result = useExtractIngredientReferences([], [], "A sentence containing oNions");
     expect(result).toEqual(new Set());
   });
 
   test("when and ingredient matches but in the existing referenceIds, do not return the referenceId", () => {
-    const result = useExtractIngredientReferences([{ note: "Onion", referenceId: "123" }], ["123"], "A sentence containing Onion", true);
+    const result = useExtractIngredientReferences([{ note: "Onion", referenceId: "123" }], ["123"], "A sentence containing Onion");
 
     expect(result).toEqual(new Set());
   });
 
   test("when an word is 2 letter of shorter, it is ignored", () => {
-    const result = useExtractIngredientReferences([{ note: "Onion", referenceId: "123" }], [], "A sentence containing On", true);
+    const result = useExtractIngredientReferences([{ note: "Onion", referenceId: "123" }], [], "A sentence containing On");
 
     expect(result).toEqual(new Set());
   });
diff --git a/frontend/composables/recipes/use-recipe-ingredients.test.ts b/frontend/composables/recipes/use-recipe-ingredients.test.ts
index 398c42da397..5e98c7728cc 100644
--- a/frontend/composables/recipes/use-recipe-ingredients.test.ts
+++ b/frontend/composables/recipes/use-recipe-ingredients.test.ts
@@ -1,8 +1,19 @@
-import { describe, test, expect } from "vitest";
+import { describe, test, expect, vi, beforeEach } from "vitest";
 import { parseIngredientText } from "./use-recipe-ingredients";
 import type { RecipeIngredient } from "~/lib/api/types/recipe";
+import { useLocales } from "../use-locales";
+
+vi.mock("../use-locales");
 
 describe(parseIngredientText.name, () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    vi.mocked(useLocales).mockReturnValue({
+      locales: [{ value: "en-US", pluralFoodHandling: "always" }],
+      locale: { value: "en-US", pluralFoodHandling: "always" },
+    } as any);
+  });
+
   const createRecipeIngredient = (overrides: Partial<RecipeIngredient>): RecipeIngredient => ({
     quantity: 1,
     food: {
@@ -128,4 +139,64 @@ describe(parseIngredientText.name, () => {
 
     expect(parseIngredientText(ingredient, 2)).toEqual("2 tablespoons diced onions");
   });
+
+  test("plural handling: 'always' strategy uses plural food with unit", () => {
+    vi.mocked(useLocales).mockReturnValue({
+      locales: [{ value: "en-US", pluralFoodHandling: "always" }],
+      locale: { value: "en-US", pluralFoodHandling: "always" },
+    } as any);
+
+    const ingredient = createRecipeIngredient({
+      quantity: 2,
+      unit: { id: "1", name: "tablespoon", pluralName: "tablespoons", useAbbreviation: false },
+      food: { id: "1", name: "diced onion", pluralName: "diced onions" },
+    });
+
+    expect(parseIngredientText(ingredient)).toEqual("2 tablespoons diced onions");
+  });
+
+  test("plural handling: 'never' strategy never uses plural food", () => {
+    vi.mocked(useLocales).mockReturnValue({
+      locales: [{ value: "en-US", pluralFoodHandling: "never" }],
+      locale: { value: "en-US", pluralFoodHandling: "never" },
+    } as any);
+
+    const ingredient = createRecipeIngredient({
+      quantity: 2,
+      unit: { id: "1", name: "tablespoon", pluralName: "tablespoons", useAbbreviation: false },
+      food: { id: "1", name: "diced onion", pluralName: "diced onions" },
+    });
+
+    expect(parseIngredientText(ingredient)).toEqual("2 tablespoons diced onion");
+  });
+
+  test("plural handling: 'without-unit' strategy uses plural food without unit", () => {
+    vi.mocked(useLocales).mockReturnValue({
+      locales: [{ value: "en-US", pluralFoodHandling: "without-unit" }],
+      locale: { value: "en-US", pluralFoodHandling: "without-unit" },
+    } as any);
+
+    const ingredient = createRecipeIngredient({
+      quantity: 2,
+      food: { id: "1", name: "diced onion", pluralName: "diced onions" },
+      unit: undefined,
+    });
+
+    expect(parseIngredientText(ingredient)).toEqual("2 diced onions");
+  });
+
+  test("plural handling: 'without-unit' strategy uses singular food with unit", () => {
+    vi.mocked(useLocales).mockReturnValue({
+      locales: [{ value: "en-US", pluralFoodHandling: "without-unit" }],
+      locale: { value: "en-US", pluralFoodHandling: "without-unit" },
+    } as any);
+
+    const ingredient = createRecipeIngredient({
+      quantity: 2,
+      unit: { id: "1", name: "tablespoon", pluralName: "tablespoons", useAbbreviation: false },
+      food: { id: "1", name: "diced onion", pluralName: "diced onions" },
+    });
+
+    expect(parseIngredientText(ingredient)).toEqual("2 tablespoons diced onion");
+  });
 });
diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_ingredients.py b/tests/integration_tests/user_recipe_tests/test_recipe_ingredients.py
index 7ab0859ba39..1a810f0eac2 100644
--- a/tests/integration_tests/user_recipe_tests/test_recipe_ingredients.py
+++ b/tests/integration_tests/user_recipe_tests/test_recipe_ingredients.py
@@ -1,7 +1,10 @@
+from unittest.mock import MagicMock
 from uuid import uuid4
 
 import pytest
+from pytest import MonkeyPatch
 
+from mealie.lang.locale_config import LocalePluralFoodHandling
 from mealie.schema.recipe.recipe_ingredient import (
     IngredientFood,
     IngredientUnit,
@@ -10,13 +13,13 @@
 
 
 @pytest.mark.parametrize(
-    ["quantity", "quantity_display_decimal", "quantity_display_fraction", "expect_plural_unit", "expect_plural_food"],
+    ["quantity", "quantity_display_decimal", "quantity_display_fraction", "expect_plural_unit"],
     [
-        [0, "", "", False, True],
-        [0.5, "0.5", "¹/₂", False, False],
-        [1, "1", "1", False, False],
-        [1.5, "1.5", "1 ¹/₂", True, True],
-        [2, "2", "2", True, True],
+        [0, "", "", False],
+        [0.5, "0.5", "¹/₂", False],
+        [1, "1", "1", False],
+        [1.5, "1.5", "1 ¹/₂", True],
+        [2, "2", "2", True],
     ],
 )
 @pytest.mark.parametrize(
@@ -163,6 +166,14 @@
     ],
 )
 @pytest.mark.parametrize("note", ["very thin", "", None])
+@pytest.mark.parametrize(
+    "plural_handling",
+    [
+        LocalePluralFoodHandling.ALWAYS,
+        LocalePluralFoodHandling.NEVER,
+        LocalePluralFoodHandling.WITHOUT_UNIT,
+    ],
+)
 def test_ingredient_display(
     quantity: float | None,
     quantity_display_decimal: str,
@@ -172,12 +183,32 @@ def test_ingredient_display(
     note: str | None,
     expect_display_fraction: bool,
     expect_plural_unit: bool,
-    expect_plural_food: bool,
     expected_unit_singular_string: str,
     expected_unit_plural_string: str,
     expected_food_singular_string: str,
     expected_food_plural_string: str,
+    plural_handling: LocalePluralFoodHandling,
+    monkeypatch: MonkeyPatch,
 ):
+
+    mock_locale_cfg = MagicMock()
+    mock_locale_cfg.plural_food_handling = plural_handling
+    monkeypatch.setattr("mealie.schema.recipe.recipe_ingredient.get_locale_context", lambda: ("en-US", mock_locale_cfg))
+
+    # Calculate expect_plural_food based on plural_handling strategy
+    if quantity and quantity <= 1:
+        expect_plural_food = False
+    else:
+        match plural_handling:
+            case LocalePluralFoodHandling.NEVER:
+                expect_plural_food = False
+            case LocalePluralFoodHandling.WITHOUT_UNIT:
+                expect_plural_food = not (quantity and unit)
+            case LocalePluralFoodHandling.ALWAYS:
+                expect_plural_food = True
+            case _:
+                expect_plural_food = False
+
     expected_components = []
     if expect_display_fraction:
         expected_components.append(quantity_display_fraction)
diff --git a/tests/unit_tests/services_tests/scraper_tests/test_cleaner.py b/tests/unit_tests/services_tests/scraper_tests/test_cleaner.py
index 8574fc7071f..1c761aee60a 100644
--- a/tests/unit_tests/services_tests/scraper_tests/test_cleaner.py
+++ b/tests/unit_tests/services_tests/scraper_tests/test_cleaner.py
@@ -4,7 +4,7 @@
 
 import pytest
 
-from mealie.lang.providers import local_provider
+from mealie.lang.providers import get_locale_provider
 from mealie.services.scraper import cleaner
 from mealie.services.scraper.scraper_strategies import RecipeScraperOpenGraph
 from tests import data as test_data
@@ -38,7 +38,7 @@
 
 @pytest.mark.parametrize("json_file,num_steps", test_cleaner_data)
 def test_cleaner_clean(json_file: Path, num_steps):
-    translator = local_provider()
+    translator = get_locale_provider()
     recipe_data = cleaner.clean(json.loads(json_file.read_text()), translator)
     assert len(recipe_data.recipe_instructions or []) == num_steps
 
@@ -46,7 +46,7 @@ def test_cleaner_clean(json_file: Path, num_steps):
 def test_html_with_recipe_data():
     path = test_data.html_healthy_pasta_bake_60759
     url = "https://www.bbc.co.uk/food/recipes/healthy_pasta_bake_60759"
-    translator = local_provider()
+    translator = get_locale_provider()
 
     open_graph_strategy = RecipeScraperOpenGraph(url, translator)
 
diff --git a/tests/unit_tests/services_tests/scraper_tests/test_cleaner_parts.py b/tests/unit_tests/services_tests/scraper_tests/test_cleaner_parts.py
index 48019e98e68..ee6b689f676 100644
--- a/tests/unit_tests/services_tests/scraper_tests/test_cleaner_parts.py
+++ b/tests/unit_tests/services_tests/scraper_tests/test_cleaner_parts.py
@@ -4,7 +4,7 @@
 
 import pytest
 
-from mealie.lang.providers import local_provider
+from mealie.lang.providers import get_locale_provider
 from mealie.services.scraper import cleaner
 
 
@@ -477,7 +477,7 @@ def test_cleaner_clean_yield_amount(case: CleanerCase):
 
 @pytest.mark.parametrize("case", time_test_cases, ids=(x.test_id for x in time_test_cases))
 def test_cleaner_clean_time(case: CleanerCase):
-    translator = local_provider()
+    translator = get_locale_provider()
     result = cleaner.clean_time(case.input, translator)
     assert case.expected == result
 
@@ -681,5 +681,5 @@ def test_cleaner_clean_nutrition(case: CleanerCase):
     ],
 )
 def test_pretty_print_timedelta(t, max_components, max_decimal_places, expected):
-    translator = local_provider()
+    translator = get_locale_provider()
     assert cleaner.pretty_print_timedelta(t, translator, max_components, max_decimal_places) == expected
diff --git a/tests/unit_tests/test_exceptions.py b/tests/unit_tests/test_exceptions.py
index d20353ddde1..4fe0a3694e3 100644
--- a/tests/unit_tests/test_exceptions.py
+++ b/tests/unit_tests/test_exceptions.py
@@ -1,9 +1,9 @@
 from mealie.core import exceptions
-from mealie.lang import local_provider
+from mealie.lang import get_locale_provider
 
 
 def test_mealie_registered_exceptions() -> None:
-    provider = local_provider()
+    provider = get_locale_provider()
 
     lookup = exceptions.mealie_registered_exceptions(provider)
 
diff --git a/tests/unit_tests/test_recipe_parser.py b/tests/unit_tests/test_recipe_parser.py
index c2662344707..e19cca5a5d0 100644
--- a/tests/unit_tests/test_recipe_parser.py
+++ b/tests/unit_tests/test_recipe_parser.py
@@ -1,6 +1,6 @@
 import pytest
 
-from mealie.lang.providers import local_provider
+from mealie.lang.providers import get_locale_provider
 from mealie.services.scraper import scraper
 from tests.utils.recipe_data import RecipeSiteTestCase, get_recipe_test_cases
 
@@ -19,7 +19,7 @@
 @pytest.mark.parametrize("recipe_test_data", test_cases)
 @pytest.mark.asyncio
 async def test_recipe_parser(recipe_test_data: RecipeSiteTestCase):
-    translator = local_provider()
+    translator = get_locale_provider()
     recipe, _ = await scraper.create_from_html(recipe_test_data.url, translator)
 
     assert recipe.slug == recipe_test_data.expected_slug