feat(discover): Tinder-style book discovery + fix duplicate books
Some checks failed
CI / UI (push) Failing after 22s
CI / Backend (push) Successful in 53s
Release / Test backend (push) Successful in 24s
Release / Check ui (push) Failing after 24s
Release / Docker / ui (push) Has been skipped
CI / Backend (pull_request) Successful in 25s
CI / UI (pull_request) Failing after 22s
Release / Docker / caddy (push) Successful in 56s
Release / Docker / backend (push) Successful in 1m38s
Release / Docker / runner (push) Successful in 3m14s
Release / Gitea Release (push) Has been skipped

- New /discover page with swipe UI: left=skip, right=like, up=read now, down=nope
- Onboarding modal to collect genre/status preferences (persisted in localStorage)
- 3-card stack with pointer-event drag, CSS fly-out animation, 5 action buttons
- Tap card for preview modal; empty state with deck reset
- Like/read-now auto-saves book to user library
- POST /api/discover/vote + DELETE for deck reset
- Discovery vote persistence via PocketBase discovery_votes collection
- Fix duplicate books: dedup by slug in getBooksBySlugs
- Fix WriteMetadata TOCTOU race: conflict-retry on concurrent insert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Admin
2026-04-02 17:33:27 +05:00
parent a771405db8
commit 7196f8e930
6 changed files with 799 additions and 3 deletions

View File

@@ -258,8 +258,26 @@ export async function getBooksBySlugs(slugs: Iterable<string>): Promise<Book[]>
// Build filter: slug='a' || slug='b' || ...
const filter = slugArr.map((s) => `slug='${s.replace(/'/g, "\\'")}'`).join(' || ');
const books = await listAll<Book>('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<string>();
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<Set<string>> {
const rows = await listAll<DiscoveryVote>(
'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<void> {
const filter = userId
? `user_id="${userId}"&&slug="${slug}"`
: `session_id="${sessionId}"&&slug="${slug}"`;
const existing = await listOne<DiscoveryVote & { id: string }>('discovery_votes', filter);
const payload: Partial<DiscoveryVote> = { 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<void> {
const filter = discoveryFilter(sessionId, userId);
const rows = await listAll<DiscoveryVote & { id: string }>('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<Book[]> {
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);
}