feat: add Mealie upload target (Tandoor replacement)
Build & Push Docker Image / test-and-build (push) Successful in 1m44s
Build & Push Docker Image / test-and-build (push) Successful in 1m44s
InstaChef could only upload to Tandoor. Add a Mealie provider and a
RECIPE_TARGET selector so the queue uploads to Mealie (cook.sal.giize.com).
- mealie-config.ts / mealie.ts: two-step create (POST /api/recipes -> slug,
PATCH /api/recipes/{slug}) + multipart image PUT. Ingredients sent as
free-text notes (Mealie PATCH rejects structured unit/food without an id).
- queue/config.ts: `target` ('mealie'|'tandoor', defaults to mealie when
MEALIE_TOKEN set) + mealie config block.
- QueueProcessor.uploadPhase: branch on target; store mealieSlug.
- QueueManager/types: build public recipe URL, add mealieSlug/recipeUrl.
- tests: buildMealiePatch mapping (free-text notes, placeholder step).
- docs/mealie-adapter-scope.md + .env.example MEALIE_* vars.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b4764ce887
commit
ba5e397788
@@ -51,6 +51,22 @@ TANDOOR_SPACE=1
|
||||
# Tandoor API token (generate in Tandoor settings)
|
||||
TANDOOR_TOKEN=tda_f9460962_c8dd_491a_a716_f11b0b3288f0
|
||||
|
||||
# ==============================================================================
|
||||
# Mealie Integration (Tandoor replacement)
|
||||
# ==============================================================================
|
||||
# Which cookbook the queue uploads to: 'mealie' or 'tandoor'.
|
||||
# Defaults to 'mealie' when MEALIE_TOKEN is set, else 'tandoor'.
|
||||
RECIPE_TARGET=mealie
|
||||
|
||||
# Mealie server URL (no trailing slash). Public host, or internal http://mealie:9000
|
||||
MEALIE_SERVER_URL=https://cook.sal.giize.com
|
||||
|
||||
# Mealie API token (Mealie: Settings > API Tokens)
|
||||
MEALIE_TOKEN=
|
||||
|
||||
# Group slug used to build recipe view URLs (self-host default: home)
|
||||
MEALIE_GROUP_SLUG=home
|
||||
|
||||
# ==============================================================================
|
||||
# Push Notifications (OPTIONAL)
|
||||
# ==============================================================================
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# InstaChef → Mealie adapter — scope
|
||||
|
||||
InstaChef currently uploads extracted recipes to **Tandoor** only. This doc scopes
|
||||
adding a **Mealie** upload target so InstaChep can post into the new Mealie cookbook
|
||||
(Tandoor replacement). Based on reading the current code + probing Mealie's live API.
|
||||
|
||||
## Current Tandoor integration (what exists)
|
||||
|
||||
| Concern | Location |
|
||||
|---|---|
|
||||
| Config (env → object) | `src/lib/server/tandoor-config.ts` — `{enabled, serverUrl, space, token}` from `TANDOOR_*` |
|
||||
| API client | `src/lib/server/tandoor.ts` — `uploadRecipeWithIngredientsDTO()` (POST `/api/recipe/`) + `uploadRecipeImage()` (PUT `/api/recipe/{id}/image/`, multipart) |
|
||||
| Call site | `src/lib/server/queue/QueueProcessor.ts::uploadPhase()` (~L321), gated by `queueConfig.tandoor.enabled` |
|
||||
| View URL | `src/lib/server/queue/QueueManager.ts` (~L250) builds `${serverUrl}/view/recipe/{id}` |
|
||||
| HTTP route | `src/routes/api/tandoor/+server.ts` (manual single-recipe upload) |
|
||||
| Source model | `ExtractedRecipe { name, servings, description, ingredients:[{item,amount,unit}], steps:string[], image }` |
|
||||
|
||||
Auth: `Authorization: Bearer <token>` (same header Mealie uses — good).
|
||||
|
||||
## Mealie API contract (verified against the running instance)
|
||||
|
||||
Auth is **identical in shape**: long-lived bearer token (mint in UI → Settings → API Tokens,
|
||||
or already minted for InstaChef on this box — see the seed result). No login/space concept.
|
||||
|
||||
Recipe create is a **two-step** flow (unlike Tandoor's single POST):
|
||||
|
||||
1. `POST /api/recipes` body `{"name": "<name>"}` → returns the **slug** (a JSON string).
|
||||
2. `PATCH /api/recipes/{slug}` body:
|
||||
```json
|
||||
{
|
||||
"description": "...",
|
||||
"recipeServings": 4,
|
||||
"recipeYield": "4 servings",
|
||||
"orgURL": "<source url>",
|
||||
"recipeIngredient": [ { "note": "300 g calamari" }, ... ],
|
||||
"recipeInstructions": [ { "text": "Step text..." }, ... ]
|
||||
}
|
||||
```
|
||||
3. Image: `PUT /api/recipes/{slug}/image` — **multipart/form-data**, fields `image` (file bytes)
|
||||
+ `extension` ("jpg"). (Alt: `POST /api/recipes/{slug}/image` with `{"url": "..."}` to let
|
||||
Mealie scrape a remote URL — handy for direct IG image URLs, skips the download.)
|
||||
|
||||
### Critical gotcha (cost us during seeding)
|
||||
Mealie's PATCH validation **rejects structured `unit`/`food` objects unless they carry an `id`**
|
||||
(`ValueError: Expected 'id' to be provided for unit`). Do **not** send `{food:{name},unit:{name}}`.
|
||||
Send each ingredient as a **free-text line** in `note` (compose `"<amount> <unit> <item>"`).
|
||||
This is simpler *and* maps directly from `ExtractedRecipe.ingredients[{item,amount,unit}]`.
|
||||
(Confirmed: 84/84 recipes imported clean once switched to free-text notes.)
|
||||
|
||||
### Mapping: ExtractedRecipe → Mealie
|
||||
- `name` → `name` (create step)
|
||||
- `description` → `description`
|
||||
- `servings` → `recipeServings` (number) and `recipeYield` = `"{n} servings"`
|
||||
- `steps[]` → `recipeInstructions[{text}]` (keep the existing "placeholder step when empty" behavior from `buildTandoorRecipeDTO`)
|
||||
- `ingredients[{item,amount,unit}]` → `recipeIngredient[{ note: join(amount, unit, item) }]`
|
||||
(drop empty amounts like `q.b.` gracefully — just omit the number)
|
||||
- `image` (http(s) URL or base64 data URL) → multipart upload (reuse the download/base64 logic
|
||||
already in `uploadRecipeImage`), OR pass the URL to the scrape endpoint when it's a plain URL.
|
||||
|
||||
## Changes required (files)
|
||||
|
||||
1. **New** `src/lib/server/mealie-config.ts` — `{enabled, serverUrl, token}` from `MEALIE_*`.
|
||||
2. **New** `src/lib/server/mealie.ts` — `uploadRecipeToMealie(recipe): {success, slug?, imageUrl?, error?}`
|
||||
and `uploadRecipeImageMealie(slug, imageUrl)`. Reuse the image download/base64/data-url helpers
|
||||
from `tandoor.ts` (extract them to `src/lib/server/image.ts` to share, or duplicate — small).
|
||||
3. **Edit** `src/lib/server/queue/config.ts` — add a `mealie` block + a target selector,
|
||||
e.g. `RECIPE_TARGET = 'tandoor' | 'mealie' | 'both'` (default `tandoor` for back-compat).
|
||||
4. **Edit** `QueueProcessor.uploadPhase()` — branch on the selected target(s); on `mealie`, call
|
||||
the new client; store the resulting slug. Keep image-failure-is-non-fatal behavior.
|
||||
5. **Edit** `QueueManager.ts` (~L250) — build the Mealie view URL. **Confirm the exact path**
|
||||
against your Mealie version (recent Mealie: `/g/{groupSlug}/r/{slug}`); grab it from the
|
||||
PATCH/GET response rather than hardcoding if possible.
|
||||
6. **Edit** `src/routes/api/tandoor/+server.ts` — optionally generalize to `/api/upload` or add a
|
||||
sibling `/api/mealie/+server.ts` mirroring it.
|
||||
7. **Tests** — mirror `src/tests/tandoor-api.spec.ts` → `mealie-api.spec.ts` (mapping + free-text
|
||||
ingredient assertion + the "no structured unit object" guard).
|
||||
|
||||
### New env (add to `.env` / `.env.example` / README)
|
||||
```
|
||||
MEALIE_ENABLED=true
|
||||
MEALIE_SERVER_URL=https://mealie.gsalucci.cloud # no trailing slash needed; config strips it
|
||||
MEALIE_TOKEN=<long-lived bearer token>
|
||||
RECIPE_TARGET=mealie # or 'both' during migration, or 'tandoor'
|
||||
```
|
||||
|
||||
## Recommended shape
|
||||
Introduce a tiny `RecipeTarget` interface — `{ uploadRecipe(recipe), uploadImage(ref, img), viewUrl(ref) }`
|
||||
— with `tandoor` and `mealie` impls, selected by `RECIPE_TARGET`. Lets you run **both** during the
|
||||
cutover (belt-and-suspenders) then flip to `mealie` only. Low risk: queue/image/notify plumbing is
|
||||
untouched; this is a new provider mirroring `tandoor.ts` with a *simpler* payload.
|
||||
|
||||
## Effort
|
||||
~1 new client (~120 lines, most logic already exists in `tandoor.ts`) + ~40 lines of wiring +
|
||||
one test file. Half a day incl. tests. No schema/queue changes.
|
||||
|
||||
## Note on the target
|
||||
Mealie instance is live on this box (internal `proxy` net, container `mealie:9000`, published web
|
||||
later via Cloudflare tunnel at `mealie.gsalucci.cloud`). The 84 rescued Tandoor recipes + Elena's
|
||||
user + an InstaChef API token are already seeded — so the moment this adapter lands, InstaChef writes
|
||||
straight into the populated cookbook.
|
||||
@@ -0,0 +1,14 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
/**
|
||||
* Server-side environment configuration for Mealie integration.
|
||||
* Mirrors tandoor-config.ts. Set these in your .env / stack env:
|
||||
* MEALIE_SERVER_URL e.g. https://cook.sal.giize.com (or internal http://mealie:9000)
|
||||
* MEALIE_TOKEN long-lived API token (Mealie: Settings > API Tokens)
|
||||
* MEALIE_GROUP_SLUG defaults to "home" (used to build recipe view URLs)
|
||||
*/
|
||||
export const mealieConfig = {
|
||||
enabled: env.MEALIE_ENABLED === 'true' || !!env.MEALIE_TOKEN,
|
||||
serverUrl: (env.MEALIE_SERVER_URL || '').replace(/\/$/, ''),
|
||||
token: env.MEALIE_TOKEN || null,
|
||||
groupSlug: env.MEALIE_GROUP_SLUG || 'home'
|
||||
};
|
||||
@@ -0,0 +1,216 @@
|
||||
import { mealieConfig } from '$lib/server/mealie-config';
|
||||
import { logError } from './utils/logger';
|
||||
|
||||
/**
|
||||
* Mealie upload client — mirrors tandoor.ts but targets the Mealie API.
|
||||
*
|
||||
* Mealie recipe creation is a two-step flow:
|
||||
* 1. POST /api/recipes { name } -> returns the slug (JSON string)
|
||||
* 2. PATCH /api/recipes/{slug} { full recipe body }
|
||||
* Then image:
|
||||
* 3. PUT /api/recipes/{slug}/image (multipart: image + extension)
|
||||
*
|
||||
* IMPORTANT: Mealie's PATCH validation rejects structured unit/food objects unless they
|
||||
* carry an `id` (ValueError: Expected 'id' to be provided for unit). So ingredients are
|
||||
* sent as free-text lines in `note` — clean for a simple cookbook and a direct map from
|
||||
* the extracted { item, amount, unit } strings.
|
||||
*/
|
||||
|
||||
interface ExtractedRecipe {
|
||||
name: string;
|
||||
servings: number | null;
|
||||
description: string | null;
|
||||
ingredients: Array<{ item: string; amount: string; unit: string }> | null;
|
||||
steps: string[] | null;
|
||||
image?: string | null;
|
||||
}
|
||||
|
||||
/** Format a raw amount string to a compact number, or '' when absent/unparseable/zero. */
|
||||
function formatAmount(amountStr: string): string {
|
||||
if (!amountStr || typeof amountStr !== 'string') return '';
|
||||
const trimmed = amountStr.trim().toLowerCase();
|
||||
if (!trimmed || trimmed === 'q.b.' || trimmed === 'qb' || trimmed === 'to taste') return '';
|
||||
const m = trimmed.match(/^[\d.,]+/);
|
||||
if (!m) return amountStr.trim();
|
||||
const n = parseFloat(m[0].replace(',', '.'));
|
||||
if (isNaN(n) || n === 0) return '';
|
||||
return Number.isInteger(n) ? String(n) : String(n);
|
||||
}
|
||||
|
||||
/** Build a single free-text ingredient line: "<amount> <unit> <item>". */
|
||||
function ingredientLine(ing: { item: string; amount: string; unit: string }): string {
|
||||
const amount = formatAmount(ing.amount);
|
||||
const unit = (ing.unit || '').trim();
|
||||
const item = (ing.item || '').trim();
|
||||
return [amount, unit, item].filter(Boolean).join(' ').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an ExtractedRecipe to the Mealie PATCH body.
|
||||
* Exported for unit testing the mapping (no structured unit/food; free-text notes only).
|
||||
*/
|
||||
export function buildMealiePatch(recipe: ExtractedRecipe): {
|
||||
description: string;
|
||||
recipeServings: number;
|
||||
recipeYield: string;
|
||||
recipeIngredient: Array<{ note: string }>;
|
||||
recipeInstructions: Array<{ text: string }>;
|
||||
} {
|
||||
const steps = recipe.steps?.length
|
||||
? recipe.steps
|
||||
: ['Vedi la ricetta completa al link in bio.'];
|
||||
const recipeInstructions = steps
|
||||
.map((s) => (s || '').trim())
|
||||
.filter(Boolean)
|
||||
.map((text) => ({ text }));
|
||||
const recipeIngredient = (recipe.ingredients || [])
|
||||
.map(ingredientLine)
|
||||
.filter(Boolean)
|
||||
.map((note) => ({ note }));
|
||||
return {
|
||||
description: recipe.description || '',
|
||||
recipeServings: recipe.servings || 0,
|
||||
recipeYield: recipe.servings ? `${recipe.servings} servings` : '',
|
||||
recipeIngredient,
|
||||
recipeInstructions
|
||||
};
|
||||
}
|
||||
|
||||
async function mealieFetch<T>(
|
||||
path: string,
|
||||
options: Partial<RequestInit> = { method: 'GET' }
|
||||
): Promise<{ ok: boolean; data?: T; error?: string }> {
|
||||
const headers = new Headers({
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${mealieConfig.token}`
|
||||
});
|
||||
if (options.headers) {
|
||||
new Headers(options.headers).forEach((v, k) => headers.set(k, v));
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${mealieConfig.serverUrl}${path}`, { ...options, headers });
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
logError(`[Mealie] API Error ${response.status}: ${response.statusText}`, body);
|
||||
return { ok: false, error: `API Error ${response.status}: ${body.slice(0, 200)}` };
|
||||
}
|
||||
const text = await response.text();
|
||||
let data: T;
|
||||
try {
|
||||
data = JSON.parse(text) as T;
|
||||
} catch {
|
||||
data = text as unknown as T; // create endpoint returns a bare slug string
|
||||
}
|
||||
return { ok: true, data };
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'Unknown error';
|
||||
logError('[Mealie] Fetch error', error);
|
||||
return { ok: false, error: `Fetch error: ${msg}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a recipe in Mealie (create shell -> patch body).
|
||||
* Returns the recipe slug on success.
|
||||
*/
|
||||
export async function uploadRecipeToMealie(
|
||||
recipe: ExtractedRecipe
|
||||
): Promise<{ success: boolean; slug?: string; imageUrl?: string; error?: string }> {
|
||||
if (!mealieConfig.token) {
|
||||
return { success: false, error: 'MEALIE_TOKEN not set' };
|
||||
}
|
||||
// 1) create shell -> slug
|
||||
const created = await mealieFetch<string>('/api/recipes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name: recipe.name })
|
||||
});
|
||||
if (!created.ok || !created.data) {
|
||||
return { success: false, error: `Failed to create recipe: ${created.error}` };
|
||||
}
|
||||
const slug = String(created.data).replace(/^"|"$/g, '').trim();
|
||||
// 2) patch full body
|
||||
const patched = await mealieFetch(`/api/recipes/${slug}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(buildMealiePatch(recipe))
|
||||
});
|
||||
if (!patched.ok) {
|
||||
return { success: false, error: `Failed to update recipe: ${patched.error}` };
|
||||
}
|
||||
return { success: true, slug, imageUrl: recipe.image || undefined };
|
||||
}
|
||||
|
||||
// --- image helpers (mirrors tandoor.ts) ---
|
||||
function isDataUrl(url: string): boolean {
|
||||
return url.startsWith('data:');
|
||||
}
|
||||
function isDirectUrl(url: string): boolean {
|
||||
return url.startsWith('http://') || url.startsWith('https://');
|
||||
}
|
||||
function parseDataUrl(dataUrl: string): { mimeType: string; base64Data: string } | null {
|
||||
const m = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
|
||||
return m ? { mimeType: m[1], base64Data: m[2] } : null;
|
||||
}
|
||||
function extFromMime(mime: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'image/jpeg': 'jpg',
|
||||
'image/jpg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/gif': 'gif',
|
||||
'image/webp': 'webp'
|
||||
};
|
||||
return map[mime] || 'jpg';
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload an image to a Mealie recipe. Accepts an http(s) URL or a base64 data URL.
|
||||
* Image failures are non-fatal to the caller (recipe already exists).
|
||||
*/
|
||||
export async function uploadMealieImage(
|
||||
slug: string,
|
||||
imageUrl: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
if (!mealieConfig.token) return { success: false, error: 'MEALIE_TOKEN not set' };
|
||||
try {
|
||||
let buffer: Buffer;
|
||||
let mimeType: string;
|
||||
if (isDataUrl(imageUrl)) {
|
||||
const parsed = parseDataUrl(imageUrl);
|
||||
if (!parsed) return { success: false, error: 'Invalid data URL' };
|
||||
buffer = Buffer.from(parsed.base64Data, 'base64');
|
||||
mimeType = parsed.mimeType;
|
||||
} else if (isDirectUrl(imageUrl)) {
|
||||
const resp = await fetch(imageUrl);
|
||||
if (!resp.ok) return { success: false, error: `Download failed: ${resp.status}` };
|
||||
mimeType = (resp.headers.get('content-type') || 'image/jpeg').split(';')[0].trim();
|
||||
buffer = Buffer.from(await resp.arrayBuffer());
|
||||
} else {
|
||||
return { success: false, error: 'Unsupported image source' };
|
||||
}
|
||||
const ext = extFromMime(mimeType);
|
||||
const file = new File([new Uint8Array(buffer)], `recipe-image.${ext}`, { type: mimeType });
|
||||
const form = new FormData();
|
||||
form.append('image', file);
|
||||
form.append('extension', ext);
|
||||
const resp = await fetch(`${mealieConfig.serverUrl}/api/recipes/${slug}/image`, {
|
||||
method: 'PUT',
|
||||
headers: { Authorization: `Bearer ${mealieConfig.token}` }, // let fetch set multipart boundary
|
||||
body: form
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const t = await resp.text().catch(() => resp.statusText);
|
||||
return { success: false, error: `Image upload failed (${resp.status}): ${t.slice(0, 150)}` };
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'Unknown error';
|
||||
logError('[Mealie] Image upload exception', error);
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a public recipe URL for the Mealie UI. */
|
||||
export function mealieRecipeUrl(slug: string): string | null {
|
||||
if (!mealieConfig.serverUrl) return null;
|
||||
return `${mealieConfig.serverUrl}/g/${mealieConfig.groupSlug}/r/${slug}`;
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { tandoorConfig } from '$lib/server/tandoor-config';
|
||||
import { mealieRecipeUrl } from '$lib/server/mealie';
|
||||
import { logError } from '../utils/logger';
|
||||
import type { QueueItem, QueueItemStatus, QueueStatusUpdate, QueueUpdateCallback } from './types';
|
||||
|
||||
@@ -224,7 +225,8 @@ export class QueueManager {
|
||||
data?.extractedText ||
|
||||
data?.thumbnail !== undefined ||
|
||||
data?.recipe ||
|
||||
data?.tandoorRecipeId
|
||||
data?.tandoorRecipeId ||
|
||||
data?.mealieSlug
|
||||
) {
|
||||
if (!item.results) {
|
||||
item.results = {};
|
||||
@@ -249,8 +251,15 @@ export class QueueManager {
|
||||
// Construct Tandoor URL
|
||||
if (tandoorConfig.serverUrl) {
|
||||
item.results.tandoorUrl = `${tandoorConfig.serverUrl}/view/recipe/${data.tandoorRecipeId}`;
|
||||
item.results.recipeUrl = item.results.tandoorUrl;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.mealieSlug) {
|
||||
item.results.mealieSlug = data.mealieSlug;
|
||||
const url = mealieRecipeUrl(data.mealieSlug);
|
||||
if (url) item.results.recipeUrl = url;
|
||||
}
|
||||
}
|
||||
|
||||
if (data?.error) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { extractTextAndThumbnail as extractWithPlaywright } from '$lib/server/ex
|
||||
import { extractTextAndThumbnail as extractWithYtDlp } from '$lib/server/instagram-extractor';
|
||||
import { extractRecipe } from '$lib/server/parser';
|
||||
import { uploadRecipeWithIngredientsDTO, uploadRecipeImage } from '$lib/server/tandoor';
|
||||
import { uploadRecipeToMealie, uploadMealieImage } from '$lib/server/mealie';
|
||||
import { pushNotificationService } from '$lib/server/notifications/PushNotificationService';
|
||||
import { queueConfig } from './config';
|
||||
import { logError } from '../utils/logger';
|
||||
@@ -319,15 +320,18 @@ export class QueueProcessor {
|
||||
* @throws Error if Tandoor upload fails
|
||||
*/
|
||||
private async uploadPhase(item: QueueItem): Promise<void> {
|
||||
// Check if Tandoor is enabled
|
||||
if (!queueConfig.tandoor.enabled) {
|
||||
// Skip if Tandoor not configured
|
||||
const target = queueConfig.target;
|
||||
const targetEnabled =
|
||||
target === 'mealie' ? queueConfig.mealie.enabled : queueConfig.tandoor.enabled;
|
||||
|
||||
// Skip if the selected cookbook is not configured
|
||||
if (!targetEnabled) {
|
||||
queueManager.addProgressEvent(item.id, {
|
||||
type: 'status',
|
||||
message: 'Tandoor not configured, skipping upload',
|
||||
message: `${target} not configured, skipping upload`,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
console.log(`[QueueProcessor] Tandoor not configured, skipping: ${item.id}`);
|
||||
console.log(`[QueueProcessor] ${target} not configured, skipping: ${item.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -341,52 +345,93 @@ export class QueueProcessor {
|
||||
|
||||
queueManager.addProgressEvent(item.id, {
|
||||
type: 'status',
|
||||
message: 'Uploading recipe to Tandoor...',
|
||||
message: `Uploading recipe to ${target}...`,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
console.log(`[QueueProcessor] Uploading to Tandoor: ${item.id}`);
|
||||
console.log(`[QueueProcessor] Uploading to ${target}: ${item.id}`);
|
||||
|
||||
// Upload recipe
|
||||
const result = await uploadRecipeWithIngredientsDTO(item.recipe);
|
||||
if (target === 'mealie') {
|
||||
// --- Mealie ---
|
||||
const result = await uploadRecipeToMealie(item.recipe);
|
||||
if (!result.success) {
|
||||
throw new Error(`Mealie upload failed: ${result.error}`);
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(`Tandoor upload failed: ${result.error}`);
|
||||
}
|
||||
|
||||
queueManager.updateStatus(item.id, 'in_progress', {
|
||||
phase: 'uploading',
|
||||
tandoorRecipeId: result.recipeId
|
||||
});
|
||||
|
||||
console.log(`[QueueProcessor] ✓ Recipe uploaded: ${item.id} → Tandoor #${result.recipeId}`);
|
||||
|
||||
// Upload image if available
|
||||
if (result.recipeId && result.imageUrl) {
|
||||
queueManager.addProgressEvent(item.id, {
|
||||
type: 'status',
|
||||
message: 'Uploading recipe image to Tandoor...',
|
||||
timestamp: new Date().toISOString()
|
||||
queueManager.updateStatus(item.id, 'in_progress', {
|
||||
phase: 'uploading',
|
||||
mealieSlug: result.slug
|
||||
});
|
||||
|
||||
const imageResult = await uploadRecipeImage(result.recipeId, result.imageUrl);
|
||||
console.log(`[QueueProcessor] ✓ Recipe uploaded: ${item.id} → Mealie /${result.slug}`);
|
||||
|
||||
if (!imageResult.success) {
|
||||
// Image upload failure is recoverable - log but don't fail
|
||||
console.warn(`[QueueProcessor] Image upload failed for ${item.id}: ${imageResult.error}`);
|
||||
if (result.slug && result.imageUrl) {
|
||||
queueManager.addProgressEvent(item.id, {
|
||||
type: 'status',
|
||||
message: `Image upload failed: ${imageResult.error}`,
|
||||
message: 'Uploading recipe image to Mealie...',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} else {
|
||||
console.log(`[QueueProcessor] ✓ Image uploaded: ${item.id}`);
|
||||
|
||||
const imageResult = await uploadMealieImage(result.slug, result.imageUrl);
|
||||
if (!imageResult.success) {
|
||||
// Image upload failure is recoverable - log but don't fail
|
||||
console.warn(
|
||||
`[QueueProcessor] Image upload failed for ${item.id}: ${imageResult.error}`
|
||||
);
|
||||
queueManager.addProgressEvent(item.id, {
|
||||
type: 'status',
|
||||
message: `Image upload failed: ${imageResult.error}`,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} else {
|
||||
console.log(`[QueueProcessor] ✓ Image uploaded: ${item.id}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// --- Tandoor ---
|
||||
const result = await uploadRecipeWithIngredientsDTO(item.recipe);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(`Tandoor upload failed: ${result.error}`);
|
||||
}
|
||||
|
||||
queueManager.updateStatus(item.id, 'in_progress', {
|
||||
phase: 'uploading',
|
||||
tandoorRecipeId: result.recipeId
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[QueueProcessor] ✓ Recipe uploaded: ${item.id} → Tandoor #${result.recipeId}`
|
||||
);
|
||||
|
||||
if (result.recipeId && result.imageUrl) {
|
||||
queueManager.addProgressEvent(item.id, {
|
||||
type: 'status',
|
||||
message: 'Uploading recipe image to Tandoor...',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
const imageResult = await uploadRecipeImage(result.recipeId, result.imageUrl);
|
||||
|
||||
if (!imageResult.success) {
|
||||
// Image upload failure is recoverable - log but don't fail
|
||||
console.warn(
|
||||
`[QueueProcessor] Image upload failed for ${item.id}: ${imageResult.error}`
|
||||
);
|
||||
queueManager.addProgressEvent(item.id, {
|
||||
type: 'status',
|
||||
message: `Image upload failed: ${imageResult.error}`,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} else {
|
||||
console.log(`[QueueProcessor] ✓ Image uploaded: ${item.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
queueManager.addProgressEvent(item.id, {
|
||||
type: 'status',
|
||||
message: 'Tandoor upload completed',
|
||||
message: `${target} upload completed`,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,6 +20,12 @@ export const queueConfig = {
|
||||
/** Maximum retry attempts for failed items (default: 3) */
|
||||
maxRetries: parseInt(env.QUEUE_MAX_RETRIES || '3', 10),
|
||||
|
||||
/**
|
||||
* Which cookbook the queue uploads to. Defaults to 'mealie' when a Mealie token is
|
||||
* present, else 'tandoor' (back-compat). Override with RECIPE_TARGET.
|
||||
*/
|
||||
target: (env.RECIPE_TARGET || (env.MEALIE_TOKEN ? 'mealie' : 'tandoor')) as 'tandoor' | 'mealie',
|
||||
|
||||
/** Tandoor integration settings */
|
||||
tandoor: {
|
||||
enabled: !!env.TANDOOR_TOKEN,
|
||||
@@ -27,6 +33,14 @@ export const queueConfig = {
|
||||
serverUrl: env.TANDOOR_SERVER_URL || null
|
||||
},
|
||||
|
||||
/** Mealie integration settings */
|
||||
mealie: {
|
||||
enabled: env.MEALIE_ENABLED === 'true' || !!env.MEALIE_TOKEN,
|
||||
token: env.MEALIE_TOKEN || null,
|
||||
serverUrl: env.MEALIE_SERVER_URL || null,
|
||||
groupSlug: env.MEALIE_GROUP_SLUG || 'home'
|
||||
},
|
||||
|
||||
/** Web Push notification settings */
|
||||
push: {
|
||||
vapidPublicKey:
|
||||
|
||||
@@ -57,6 +57,10 @@ export interface ProcessingResults {
|
||||
tandoorRecipeId?: number;
|
||||
/** Tandoor recipe URL (constructed from ID) */
|
||||
tandoorUrl?: string;
|
||||
/** Mealie recipe slug */
|
||||
mealieSlug?: string;
|
||||
/** Recipe URL in the target cookbook (Tandoor or Mealie) */
|
||||
recipeUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildMealiePatch } from '$lib/server/mealie';
|
||||
|
||||
describe('buildMealiePatch (Tandoor → Mealie mapping)', () => {
|
||||
const base = {
|
||||
name: 'Test Recipe',
|
||||
servings: 4,
|
||||
description: 'A test recipe',
|
||||
ingredients: [
|
||||
{ item: 'calamari', amount: '300', unit: 'g' },
|
||||
{ item: 'sale', amount: 'q.b.', unit: '' },
|
||||
{ item: 'patate', amount: '2', unit: '' }
|
||||
],
|
||||
steps: ['Boil water', 'Add potatoes'],
|
||||
image: null
|
||||
};
|
||||
|
||||
it('maps ingredients to free-text notes (no structured unit/food objects)', () => {
|
||||
const patch = buildMealiePatch(base);
|
||||
expect(patch.recipeIngredient).toEqual([
|
||||
{ note: '300 g calamari' },
|
||||
{ note: 'sale' }, // unparseable amount + no unit collapses to the item
|
||||
{ note: '2 patate' }
|
||||
]);
|
||||
// Guard: never emit structured unit/food (Mealie PATCH rejects them without an id)
|
||||
for (const ing of patch.recipeIngredient) {
|
||||
expect(ing).not.toHaveProperty('unit');
|
||||
expect(ing).not.toHaveProperty('food');
|
||||
expect(ing).not.toHaveProperty('quantity');
|
||||
}
|
||||
});
|
||||
|
||||
it('maps steps to recipeInstructions and servings', () => {
|
||||
const patch = buildMealiePatch(base);
|
||||
expect(patch.recipeInstructions).toEqual([{ text: 'Boil water' }, { text: 'Add potatoes' }]);
|
||||
expect(patch.recipeServings).toBe(4);
|
||||
expect(patch.recipeYield).toBe('4 servings');
|
||||
expect(patch.description).toBe('A test recipe');
|
||||
});
|
||||
|
||||
it('inserts a placeholder step when none are provided', () => {
|
||||
const patch = buildMealiePatch({ ...base, steps: null });
|
||||
expect(patch.recipeInstructions.length).toBe(1);
|
||||
expect(patch.recipeInstructions[0].text).toContain('link in bio');
|
||||
});
|
||||
|
||||
it('handles empty ingredients/servings gracefully', () => {
|
||||
const patch = buildMealiePatch({ ...base, ingredients: null, servings: null });
|
||||
expect(patch.recipeIngredient).toEqual([]);
|
||||
expect(patch.recipeServings).toBe(0);
|
||||
expect(patch.recipeYield).toBe('');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user