diff --git a/glance-system/glance-helper.yaml b/glance-system/glance-helper.yaml index 0f17182..58b5caf 100644 --- a/glance-system/glance-helper.yaml +++ b/glance-system/glance-helper.yaml @@ -246,9 +246,9 @@ data: return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST) # ================================ - # Tandoor "Meal of the Day" + # Tandoor "Meal of the Day" - Enhanced Version # ================================ - from datetime import datetime, timezone + from datetime import datetime, timezone, timedelta from zoneinfo import ZoneInfo from urllib.parse import urlencode from fastapi import HTTPException, Query @@ -265,6 +265,9 @@ data: DATA_DIR.mkdir(parents=True, exist_ok=True) COOKED_PATH = DATA_DIR / "tandoor-cooked.json" PICKS_PATH = DATA_DIR / "tandoor-picks.json" + + # Cooldown: don't suggest recipes cooked within this many days + TANDOOR_COOLDOWN_DAYS = int(os.getenv("TANDOOR_COOLDOWN_DAYS", "14")) def _today_str() -> str: """YYYY-MM-DD in Europe/Budapest if tzdata exists, else UTC.""" @@ -304,7 +307,6 @@ data: def _fetch_all_recipes() -> list[dict]: if not TANDOOR_INTERNAL_URL: raise HTTPException(status_code=500, detail="TANDOOR_INTERNAL_URL is not set") - # Prefer paginated /api/recipe/ url = f"{TANDOOR_INTERNAL_URL}/api/recipe/?page_size=200" out = [] for _ in range(100): # safety @@ -317,31 +319,115 @@ data: url = j.get("next") if not url: break - # `next` may be absolute internal URL already; keep as-is. return out - def _compute_daily_picks(count: int) -> dict: + # ================================ + # Cooked History Management + # ================================ + # Structure: {"": ["2026-01-22", "2026-01-15", ...], ...} + + def _load_cooked_history() -> dict[str, list[str]]: + """Load cooked history. Returns {recipe_id_str: [date_str, ...]}""" + return _load_json(COOKED_PATH, {}) + + def _save_cooked_history(history: dict[str, list[str]]) -> None: + _save_json(COOKED_PATH, history) + + def _get_cooked_count(history: dict, recipe_id: int) -> int: + """Get total times a recipe has been cooked.""" + return len(history.get(str(recipe_id), [])) + + def _get_recently_cooked_ids(history: dict, days: int) -> set[int]: + """Get recipe IDs cooked within the last N days.""" + if days <= 0: + return set() + try: + tz = ZoneInfo("Europe/Budapest") + except Exception: + tz = timezone.utc + + cutoff = (datetime.now(tz) - timedelta(days=days)).strftime("%Y-%m-%d") + recent = set() + for rid_str, dates in history.items(): + for d in dates: + if d >= cutoff: + recent.add(int(rid_str)) + break + return recent + + def _record_cooked(recipe_id: int) -> dict[str, list[str]]: + """Record that a recipe was cooked today. Returns updated history.""" + history = _load_cooked_history() + rid_str = str(recipe_id) today = _today_str() - cooked = _load_json(COOKED_PATH, {}) - cooked_today = set(cooked.get(today, [])) + + if rid_str not in history: + history[rid_str] = [] + + # Avoid duplicate entries for same day + if today not in history[rid_str]: + history[rid_str].append(today) + # Keep sorted (newest first) for easier reading + history[rid_str] = sorted(history[rid_str], reverse=True) + + _save_cooked_history(history) + return history + + # ================================ + # Daily Picks Logic + # ================================ + + def _compute_daily_picks(count: int, cooldown_days: int) -> dict: + today = _today_str() + history = _load_cooked_history() + recently_cooked = _get_recently_cooked_ids(history, cooldown_days) picks_doc = _load_json(PICKS_PATH, {}) - if picks_doc.get("date") == today and isinstance(picks_doc.get("ids"), list) and picks_doc.get("count") == count: - return picks_doc # stable for the day + + # Check if we need to regenerate picks + needs_regen = False + if picks_doc.get("date") != today: + needs_regen = True + elif picks_doc.get("count") != count: + needs_regen = True + elif picks_doc.get("cooldown") != cooldown_days: + needs_regen = True + else: + # Check if we can add more picks (new recipes added mid-day) + current_ids = set(picks_doc.get("ids", [])) + if len(current_ids) < count: + recipes = _fetch_all_recipes() + available = [r.get("id") for r in recipes + if r.get("id") is not None + and r.get("id") not in current_ids + and r.get("id") not in recently_cooked] + if available: + needs_regen = True + + if not needs_regen: + return picks_doc + # Generate new picks recipes = _fetch_all_recipes() - # Make a stable deterministic base random seed once, but still "true daily random": - # created once per day and stored. - available = [r for r in recipes if r.get("id") not in cooked_today] + available = [r for r in recipes + if r.get("id") is not None + and r.get("id") not in recently_cooked] + if not available: - available = recipes[:] # fallback: if everything cooked, ignore filter + # Fallback: if everything is in cooldown, ignore cooldown + available = recipes[:] chosen = random.sample(available, k=min(count, len(available))) if available else [] - picks_doc = {"date": today, "count": count, "ids": [r.get("id") for r in chosen if r.get("id") is not None]} + picks_doc = { + "date": today, + "count": count, + "cooldown": cooldown_days, + "ids": [r.get("id") for r in chosen if r.get("id") is not None] + } _save_json(PICKS_PATH, picks_doc) return picks_doc - def _build_items_from_ids(ids: list[int]) -> tuple[list[dict], int]: + def _build_items_from_ids(ids: list[int], history: dict) -> tuple[list[dict], int]: recipes = _fetch_all_recipes() by_id = {r.get("id"): r for r in recipes if r.get("id") is not None} items = [] @@ -355,45 +441,88 @@ data: if GLANCE_HELPER_KEY: cook_params["key"] = GLANCE_HELPER_KEY cook_url = f"{GLANCE_HELPER_PUBLIC_URL}/tandoor/cook?{urlencode(cook_params)}" if GLANCE_HELPER_PUBLIC_URL else "" - items.append({"id": rid, "name": r.get("name") or "", "image": img, "url": url, "cook_url": cook_url}) + + items.append({ + "id": rid, + "name": r.get("name") or "", + "image": img, + "url": url, + "cook_url": cook_url, + "cooked_count": _get_cooked_count(history, rid) + }) return items, len(recipes) @APP.get("/tandoor/daily") - def tandoor_daily(count: int = Query(3, ge=1, le=10)): - picks_doc = _compute_daily_picks(count) + def tandoor_daily(count: int = Query(3, ge=1, le=10), cooldown: int = Query(None, ge=0, le=365)): + """ + Get daily meal suggestions. + + - count: Number of suggestions (1-10, default 3) + - cooldown: Days to exclude recently cooked meals (0-365, default from env TANDOOR_COOLDOWN_DAYS or 14) + """ + cooldown_days = cooldown if cooldown is not None else TANDOOR_COOLDOWN_DAYS + picks_doc = _compute_daily_picks(count, cooldown_days) ids = picks_doc.get("ids", []) - items, total = _build_items_from_ids(ids) - return {"date": picks_doc.get("date"), "total_recipes": total, "items": items} + history = _load_cooked_history() + items, total = _build_items_from_ids(ids, history) + return { + "date": picks_doc.get("date"), + "total_recipes": total, + "cooldown_days": cooldown_days, + "items": items + } @APP.get("/tandoor/cook") def tandoor_cook(id: int, key: str | None = None, redirect: str | None = None): + """Mark a recipe as cooked today.""" if GLANCE_HELPER_KEY and key != GLANCE_HELPER_KEY: raise HTTPException(status_code=403, detail="Invalid key") today = _today_str() - cooked = _load_json(COOKED_PATH, {}) - cooked_today = set(cooked.get(today, [])) - cooked_today.add(id) - cooked[today] = sorted(list(cooked_today)) - _save_json(COOKED_PATH, cooked) + + # Record in permanent history + history = _record_cooked(id) + recently_cooked = _get_recently_cooked_ids(history, TANDOOR_COOLDOWN_DAYS) - # Remove from today's picks and try to refill to keep count + # Remove from today's picks and try to refill picks = _load_json(PICKS_PATH, {}) if picks.get("date") == today and isinstance(picks.get("ids"), list): ids = [x for x in picks["ids"] if x != id] target = int(picks.get("count") or len(ids)) + if len(ids) < target: recipes = _fetch_all_recipes() - avoid = set(ids) | cooked_today - candidates = [r.get("id") for r in recipes if r.get("id") is not None and r.get("id") not in avoid] + avoid = set(ids) | recently_cooked + candidates = [r.get("id") for r in recipes + if r.get("id") is not None + and r.get("id") not in avoid] if candidates: ids.append(random.choice(candidates)) + picks["ids"] = ids[:target] _save_json(PICKS_PATH, picks) if redirect: return RedirectResponse(url=redirect, status_code=302) - return {"ok": True, "date": today, "cooked": id} + + return { + "ok": True, + "date": today, + "cooked_id": id, + "cooked_total": _get_cooked_count(history, id) + } + + @APP.get("/tandoor/history") + def tandoor_history(): + """Get full cooked history (for debugging/stats).""" + history = _load_cooked_history() + return { + "recipes": { + int(k): {"dates": v, "count": len(v)} + for k, v in history.items() + }, + "total_cooks": sum(len(v) for v in history.values()) + } # ================================ # Version Checker - Container Version Monitoring diff --git a/glance-system/glance-kisfenyo.yaml b/glance-system/glance-kisfenyo.yaml index 1452661..88aea02 100644 --- a/glance-system/glance-kisfenyo.yaml +++ b/glance-system/glance-kisfenyo.yaml @@ -783,6 +783,7 @@ data: url: http://glance-helper.glance-system.svc.cluster.local:8000/tandoor/daily parameters: count: 3 + cooldown: 14 # Optional: override default cooldown days options: tandoor_url: https://tandoor.dooplex.hu @@ -798,15 +799,16 @@ data: .mw-meta { opacity:.65; font-size:12px; display:flex; justify-content:space-between; align-items:center; } .mw-box { position:relative; border-radius:14px; background:rgba(255,255,255,0.04); box-shadow:0 0 0 1px rgba(255,255,255,0.06) inset; overflow:hidden; } .mw-box input { display:none; } - .mw-track { display:flex; transition:transform 0.3s ease; } + .mw-track { display:flex; } .mw-s { min-width:100%; flex-shrink:0; } .mw-img { height:150px; background:rgba(0,0,0,0.15); overflow:hidden; } .mw-img img { width:100%; height:100%; object-fit:cover; } .mw-noimg { height:150px; display:flex; align-items:center; justify-content:center; opacity:.5; font-size:12px; } - .mw-name { padding:10px 12px 6px; font-weight:700; opacity:.95; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } + .mw-name { padding:10px 12px 4px; font-weight:700; opacity:.95; line-height:1.3; } + .mw-stats { padding:0 12px 6px; font-size:11px; opacity:.5; } .mw-acts { padding:0 12px 10px; display:flex; gap:10px; opacity:.8; font-size:12px; } .mw-acts a, .mw-link { text-decoration:none; color:inherit; display:block; } - /* Arrows - hidden by default, shown on hover */ + /* Arrows */ .mw-p, .mw-n { position:absolute; top:75px; transform:translateY(-50%); width:26px; height:26px; border-radius:50%; background:rgba(0,0,0,0.6); color:#fff; align-items:center; justify-content:center; font-size:11px; cursor:pointer; z-index:5; display:none; } .mw-p { left:6px; } .mw-n { right:6px; } @@ -833,12 +835,13 @@ data:
{{ range $i, $_ := $items }}{{ end }}
- {{ range $r := $items }}{{ $img := $r.String "image" }}{{ $url := $r.String "url" }}{{ $cook := $r.String "cook_url" }} + {{ range $r := $items }}{{ $img := $r.String "image" }}{{ $url := $r.String "url" }}{{ $cook := $r.String "cook_url" }}{{ $cooked := $r.Int "cooked_count" }}
{{ if $img }}{{ else }}
No image
{{ end }}
{{ $r.String "name" }}
+ {{ if gt $cooked 0 }}
Cooked {{ $cooked }}× before
{{ end }}
{{ end }}