diff --git a/backend/internal/storage/store.go b/backend/internal/storage/store.go index ad789c1..7616e3c 100644 --- a/backend/internal/storage/store.go +++ b/backend/internal/storage/store.go @@ -74,12 +74,24 @@ func (s *Store) WriteMetadata(ctx context.Context, meta domain.BookMeta) error { "rating": meta.Rating, } // Upsert via filter: if exists PATCH, otherwise POST. + // Use a conflict-retry pattern to handle concurrent scrapes racing to insert + // the same slug: if POST fails (or another concurrent writer beat us to it), + // re-fetch and PATCH instead. existing, err := s.getBookBySlug(ctx, meta.Slug) if err != nil && err != ErrNotFound { return fmt.Errorf("WriteMetadata: %w", err) } if err == ErrNotFound { - return s.pb.post(ctx, "/api/collections/books/records", payload, nil) + postErr := s.pb.post(ctx, "/api/collections/books/records", payload, nil) + if postErr == nil { + return nil + } + // POST failed — a concurrent writer may have inserted the same slug. + // Re-fetch and fall through to PATCH. + existing, err = s.getBookBySlug(ctx, meta.Slug) + if err != nil { + return postErr // original POST error is more informative + } } return s.pb.patch(ctx, fmt.Sprintf("/api/collections/books/records/%s", existing.ID), payload) } diff --git a/ui/src/lib/server/pocketbase.ts b/ui/src/lib/server/pocketbase.ts index 733d2ed..faae223 100644 --- a/ui/src/lib/server/pocketbase.ts +++ b/ui/src/lib/server/pocketbase.ts @@ -258,8 +258,26 @@ export async function getBooksBySlugs(slugs: Iterable): Promise // Build filter: slug='a' || slug='b' || ... const filter = slugArr.map((s) => `slug='${s.replace(/'/g, "\\'")}'`).join(' || '); const books = await listAll('books', filter, '+title'); - log.debug('pocketbase', 'getBooksBySlugs', { requested: slugArr.length, found: books.length }); - return books; + + // Deduplicate by slug — PocketBase may have multiple records for the same + // slug if the scraper ran concurrently or the upsert raced. First record wins. + const seen = new Set(); + const deduped = books.filter((b) => { + if (seen.has(b.slug)) return false; + seen.add(b.slug); + return true; + }); + + if (deduped.length !== books.length) { + log.warn('pocketbase', 'getBooksBySlugs: duplicate slugs in DB', { + requested: slugArr.length, + raw: books.length, + deduped: deduped.length + }); + } else { + log.debug('pocketbase', 'getBooksBySlugs', { requested: slugArr.length, found: books.length }); + } + return deduped; } /** Invalidate the books cache (call after a book is created/updated/deleted). */ @@ -1644,3 +1662,110 @@ export async function getSubscriptionFeed( feed.sort((a, b) => b.updated.localeCompare(a.updated)); return feed.slice(0, limit).map(({ book, readerUsername }) => ({ book, readerUsername })); } + +// ─── Discovery ──────────────────────────────────────────────────────────────── +// NOTE: Requires a `discovery_votes` collection in PocketBase with fields: +// - session_id (text, required) +// - user_id (text, optional) +// - slug (text, required) +// - action (text, required) — one of: like | skip | nope | read_now + +export interface DiscoveryVote { + id?: string; + session_id: string; + user_id?: string; + slug: string; + action: 'like' | 'skip' | 'nope' | 'read_now'; +} + +export interface DiscoveryPrefs { + genres: string[]; + status: 'either' | 'ongoing' | 'completed'; +} + +function parseGenresLocal(genres: string[] | string): string[] { + if (Array.isArray(genres)) return genres; + if (!genres) return []; + try { return JSON.parse(genres) as string[]; } catch { return []; } +} + +function discoveryFilter(sessionId: string, userId?: string): string { + if (userId) return `user_id="${userId}"`; + return `session_id="${sessionId}"`; +} + +export async function getVotedSlugs(sessionId: string, userId?: string): Promise> { + const rows = await listAll( + 'discovery_votes', + discoveryFilter(sessionId, userId) + ).catch(() => [] as DiscoveryVote[]); + return new Set(rows.map((r) => r.slug)); +} + +export async function upsertDiscoveryVote( + sessionId: string, + slug: string, + action: DiscoveryVote['action'], + userId?: string +): Promise { + const filter = userId + ? `user_id="${userId}"&&slug="${slug}"` + : `session_id="${sessionId}"&&slug="${slug}"`; + const existing = await listOne('discovery_votes', filter); + const payload: Partial = { session_id: sessionId, slug, action }; + if (userId) payload.user_id = userId; + + if (existing) { + const res = await pbPatch(`/api/collections/discovery_votes/records/${existing.id}`, payload); + if (!res.ok) log.warn('pocketbase', 'upsertDiscoveryVote PATCH failed', { slug, status: res.status }); + } else { + const res = await pbPost('/api/collections/discovery_votes/records', payload); + if (!res.ok) log.warn('pocketbase', 'upsertDiscoveryVote POST failed', { slug, status: res.status }); + } +} + +export async function clearDiscoveryVotes(sessionId: string, userId?: string): Promise { + const filter = discoveryFilter(sessionId, userId); + const rows = await listAll('discovery_votes', filter).catch(() => []); + await Promise.all( + rows.map((r) => + pbDelete(`/api/collections/discovery_votes/records/${r.id}`).catch(() => {}) + ) + ); +} + +export async function getBooksForDiscovery( + sessionId: string, + userId?: string, + prefs?: DiscoveryPrefs +): Promise { + const [allBooks, votedSlugs, savedSlugs] = await Promise.all([ + listBooks(), + getVotedSlugs(sessionId, userId), + getSavedSlugs(sessionId, userId) + ]); + + let candidates = allBooks.filter((b) => !votedSlugs.has(b.slug) && !savedSlugs.has(b.slug)); + + if (prefs?.genres?.length) { + const preferred = new Set(prefs.genres.map((g) => g.toLowerCase())); + const genreFiltered = candidates.filter((b) => { + const genres = parseGenresLocal(b.genres); + return genres.some((g) => preferred.has(g.toLowerCase())); + }); + if (genreFiltered.length >= 5) candidates = genreFiltered; + } + + if (prefs?.status && prefs.status !== 'either') { + const sf = candidates.filter((b) => b.status?.toLowerCase().includes(prefs.status)); + if (sf.length >= 3) candidates = sf; + } + + // Fisher-Yates shuffle + for (let i = candidates.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [candidates[i], candidates[j]] = [candidates[j], candidates[i]]; + } + + return candidates.slice(0, 50); +} diff --git a/ui/src/routes/+layout.svelte b/ui/src/routes/+layout.svelte index 4de6647..a00e933 100644 --- a/ui/src/routes/+layout.svelte +++ b/ui/src/routes/+layout.svelte @@ -302,6 +302,12 @@ > {m.nav_library()} + + (menuOpen = false)} + class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/discover') ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}" + > + Discover + (menuOpen = false)} diff --git a/ui/src/routes/api/discover/vote/+server.ts b/ui/src/routes/api/discover/vote/+server.ts new file mode 100644 index 0000000..c3b44de --- /dev/null +++ b/ui/src/routes/api/discover/vote/+server.ts @@ -0,0 +1,32 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { upsertDiscoveryVote, clearDiscoveryVotes, saveBook } from '$lib/server/pocketbase'; + +const VALID_ACTIONS = ['like', 'skip', 'nope', 'read_now'] as const; +type Action = (typeof VALID_ACTIONS)[number]; + +export const POST: RequestHandler = async ({ request, locals }) => { + const body = await request.json().catch(() => null); + if (!body || typeof body.slug !== 'string' || !VALID_ACTIONS.includes(body.action)) { + error(400, 'Expected { slug, action }'); + } + const action = body.action as Action; + try { + await upsertDiscoveryVote(locals.sessionId, body.slug, action, locals.user?.id); + if (action === 'like' || action === 'read_now') { + await saveBook(locals.sessionId, body.slug, locals.user?.id); + } + } catch (e) { + error(500, 'Failed to record vote'); + } + return json({ ok: true }); +}; + +export const DELETE: RequestHandler = async ({ locals }) => { + try { + await clearDiscoveryVotes(locals.sessionId, locals.user?.id); + } catch { + error(500, 'Failed to clear votes'); + } + return json({ ok: true }); +}; diff --git a/ui/src/routes/discover/+page.server.ts b/ui/src/routes/discover/+page.server.ts new file mode 100644 index 0000000..4ae6d9f --- /dev/null +++ b/ui/src/routes/discover/+page.server.ts @@ -0,0 +1,14 @@ +import type { PageServerLoad } from './$types'; +import { getBooksForDiscovery } from '$lib/server/pocketbase'; +import type { DiscoveryPrefs } from '$lib/server/pocketbase'; + +export const load: PageServerLoad = async ({ locals, url }) => { + let prefs: DiscoveryPrefs | undefined; + const prefsParam = url.searchParams.get('prefs'); + if (prefsParam) { + try { prefs = JSON.parse(prefsParam) as DiscoveryPrefs; } catch { /* ignore */ } + } + + const books = await getBooksForDiscovery(locals.sessionId, locals.user?.id, prefs).catch(() => []); + return { books }; +}; diff --git a/ui/src/routes/discover/+page.svelte b/ui/src/routes/discover/+page.svelte new file mode 100644 index 0000000..4d71835 --- /dev/null +++ b/ui/src/routes/discover/+page.svelte @@ -0,0 +1,600 @@ + + + +{#if showOnboarding} +
+
+
+
+

