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
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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user