Merge feature/RECIPE-0009_deduplication_notifications_ui
Some checks failed
Build & Push Docker Image / test-and-build (push) Failing after 31s
Some checks failed
Build & Push Docker Image / test-and-build (push) Failing after 31s
RECIPE-0009: deduplication, notifications, UI improvements - Iteration 0: deduplication, push notification subscribe, UI - Iteration 1: footer status bar, icon-only buttons - Iteration 2: ARIA-compliant footer icon contrast yt-dlp extractor: - Replace Playwright scraper with yt-dlp subprocess - Feature flag EXTRACTOR_BACKEND (ytdlp|playwright) - Dockerfile: add yt-dlp via pip3 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
40
.env.example
40
.env.example
@@ -7,15 +7,23 @@
|
|||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# LLM Configuration (REQUIRED)
|
# LLM Configuration (REQUIRED)
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# OpenAI-compatible API endpoint (OpenAI, LM Studio, Ollama, LiteLLM, etc.)
|
# OpenAI-compatible API endpoint. Production: llama-swap on ideapad.
|
||||||
OPENAI_BASE_URL=http://localhost:1234/v1
|
# llama-swap loads models on demand and unloads them after globalTTL (10 min).
|
||||||
|
OPENAI_BASE_URL=http://192.168.1.50:8080/v1
|
||||||
|
|
||||||
# API key for authentication
|
# API key for authentication (llama-swap accepts any non-empty value).
|
||||||
OPENAI_API_KEY=your-api-key-here
|
OPENAI_API_KEY=sk-llama-local
|
||||||
|
|
||||||
# Model to use for recipe extraction
|
# Model to use for recipe extraction. Available on the ideapad llama-swap stack:
|
||||||
# Examples: gpt-4o, gpt-4o-mini, llama-3.1, mistral, etc.
|
# gemma4-e4b-q6k (recommended — 4B, 65k ctx, 31 TPS)
|
||||||
LLM_MODEL=google/gemma-3-4b
|
# gemma4-e2b-q8_0 (faster — 2B, 65k ctx, 55 TPS)
|
||||||
|
# qwen3.5-4b-q8_0 (fallback — 22 TPS)
|
||||||
|
# phi4-mini-q8_0, granite-3.3-8b-q6k, plus larger MoE variants
|
||||||
|
LLM_MODEL=gemma4-e4b-q6k
|
||||||
|
|
||||||
|
# Per-request LLM timeout in ms. Must cover llama-swap cold-load (~5–30s for
|
||||||
|
# small models) plus generation time. Default 120000.
|
||||||
|
LLM_REQUEST_TIMEOUT_MS=120000
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# Queue Configuration (OPTIONAL)
|
# Queue Configuration (OPTIONAL)
|
||||||
@@ -55,9 +63,23 @@ VAPID_PUBLIC_KEY=BNextdcB_fQ0BVvyGioM5L8Tf9vKQjs-WnF-rUbnU8MdWIZQYfggIHxBnW21I-l
|
|||||||
VAPID_PRIVATE_KEY=JwxI_KcsBcehYcTOufMcbVWJjCq1QbH5FJmSyQuG680
|
VAPID_PRIVATE_KEY=JwxI_KcsBcehYcTOufMcbVWJjCq1QbH5FJmSyQuG680
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# Authentication Scheduler (OPTIONAL)
|
# Instagram Extraction Backend
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# Enable automatic Instagram authentication renewal
|
# Which extractor to use:
|
||||||
|
# ytdlp (default) — yt-dlp subprocess, stateless, Sablier-safe
|
||||||
|
# playwright — legacy Playwright stealth scraper, requires
|
||||||
|
# secrets/auth.json + AUTH_SCHEDULER_* below
|
||||||
|
EXTRACTOR_BACKEND=ytdlp
|
||||||
|
|
||||||
|
# Optional Netscape-format cookies file for login-walled reels.
|
||||||
|
# yt-dlp picks it up automatically if it exists at /app/secrets/cookies.txt
|
||||||
|
# (Docker) or ./secrets/cookies.txt (local). No automation; export from a
|
||||||
|
# browser when an extraction starts hitting login walls.
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Authentication Scheduler (LEGACY — only relevant when EXTRACTOR_BACKEND=playwright)
|
||||||
|
# ==============================================================================
|
||||||
|
# Enable automatic Instagram authentication renewal (Playwright backend only)
|
||||||
AUTH_SCHEDULER_ENABLED=true
|
AUTH_SCHEDULER_ENABLED=true
|
||||||
|
|
||||||
# Renewal interval in minutes (default: 720 = 12 hours)
|
# Renewal interval in minutes (default: 720 = 12 hours)
|
||||||
|
|||||||
44
.gitea/workflows/build.yml
Normal file
44
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
name: Build & Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-and-build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '24'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.sal.giize.com
|
||||||
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: git.sal.giize.com/mozempk/insta-recipe:latest
|
||||||
|
cache-from: type=registry,ref=git.sal.giize.com/mozempk/insta-recipe:buildcache
|
||||||
|
cache-to: type=registry,ref=git.sal.giize.com/mozempk/insta-recipe:buildcache,mode=max
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -18,6 +18,9 @@ Thumbs.db
|
|||||||
!.env.example
|
!.env.example
|
||||||
!.env.test
|
!.env.test
|
||||||
|
|
||||||
|
# Secrets (never commit cookies, tokens or credentials)
|
||||||
|
secrets/
|
||||||
|
|
||||||
# Local certificates
|
# Local certificates
|
||||||
.ssl/
|
.ssl/
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
FROM node:24-alpine
|
FROM node:24-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install Playwright system dependencies
|
# Install yt-dlp (primary Instagram extractor) and Playwright system dependencies (fallback)
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
|
python3 \
|
||||||
|
py3-pip \
|
||||||
chromium \
|
chromium \
|
||||||
font-liberation \
|
font-liberation \
|
||||||
font-noto \
|
font-noto \
|
||||||
font-noto-cjk
|
font-noto-cjk && \
|
||||||
|
pip3 install --break-system-packages yt-dlp
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ services:
|
|||||||
# Playwright Configuration
|
# Playwright Configuration
|
||||||
- DISPLAY=:99
|
- DISPLAY=:99
|
||||||
|
|
||||||
|
# Extractor backend: 'ytdlp' (default) or 'playwright' (legacy fallback)
|
||||||
|
- EXTRACTOR_BACKEND=${EXTRACTOR_BACKEND:-ytdlp}
|
||||||
|
|
||||||
# Node.js Environment
|
# Node.js Environment
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
security_opt:
|
security_opt:
|
||||||
|
|||||||
699
docs/FINDINGS.md
699
docs/FINDINGS.md
@@ -2446,3 +2446,702 @@ npm audit # Must show 0 vulnerabilities (preserved from iteration 0)
|
|||||||
- Verification: Sequential (after all fixes applied)
|
- Verification: Sequential (after all fixes applied)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### [Planner] Research Notes - RECIPE-0009 (2026-02-18)
|
||||||
|
|
||||||
|
**Task:** Implement URL deduplication, automatic notification subscription, UI improvements, and notification redirect fix
|
||||||
|
|
||||||
|
#### Web Push API Permission Requirements - RECIPE-0009
|
||||||
|
|
||||||
|
**Research Date:** 2026-02-18
|
||||||
|
**Source:** W3C Push API Specification, MDN Web Docs, browser security policies, existing PushNotificationManager.ts implementation
|
||||||
|
|
||||||
|
**Security Requirement:**
|
||||||
|
|
||||||
|
Per W3C Push API specification, `Notification.requestPermission()` **requires user gesture** - cannot be called programmatically without user interaction.
|
||||||
|
|
||||||
|
**Browser Behavior:**
|
||||||
|
|
||||||
|
- **Permission States**: `"default"` (not requested), `"granted"` (allowed), `"denied"` (blocked)
|
||||||
|
- **User Gesture Required**: Click, tap, keypress triggers permission prompt
|
||||||
|
- **No Automatic Subscription**: Calling `requestPermission()` on page load fails silently or throws error in strict mode
|
||||||
|
- **Best Practice**: Attach to meaningful user action (button click preferred)
|
||||||
|
|
||||||
|
**Implementation Pattern for "Automatic" Subscription:**
|
||||||
|
|
||||||
|
Since true automatic subscription violates browser security policy, the approach is:
|
||||||
|
|
||||||
|
1. Listen for **first user interaction** (click/touch) anywhere on page
|
||||||
|
2. Check notification state: supported, not denied, not subscribed
|
||||||
|
3. Call `pushNotificationManager.subscribe()` on first interaction
|
||||||
|
4. Remove listener after first attempt (one-shot behavior)
|
||||||
|
|
||||||
|
**Code Pattern:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function setupAutoSubscribe() {
|
||||||
|
const attemptSubscribe = async () => {
|
||||||
|
const state = pushNotificationManager.getState();
|
||||||
|
if (state.supported && state.permission !== 'denied' && !state.subscribed) {
|
||||||
|
await pushNotificationManager.subscribe();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for first user interaction
|
||||||
|
document.addEventListener('click', attemptSubscribe, { once: true });
|
||||||
|
document.addEventListener('touchstart', attemptSubscribe, { once: true });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why This is "Best Practice" Automatic:**
|
||||||
|
|
||||||
|
- Requires minimal user action (any click/touch, not explicit "Enable" button)
|
||||||
|
- Non-intrusive (happens in background after natural interaction)
|
||||||
|
- Complies with W3C security requirements
|
||||||
|
- Avoids annoying permission prompts on page load
|
||||||
|
- Mobile-friendly (touchstart event)
|
||||||
|
|
||||||
|
**Alternative Approaches Considered:**
|
||||||
|
|
||||||
|
1. **Prompt on page load** — REJECTED: Violates security policy, creates poor UX
|
||||||
|
2. **Delay with setTimeout** — REJECTED: Still violates user gesture requirement
|
||||||
|
3. **IntersectionObserver trick** — REJECTED: Does not satisfy user gesture requirement
|
||||||
|
4. **Explicit "Enable Notifications" button** — VALID but less automatic than requested
|
||||||
|
|
||||||
|
**Conclusion:** First-interaction subscription is the most automatic approach allowed by browser standards while maintaining user control.
|
||||||
|
|
||||||
|
**References:**
|
||||||
|
|
||||||
|
- W3C Push API: https://www.w3.org/TR/push-api/
|
||||||
|
- MDN Notification.requestPermission: https://developer.mozilla.org/en-US/docs/Web/API/Notification/requestPermission
|
||||||
|
- Existing implementation: [src/lib/client/PushNotificationManager.ts](src/lib/client/PushNotificationManager.ts#L123-161)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Queue URL Deduplication Strategy - RECIPE-0009
|
||||||
|
|
||||||
|
**Research Date:** 2026-02-18
|
||||||
|
**Source:** QueueManager.ts architecture analysis, types.ts interface definitions, existing queue operations
|
||||||
|
|
||||||
|
**Current Queue Structure:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// QueueManager.ts line 44-45
|
||||||
|
private items: Map<string, QueueItem> = new Map();
|
||||||
|
```
|
||||||
|
|
||||||
|
- Storage: `Map<string, QueueItem>` with UUID keys
|
||||||
|
- No secondary index: URL lookups require linear search through values
|
||||||
|
- In-memory only: No persistence across server restarts
|
||||||
|
- Typical size: < 100 items (based on usage patterns)
|
||||||
|
|
||||||
|
**Deduplication Requirements:**
|
||||||
|
|
||||||
|
1. Check if URL already exists in queue before creating new item
|
||||||
|
2. If duplicate found: Return existing item, do NOT create new entry
|
||||||
|
3. API layer: Respond with `duplicate: true` and existing item details
|
||||||
|
4. Message level: Info (not error) - duplicate is expected behavior
|
||||||
|
|
||||||
|
**Implementation Approach:**
|
||||||
|
|
||||||
|
**Option A - Linear Search (Chosen):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
findByUrl(url: string): QueueItem | undefined {
|
||||||
|
for (const item of this.items.values()) {
|
||||||
|
if (item.url === url) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Complexity**: O(n) where n = queue size
|
||||||
|
- **Performance**: Acceptable for n < 100 (~1-2ms on modern hardware)
|
||||||
|
- **Simplicity**: No additional data structures, no risk of index desync
|
||||||
|
- **Consistency**: Single source of truth (items Map)
|
||||||
|
|
||||||
|
**Option B - Secondary URL Index (Rejected):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
private items: Map<string, QueueItem> = new Map();
|
||||||
|
private urlIndex: Map<string, string> = new Map(); // url -> id
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Complexity**: O(1) lookup, but requires maintaining two structures
|
||||||
|
- **Risk**: Index desync if remove() doesn't clean both Maps
|
||||||
|
- **Overhead**: 2x memory for keys, more complex implementation
|
||||||
|
- **Benefit**: Marginal for queue size < 1000
|
||||||
|
|
||||||
|
**Design Decision:** Option A (linear search) chosen for simplicity and reliability at current scale.
|
||||||
|
|
||||||
|
**API Response Format:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Duplicate detected
|
||||||
|
{
|
||||||
|
duplicate: true,
|
||||||
|
message: "This recipe is already in the queue",
|
||||||
|
item: { id, url, status, enqueuedAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
// New item
|
||||||
|
{
|
||||||
|
duplicate: false,
|
||||||
|
item: { id, url, status, enqueuedAt }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**User Experience:**
|
||||||
|
|
||||||
|
- Frontend checks `response.duplicate === true`
|
||||||
|
- Shows info toast: "This recipe is already in queue [View]"
|
||||||
|
- No error state, no failed request
|
||||||
|
- Links to existing queue item
|
||||||
|
|
||||||
|
**Edge Cases Handled:**
|
||||||
|
|
||||||
|
1. **Multiple rapid requests**: First wins, rest return duplicate
|
||||||
|
2. **URL normalization**: URLs compared as-is (no normalization in v1)
|
||||||
|
3. **Completed items**: Duplicates found even if status is success/error
|
||||||
|
4. **Retry scenario**: Retry uses existing queue item ID, not new URL submission
|
||||||
|
|
||||||
|
**Future Considerations:**
|
||||||
|
|
||||||
|
- URL normalization (trailing slash, query params, fragments)
|
||||||
|
- Time-based deduplication window (only check items from last N hours)
|
||||||
|
- Content-based deduplication (recipe fingerprint from parsed data)
|
||||||
|
|
||||||
|
**References:**
|
||||||
|
|
||||||
|
- QueueManager implementation: [src/lib/server/queue/QueueManager.ts](src/lib/server/queue/QueueManager.ts#L44-95)
|
||||||
|
- QueueItem type definition: [src/lib/server/queue/types.ts](src/lib/server/queue/types.ts#L57-100)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Service Worker Notification Data Flow - RECIPE-0009
|
||||||
|
|
||||||
|
**Research Date:** 2026-02-18
|
||||||
|
**Source:** Code analysis of notification pipeline from QueueProcessor → PushNotificationService → Service Worker
|
||||||
|
|
||||||
|
**Notification Payload Journey:**
|
||||||
|
|
||||||
|
**Step 1: QueueProcessor sends notification (Line 418-420)**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await pushNotificationService.notifySuccess(
|
||||||
|
item.id,
|
||||||
|
item.results?.recipe?.name,
|
||||||
|
item.results?.tandoorUrl // ← tandoorUrl passed here
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: PushNotificationService creates payload (Lines 162-181)**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const payload: NotificationPayload = {
|
||||||
|
type: 'success',
|
||||||
|
itemId,
|
||||||
|
recipeName,
|
||||||
|
body: recipeName ? `Recipe "${recipeName}" has been extracted...` : ...,
|
||||||
|
tag: `recipe-success-${itemId}`,
|
||||||
|
requireInteraction: true,
|
||||||
|
analytics: { ... }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tandoorUrl) {
|
||||||
|
payload.body += ' View it in Tandoor.';
|
||||||
|
// Note: tandoorUrl NOT explicitly added to payload object
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Issue Found:** `tandoorUrl` parameter received but **not stored in payload object**!
|
||||||
|
|
||||||
|
**Step 3: Service Worker receives push event (Line 123)**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
data = event.data.json(); // ← Payload becomes data object
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Notification created with data (Lines 130-136)**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const options: NotificationOptions = {
|
||||||
|
body: data.body,
|
||||||
|
data: data, // ← Full payload stored in data field
|
||||||
|
// ...
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Click handler accesses data (Line 183-191)**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const data = event.notification.data;
|
||||||
|
const action = event.action;
|
||||||
|
|
||||||
|
if (action === 'view' && data?.itemId) {
|
||||||
|
url = `/?highlight=${data.itemId}`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Current Bug:** `data.tandoorUrl` is undefined because `PushNotificationService.notifySuccess()` doesn't add it to payload.
|
||||||
|
|
||||||
|
**Fix Required in PushNotificationService.ts (Line 162-181):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const payload: NotificationPayload = {
|
||||||
|
type: 'success',
|
||||||
|
itemId,
|
||||||
|
recipeName,
|
||||||
|
tandoorUrl, // ← Add this line
|
||||||
|
body: recipeName ? `Recipe "${recipeName}" has been extracted...` : ...,
|
||||||
|
tag: `recipe-success-${itemId}`,
|
||||||
|
requireInteraction: true,
|
||||||
|
analytics: { ... }
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Then Service Worker Can Use It:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (action === 'view' && data?.tandoorUrl) {
|
||||||
|
url = data.tandoorUrl; // Redirect to Tandoor
|
||||||
|
} else if (action === 'view' && data?.itemId) {
|
||||||
|
url = `/?highlight=${data.itemId}`; // Fallback to dashboard
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**NotificationPayload Interface Update Required:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 20-28 in PushNotificationService.ts
|
||||||
|
interface NotificationPayload {
|
||||||
|
title?: string;
|
||||||
|
body: string;
|
||||||
|
type: 'success' | 'error' | 'progress';
|
||||||
|
itemId: string;
|
||||||
|
recipeName?: string;
|
||||||
|
tandoorUrl?: string; // ← Add this line
|
||||||
|
tag?: string;
|
||||||
|
requireInteraction?: boolean;
|
||||||
|
analytics?: any;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
|
||||||
|
- QueueProcessor already passes `item.results?.tandoorUrl` correctly
|
||||||
|
- `item.results.tandoorUrl` is set by QueueProcessor line 329-331 when Tandoor upload succeeds
|
||||||
|
- Format: `${TANDOOR_BASE_URL}/view/recipe/${recipeId}`
|
||||||
|
- Example: `https://tandoor.example.com/view/recipe/123`
|
||||||
|
|
||||||
|
**References:**
|
||||||
|
|
||||||
|
- QueueProcessor notification call: [src/lib/server/queue/QueueProcessor.ts](src/lib/server/queue/QueueProcessor.ts#L418-420)
|
||||||
|
- PushNotificationService: [src/lib/server/notifications/PushNotificationService.ts](src/lib/server/notifications/PushNotificationService.ts#L158-183)
|
||||||
|
- Service Worker push handler: [src/service-worker.ts](src/service-worker.ts#L112-170)
|
||||||
|
- Service Worker click handler: [src/service-worker.ts](src/service-worker.ts#L176-207)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Homepage UI Component Visibility Analysis - RECIPE-0009
|
||||||
|
|
||||||
|
**Research Date:** 2026-02-18
|
||||||
|
**Source:** +page.svelte component structure analysis
|
||||||
|
|
||||||
|
**Current Behavior:**
|
||||||
|
|
||||||
|
**Add Recipe Component Locations:**
|
||||||
|
|
||||||
|
1. **Empty State** (Lines 280-302): Shows when `!loading && filteredItems.length === 0`
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
{#if !loading && filteredItems.length === 0}
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<!-- ... -->
|
||||||
|
<a href="/share" class="...">
|
||||||
|
Add Recipe URL
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **No Persistent Component**: When queue has items, no "Add Recipe" button visible
|
||||||
|
|
||||||
|
**User Complaint:** "Do not hide the add recipe component when there are items in the queue"
|
||||||
|
|
||||||
|
**Issue:** Add recipe link only appears in empty state conditional block.
|
||||||
|
|
||||||
|
**Solution:** Add persistent "Add Recipe" button to action bar (always visible)
|
||||||
|
|
||||||
|
**Implementation Location:** Lines 224-254 (Action Bar section)
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<div class="mb-6 flex flex-col sm:flex-row gap-4 justify-between items-start sm:items-center">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<!-- Filter Tabs -->
|
||||||
|
{#each filters as filterOption}
|
||||||
|
<button>...</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Refresh Button -->
|
||||||
|
<button>...</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<div class="mb-6 flex flex-col sm:flex-row gap-4 justify-between items-start sm:items-center">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<!-- Filter Dropdown -->
|
||||||
|
<select>...</select>
|
||||||
|
|
||||||
|
<!-- Refresh Button -->
|
||||||
|
<button>...</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Recipe Button (ALWAYS VISIBLE) -->
|
||||||
|
<a href="/share" class="...">
|
||||||
|
Add Recipe URL
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
|
||||||
|
- Always accessible regardless of queue state
|
||||||
|
- Consistent UI (no disappearing elements)
|
||||||
|
- Better UX for power users (add multiple recipes quickly)
|
||||||
|
- Maintains empty state link for discoverability
|
||||||
|
|
||||||
|
**Filter Consolidation Rationale:**
|
||||||
|
|
||||||
|
Current filter tabs take significant horizontal space (5 buttons). Consolidating to dropdown:
|
||||||
|
|
||||||
|
- Frees space for persistent "Add Recipe" button
|
||||||
|
- Keeps filter + refresh on same row (per requirement)
|
||||||
|
- Mobile-friendly (dropdown vs. wrapping buttons)
|
||||||
|
- Still shows item counts in dropdown options
|
||||||
|
|
||||||
|
**References:**
|
||||||
|
|
||||||
|
- Homepage component: [src/routes/+page.svelte](src/routes/+page.svelte#L215-302)
|
||||||
|
- Empty state section: [src/routes/+page.svelte](src/routes/+page.svelte#L280-302)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [Planner] Research Notes - RECIPE-0009 Iteration 1 (2026-02-18)
|
||||||
|
|
||||||
|
**Task:** UI enhancements - footer status bar, icon-only buttons, toggle Add Recipe visibility
|
||||||
|
|
||||||
|
#### Current Homepage UI Structure Analysis
|
||||||
|
|
||||||
|
**Research Date:** 2026-02-18
|
||||||
|
**Source:** Analysis of [src/routes/+page.svelte](src/routes/+page.svelte), iteration 0 implementation
|
||||||
|
|
||||||
|
**Current Implementation (Iteration 0)**:
|
||||||
|
|
||||||
|
1. **Connection Status Widget** (lines 369-383):
|
||||||
|
- Fixed position: bottom-right (`fixed bottom-4 right-4`)
|
||||||
|
- Shows connection status with colored dot + text label
|
||||||
|
- Shows last ping timestamp
|
||||||
|
- Will be REMOVED and replaced with footer bar
|
||||||
|
|
||||||
|
2. **Action Bar** (lines 263-297):
|
||||||
|
- Filter dropdown (lines 266-276)
|
||||||
|
- Refresh button with icon + text (lines 277-285)
|
||||||
|
- Add Recipe button with icon + text (lines 288-297)
|
||||||
|
- Currently: Add Recipe button ALWAYS visible (iteration 0 requirement)
|
||||||
|
|
||||||
|
3. **Empty State** (lines 310-342):
|
||||||
|
- Shows when `!loading && filteredItems.length === 0`
|
||||||
|
- Contains "Add Recipe URL" link
|
||||||
|
|
||||||
|
**Changes Required for Iteration 1**:
|
||||||
|
|
||||||
|
1. Remove floating connection status widget
|
||||||
|
2. Add footer status bar (icons only)
|
||||||
|
3. Convert refresh button to icon-only
|
||||||
|
4. Convert Add Recipe button to icon-only
|
||||||
|
5. Toggle Add Recipe button visibility (hide when empty, show when has items)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Footer Status Bar Design - RECIPE-0009 Iteration 1
|
||||||
|
|
||||||
|
**Research Date:** 2026-02-18
|
||||||
|
**Source:** Web PWA patterns, existing codebase styling patterns
|
||||||
|
|
||||||
|
**Design Requirements**:
|
||||||
|
|
||||||
|
- **Position**: Fixed at bottom (`fixed bottom-0 left-0 right-0`)
|
||||||
|
- **Layout**: Full width with max-width container matching page layout (`max-w-6xl`)
|
||||||
|
- **Content**: Two sections (notification status left, live updates right)
|
||||||
|
- **Display**: Icons only, no text labels
|
||||||
|
- **Accessibility**: title and aria-label attributes on interactive elements
|
||||||
|
- **Z-index**: `z-50` to ensure visibility above all content
|
||||||
|
- **Visual**: White background, top border, shadow for lift effect
|
||||||
|
|
||||||
|
**State Integration**:
|
||||||
|
|
||||||
|
Footer needs access to two state sources:
|
||||||
|
|
||||||
|
1. **Notification Status**: Via `pushNotificationManager.getState()`
|
||||||
|
- Need to add `notificationViewModel` state variable in +page.svelte
|
||||||
|
- Subscribe to state changes in `onMount`
|
||||||
|
- Cleanup subscription in `onDestroy`
|
||||||
|
|
||||||
|
2. **Connection Status**: Already exists as `connectionStatus` state
|
||||||
|
- Reuse existing variable
|
||||||
|
- States: 'connecting' | 'connected' | 'disconnected'
|
||||||
|
|
||||||
|
**Notification Icon Logic**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (!supported || permission === 'denied') {
|
||||||
|
// Show bell with slash (not supported/denied)
|
||||||
|
icon = 'bell-slash';
|
||||||
|
color = 'text-gray-400';
|
||||||
|
} else if (subscribed) {
|
||||||
|
// Show bell icon (enabled)
|
||||||
|
icon = 'bell';
|
||||||
|
color = 'text-green-600';
|
||||||
|
} else {
|
||||||
|
// Show bell icon (available but not enabled)
|
||||||
|
icon = 'bell';
|
||||||
|
color = 'text-gray-400';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Live Update Indicator Logic**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (connectionStatus === 'connected') {
|
||||||
|
dotColor = 'bg-green-400';
|
||||||
|
title = 'Live updates active';
|
||||||
|
} else if (connectionStatus === 'connecting') {
|
||||||
|
dotColor = 'bg-yellow-400';
|
||||||
|
title = 'Connecting to live updates...';
|
||||||
|
} else {
|
||||||
|
dotColor = 'bg-red-400';
|
||||||
|
title = 'Live updates disconnected';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Click Behavior**:
|
||||||
|
|
||||||
|
Clicking notification icon scrolls to NotificationSettings component:
|
||||||
|
```typescript
|
||||||
|
onclick={() => {
|
||||||
|
document.querySelector('[data-notification-settings]')?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires adding `data-notification-settings` attribute to NotificationSettings wrapper.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Icon-Only Button Patterns - RECIPE-0009 Iteration 1
|
||||||
|
|
||||||
|
**Research Date:** 2026-02-18
|
||||||
|
**Source:** Existing codebase button styles, Tailwind CSS documentation, WCAG 2.1 guidelines
|
||||||
|
|
||||||
|
**Current Button Pattern (with text)**:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<button class="flex items-center space-x-2 px-4 py-2 ...">
|
||||||
|
<svg class="w-4 h-4" ... />
|
||||||
|
<span>Button Text</span>
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Padding: `px-4 py-2` (horizontal + vertical)
|
||||||
|
- Icon size: `w-4 h-4` (16x16px)
|
||||||
|
- Spacing: `space-x-2` (gap between icon and text)
|
||||||
|
|
||||||
|
**Icon-Only Button Pattern**:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<button
|
||||||
|
title="Button description"
|
||||||
|
aria-label="Button description"
|
||||||
|
class="p-2 ..."
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" ... />
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Changes**:
|
||||||
|
- Padding: `p-2` (square/circular button)
|
||||||
|
- Icon size: `w-5 h-5` (20x20px - slightly larger for better visibility)
|
||||||
|
- Remove: `space-x-2` class (no text to space from)
|
||||||
|
- Add: `title` attribute (tooltip on hover)
|
||||||
|
- Add: `aria-label` attribute (screen reader accessibility)
|
||||||
|
|
||||||
|
**Accessibility Requirements** (WCAG 2.1):
|
||||||
|
|
||||||
|
1. **Title Attribute**: Provides tooltip text for sighted users on hover
|
||||||
|
2. **Aria-label Attribute**: Provides accessible name for screen readers
|
||||||
|
3. **Minimum Touch Target**: 24x24px recommended (20x20px icon + 8px padding = 36x36px total ✓)
|
||||||
|
4. **Color Contrast**: Must meet 3:1 ratio for non-text (icons)
|
||||||
|
|
||||||
|
**Examples**:
|
||||||
|
|
||||||
|
Refresh button:
|
||||||
|
```svelte
|
||||||
|
<button
|
||||||
|
title="Refresh queue"
|
||||||
|
aria-label="Refresh queue"
|
||||||
|
class="p-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 ..."
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" ... />
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
Add Recipe button:
|
||||||
|
```svelte
|
||||||
|
<a
|
||||||
|
href="/share"
|
||||||
|
title="Add recipe URL"
|
||||||
|
aria-label="Add recipe URL"
|
||||||
|
class="p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 ..."
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" ... />
|
||||||
|
</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Add Recipe Button Visibility Logic - RECIPE-0009 Iteration 1
|
||||||
|
|
||||||
|
**Research Date:** 2026-02-18
|
||||||
|
**Source:** context_compact.yaml requirement analysis, UX patterns
|
||||||
|
|
||||||
|
**Iteration 0 Implementation**:
|
||||||
|
- Add Recipe button ALWAYS visible in controls bar
|
||||||
|
- Rationale: User complained "do not hide the add recipe component when there are items in the queue"
|
||||||
|
|
||||||
|
**Iteration 1 Requirement**:
|
||||||
|
> "Toggle "Add Recipe" button visibility in controls bar (hide when queue empty, show when items exist - opposite of placeholder rule)"
|
||||||
|
|
||||||
|
**Interpretation**:
|
||||||
|
|
||||||
|
"Opposite of placeholder rule":
|
||||||
|
- Placeholder (empty state) shows when: `items.length === 0`
|
||||||
|
- Add Recipe button in controls shows when: `items.length > 0` (opposite condition)
|
||||||
|
|
||||||
|
**Logic**:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
{#if items.length > 0}
|
||||||
|
<a href="/share" title="Add recipe URL" aria-label="Add recipe URL" ...>
|
||||||
|
<svg ... />
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
|
||||||
|
1. **Empty State**: When queue is empty, user sees empty state with centered "Add Recipe URL" link
|
||||||
|
2. **Non-Empty State**: When queue has items, controls bar shows Add Recipe button (icon-only)
|
||||||
|
3. **No Redundancy**: Button doesn't appear when empty state link is already visible
|
||||||
|
4. **Consistent Access**: User always has access to "Add Recipe" via either empty state link OR controls bar button
|
||||||
|
|
||||||
|
**UX Benefits**:
|
||||||
|
|
||||||
|
- Cleaner UI when queue is empty (no redundant button)
|
||||||
|
- Convenient access when queue has items (quick add more recipes)
|
||||||
|
- Fulfills opposite condition of empty state placeholder
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Svelte 5 Notification State Management
|
||||||
|
|
||||||
|
**Research Date:** 2026-02-18
|
||||||
|
**Source:** Existing iteration 0 implementation, [PushNotificationManager.ts](src/lib/client/PushNotificationManager.ts)
|
||||||
|
|
||||||
|
**NotificationState Type**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface NotificationState {
|
||||||
|
supported: boolean;
|
||||||
|
permission: NotificationPermission; // 'default' | 'granted' | 'denied'
|
||||||
|
subscribed: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**State Subscription Pattern**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Import type
|
||||||
|
import type { NotificationState } from '$lib/client/PushNotificationManager';
|
||||||
|
|
||||||
|
// Declare state
|
||||||
|
let notificationViewModel = $state<NotificationState | null>(null);
|
||||||
|
|
||||||
|
// Subscribe in onMount
|
||||||
|
onMount(() => {
|
||||||
|
// ... existing code ...
|
||||||
|
|
||||||
|
const unsubscribeNotifications = pushNotificationManager.onStateChange((newState) => {
|
||||||
|
notificationViewModel = newState;
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribeNotifications?.();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cleanup in onDestroy**:
|
||||||
|
|
||||||
|
Current onDestroy only cleans up `eventSource`. Need to also cleanup notification subscription:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
onDestroy(() => {
|
||||||
|
if (eventSource) {
|
||||||
|
console.log('[SSE] Closing connection on component destroy');
|
||||||
|
eventSource.close();
|
||||||
|
connectionStatus = 'disconnected';
|
||||||
|
}
|
||||||
|
// No cleanup needed - handled by onMount return callback
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: Svelte 5's `onMount` return function handles cleanup automatically when component unmounts.
|
||||||
|
|
||||||
|
**State Access in Footer**:
|
||||||
|
|
||||||
|
Footer component needs null-safe access since initial state is `null`:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
{#if notificationViewModel}
|
||||||
|
{#if !notificationViewModel.supported || notificationViewModel.permission === 'denied'}
|
||||||
|
<!-- Show disabled icon -->
|
||||||
|
{:else if notificationViewModel.subscribed}
|
||||||
|
<!-- Show enabled icon -->
|
||||||
|
{:else}
|
||||||
|
<!-- Show available icon -->
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<!-- Loading state - show gray icon -->
|
||||||
|
<svg class="w-5 h-5 text-gray-400" ... />
|
||||||
|
{/if}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Initial State Handling**:
|
||||||
|
|
||||||
|
`pushNotificationManager.onStateChange()` sends initial state immediately on subscription, so `notificationViewModel` will be populated almost instantly after component mount.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Document Version:** 3.0
|
||||||
|
**Last Updated by:** Planner Agent (RECIPE-0009 Iteration 1)
|
||||||
|
**Next Update:** Developer Agent
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
{
|
|
||||||
"cookies": [
|
|
||||||
{
|
|
||||||
"name": "csrftoken",
|
|
||||||
"value": "SDRORLyWEsWWty2ZoVGdER",
|
|
||||||
"domain": ".instagram.com",
|
|
||||||
"path": "/",
|
|
||||||
"expires": 1805935216.410097,
|
|
||||||
"httpOnly": false,
|
|
||||||
"secure": true,
|
|
||||||
"sameSite": "Lax"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "datr",
|
|
||||||
"value": "isQuaeXe5-2mFvFSOdcgVq0u",
|
|
||||||
"domain": ".instagram.com",
|
|
||||||
"path": "/",
|
|
||||||
"expires": 1799232653.525143,
|
|
||||||
"httpOnly": true,
|
|
||||||
"secure": true,
|
|
||||||
"sameSite": "None"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ig_did",
|
|
||||||
"value": "5650C8B9-B8D8-4102-9B49-F0668CE34202",
|
|
||||||
"domain": ".instagram.com",
|
|
||||||
"path": "/",
|
|
||||||
"expires": 1796208680.653147,
|
|
||||||
"httpOnly": true,
|
|
||||||
"secure": true,
|
|
||||||
"sameSite": "Lax"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "mid",
|
|
||||||
"value": "aS7EigALAAHxXAxrkYg18Fzi-SR7",
|
|
||||||
"domain": ".instagram.com",
|
|
||||||
"path": "/",
|
|
||||||
"expires": 1799232653.525191,
|
|
||||||
"httpOnly": true,
|
|
||||||
"secure": true,
|
|
||||||
"sameSite": "None"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ds_user_id",
|
|
||||||
"value": "59661903731",
|
|
||||||
"domain": ".instagram.com",
|
|
||||||
"path": "/",
|
|
||||||
"expires": 1779151216.410198,
|
|
||||||
"httpOnly": false,
|
|
||||||
"secure": true,
|
|
||||||
"sameSite": "None"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sessionid",
|
|
||||||
"value": "59661903731%3AbekaIlo4nn7x2n%3A29%3AAYhv1LJUsfRtBSH-WmDLVrxiM7T9UotIOM3XY3iHKQ",
|
|
||||||
"domain": ".instagram.com",
|
|
||||||
"path": "/",
|
|
||||||
"expires": 1797910987.674116,
|
|
||||||
"httpOnly": true,
|
|
||||||
"secure": true,
|
|
||||||
"sameSite": "Lax"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "wd",
|
|
||||||
"value": "1280x720",
|
|
||||||
"domain": ".instagram.com",
|
|
||||||
"path": "/",
|
|
||||||
"expires": 1771980018,
|
|
||||||
"httpOnly": false,
|
|
||||||
"secure": true,
|
|
||||||
"sameSite": "Lax"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "rur",
|
|
||||||
"value": "\"CLN\\05459661903731\\0541802911216:01fe0df629634929a5afb2d329011423972fad13b80d44aa8d827064e1ffa5112234bd5f\"",
|
|
||||||
"domain": ".instagram.com",
|
|
||||||
"path": "/",
|
|
||||||
"expires": -1,
|
|
||||||
"httpOnly": true,
|
|
||||||
"secure": true,
|
|
||||||
"sameSite": "Lax"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"origins": [
|
|
||||||
{
|
|
||||||
"origin": "https://www.instagram.com",
|
|
||||||
"localStorage": [
|
|
||||||
{
|
|
||||||
"name": "chatd-deviceid",
|
|
||||||
"value": "47cc5bd4-431f-4054-8aaf-3e05aa303bd0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "hb_timestamp",
|
|
||||||
"value": "1771374318548"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "IGSession",
|
|
||||||
"value": "k75336:1771377018401"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "mutex_polaris_banzai",
|
|
||||||
"value": "dvrlku:1771375219401"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "pixel_fire_ts",
|
|
||||||
"value": "1771121302843"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "signal_flush_timestamp",
|
|
||||||
"value": "1771375100382"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Session",
|
|
||||||
"value": "2wm58s:1771375253401"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "has_interop_upgraded",
|
|
||||||
"value": "{\"lastCheckedAt\":1766366944051,\"status\":false}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ig_boost_on_web_campaign_upsell_shown",
|
|
||||||
"value": "false"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "mutex_banzai",
|
|
||||||
"value": "dvrlku:1771375219401"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "banzai:last_storage_flush",
|
|
||||||
"value": "1771366998859.2"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -26,7 +26,14 @@ type CaptionCandidate = {
|
|||||||
brCount: number;
|
brCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProgressEventType = 'status' | 'method' | 'retry' | 'error' | 'thumbnail' | 'complete';
|
export type ProgressEventType =
|
||||||
|
| 'status'
|
||||||
|
| 'method'
|
||||||
|
| 'retry'
|
||||||
|
| 'error'
|
||||||
|
| 'thumbnail'
|
||||||
|
| 'complete'
|
||||||
|
| 'model_loading';
|
||||||
|
|
||||||
export interface ProgressEvent {
|
export interface ProgressEvent {
|
||||||
type: ProgressEventType;
|
type: ProgressEventType;
|
||||||
|
|||||||
193
src/lib/server/instagram-extractor.ts
Normal file
193
src/lib/server/instagram-extractor.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* Instagram extractor — yt-dlp subprocess implementation.
|
||||||
|
*
|
||||||
|
* Replaces the Playwright-based scraper. yt-dlp is maintained against
|
||||||
|
* Instagram's frontend churn, has no in-process state, and works on public
|
||||||
|
* reels without authentication. Login-walled reels can be supported by
|
||||||
|
* dropping a Netscape-format cookies file at the path under SECRETS_DIR.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { logError } from './utils/logger';
|
||||||
|
import type { ExtractedContent, ProgressCallback } from './extraction';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
const YTDLP_TIMEOUT_MS = 60_000;
|
||||||
|
const IMAGE_FETCH_TIMEOUT_MS = 10_000;
|
||||||
|
const USER_AGENT =
|
||||||
|
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
|
||||||
|
|
||||||
|
const COOKIE_PATHS = ['/app/secrets/cookies.txt', './secrets/cookies.txt'];
|
||||||
|
|
||||||
|
function resolveCookiePath(): string | null {
|
||||||
|
for (const p of COOKIE_PATHS) {
|
||||||
|
if (existsSync(p)) return p;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YtDlpJson {
|
||||||
|
description?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
thumbnail?: string | null;
|
||||||
|
thumbnails?: Array<{ url?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickThumbnailUrl(data: YtDlpJson): string | null {
|
||||||
|
if (data.thumbnail) return data.thumbnail;
|
||||||
|
const first = (data.thumbnails ?? []).find((t) => t?.url);
|
||||||
|
return first?.url ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchImageAsBase64(imageUrl: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(imageUrl, {
|
||||||
|
signal: AbortSignal.timeout(IMAGE_FETCH_TIMEOUT_MS)
|
||||||
|
});
|
||||||
|
if (response.status !== 200) return null;
|
||||||
|
const contentType = response.headers.get('content-type') ?? '';
|
||||||
|
if (!contentType.startsWith('image/')) return null;
|
||||||
|
const buf = Buffer.from(await response.arrayBuffer());
|
||||||
|
return `data:${contentType};base64,${buf.toString('base64')}`;
|
||||||
|
} catch (e) {
|
||||||
|
logError('[ytdlp] Thumbnail fetch failed', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyYtDlpError(stderr: string): { recoverable: boolean; reason: string } {
|
||||||
|
const lower = stderr.toLowerCase();
|
||||||
|
if (
|
||||||
|
lower.includes('login required') ||
|
||||||
|
lower.includes('login_required') ||
|
||||||
|
lower.includes('private') ||
|
||||||
|
lower.includes('rate-limit') ||
|
||||||
|
lower.includes('rate limit')
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
recoverable: false,
|
||||||
|
reason:
|
||||||
|
'Instagram requires authentication for this reel. Drop a Netscape cookies.txt at secrets/cookies.txt and retry.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (lower.includes('unsupported url')) {
|
||||||
|
return { recoverable: false, reason: 'URL not recognised by yt-dlp.' };
|
||||||
|
}
|
||||||
|
if (lower.includes('http error 404') || lower.includes('does not exist')) {
|
||||||
|
return { recoverable: false, reason: 'Reel not found (404).' };
|
||||||
|
}
|
||||||
|
return { recoverable: true, reason: stderr.split('\n').filter(Boolean).slice(-2).join(' ') };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract caption text + thumbnail data-URL from an Instagram reel.
|
||||||
|
*
|
||||||
|
* Mirrors the signature of the legacy Playwright extractor so QueueProcessor
|
||||||
|
* needs no contract change. ProgressCallback events use existing types
|
||||||
|
* (`status`, `method`, `error`) so the SSE consumers do not need updates.
|
||||||
|
*/
|
||||||
|
export async function extractTextAndThumbnail(
|
||||||
|
url: string,
|
||||||
|
progressCallback?: ProgressCallback
|
||||||
|
): Promise<ExtractedContent> {
|
||||||
|
progressCallback?.({
|
||||||
|
type: 'status',
|
||||||
|
message: 'Invoking yt-dlp...',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
const cookies = resolveCookiePath();
|
||||||
|
if (cookies) {
|
||||||
|
progressCallback?.({
|
||||||
|
type: 'status',
|
||||||
|
message: `Using cookies from ${cookies}`,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
'--dump-single-json',
|
||||||
|
'--skip-download',
|
||||||
|
'--no-warnings',
|
||||||
|
'--no-call-home',
|
||||||
|
'--socket-timeout',
|
||||||
|
'20',
|
||||||
|
'--user-agent',
|
||||||
|
USER_AGENT,
|
||||||
|
...(cookies ? ['--cookies', cookies] : []),
|
||||||
|
url
|
||||||
|
];
|
||||||
|
|
||||||
|
let stdout: string;
|
||||||
|
try {
|
||||||
|
const result = await execFileAsync('yt-dlp', args, {
|
||||||
|
timeout: YTDLP_TIMEOUT_MS,
|
||||||
|
maxBuffer: 10 * 1024 * 1024
|
||||||
|
});
|
||||||
|
stdout = result.stdout;
|
||||||
|
} catch (e: any) {
|
||||||
|
const stderr = String(e?.stderr ?? e?.message ?? '');
|
||||||
|
const code = e?.code;
|
||||||
|
if (code === 'ENOENT') {
|
||||||
|
throw new Error(
|
||||||
|
'yt-dlp is not installed in this container. Add it to the Dockerfile.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { recoverable, reason } = classifyYtDlpError(stderr);
|
||||||
|
progressCallback?.({
|
||||||
|
type: 'error',
|
||||||
|
message: `yt-dlp failed: ${reason}`,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
const err = new Error(`yt-dlp extraction failed: ${reason}`);
|
||||||
|
// QueueProcessor.isRecoverableError() classifies on message; surface keywords.
|
||||||
|
if (!recoverable) (err as any).nonRecoverable = true;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: YtDlpJson;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(stdout);
|
||||||
|
} catch (e) {
|
||||||
|
logError('[ytdlp] Failed to parse yt-dlp JSON output', e);
|
||||||
|
throw new Error('yt-dlp returned invalid JSON');
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyText = (data.description ?? data.title ?? '').trim();
|
||||||
|
if (!bodyText) {
|
||||||
|
throw new Error('yt-dlp returned no description for this reel');
|
||||||
|
}
|
||||||
|
|
||||||
|
progressCallback?.({
|
||||||
|
type: 'status',
|
||||||
|
message: `Caption extracted (${bodyText.length} chars)`,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
let thumbnail: string | null = null;
|
||||||
|
const thumbUrl = pickThumbnailUrl(data);
|
||||||
|
if (thumbUrl) {
|
||||||
|
progressCallback?.({
|
||||||
|
type: 'thumbnail',
|
||||||
|
message: 'Fetching thumbnail...',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
thumbnail = await fetchImageAsBase64(thumbUrl);
|
||||||
|
progressCallback?.({
|
||||||
|
type: 'status',
|
||||||
|
message: thumbnail ? 'Thumbnail fetched' : 'Thumbnail fetch failed (continuing without)',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
progressCallback?.({
|
||||||
|
type: 'complete',
|
||||||
|
message: 'Extraction complete',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
return { bodyText, thumbnail };
|
||||||
|
}
|
||||||
@@ -2,15 +2,24 @@ import OpenAI from 'openai';
|
|||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
import { logError } from './utils/logger';
|
import { logError } from './utils/logger';
|
||||||
|
|
||||||
|
const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
const parseTimeoutMs = (raw: string | undefined): number => {
|
||||||
|
if (!raw) return DEFAULT_REQUEST_TIMEOUT_MS;
|
||||||
|
const n = Number(raw);
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : DEFAULT_REQUEST_TIMEOUT_MS;
|
||||||
|
};
|
||||||
|
|
||||||
export const createLLM = () => {
|
export const createLLM = () => {
|
||||||
// Detect if we are using Ollama or OpenAI based on URL
|
|
||||||
const baseURL = env.OPENAI_BASE_URL;
|
const baseURL = env.OPENAI_BASE_URL;
|
||||||
const apiKey = env.OPENAI_API_KEY;
|
const apiKey = env.OPENAI_API_KEY;
|
||||||
const model = env.LLM_MODEL || 'gpt-4o';
|
const model = env.LLM_MODEL || 'gpt-4o';
|
||||||
|
const timeout = parseTimeoutMs(env.LLM_REQUEST_TIMEOUT_MS);
|
||||||
|
|
||||||
console.log('[LLM] Initializing client...');
|
console.log('[LLM] Initializing client...');
|
||||||
console.log('[LLM] Base URL:', baseURL);
|
console.log('[LLM] Base URL:', baseURL);
|
||||||
console.log('[LLM] Model:', model);
|
console.log('[LLM] Model:', model);
|
||||||
|
console.log('[LLM] Request timeout (ms):', timeout);
|
||||||
|
|
||||||
if (!baseURL) {
|
if (!baseURL) {
|
||||||
throw new Error('OPENAI_BASE_URL environment variable is not set');
|
throw new Error('OPENAI_BASE_URL environment variable is not set');
|
||||||
@@ -22,7 +31,9 @@ export const createLLM = () => {
|
|||||||
|
|
||||||
const client = new OpenAI({
|
const client = new OpenAI({
|
||||||
apiKey,
|
apiKey,
|
||||||
baseURL
|
baseURL,
|
||||||
|
timeout,
|
||||||
|
maxRetries: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
return { client, model };
|
return { client, model };
|
||||||
@@ -43,6 +54,47 @@ export async function checkLLMHealth(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip a trailing /v1 (or /v1/) from a base URL to get the llama-swap root.
|
||||||
|
* llama-swap exposes both /v1/* (OpenAI-compatible) and /running, /upstream, etc.
|
||||||
|
* at the bare root.
|
||||||
|
*/
|
||||||
|
function llamaSwapRoot(baseURL: string): string {
|
||||||
|
return baseURL.replace(/\/v1\/?$/, '').replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RunningModelEntry {
|
||||||
|
model: string;
|
||||||
|
state?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query llama-swap's /running endpoint and report whether `model` is currently
|
||||||
|
* loaded and ready to serve. Returns false on any error (treat as cold).
|
||||||
|
*
|
||||||
|
* Why we don't fold this into checkModelAvailability(): /v1/models lists every
|
||||||
|
* model llama-swap is configured to swap to (not just loaded ones), while
|
||||||
|
* /running returns only the in-VRAM instance. Both signals are useful.
|
||||||
|
*/
|
||||||
|
export async function isModelLoaded(model: string): Promise<boolean> {
|
||||||
|
const baseURL = env.OPENAI_BASE_URL;
|
||||||
|
if (!baseURL) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `${llamaSwapRoot(baseURL)}/running`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
signal: AbortSignal.timeout(5_000)
|
||||||
|
});
|
||||||
|
if (!response.ok) return false;
|
||||||
|
const data = (await response.json()) as { running?: RunningModelEntry[] };
|
||||||
|
const running = data.running ?? [];
|
||||||
|
return running.some((m) => m.model === model && (m.state ?? 'ready') === 'ready');
|
||||||
|
} catch (e) {
|
||||||
|
logError('[LLM] isModelLoaded check failed', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a specific model is available in the OpenAI-compatible API
|
* Check if a specific model is available in the OpenAI-compatible API
|
||||||
* @param model - The model ID to check for availability
|
* @param model - The model ID to check for availability
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { createLLM, checkModelAvailability } from './llm';
|
import { createLLM, checkModelAvailability, isModelLoaded } from './llm';
|
||||||
import { zodResponseFormat } from 'openai/helpers/zod';
|
import { zodResponseFormat } from 'openai/helpers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { RECIPE_DETECTION_PROMPT, RECIPE_EXTRACTION_PROMPT } from './prompts/recipe-extraction';
|
import { RECIPE_DETECTION_PROMPT, RECIPE_EXTRACTION_PROMPT } from './prompts/recipe-extraction';
|
||||||
import { logError } from './utils/logger';
|
import { logError } from './utils/logger';
|
||||||
|
import type { ProgressCallback } from './extraction';
|
||||||
|
|
||||||
const RecipeSchema = z.object({
|
const RecipeSchema = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
@@ -144,11 +145,33 @@ export async function parseRecipe(text: string): Promise<Recipe> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Complete workflow: detect recipe and parse if found
|
* Complete workflow: detect recipe and parse if found.
|
||||||
|
*
|
||||||
|
* Emits a `model_loading` progress event (if a callback is supplied) when the
|
||||||
|
* configured llama-swap model is not yet warm — the first request after idle
|
||||||
|
* blocks for several seconds while llama-swap loads the model into VRAM.
|
||||||
|
*
|
||||||
* @param text - The text to analyze
|
* @param text - The text to analyze
|
||||||
|
* @param progressCallback - Optional callback for surfacing cold-load state
|
||||||
* @returns Parsed recipe object if detected, null otherwise
|
* @returns Parsed recipe object if detected, null otherwise
|
||||||
*/
|
*/
|
||||||
export async function extractRecipe(text: string): Promise<Recipe | null> {
|
export async function extractRecipe(
|
||||||
|
text: string,
|
||||||
|
progressCallback?: ProgressCallback
|
||||||
|
): Promise<Recipe | null> {
|
||||||
|
if (progressCallback) {
|
||||||
|
const { model } = createLLM();
|
||||||
|
const warm = await isModelLoaded(model);
|
||||||
|
if (!warm) {
|
||||||
|
progressCallback({
|
||||||
|
type: 'model_loading',
|
||||||
|
message: `Inference server cold — loading ${model} into VRAM (5–30s)...`,
|
||||||
|
data: { model },
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isRecipe = await detectRecipe(text);
|
const isRecipe = await detectRecipe(text);
|
||||||
|
|
||||||
if (!isRecipe) {
|
if (!isRecipe) {
|
||||||
|
|||||||
@@ -47,6 +47,21 @@ export class QueueManager {
|
|||||||
/** Set of subscriber callbacks */
|
/** Set of subscriber callbacks */
|
||||||
private subscribers: Set<QueueUpdateCallback> = new Set();
|
private subscribers: Set<QueueUpdateCallback> = new Set();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find queue item by URL
|
||||||
|
*
|
||||||
|
* @param url - Instagram URL to search for
|
||||||
|
* @returns Existing queue item or undefined
|
||||||
|
*/
|
||||||
|
findByUrl(url: string): QueueItem | undefined {
|
||||||
|
for (const item of this.items.values()) {
|
||||||
|
if (item.url === url) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add URL to processing queue
|
* Add URL to processing queue
|
||||||
*
|
*
|
||||||
@@ -60,6 +75,13 @@ export class QueueManager {
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
enqueue(url: string): QueueItem {
|
enqueue(url: string): QueueItem {
|
||||||
|
// Check for duplicate URL
|
||||||
|
const existingItem = this.findByUrl(url);
|
||||||
|
if (existingItem) {
|
||||||
|
console.log(`[QueueManager] Duplicate URL detected: ${url}, returning existing item ${existingItem.id}`);
|
||||||
|
return existingItem;
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const item: QueueItem = {
|
const item: QueueItem = {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
|
|||||||
@@ -12,15 +12,30 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { queueManager } from './QueueManager';
|
import { queueManager } from './QueueManager';
|
||||||
import { extractTextAndThumbnail } from '$lib/server/extraction';
|
import { extractTextAndThumbnail as extractWithPlaywright } from '$lib/server/extraction';
|
||||||
|
import { extractTextAndThumbnail as extractWithYtDlp } from '$lib/server/instagram-extractor';
|
||||||
import { extractRecipe } from '$lib/server/parser';
|
import { extractRecipe } from '$lib/server/parser';
|
||||||
import { uploadRecipeWithIngredientsDTO, uploadRecipeImage } from '$lib/server/tandoor';
|
import { uploadRecipeWithIngredientsDTO, uploadRecipeImage } from '$lib/server/tandoor';
|
||||||
import { pushNotificationService } from '$lib/server/notifications/PushNotificationService';
|
import { pushNotificationService } from '$lib/server/notifications/PushNotificationService';
|
||||||
import { queueConfig } from './config';
|
import { queueConfig } from './config';
|
||||||
import { logError } from '../utils/logger';
|
import { logError } from '../utils/logger';
|
||||||
import type { ProgressEvent } from '$lib/server/extraction';
|
import { env } from '$env/dynamic/private';
|
||||||
|
import type { ProgressEvent, ExtractedContent, ProgressCallback } from '$lib/server/extraction';
|
||||||
import type { QueueItem } from './types';
|
import type { QueueItem } from './types';
|
||||||
|
|
||||||
|
// Feature flag: pick which Instagram extractor backend to invoke.
|
||||||
|
// Default to yt-dlp; set EXTRACTOR_BACKEND=playwright to fall back to the
|
||||||
|
// legacy stealth scraper while we verify the new path.
|
||||||
|
const extractTextAndThumbnail = (
|
||||||
|
url: string,
|
||||||
|
cb?: ProgressCallback
|
||||||
|
): Promise<ExtractedContent> => {
|
||||||
|
const backend = (env.EXTRACTOR_BACKEND ?? 'ytdlp').toLowerCase();
|
||||||
|
return backend === 'playwright'
|
||||||
|
? extractWithPlaywright(url, cb)
|
||||||
|
: extractWithYtDlp(url, cb);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queue processor with configurable concurrency
|
* Queue processor with configurable concurrency
|
||||||
*
|
*
|
||||||
@@ -250,7 +265,9 @@ export class QueueProcessor {
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[QueueProcessor] Parsing recipe: ${item.id}`);
|
console.log(`[QueueProcessor] Parsing recipe: ${item.id}`);
|
||||||
const recipe = await extractRecipe(item.extractedText);
|
const recipe = await extractRecipe(item.extractedText, (event) => {
|
||||||
|
queueManager.addProgressEvent(item.id, event);
|
||||||
|
});
|
||||||
|
|
||||||
if (!recipe) {
|
if (!recipe) {
|
||||||
throw new Error('Failed to parse recipe from extracted text');
|
throw new Error('Failed to parse recipe from extracted text');
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
import QueueItemCard from './components/QueueItemCard.svelte';
|
import QueueItemCard from './components/QueueItemCard.svelte';
|
||||||
import NotificationSettings from './components/NotificationSettings.svelte';
|
import NotificationSettings from './components/NotificationSettings.svelte';
|
||||||
import { replaceState } from '$app/navigation';
|
import { replaceState } from '$app/navigation';
|
||||||
|
import { pushNotificationManager } from '$lib/client/PushNotificationManager';
|
||||||
|
import type { NotificationState } from '$lib/client/PushNotificationManager';
|
||||||
|
|
||||||
let items = $state<QueueItem[]>([]);
|
let items = $state<QueueItem[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
@@ -14,6 +16,8 @@
|
|||||||
let eventSource = $state<EventSource | null>(null);
|
let eventSource = $state<EventSource | null>(null);
|
||||||
let connectionStatus = $state<'connecting' | 'connected' | 'disconnected'>('disconnected');
|
let connectionStatus = $state<'connecting' | 'connected' | 'disconnected'>('disconnected');
|
||||||
let lastPing = $state<string | null>(null);
|
let lastPing = $state<string | null>(null);
|
||||||
|
let hasAttemptedAutoSubscribe = $state(false);
|
||||||
|
let notificationViewModel = $state<NotificationState | null>(null);
|
||||||
|
|
||||||
// Get highlighted item ID from URL params (when redirected from Share page)
|
// Get highlighted item ID from URL params (when redirected from Share page)
|
||||||
let highlightId = $derived($page.url.searchParams.get('highlight'));
|
let highlightId = $derived($page.url.searchParams.get('highlight'));
|
||||||
@@ -35,10 +39,16 @@
|
|||||||
return items.filter(item => item.status === filter);
|
return items.filter(item => item.status === filter);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let unsubscribeNotifications: (() => void) | undefined;
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await loadQueueItems();
|
await loadQueueItems();
|
||||||
if (browser) {
|
if (browser) {
|
||||||
startSSEConnection();
|
startSSEConnection();
|
||||||
|
setupAutoSubscribe();
|
||||||
|
unsubscribeNotifications = pushNotificationManager.onStateChange((newState) => {
|
||||||
|
notificationViewModel = newState;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,6 +58,8 @@
|
|||||||
eventSource.close();
|
eventSource.close();
|
||||||
connectionStatus = 'disconnected';
|
connectionStatus = 'disconnected';
|
||||||
}
|
}
|
||||||
|
// Add notification state cleanup
|
||||||
|
unsubscribeNotifications?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadQueueItems() {
|
async function loadQueueItems() {
|
||||||
@@ -125,6 +137,41 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup automatic notification subscription on first user interaction
|
||||||
|
*
|
||||||
|
* Follows Web Push API best practices: subscription requires user gesture.
|
||||||
|
* Listens for first click/touch anywhere on page, checks if notifications
|
||||||
|
* are supported but not subscribed, then auto-subscribes.
|
||||||
|
*/
|
||||||
|
function setupAutoSubscribe() {
|
||||||
|
if (hasAttemptedAutoSubscribe) return;
|
||||||
|
|
||||||
|
const attemptSubscribe = async () => {
|
||||||
|
if (hasAttemptedAutoSubscribe) return;
|
||||||
|
hasAttemptedAutoSubscribe = true;
|
||||||
|
|
||||||
|
const state = pushNotificationManager.getState();
|
||||||
|
|
||||||
|
// Only auto-subscribe if:
|
||||||
|
// - Browser supports notifications
|
||||||
|
// - Permission is not denied
|
||||||
|
// - Not already subscribed
|
||||||
|
if (state.supported && state.permission !== 'denied' && !state.subscribed) {
|
||||||
|
console.log('[HomePage] Auto-subscribing to notifications on first interaction');
|
||||||
|
await pushNotificationManager.subscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove listener after first attempt
|
||||||
|
document.removeEventListener('click', attemptSubscribe);
|
||||||
|
document.removeEventListener('touchstart', attemptSubscribe);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for first user interaction
|
||||||
|
document.addEventListener('click', attemptSubscribe, { once: true });
|
||||||
|
document.addEventListener('touchstart', attemptSubscribe, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
function updateQueueItem(update: QueueStatusUpdate) {
|
function updateQueueItem(update: QueueStatusUpdate) {
|
||||||
// Find and update the item in the list
|
// Find and update the item in the list
|
||||||
const itemIndex = items.findIndex(item => item.id === update.itemId);
|
const itemIndex = items.findIndex(item => item.id === update.itemId);
|
||||||
@@ -223,36 +270,50 @@
|
|||||||
|
|
||||||
<!-- Action Bar -->
|
<!-- Action Bar -->
|
||||||
<div class="mb-6 flex flex-col sm:flex-row gap-4 justify-between items-start sm:items-center">
|
<div class="mb-6 flex flex-col sm:flex-row gap-4 justify-between items-start sm:items-center">
|
||||||
<!-- Filter Tabs -->
|
<div class="flex items-center gap-4 w-full sm:w-auto">
|
||||||
<div class="flex flex-wrap gap-2">
|
<!-- Filter Dropdown -->
|
||||||
{#each filters as filterOption}
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<label for="filter-select" class="text-sm font-medium text-gray-700">Filter:</label>
|
||||||
onclick={() => filter = filterOption.id}
|
<select
|
||||||
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {filter === filterOption.id
|
id="filter-select"
|
||||||
? 'bg-blue-600 text-white'
|
bind:value={filter}
|
||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'}"
|
class="px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium bg-white text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors"
|
||||||
>
|
>
|
||||||
{filterOption.name}
|
{#each filters as filterOption}
|
||||||
{#if filterOption.count > 0}
|
<option value={filterOption.id}>
|
||||||
<span class="ml-1 {filter === filterOption.id ? 'text-blue-100' : 'text-gray-500'}">
|
{filterOption.name} ({filterOption.count})
|
||||||
({filterOption.count})
|
</option>
|
||||||
</span>
|
{/each}
|
||||||
{/if}
|
</select>
|
||||||
</button>
|
</div>
|
||||||
{/each}
|
|
||||||
|
<!-- Refresh Button (moved to same row) -->
|
||||||
|
<button
|
||||||
|
onclick={loadQueueItems}
|
||||||
|
disabled={loading}
|
||||||
|
title="Refresh queue"
|
||||||
|
aria-label="Refresh queue"
|
||||||
|
class="flex items-center p-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5 {loading ? 'animate-spin' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Refresh Button -->
|
<!-- Add Recipe Button (icon-only, visible when items exist) -->
|
||||||
<button
|
{#if items.length > 0}
|
||||||
onclick={loadQueueItems}
|
<a
|
||||||
disabled={loading}
|
href="/share"
|
||||||
class="flex items-center space-x-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 disabled:opacity-50 transition-colors"
|
title="Add recipe URL"
|
||||||
>
|
aria-label="Add recipe URL"
|
||||||
<svg class="w-4 h-4 {loading ? 'animate-spin' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
class="inline-flex items-center p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
>
|
||||||
</svg>
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<span>Refresh</span>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||||
</button>
|
</svg>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
@@ -316,28 +377,53 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Notification Settings - Always visible -->
|
<!-- Notification Settings - Always visible -->
|
||||||
<div class="mt-8">
|
<div class="mt-8" data-notification-settings>
|
||||||
<NotificationSettings />
|
<NotificationSettings />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Connection Status -->
|
<!-- Footer Status Bar (icons only) -->
|
||||||
<div class="fixed bottom-4 right-4">
|
<div class="fixed bottom-0 left-0 right-0 bg-white border-t shadow-lg z-50">
|
||||||
<div class="flex items-center space-x-2 px-3 py-2 bg-white border rounded-lg shadow-sm text-sm">
|
<div class="mx-auto max-w-6xl px-6 py-3 flex items-center justify-between">
|
||||||
<div class="w-2 h-2 rounded-full {
|
<!-- Notification Status Icon (left) -->
|
||||||
connectionStatus === 'connected' ? 'bg-green-400' :
|
<button
|
||||||
connectionStatus === 'connecting' ? 'bg-yellow-400' :
|
onclick={() => {
|
||||||
'bg-red-400'
|
// Scroll to NotificationSettings component
|
||||||
}"></div>
|
document.querySelector('[data-notification-settings]')?.scrollIntoView({ behavior: 'smooth' });
|
||||||
<span class="text-gray-600">
|
}}
|
||||||
{connectionStatus === 'connected' ? 'Live updates' :
|
title={notificationViewModel?.subscribed ? 'Notifications enabled' : notificationViewModel?.supported ? 'Notifications disabled' : 'Notifications not supported'}
|
||||||
connectionStatus === 'connecting' ? 'Connecting...' :
|
aria-label="Notification status"
|
||||||
'Disconnected'}
|
class="p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||||
</span>
|
>
|
||||||
{#if lastPing}
|
{#if !notificationViewModel?.supported || notificationViewModel?.permission === 'denied'}
|
||||||
<span class="text-xs text-gray-400">
|
<!-- Not supported / denied - bell with slash -->
|
||||||
({new Date(lastPing).toLocaleTimeString()})
|
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</span>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728L5.636 5.636m12.728 12.728L18.364 5.636M5.636 18.364l12.728-12.728"></path>
|
||||||
{/if}
|
</svg>
|
||||||
|
{:else if notificationViewModel?.subscribed}
|
||||||
|
<!-- Enabled - bell icon (green) -->
|
||||||
|
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-5-5-5 5h5v-8a1 1 0 011-1h8a1 1 0 011 1v1a1 1 0 01-1 1h-7v7z"></path>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<!-- Disabled - bell icon (gray) -->
|
||||||
|
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-5-5-5 5h5v-8a1 1 0 011-1h8a1 1 0 011 1v1a1 1 0 01-1 1h-7v7z"></path>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Live Update Indicator (right) -->
|
||||||
|
<div
|
||||||
|
title={connectionStatus === 'connected' ? 'Live updates active' : connectionStatus === 'connecting' ? 'Connecting to live updates...' : 'Live updates disconnected'}
|
||||||
|
aria-label="Live update status"
|
||||||
|
class="flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<div class="w-2 h-2 rounded-full {
|
||||||
|
connectionStatus === 'connected' ? 'bg-green-600' :
|
||||||
|
connectionStatus === 'connecting' ? 'bg-yellow-600' :
|
||||||
|
'bg-red-600'
|
||||||
|
}"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,34 +1,48 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { checkLLMHealth } from '$lib/server/llm';
|
import { env } from '$env/dynamic/private';
|
||||||
|
import { checkLLMHealth, isModelLoaded } from '$lib/server/llm';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Health check endpoint for LLM service
|
* Health check endpoint for the LLM service (llama-swap on ideapad).
|
||||||
* Tests connectivity to LM Studio or OpenAI-compatible endpoint
|
*
|
||||||
|
* Three states:
|
||||||
|
* - ok → endpoint reachable AND configured model is loaded in VRAM
|
||||||
|
* - warming → endpoint reachable but configured model not yet loaded
|
||||||
|
* (next request will trigger a cold load)
|
||||||
|
* - error → endpoint unreachable
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const isHealthy = await checkLLMHealth();
|
const reachable = await checkLLMHealth();
|
||||||
|
const configuredModel = env.LLM_MODEL || 'gpt-4o';
|
||||||
|
|
||||||
if (isHealthy) {
|
if (!reachable) {
|
||||||
return json({
|
|
||||||
status: 'healthy',
|
|
||||||
message: 'LLM service is accessible'
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
return json(
|
return json(
|
||||||
{
|
{
|
||||||
status: 'unhealthy',
|
status: 'error',
|
||||||
message: 'LLM service is not accessible'
|
message: 'LLM service is not accessible',
|
||||||
|
configuredModel
|
||||||
},
|
},
|
||||||
{ status: 503 }
|
{ status: 503 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const warm = await isModelLoaded(configuredModel);
|
||||||
|
return json({
|
||||||
|
status: warm ? 'ok' : 'warming',
|
||||||
|
message: warm
|
||||||
|
? `Model ${configuredModel} loaded and ready`
|
||||||
|
: `Model ${configuredModel} configured; next request will trigger a cold load`,
|
||||||
|
configuredModel,
|
||||||
|
loaded: warm
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
return json(
|
return json(
|
||||||
{
|
{
|
||||||
status: 'error',
|
status: 'error',
|
||||||
message: errorMessage
|
message: errorMessage,
|
||||||
|
configuredModel: env.LLM_MODEL || 'gpt-4o'
|
||||||
},
|
},
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -50,15 +50,35 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
throw new ValidationError(validation.error || 'Invalid Instagram URL');
|
throw new ValidationError(validation.error || 'Invalid Instagram URL');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enqueue the URL
|
// Check for duplicate before enqueueing
|
||||||
|
const existingItem = queueManager.findByUrl(url);
|
||||||
|
|
||||||
|
if (existingItem) {
|
||||||
|
// Return info response for duplicate
|
||||||
|
return json({
|
||||||
|
duplicate: true,
|
||||||
|
message: 'This recipe is already in the queue',
|
||||||
|
item: {
|
||||||
|
id: existingItem.id,
|
||||||
|
url: existingItem.url,
|
||||||
|
status: existingItem.status,
|
||||||
|
enqueuedAt: existingItem.enqueuedAt
|
||||||
|
}
|
||||||
|
}, { status: 200 }); // 200 OK, not an error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enqueue new URL
|
||||||
const queueItem = queueManager.enqueue(url);
|
const queueItem = queueManager.enqueue(url);
|
||||||
|
|
||||||
// Return minimal response (full details available at GET /api/queue/{id})
|
// Return success response
|
||||||
return json({
|
return json({
|
||||||
id: queueItem.id,
|
duplicate: false,
|
||||||
url: queueItem.url,
|
item: {
|
||||||
status: queueItem.status,
|
id: queueItem.id,
|
||||||
enqueuedAt: queueItem.enqueuedAt
|
url: queueItem.url,
|
||||||
|
status: queueItem.status,
|
||||||
|
enqueuedAt: queueItem.enqueuedAt
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleApiError(error);
|
return handleApiError(error);
|
||||||
|
|||||||
@@ -12,10 +12,6 @@
|
|||||||
|
|
||||||
let unsubscribe: (() => void) | null = null;
|
let unsubscribe: (() => void) | null = null;
|
||||||
|
|
||||||
// Test notification state
|
|
||||||
let testLoading = $state<boolean>(false);
|
|
||||||
let testMessage = $state<string | null>(null);
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Subscribe to state changes
|
// Subscribe to state changes
|
||||||
unsubscribe = pushNotificationManager.onStateChange((newState) => {
|
unsubscribe = pushNotificationManager.onStateChange((newState) => {
|
||||||
@@ -54,35 +50,6 @@
|
|||||||
function canToggle(): boolean {
|
function canToggle(): boolean {
|
||||||
return viewModel.supported && viewModel.permission !== 'denied' && !viewModel.loading;
|
return viewModel.supported && viewModel.permission !== 'denied' && !viewModel.loading;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendTestNotification(type: 'success' | 'error' | 'progress') {
|
|
||||||
testLoading = true;
|
|
||||||
testMessage = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/notifications/test', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ type })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to send test notification');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
testMessage = `✓ Test ${type} notification sent to ${result.subscriberCount} subscriber(s)`;
|
|
||||||
} catch (error) {
|
|
||||||
testMessage = `✗ Error: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
|
||||||
} finally {
|
|
||||||
testLoading = false;
|
|
||||||
|
|
||||||
// Auto-dismiss message after 3 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
testMessage = null;
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="bg-white border rounded-lg shadow-sm p-6">
|
<div class="bg-white border rounded-lg shadow-sm p-6">
|
||||||
@@ -212,54 +179,4 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Test Notification Buttons (only shown when subscribed) -->
|
|
||||||
{#if viewModel.subscribed}
|
|
||||||
<div class="mt-6 pt-6 border-t border-gray-200">
|
|
||||||
<h4 class="text-sm font-medium text-gray-900 mb-3">Test Notifications</h4>
|
|
||||||
<p class="text-sm text-gray-600 mb-4">
|
|
||||||
Send a test notification to verify your subscription is working correctly.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<button
|
|
||||||
onclick={() => sendTestNotification('success')}
|
|
||||||
disabled={testLoading || viewModel.loading}
|
|
||||||
class="px-3 py-2 bg-green-600 text-white text-sm font-medium rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
||||||
>
|
|
||||||
{testLoading ? 'Sending...' : 'Test Success'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onclick={() => sendTestNotification('error')}
|
|
||||||
disabled={testLoading || viewModel.loading}
|
|
||||||
class="px-3 py-2 bg-red-600 text-white text-sm font-medium rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
||||||
>
|
|
||||||
{testLoading ? 'Sending...' : 'Test Error'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onclick={() => sendTestNotification('progress')}
|
|
||||||
disabled={testLoading || viewModel.loading}
|
|
||||||
class="px-3 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
||||||
>
|
|
||||||
{testLoading ? 'Sending...' : 'Test Progress'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Test Message -->
|
|
||||||
{#if testMessage}
|
|
||||||
<div class="mt-4 p-3 rounded-lg {testMessage.startsWith('✓') ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}">
|
|
||||||
<div class="flex items-start space-x-2">
|
|
||||||
<svg class="w-4 h-4 flex-shrink-0 mt-0.5 {testMessage.startsWith('✓') ? 'text-green-400' : 'text-red-400'}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={testMessage.startsWith('✓') ? "M5 13l4 4L19 7" : "M6 18L18 6M6 6l12 12"}></path>
|
|
||||||
</svg>
|
|
||||||
<div class="text-sm {testMessage.startsWith('✓') ? 'text-green-800' : 'text-red-800'}">
|
|
||||||
{testMessage}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
import { page } from 'vitest/browser';
|
|
||||||
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { render } from 'vitest-browser-svelte';
|
|
||||||
import NotificationSettings from './NotificationSettings.svelte';
|
|
||||||
import { pushNotificationManager } from '$lib/client/PushNotificationManager';
|
|
||||||
|
|
||||||
// Mock the pushNotificationManager
|
|
||||||
vi.mock('$lib/client/PushNotificationManager', () => ({
|
|
||||||
pushNotificationManager: {
|
|
||||||
onStateChange: vi.fn(),
|
|
||||||
toggleSubscription: vi.fn()
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('NotificationSettings test buttons', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
// Mock fetch using vi.stubGlobal for browser environment
|
|
||||||
vi.stubGlobal('fetch', vi.fn());
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should not show test buttons when not subscribed', async () => {
|
|
||||||
vi.mocked(pushNotificationManager.onStateChange).mockImplementation((callback) => {
|
|
||||||
callback({
|
|
||||||
supported: true,
|
|
||||||
permission: 'granted',
|
|
||||||
subscribed: false,
|
|
||||||
loading: false,
|
|
||||||
error: null
|
|
||||||
});
|
|
||||||
return () => {};
|
|
||||||
});
|
|
||||||
|
|
||||||
render(NotificationSettings);
|
|
||||||
|
|
||||||
// Test Notifications section should not be visible
|
|
||||||
const testSection = page.getByText('Test Notifications');
|
|
||||||
await expect.element(testSection).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should show test buttons when subscribed', async () => {
|
|
||||||
vi.mocked(pushNotificationManager.onStateChange).mockImplementation((callback) => {
|
|
||||||
callback({
|
|
||||||
supported: true,
|
|
||||||
permission: 'granted',
|
|
||||||
subscribed: true,
|
|
||||||
loading: false,
|
|
||||||
error: null
|
|
||||||
});
|
|
||||||
return () => {};
|
|
||||||
});
|
|
||||||
|
|
||||||
render(NotificationSettings);
|
|
||||||
|
|
||||||
await expect.element(page.getByText('Test Success')).toBeInTheDocument();
|
|
||||||
await expect.element(page.getByText('Test Error')).toBeInTheDocument();
|
|
||||||
await expect.element(page.getByText('Test Progress')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should send test success notification on button click', async () => {
|
|
||||||
vi.mocked(fetch).mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({ success: true, subscriberCount: 1 })
|
|
||||||
} as Response);
|
|
||||||
|
|
||||||
vi.mocked(pushNotificationManager.onStateChange).mockImplementation((callback) => {
|
|
||||||
callback({
|
|
||||||
supported: true,
|
|
||||||
permission: 'granted',
|
|
||||||
subscribed: true,
|
|
||||||
loading: false,
|
|
||||||
error: null
|
|
||||||
});
|
|
||||||
return () => {};
|
|
||||||
});
|
|
||||||
|
|
||||||
render(NotificationSettings);
|
|
||||||
|
|
||||||
const button = page.getByText('Test Success');
|
|
||||||
await button.click();
|
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledWith('/api/notifications/test', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ type: 'success' })
|
|
||||||
});
|
|
||||||
|
|
||||||
const successMessage = page.getByText(/✓ Test success notification sent to 1 subscriber/i);
|
|
||||||
await expect.element(successMessage).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should send test error notification on button click', async () => {
|
|
||||||
vi.mocked(fetch).mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({ success: true, subscriberCount: 2 })
|
|
||||||
} as Response);
|
|
||||||
|
|
||||||
vi.mocked(pushNotificationManager.onStateChange).mockImplementation((callback) => {
|
|
||||||
callback({
|
|
||||||
supported: true,
|
|
||||||
permission: 'granted',
|
|
||||||
subscribed: true,
|
|
||||||
loading: false,
|
|
||||||
error: null
|
|
||||||
});
|
|
||||||
return () => {};
|
|
||||||
});
|
|
||||||
|
|
||||||
render(NotificationSettings);
|
|
||||||
|
|
||||||
const button = page.getByText('Test Error');
|
|
||||||
await button.click();
|
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledWith('/api/notifications/test', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ type: 'error' })
|
|
||||||
});
|
|
||||||
|
|
||||||
const successMessage = page.getByText(/✓ Test error notification sent/i);
|
|
||||||
await expect.element(successMessage).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should send test progress notification on button click', async () => {
|
|
||||||
vi.mocked(fetch).mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({ success: true, subscriberCount: 1 })
|
|
||||||
} as Response);
|
|
||||||
|
|
||||||
vi.mocked(pushNotificationManager.onStateChange).mockImplementation((callback) => {
|
|
||||||
callback({
|
|
||||||
supported: true,
|
|
||||||
permission: 'granted',
|
|
||||||
subscribed: true,
|
|
||||||
loading: false,
|
|
||||||
error: null
|
|
||||||
});
|
|
||||||
return () => {};
|
|
||||||
});
|
|
||||||
|
|
||||||
render(NotificationSettings);
|
|
||||||
|
|
||||||
const button = page.getByText('Test Progress');
|
|
||||||
await button.click();
|
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledWith('/api/notifications/test', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ type: 'progress' })
|
|
||||||
});
|
|
||||||
|
|
||||||
const successMessage = page.getByText(/✓ Test progress notification sent/i);
|
|
||||||
await expect.element(successMessage).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should display error message on failed request', async () => {
|
|
||||||
vi.mocked(fetch).mockResolvedValue({
|
|
||||||
ok: false,
|
|
||||||
status: 500
|
|
||||||
} as Response);
|
|
||||||
|
|
||||||
vi.mocked(pushNotificationManager.onStateChange).mockImplementation((callback) => {
|
|
||||||
callback({
|
|
||||||
supported: true,
|
|
||||||
permission: 'granted',
|
|
||||||
subscribed: true,
|
|
||||||
loading: false,
|
|
||||||
error: null
|
|
||||||
});
|
|
||||||
return () => {};
|
|
||||||
});
|
|
||||||
|
|
||||||
render(NotificationSettings);
|
|
||||||
|
|
||||||
const button = page.getByText('Test Success');
|
|
||||||
await button.click();
|
|
||||||
|
|
||||||
const errorMessage = page.getByText(/✗ Error:/i);
|
|
||||||
await expect.element(errorMessage).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should auto-dismiss message after 3 seconds', async () => {
|
|
||||||
vi.mocked(fetch).mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({ success: true, subscriberCount: 1 })
|
|
||||||
} as Response);
|
|
||||||
|
|
||||||
vi.mocked(pushNotificationManager.onStateChange).mockImplementation((callback) => {
|
|
||||||
callback({
|
|
||||||
supported: true,
|
|
||||||
permission: 'granted',
|
|
||||||
subscribed: true,
|
|
||||||
loading: false,
|
|
||||||
error: null
|
|
||||||
});
|
|
||||||
return () => {};
|
|
||||||
});
|
|
||||||
|
|
||||||
render(NotificationSettings);
|
|
||||||
|
|
||||||
const button = page.getByText('Test Success');
|
|
||||||
await button.click();
|
|
||||||
|
|
||||||
// Message should appear
|
|
||||||
const successMessage = page.getByText(/✓ Test success notification sent to 1 subscriber/i);
|
|
||||||
await expect.element(successMessage).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should disable buttons during loading', async () => {
|
|
||||||
// Create a promise that we can control
|
|
||||||
let resolvePromise: ((value: any) => void) | undefined;
|
|
||||||
const fetchPromise = new Promise((resolve) => {
|
|
||||||
resolvePromise = resolve;
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mocked(fetch).mockReturnValue(fetchPromise as any);
|
|
||||||
|
|
||||||
vi.mocked(pushNotificationManager.onStateChange).mockImplementation((callback) => {
|
|
||||||
callback({
|
|
||||||
supported: true,
|
|
||||||
permission: 'granted',
|
|
||||||
subscribed: true,
|
|
||||||
loading: false,
|
|
||||||
error: null
|
|
||||||
});
|
|
||||||
return () => {};
|
|
||||||
});
|
|
||||||
|
|
||||||
render(NotificationSettings);
|
|
||||||
|
|
||||||
const successButton = page.getByRole('button', { name: 'Test Success' });
|
|
||||||
|
|
||||||
// Click a button to start loading
|
|
||||||
await successButton.click();
|
|
||||||
|
|
||||||
// Button should show "Sending..." text
|
|
||||||
const sendingButton = page.getByRole('button', { name: 'Sending...' }).first();
|
|
||||||
await expect.element(sendingButton).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Cleanup - resolve the promise
|
|
||||||
resolvePromise?.({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({ success: true, subscriberCount: 1 })
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
type HealthStatus = 'checking' | 'ok' | 'warming' | 'error';
|
||||||
|
|
||||||
interface HealthState {
|
interface HealthState {
|
||||||
status: 'checking' | 'healthy' | 'unhealthy' | 'error';
|
status: HealthStatus;
|
||||||
message: string;
|
message: string;
|
||||||
|
configuredModel: string;
|
||||||
lastChecked: Date | null;
|
lastChecked: Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,6 +17,7 @@
|
|||||||
let health = $state<HealthState>({
|
let health = $state<HealthState>({
|
||||||
status: 'checking',
|
status: 'checking',
|
||||||
message: '',
|
message: '',
|
||||||
|
configuredModel: '',
|
||||||
lastChecked: null
|
lastChecked: null
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -21,24 +25,26 @@
|
|||||||
try {
|
try {
|
||||||
const res = await fetch('/api/llm-health');
|
const res = await fetch('/api/llm-health');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
const status: HealthStatus =
|
||||||
|
data.status === 'ok' ? 'ok' : data.status === 'warming' ? 'warming' : 'error';
|
||||||
health = {
|
health = {
|
||||||
status: data.status === 'healthy' ? 'healthy' : 'unhealthy',
|
status,
|
||||||
message: data.message,
|
message: data.message ?? '',
|
||||||
|
configuredModel: data.configuredModel ?? '',
|
||||||
lastChecked: new Date()
|
lastChecked: new Date()
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
health = {
|
health = {
|
||||||
status: 'error',
|
status: 'error',
|
||||||
message: e instanceof Error ? e.message : 'Network error',
|
message: e instanceof Error ? e.message : 'Network error',
|
||||||
|
configuredModel: '',
|
||||||
lastChecked: new Date()
|
lastChecked: new Date()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use onMount instead of $effect for timer-based side effects
|
|
||||||
// onMount only runs in browser, no SSR guard needed
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
checkHealth(); // Initial check
|
checkHealth();
|
||||||
const interval = setInterval(checkHealth, pollInterval);
|
const interval = setInterval(checkHealth, pollInterval);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
});
|
});
|
||||||
@@ -48,12 +54,12 @@
|
|||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
{#if health.status === 'checking'}
|
{#if health.status === 'checking'}
|
||||||
🟡 <span>Checking LLM...</span>
|
🟡 <span>Checking LLM...</span>
|
||||||
{:else if health.status === 'healthy'}
|
{:else if health.status === 'ok'}
|
||||||
🟢 <span class="text-green-600">LLM Ready</span>
|
🟢 <span class="text-green-600">LLM Ready</span>
|
||||||
{:else if health.status === 'unhealthy'}
|
{:else if health.status === 'warming'}
|
||||||
🔴 <span class="text-red-600">LLM Unavailable</span>
|
🟡 <span class="text-yellow-600">LLM Cold ({health.configuredModel})</span>
|
||||||
{:else}
|
{:else}
|
||||||
🔴 <span class="text-red-600">LLM Error</span>
|
🔴 <span class="text-red-600">LLM Unavailable</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-gray-500" title={health.message}>
|
<div class="text-xs text-gray-500" title={health.message}>
|
||||||
|
|||||||
131
src/routes/test/+page.svelte
Normal file
131
src/routes/test/+page.svelte
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { pushNotificationManager, type NotificationState } from '$lib/client/PushNotificationManager';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
let viewModel = $state<NotificationState>({
|
||||||
|
supported: false,
|
||||||
|
permission: 'default',
|
||||||
|
subscribed: false,
|
||||||
|
loading: false,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
|
||||||
|
let testLoading = $state<boolean>(false);
|
||||||
|
let testMessage = $state<string | null>(null);
|
||||||
|
let unsubscribe: (() => void) | null = null;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
unsubscribe = pushNotificationManager.onStateChange((newState) => {
|
||||||
|
viewModel = newState;
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe?.();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
async function sendTestNotification(type: 'success' | 'error' | 'progress') {
|
||||||
|
testLoading = true;
|
||||||
|
testMessage = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/notifications/test', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ type })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to send test notification');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
testMessage = `✓ Test ${type} notification sent to ${result.subscriberCount} subscriber(s)`;
|
||||||
|
} catch (error) {
|
||||||
|
testMessage = `✗ Error: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||||
|
} finally {
|
||||||
|
testLoading = false;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
testMessage = null;
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>InstaRecipe - Notification Tests</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="mx-auto p-6 max-w-4xl">
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-bold mb-2">Notification Testing</h1>
|
||||||
|
<p class="text-gray-600">Debug endpoint for testing push notifications</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !viewModel.subscribed}
|
||||||
|
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
|
||||||
|
<div class="flex items-start space-x-2">
|
||||||
|
<svg class="w-5 h-5 text-yellow-400 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 15.5c-.77.833.192 2.5 1.732 2.5z"></path>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-yellow-800">Not Subscribed</div>
|
||||||
|
<div class="text-sm text-yellow-700">
|
||||||
|
You must enable push notifications on the <a href="/" class="underline">homepage</a> before testing.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="bg-white border rounded-lg shadow-sm p-6">
|
||||||
|
<h2 class="text-lg font-medium text-gray-900 mb-4">Test Notifications</h2>
|
||||||
|
<p class="text-sm text-gray-600 mb-6">
|
||||||
|
Send test notifications to verify your subscription is working correctly.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-3 mb-6">
|
||||||
|
<button
|
||||||
|
onclick={() => sendTestNotification('success')}
|
||||||
|
disabled={testLoading || !viewModel.subscribed}
|
||||||
|
class="px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{testLoading ? 'Sending...' : 'Test Success'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={() => sendTestNotification('error')}
|
||||||
|
disabled={testLoading || !viewModel.subscribed}
|
||||||
|
class="px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{testLoading ? 'Sending...' : 'Test Error'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={() => sendTestNotification('progress')}
|
||||||
|
disabled={testLoading || !viewModel.subscribed}
|
||||||
|
class="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{testLoading ? 'Sending...' : 'Test Progress'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if testMessage}
|
||||||
|
<div class="p-3 rounded-lg {testMessage.startsWith('✓') ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}">
|
||||||
|
<div class="flex items-start space-x-2">
|
||||||
|
<svg class="w-4 h-4 flex-shrink-0 mt-0.5 {testMessage.startsWith('✓') ? 'text-green-400' : 'text-red-400'}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={testMessage.startsWith('✓') ? "M5 13l4 4L19 7" : "M6 18L18 6M6 6l12 12"}></path>
|
||||||
|
</svg>
|
||||||
|
<div class="text-sm {testMessage.startsWith('✓') ? 'text-green-800' : 'text-red-800'}">
|
||||||
|
{testMessage}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<a href="/" class="text-sm text-blue-600 hover:text-blue-700">← Back to Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -183,22 +183,36 @@ self.addEventListener('notificationclick', (event) => {
|
|||||||
|
|
||||||
let url = '/';
|
let url = '/';
|
||||||
|
|
||||||
if (action === 'view' && data?.itemId) {
|
// Handle 'view' action - redirect to Tandoor if available
|
||||||
url = `/?highlight=${data.itemId}`;
|
if (action === 'view') {
|
||||||
|
if (data?.tandoorUrl) {
|
||||||
|
// Success notification with Tandoor URL - redirect to recipe
|
||||||
|
url = data.tandoorUrl;
|
||||||
|
} else if (data?.itemId) {
|
||||||
|
// Fallback to dashboard highlight
|
||||||
|
url = `/?highlight=${data.itemId}`;
|
||||||
|
}
|
||||||
} else if (action === 'retry' && data?.itemId) {
|
} else if (action === 'retry' && data?.itemId) {
|
||||||
// Navigate to dashboard and trigger retry via postMessage
|
// Navigate to dashboard and trigger retry
|
||||||
url = `/?highlight=${data.itemId}&action=retry`;
|
url = `/?highlight=${data.itemId}&action=retry`;
|
||||||
} else if (data?.itemId) {
|
} else if (data?.itemId) {
|
||||||
|
// Default: highlight item in dashboard
|
||||||
url = `/?highlight=${data.itemId}`;
|
url = `/?highlight=${data.itemId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientsList) => {
|
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientsList) => {
|
||||||
// Check if there's already a window/tab open
|
// For external URLs (Tandoor), always open new window
|
||||||
|
if (url.startsWith('http') && !url.includes(self.location.origin)) {
|
||||||
|
if (clients.openWindow) {
|
||||||
|
return clients.openWindow(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For internal URLs, check for existing window
|
||||||
for (const client of clientsList) {
|
for (const client of clientsList) {
|
||||||
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
||||||
return client.focus().then(() => {
|
return client.focus().then(() => {
|
||||||
// Send message to the client about the action
|
|
||||||
return client.postMessage({
|
return client.postMessage({
|
||||||
type: 'notification-action',
|
type: 'notification-action',
|
||||||
action: action,
|
action: action,
|
||||||
@@ -208,7 +222,7 @@ self.addEventListener('notificationclick', (event) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no window is open, open a new one
|
// No window open, open new one
|
||||||
if (clients.openWindow) {
|
if (clients.openWindow) {
|
||||||
return clients.openWindow(url);
|
return clients.openWindow(url);
|
||||||
}
|
}
|
||||||
|
|||||||
49
src/tests/instagram-extractor.integration.spec.ts
Normal file
49
src/tests/instagram-extractor.integration.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* E2E integration test for the yt-dlp Instagram extractor.
|
||||||
|
*
|
||||||
|
* Makes real network calls (yt-dlp + Instagram CDN). Requires:
|
||||||
|
* - yt-dlp installed on PATH
|
||||||
|
* - Network access to instagram.com
|
||||||
|
* - EXTRACTOR_E2E=1 env var (safety guard to avoid running in normal test runs)
|
||||||
|
*
|
||||||
|
* Run with:
|
||||||
|
* EXTRACTOR_E2E=1 npm test -- src/tests/instagram-extractor.e2e.spec.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { extractTextAndThumbnail } from '$lib/server/instagram-extractor';
|
||||||
|
|
||||||
|
const E2E = !!process.env.EXTRACTOR_E2E;
|
||||||
|
|
||||||
|
describe.skipIf(!E2E)('instagram-extractor E2E (requires yt-dlp + network)', () => {
|
||||||
|
// Public reels that have previously been in the app queue
|
||||||
|
const TEST_REELS = [
|
||||||
|
{
|
||||||
|
url: 'https://www.instagram.com/reel/DX4XEDZt3qT/',
|
||||||
|
expectKeyword: 'pizza'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://www.instagram.com/reel/DUtHm2EiD26/',
|
||||||
|
expectKeyword: 'noodles'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { url, expectKeyword } of TEST_REELS) {
|
||||||
|
it(`extracts caption from ${url}`, async () => {
|
||||||
|
const events: { type: string; message: string }[] = [];
|
||||||
|
const result = await extractTextAndThumbnail(url, (e) =>
|
||||||
|
events.push(e as { type: string; message: string })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.bodyText.length).toBeGreaterThan(20);
|
||||||
|
expect(result.bodyText.toLowerCase()).toContain(expectKeyword);
|
||||||
|
|
||||||
|
if (result.thumbnail !== null) {
|
||||||
|
expect(result.thumbnail).toMatch(/^data:image\//);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(events.some((e) => e.type === 'complete')).toBe(true);
|
||||||
|
expect(events.some((e) => e.type === 'status' && e.message.includes('yt-dlp'))).toBe(true);
|
||||||
|
}, 90_000);
|
||||||
|
}
|
||||||
|
});
|
||||||
171
src/tests/instagram-extractor.spec.ts
Normal file
171
src/tests/instagram-extractor.spec.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
// Mock node:child_process before importing the SUT. The SUT uses
|
||||||
|
// promisify(execFile); without the Node-internal special handling, promisify
|
||||||
|
// would only forward the first callback arg. We sidestep that by returning a
|
||||||
|
// pre-promisified function tagged with util.promisify.custom that resolves
|
||||||
|
// to {stdout, stderr}.
|
||||||
|
import * as util from 'node:util';
|
||||||
|
const execFileMock = vi.fn();
|
||||||
|
vi.mock('node:child_process', () => {
|
||||||
|
const execFile: any = () => {
|
||||||
|
throw new Error('callback form not used in tests');
|
||||||
|
};
|
||||||
|
execFile[util.promisify.custom] = (cmd: string, args: string[], opts: any) =>
|
||||||
|
execFileMock(cmd, args, opts);
|
||||||
|
return { execFile };
|
||||||
|
});
|
||||||
|
|
||||||
|
const existsSyncMock = vi.fn();
|
||||||
|
vi.mock('node:fs', () => ({
|
||||||
|
existsSync: (p: string) => existsSyncMock(p)
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { extractTextAndThumbnail } from '../lib/server/instagram-extractor';
|
||||||
|
|
||||||
|
describe('instagram-extractor (yt-dlp backend)', () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
execFileMock.mockReset();
|
||||||
|
existsSyncMock.mockReset();
|
||||||
|
existsSyncMock.mockReturnValue(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses yt-dlp JSON and returns bodyText + thumbnail data URI', async () => {
|
||||||
|
execFileMock.mockResolvedValue({
|
||||||
|
stdout: JSON.stringify({
|
||||||
|
description: 'Pasta carbonara: 200g spaghetti, 100g pancetta, 2 eggs.',
|
||||||
|
thumbnail: 'https://example.com/thumb.jpg'
|
||||||
|
}),
|
||||||
|
stderr: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
|
status: 200,
|
||||||
|
headers: { get: () => 'image/jpeg' },
|
||||||
|
arrayBuffer: () => Promise.resolve(new Uint8Array([1, 2, 3]).buffer)
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const result = await extractTextAndThumbnail('https://www.instagram.com/reel/abc123/');
|
||||||
|
|
||||||
|
expect(result.bodyText).toContain('carbonara');
|
||||||
|
expect(result.thumbnail).toMatch(/^data:image\/jpeg;base64,/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to first thumbnails entry when top-level thumbnail is absent', async () => {
|
||||||
|
execFileMock.mockResolvedValue({
|
||||||
|
stdout: JSON.stringify({
|
||||||
|
description: 'Recipe text',
|
||||||
|
thumbnails: [{ url: 'https://example.com/alt-thumb.jpg' }]
|
||||||
|
}),
|
||||||
|
stderr: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
|
status: 200,
|
||||||
|
headers: { get: () => 'image/png' },
|
||||||
|
arrayBuffer: () => Promise.resolve(new Uint8Array([4, 5, 6]).buffer)
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const result = await extractTextAndThumbnail('https://www.instagram.com/reel/abc/');
|
||||||
|
expect(result.thumbnail).toMatch(/^data:image\/png;base64,/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null thumbnail when fetch fails', async () => {
|
||||||
|
execFileMock.mockResolvedValue({
|
||||||
|
stdout: JSON.stringify({
|
||||||
|
description: 'Recipe text',
|
||||||
|
thumbnail: 'https://example.com/missing.jpg'
|
||||||
|
}),
|
||||||
|
stderr: ''
|
||||||
|
});
|
||||||
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
|
status: 404,
|
||||||
|
headers: { get: () => 'text/html' },
|
||||||
|
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0))
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const result = await extractTextAndThumbnail('https://www.instagram.com/reel/abc/');
|
||||||
|
expect(result.bodyText).toBe('Recipe text');
|
||||||
|
expect(result.thumbnail).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes --cookies flag when secrets/cookies.txt exists', async () => {
|
||||||
|
existsSyncMock.mockImplementation((p: string) => p.endsWith('cookies.txt'));
|
||||||
|
execFileMock.mockResolvedValue({
|
||||||
|
stdout: JSON.stringify({ description: 'x', thumbnail: null }),
|
||||||
|
stderr: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
await extractTextAndThumbnail('https://www.instagram.com/reel/abc/');
|
||||||
|
|
||||||
|
const [, args] = execFileMock.mock.calls[0];
|
||||||
|
expect(args).toContain('--cookies');
|
||||||
|
const idx = (args as string[]).indexOf('--cookies');
|
||||||
|
expect((args as string[])[idx + 1]).toMatch(/cookies\.txt$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits --cookies flag when no cookie file is present', async () => {
|
||||||
|
existsSyncMock.mockReturnValue(false);
|
||||||
|
execFileMock.mockResolvedValue({
|
||||||
|
stdout: JSON.stringify({ description: 'x', thumbnail: null }),
|
||||||
|
stderr: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
await extractTextAndThumbnail('https://www.instagram.com/reel/abc/');
|
||||||
|
|
||||||
|
const [, args] = execFileMock.mock.calls[0];
|
||||||
|
expect(args).not.toContain('--cookies');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws non-recoverable error on "Login required" stderr', async () => {
|
||||||
|
const err: any = new Error('yt-dlp failed');
|
||||||
|
err.stderr = 'ERROR: [Instagram] xyz: Login required to access this post.';
|
||||||
|
execFileMock.mockRejectedValue(err);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
extractTextAndThumbnail('https://www.instagram.com/reel/private/')
|
||||||
|
).rejects.toThrow(/authentication/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws clear error when yt-dlp binary is missing (ENOENT)', async () => {
|
||||||
|
const err: any = new Error('not found');
|
||||||
|
err.code = 'ENOENT';
|
||||||
|
execFileMock.mockRejectedValue(err);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
extractTextAndThumbnail('https://www.instagram.com/reel/abc/')
|
||||||
|
).rejects.toThrow(/yt-dlp is not installed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when description is empty', async () => {
|
||||||
|
execFileMock.mockResolvedValue({
|
||||||
|
stdout: JSON.stringify({ description: '', thumbnail: null }),
|
||||||
|
stderr: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
extractTextAndThumbnail('https://www.instagram.com/reel/empty/')
|
||||||
|
).rejects.toThrow(/no description/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits progress events through the callback', async () => {
|
||||||
|
execFileMock.mockResolvedValue({
|
||||||
|
stdout: JSON.stringify({ description: 'x', thumbnail: null }),
|
||||||
|
stderr: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const events: any[] = [];
|
||||||
|
await extractTextAndThumbnail('https://www.instagram.com/reel/abc/', (e) =>
|
||||||
|
events.push(e)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(events.some((e) => e.type === 'status' && e.message.includes('yt-dlp'))).toBe(true);
|
||||||
|
expect(events.some((e) => e.type === 'complete')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
120
src/tests/notification-auto-subscribe.svelte.spec.ts
Normal file
120
src/tests/notification-auto-subscribe.svelte.spec.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import HomePage from '../routes/+page.svelte';
|
||||||
|
import { pushNotificationManager } from '$lib/client/PushNotificationManager';
|
||||||
|
|
||||||
|
// Mock $app/environment
|
||||||
|
vi.mock('$app/environment', () => ({
|
||||||
|
browser: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock fetch for queue API calls
|
||||||
|
globalThis.fetch = vi.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ items: [], stats: { total: 0, pending: 0, in_progress: 0, completed: 0, error: 0 } })
|
||||||
|
} as Response)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mock EventSource for SSE
|
||||||
|
globalThis.EventSource = vi.fn(() => ({
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
close: vi.fn(),
|
||||||
|
onerror: null,
|
||||||
|
onmessage: null,
|
||||||
|
onopen: null,
|
||||||
|
readyState: 0,
|
||||||
|
url: '',
|
||||||
|
withCredentials: false,
|
||||||
|
CONNECTING: 0,
|
||||||
|
OPEN: 1,
|
||||||
|
CLOSED: 2,
|
||||||
|
dispatchEvent: vi.fn()
|
||||||
|
})) as any;
|
||||||
|
|
||||||
|
vi.mock('$lib/client/PushNotificationManager', () => ({
|
||||||
|
pushNotificationManager: {
|
||||||
|
getState: vi.fn(),
|
||||||
|
subscribe: vi.fn(),
|
||||||
|
onStateChange: vi.fn(() => () => {})
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Automatic Notification Subscription', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(pushNotificationManager.getState).mockReturnValue({
|
||||||
|
supported: true,
|
||||||
|
permission: 'default',
|
||||||
|
subscribed: false,
|
||||||
|
loading: false,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should auto-subscribe on first user click when supported and not subscribed', async () => {
|
||||||
|
render(HomePage);
|
||||||
|
|
||||||
|
// Wait for component to mount and set up event listeners
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Simulate user click anywhere on page
|
||||||
|
document.body.click();
|
||||||
|
|
||||||
|
// Wait for event handlers to process
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
expect(pushNotificationManager.subscribe).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not auto-subscribe if permission denied', async () => {
|
||||||
|
vi.mocked(pushNotificationManager.getState).mockReturnValue({
|
||||||
|
supported: true,
|
||||||
|
permission: 'denied',
|
||||||
|
subscribed: false,
|
||||||
|
loading: false,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
|
||||||
|
render(HomePage);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
document.body.click();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
expect(pushNotificationManager.subscribe).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not auto-subscribe if already subscribed', async () => {
|
||||||
|
vi.mocked(pushNotificationManager.getState).mockReturnValue({
|
||||||
|
supported: true,
|
||||||
|
permission: 'granted',
|
||||||
|
subscribed: true,
|
||||||
|
loading: false,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
|
||||||
|
render(HomePage);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
document.body.click();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
expect(pushNotificationManager.subscribe).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should only attempt subscription once', async () => {
|
||||||
|
render(HomePage);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
document.body.click();
|
||||||
|
document.body.click();
|
||||||
|
document.body.click();
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
expect(pushNotificationManager.subscribe).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -37,13 +37,14 @@ describe('Queue API Endpoints', () => {
|
|||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
expect(data.id).toBeTruthy();
|
expect(data.duplicate).toBe(false);
|
||||||
expect(data.url).toBe('https://instagram.com/p/ABC123');
|
expect(data.item.id).toBeTruthy();
|
||||||
expect(data.status).toBe('pending');
|
expect(data.item.url).toBe('https://instagram.com/p/ABC123');
|
||||||
expect(data.enqueuedAt).toBeTruthy();
|
expect(data.item.status).toBe('pending');
|
||||||
|
expect(data.item.enqueuedAt).toBeTruthy();
|
||||||
|
|
||||||
// Verify item exists in queue
|
// Verify item exists in queue
|
||||||
const item = queueManager.get(data.id);
|
const item = queueManager.get(data.item.id);
|
||||||
expect(item).toBeTruthy();
|
expect(item).toBeTruthy();
|
||||||
expect(item?.url).toBe('https://instagram.com/p/ABC123');
|
expect(item?.url).toBe('https://instagram.com/p/ABC123');
|
||||||
});
|
});
|
||||||
@@ -63,10 +64,11 @@ describe('Queue API Endpoints', () => {
|
|||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
expect(data.url).toBe('https://www.instagram.com/p/XYZ789');
|
expect(data.duplicate).toBe(false);
|
||||||
|
expect(data.item.url).toBe('https://www.instagram.com/p/XYZ789');
|
||||||
|
|
||||||
// Verify item exists in queue
|
// Verify item exists in queue
|
||||||
const item = queueManager.get(data.id);
|
const item = queueManager.get(data.item.id);
|
||||||
expect(item).toBeTruthy();
|
expect(item).toBeTruthy();
|
||||||
expect(item?.url).toBe('https://www.instagram.com/p/XYZ789');
|
expect(item?.url).toBe('https://www.instagram.com/p/XYZ789');
|
||||||
});
|
});
|
||||||
@@ -83,7 +85,8 @@ describe('Queue API Endpoints', () => {
|
|||||||
const response = await queuePOST({ request } as any);
|
const response = await queuePOST({ request } as any);
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
expect(data.url).toBe('https://instagram.com/reel/ABC123');
|
expect(data.duplicate).toBe(false);
|
||||||
|
expect(data.item.url).toBe('https://instagram.com/reel/ABC123');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should accept Instagram URLs with query parameters', async () => {
|
it('should accept Instagram URLs with query parameters', async () => {
|
||||||
@@ -98,7 +101,8 @@ describe('Queue API Endpoints', () => {
|
|||||||
const response = await queuePOST({ request } as any);
|
const response = await queuePOST({ request } as any);
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
expect(data.url).toBe(
|
expect(data.duplicate).toBe(false);
|
||||||
|
expect(data.item.url).toBe(
|
||||||
'https://www.instagram.com/reel/DSevV5CDcNm/?utm_source=ig_web_copy_link'
|
'https://www.instagram.com/reel/DSevV5CDcNm/?utm_source=ig_web_copy_link'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -234,6 +238,37 @@ describe('Queue API Endpoints', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('POST /api/queue deduplication', () => {
|
||||||
|
it('should return duplicate flag when URL already exists', async () => {
|
||||||
|
const url = 'https://instagram.com/p/DUP123';
|
||||||
|
|
||||||
|
// First request
|
||||||
|
const request1 = new Request('http://localhost/api/queue', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url })
|
||||||
|
});
|
||||||
|
const response1 = await queuePOST({ request: request1 } as any);
|
||||||
|
const data1 = await response1.json();
|
||||||
|
|
||||||
|
expect(data1.duplicate).toBe(false);
|
||||||
|
|
||||||
|
// Second request (duplicate)
|
||||||
|
const request2 = new Request('http://localhost/api/queue', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url })
|
||||||
|
});
|
||||||
|
const response2 = await queuePOST({ request: request2 } as any);
|
||||||
|
const data2 = await response2.json();
|
||||||
|
|
||||||
|
expect(response2.status).toBe(200);
|
||||||
|
expect(data2.duplicate).toBe(true);
|
||||||
|
expect(data2.message).toContain('already in the queue');
|
||||||
|
expect(data2.item.id).toBe(data1.item.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('GET /api/queue', () => {
|
describe('GET /api/queue', () => {
|
||||||
it('should return empty list when no items', async () => {
|
it('should return empty list when no items', async () => {
|
||||||
const url = new URL('http://localhost/api/queue');
|
const url = new URL('http://localhost/api/queue');
|
||||||
|
|||||||
@@ -353,4 +353,28 @@ describe('QueueManager', () => {
|
|||||||
expect(callback3).toHaveBeenCalled();
|
expect(callback3).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('deduplication', () => {
|
||||||
|
it('should return existing item when enqueueing duplicate URL', () => {
|
||||||
|
const url = 'https://instagram.com/p/ABC123';
|
||||||
|
const firstItem = queueManager.enqueue(url);
|
||||||
|
const secondItem = queueManager.enqueue(url);
|
||||||
|
|
||||||
|
expect(secondItem.id).toBe(firstItem.id);
|
||||||
|
expect(queueManager.getAll()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find item by URL', () => {
|
||||||
|
const url = 'https://instagram.com/p/TEST123';
|
||||||
|
const item = queueManager.enqueue(url);
|
||||||
|
|
||||||
|
const found = queueManager.findByUrl(url);
|
||||||
|
expect(found?.id).toBe(item.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined when URL not found', () => {
|
||||||
|
const found = queueManager.findByUrl('https://instagram.com/p/NOTFOUND');
|
||||||
|
expect(found).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ vi.mock('$lib/server/tandoor', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { queueManager } from '$lib/server/queue/QueueManager';
|
import { queueManager } from '$lib/server/queue/QueueManager';
|
||||||
import * as extraction from '$lib/server/extraction';
|
import * as instagramExtractor from '$lib/server/instagram-extractor';
|
||||||
import { queueProcessor } from '$lib/server/queue/QueueProcessor';
|
import { queueProcessor } from '$lib/server/queue/QueueProcessor';
|
||||||
|
|
||||||
describe('QueueProcessor logging', () => {
|
describe('QueueProcessor logging', () => {
|
||||||
@@ -50,8 +50,8 @@ describe('QueueProcessor logging', () => {
|
|||||||
(complexError as any).code = 'ERR_TEST';
|
(complexError as any).code = 'ERR_TEST';
|
||||||
(complexError as any).details = { phase: 'extraction', retries: 3 };
|
(complexError as any).details = { phase: 'extraction', retries: 3 };
|
||||||
|
|
||||||
// Mock extraction to fail BEFORE starting processor
|
// Mock extraction to fail BEFORE starting processor (default backend = ytdlp)
|
||||||
const extractSpy = vi.spyOn(extraction, 'extractTextAndThumbnail');
|
const extractSpy = vi.spyOn(instagramExtractor, 'extractTextAndThumbnail');
|
||||||
extractSpy.mockRejectedValueOnce(complexError);
|
extractSpy.mockRejectedValueOnce(complexError);
|
||||||
|
|
||||||
const item = queueManager.enqueue('https://instagram.com/p/TEST');
|
const item = queueManager.enqueue('https://instagram.com/p/TEST');
|
||||||
|
|||||||
@@ -35,13 +35,21 @@ vi.mock('$lib/server/queue/config', () => ({
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock external dependencies BEFORE importing QueueProcessor
|
// Mock external dependencies BEFORE importing QueueProcessor.
|
||||||
|
// QueueProcessor.extractionPhase picks between two extractor modules based on
|
||||||
|
// EXTRACTOR_BACKEND; mock both so behavior is identical regardless of default.
|
||||||
vi.mock('$lib/server/extraction', () => ({
|
vi.mock('$lib/server/extraction', () => ({
|
||||||
extractTextAndThumbnail: vi.fn().mockResolvedValue({
|
extractTextAndThumbnail: vi.fn().mockResolvedValue({
|
||||||
bodyText: 'Default recipe text',
|
bodyText: 'Default recipe text',
|
||||||
thumbnail: null
|
thumbnail: null
|
||||||
})
|
})
|
||||||
}));
|
}));
|
||||||
|
vi.mock('$lib/server/instagram-extractor', () => ({
|
||||||
|
extractTextAndThumbnail: vi.fn().mockResolvedValue({
|
||||||
|
bodyText: 'Default recipe text',
|
||||||
|
thumbnail: null
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('$lib/server/parser', () => ({
|
vi.mock('$lib/server/parser', () => ({
|
||||||
extractRecipe: vi.fn().mockResolvedValue({
|
extractRecipe: vi.fn().mockResolvedValue({
|
||||||
@@ -62,11 +70,16 @@ vi.mock('$lib/server/tandoor', () => ({
|
|||||||
})
|
})
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { extractTextAndThumbnail } from '$lib/server/extraction';
|
import { extractTextAndThumbnail as extractFromExtraction } from '$lib/server/extraction';
|
||||||
|
import { extractTextAndThumbnail as extractFromYtDlp } from '$lib/server/instagram-extractor';
|
||||||
import { extractRecipe } from '$lib/server/parser';
|
import { extractRecipe } from '$lib/server/parser';
|
||||||
import { uploadRecipeWithIngredientsDTO, uploadRecipeImage } from '$lib/server/tandoor';
|
import { uploadRecipeWithIngredientsDTO, uploadRecipeImage } from '$lib/server/tandoor';
|
||||||
import * as configModule from '$lib/server/queue/config';
|
import * as configModule from '$lib/server/queue/config';
|
||||||
|
|
||||||
|
// Alias used by existing assertions; default backend is ytdlp so the new
|
||||||
|
// instagram-extractor mock is what the processor actually invokes.
|
||||||
|
const extractTextAndThumbnail = extractFromYtDlp;
|
||||||
|
|
||||||
// Import processor AFTER mocks - it will auto-start (imported for side effects)
|
// Import processor AFTER mocks - it will auto-start (imported for side effects)
|
||||||
import '$lib/server/queue/QueueProcessor';
|
import '$lib/server/queue/QueueProcessor';
|
||||||
|
|
||||||
@@ -78,8 +91,13 @@ describe('QueueProcessor Integration Tests', () => {
|
|||||||
// Reset mocks and their implementations
|
// Reset mocks and their implementations
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
|
|
||||||
// Set default mock implementations
|
// Set default mock implementations on BOTH backend modules so the test
|
||||||
vi.mocked(extractTextAndThumbnail).mockResolvedValue({
|
// behavior is invariant to EXTRACTOR_BACKEND.
|
||||||
|
vi.mocked(extractFromExtraction).mockResolvedValue({
|
||||||
|
bodyText: 'Default recipe text',
|
||||||
|
thumbnail: null
|
||||||
|
});
|
||||||
|
vi.mocked(extractFromYtDlp).mockResolvedValue({
|
||||||
bodyText: 'Default recipe text',
|
bodyText: 'Default recipe text',
|
||||||
thumbnail: null
|
thumbnail: null
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user