What do you like to read?

+

We'll show you books you'll actually enjoy. Skip to see everything.

+
+ + +
+

Genres

+
+ {#each GENRES as genre} + + {/each} +
+
+ + +
+

Status

+
+ {#each (['either', 'ongoing', 'completed'] as const) as s} + + {/each} +
+
+ +
+ + +
+
+
+
+{/if} + + +{#if showPreview && currentBook} +
(showPreview = false)} +> +
+
e.stopPropagation()} + > + +
+ {#if currentBook.cover} + {currentBook.title} +
+ {:else} +
+ + + +
+ {/if} +
+ +
+

{currentBook.title}

+ {#if currentBook.author} +

{currentBook.author}

+ {/if} + {#if currentBook.summary} +

{currentBook.summary}

+ {/if} +
+ {#each parseBookGenres(currentBook.genres).slice(0, 4) as genre} + {genre} + {/each} + {#if currentBook.status} + {currentBook.status} + {/if} + {#if currentBook.total_chapters} + {currentBook.total_chapters} ch. + {/if} +
+ +
+ + + +
+
+
+
+{/if} + + +
+ +
+
+

Discover

+ {#if !deckEmpty} +

{totalRemaining} books left

+ {/if} +
+ +
+ + {#if deckEmpty} + +
+ {:else} + +
+ + + {#if nextNextBook} +
+ {#if nextNextBook.cover} + + {:else} +
+ {/if} +
+ {/if} + + + {#if nextBook} +
+ {#if nextBook.cover} + + {:else} +
+ {/if} +
+ {/if} + + +
+ + {#if currentBook.cover} + {currentBook.title} + {:else} +
+ + + +
+ {/if} + + +
+
+

{currentBook.title}

+ {#if currentBook.author} +

{currentBook.author}

+ {/if} +
+ {#each parseBookGenres(currentBook.genres).slice(0, 2) as genre} + {genre} + {/each} + {#if currentBook.status} + {currentBook.status} + {/if} + {#if currentBook.total_chapters} + {currentBook.total_chapters} ch. + {/if} +
+
+ + +
+ LIKE +
+ + +
+ SKIP +
+ + +
+ READ NOW +
+ + +
+ NOPE +
+
+
+ + +
+ + + + + + + + + + + + + + +
+ + +

+ Swipe or tap buttons · Tap card for details +

+ {/if} +