diff --git a/documentation/TABLEOFCONTENTS.md b/documentation/TABLEOFCONTENTS.md index 0d7fb76..2bc77e6 100644 --- a/documentation/TABLEOFCONTENTS.md +++ b/documentation/TABLEOFCONTENTS.md @@ -77,6 +77,7 @@ - **Component catalog (cards, badges, forms)** → [frontend/components.md](frontend/components.md) - **RequestCard, StatusBadge, ProgressBar** → [frontend/components.md](frontend/components.md) - **Pages: home, search, requests, profile** → [frontend/components.md](frontend/components.md) +- **Home page sections (per-user, configurable)** → [features/home-sections.md](features/home-sections.md) ## BookDate (AI Recommendations) - **AI-powered recommendations, swipe interface** → [features/bookdate.md](features/bookdate.md) @@ -150,3 +151,6 @@ **"Why do BookDate library books show placeholders?"** → [features/library-thumbnail-cache.md](features/library-thumbnail-cache.md) **"How does file hash matching work?"** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md) **"Why is ABS matching the wrong book?"** → [fixes/file-hash-matching.md](fixes/file-hash-matching.md) (file hash prevents false positives) +**"How do I customize my home page?"** → [features/home-sections.md](features/home-sections.md) +**"How do Audible categories work?"** → [features/home-sections.md](features/home-sections.md) +**"How do I add category sections to the home page?"** → [features/home-sections.md](features/home-sections.md) diff --git a/documentation/backend/services/scheduler.md b/documentation/backend/services/scheduler.md index 202c2c3..da38edb 100644 --- a/documentation/backend/services/scheduler.md +++ b/documentation/backend/services/scheduler.md @@ -129,10 +129,10 @@ interface ScheduledJob { ## Audible Refresh Processor **Implementation:** -1. Clear previous `isPopular`/`isNewRelease` flags -2. Fetch 200 popular + 200 new releases (multi-page scraping) -3. Download and cache cover thumbnails locally (stored in `/app/cache/thumbnails`) -4. Store/update in DB with category flags, rankings (`popularRank`, `newReleaseRank`), and cached cover paths +1. Fetch 200 popular + 200 new releases (multi-page scraping) +2. Download and cache cover thumbnails locally (stored in `/app/cache/thumbnails`) +3. Wipe and re-populate `AudibleCacheCategory` entries with reserved IDs (`__popular__`, `__new_releases__`) and user-configured category IDs +4. Upsert book metadata in `AudibleCache`, ranked entries in `AudibleCacheCategory` 5. Record sync timestamp (`lastAudibleSync`) 6. Clean up unused thumbnails (removes covers for audiobooks no longer in cache) 7. Perform fuzzy matching (70% threshold) against Plex library diff --git a/documentation/features/home-sections.md b/documentation/features/home-sections.md new file mode 100644 index 0000000..ef75295 --- /dev/null +++ b/documentation/features/home-sections.md @@ -0,0 +1,64 @@ +# Home Page Sections (Per-User Configurable) + +**Status:** Implemented | Per-user home page with configurable sections (popular, new releases, Audible categories) + +## Overview +Users customize their home page by adding/removing/reordering sections. Each section displays audiobooks from a specific source: built-in Popular, New Releases, or scraped Audible categories. + +## Data Models + +**UserHomeSection** (`user_home_sections`): +- `id`, `userId` (FK User), `sectionType` ('popular'|'new_releases'|'category'), `categoryId` (nullable), `categoryName` (nullable), `sortOrder` (int) +- Unique: `(userId, sectionType, categoryId)` +- Default: Popular (0) + New Releases (1) created on first access + +**AudibleCacheCategory** (`audible_cache_categories`): +- `id`, `asin`, `categoryId`, `rank`, `lastSyncedAt` +- Unique: `(asin, categoryId)`, Indexes: `categoryId`, `(categoryId, rank)` + +## API Endpoints + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/api/user/home-sections` | user | Returns sections + nextRefresh | +| PUT | `/api/user/home-sections` | user | Save full config (delete-recreate), max 10 | +| GET | `/api/audible/categories` | user | Live scrape top-level categories | +| GET | `/api/audiobooks/category/[categoryId]` | public | Paginated category books from cache | + +## Refresh Processor (Unified Storage) +- All section data stored in `AudibleCacheCategory` with reserved IDs: `__popular__` and `__new_releases__` for built-in sections +- Popular/new-releases use same wipe-and-populate pattern as user categories +- After built-in sections, queries DISTINCT categoryIds from `UserHomeSection` +- Per section: wipe `AudibleCacheCategory` rows, scrape, upsert `AudibleCache` metadata, insert ranked category entries +- Batch cooldown between sections (10-20s random) +- Constants exported from `audible-refresh.processor.ts`: `POPULAR_CATEGORY_ID`, `NEW_RELEASES_CATEGORY_ID` + +## AudibleService Methods +- `getCategories()`: Scrapes `{baseUrl}/categories`, returns `{id, name}[]` +- `getCategoryBooks(categoryId, limit)`: Scrapes `/search?node={id}&pageSize=50&sort=popularity-rank`, up to 200 results + +## Frontend +- **Hooks:** `useHomeSections()`, `useCategoryAudiobooks()`, `useAudibleCategories()` in `src/lib/hooks/useHomeSections.ts` +- **Config Modal:** `src/components/home/HomeSectionConfigModal.tsx` — drag-and-drop (desktop), up/down arrows (mobile), auto-save with debounce +- **Section Component:** `src/components/home/HomeSection.tsx` — renders individual section with color-coded header +- **Home Page:** `src/app/page.tsx` — dynamic sections from user config, gear icon for customize +- **Pagination:** `src/components/ui/UnifiedPagination.tsx` — updated to support 1-12 dynamic sections + +## Key Decisions +- 10 section limit per user (total) +- Category picker scraped live (no categories table) +- Top-level categories only (v1) +- Wipe-and-re-scrape per category during refresh +- Deduplication of categories across users before scraping +- If category disappears, user sees empty section +- 10-color palette assigned by sort order + +## Files +- Schema: `prisma/schema.prisma` (UserHomeSection, AudibleCacheCategory) +- Migration: `prisma/migrations/20260306000000_add_home_sections/migration.sql` +- Service: `src/lib/integrations/audible.service.ts` (getCategories, getCategoryBooks) +- Processor: `src/lib/processors/audible-refresh.processor.ts` +- API Routes: `src/app/api/user/home-sections/route.ts`, `src/app/api/audible/categories/route.ts`, `src/app/api/audiobooks/category/[categoryId]/route.ts` +- Hooks: `src/lib/hooks/useHomeSections.ts` +- Components: `src/components/home/HomeSectionConfigModal.tsx`, `src/components/home/HomeSection.tsx` +- Tests: `tests/api/home-sections.routes.test.ts`, `tests/processors/audible-refresh.processor.test.ts` diff --git a/documentation/integrations/audible.md b/documentation/integrations/audible.md index 8d6d706..9711695 100644 --- a/documentation/integrations/audible.md +++ b/documentation/integrations/audible.md @@ -128,11 +128,11 @@ Single matching algorithm used everywhere (search, popular, new-releases, jobs). Discovery APIs serve cached data from DB with real-time matching. **Flow:** -1. `audible_refresh` job runs daily → fetches 200 popular + 200 new releases +1. `audible_refresh` job runs daily → fetches 200 popular + 200 new releases + user-configured categories 2. Downloads and caches cover thumbnails locally (reduces Audible load) -3. Stores in DB with flags (`isPopular`, `isNewRelease`) and rankings +3. Stores metadata in `audible_cache`, ranked entries in `audible_cache_categories` with reserved IDs (`__popular__`, `__new_releases__`) and user category IDs 4. Cleans up unused thumbnails after sync -5. API routes query DB → apply real-time matching → return enriched results +5. API routes query `AudibleCacheCategory` by categoryId → join with `AudibleCache` metadata → apply real-time matching → return enriched results 6. Homepage loads instantly (no Audible API hits) ## Thumbnail Caching diff --git a/prisma/migrations/20260306000000_add_home_sections/migration.sql b/prisma/migrations/20260306000000_add_home_sections/migration.sql new file mode 100644 index 0000000..9c7a681 --- /dev/null +++ b/prisma/migrations/20260306000000_add_home_sections/migration.sql @@ -0,0 +1,49 @@ +-- CreateTable +CREATE TABLE "user_home_sections" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "section_type" TEXT NOT NULL, + "category_id" TEXT, + "category_name" TEXT, + "sort_order" INTEGER NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "user_home_sections_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "audible_cache_categories" ( + "id" TEXT NOT NULL, + "asin" TEXT NOT NULL, + "category_id" TEXT NOT NULL, + "rank" INTEGER NOT NULL, + "last_synced_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "audible_cache_categories_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "user_home_sections_user_id_idx" ON "user_home_sections"("user_id"); + +-- CreateIndex +CREATE INDEX "user_home_sections_sort_order_idx" ON "user_home_sections"("sort_order"); + +-- CreateIndex +CREATE UNIQUE INDEX "user_home_sections_user_id_section_type_category_id_key" ON "user_home_sections"("user_id", "section_type", "category_id"); + +-- CreateIndex +CREATE INDEX "audible_cache_categories_category_id_idx" ON "audible_cache_categories"("category_id"); + +-- CreateIndex +CREATE INDEX "audible_cache_categories_asin_idx" ON "audible_cache_categories"("asin"); + +-- CreateIndex +CREATE INDEX "audible_cache_categories_category_id_rank_idx" ON "audible_cache_categories"("category_id", "rank"); + +-- CreateIndex +CREATE UNIQUE INDEX "audible_cache_categories_asin_category_id_key" ON "audible_cache_categories"("asin", "category_id"); + +-- AddForeignKey +ALTER TABLE "user_home_sections" ADD CONSTRAINT "user_home_sections_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260307000000_remove_popular_newrelease_flags/migration.sql b/prisma/migrations/20260307000000_remove_popular_newrelease_flags/migration.sql new file mode 100644 index 0000000..f5c9326 --- /dev/null +++ b/prisma/migrations/20260307000000_remove_popular_newrelease_flags/migration.sql @@ -0,0 +1,17 @@ +-- DropIndex +DROP INDEX IF EXISTS "audible_cache_is_popular_idx"; + +-- DropIndex +DROP INDEX IF EXISTS "audible_cache_is_new_release_idx"; + +-- DropIndex +DROP INDEX IF EXISTS "audible_cache_popular_rank_idx"; + +-- DropIndex +DROP INDEX IF EXISTS "audible_cache_new_release_rank_idx"; + +-- AlterTable - Remove legacy discovery flag columns (now stored in audible_cache_categories) +ALTER TABLE "audible_cache" DROP COLUMN "is_popular", +DROP COLUMN "is_new_release", +DROP COLUMN "popular_rank", +DROP COLUMN "new_release_rank"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 174d882..2c3ac6a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -72,6 +72,7 @@ model User { apiTokens ApiToken[] @relation("UserApiTokens") watchedSeries WatchedSeries[] watchedAuthors WatchedAuthor[] + homeSections UserHomeSection[] @@index([plexId]) @@index([role]) @@ -98,12 +99,6 @@ model AudibleCache { rating Decimal? @db.Decimal(3, 2) genres Json @default("[]") - // Discovery categories - isPopular Boolean @default(false) @map("is_popular") - isNewRelease Boolean @default(false) @map("is_new_release") - popularRank Int? @map("popular_rank") - newReleaseRank Int? @map("new_release_rank") - lastSyncedAt DateTime @default(now()) @map("last_synced_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -111,10 +106,6 @@ model AudibleCache { @@index([asin]) @@index([title]) @@index([author]) - @@index([isPopular]) - @@index([isNewRelease]) - @@index([popularRank]) - @@index([newReleaseRank]) @@map("audible_cache") } @@ -647,3 +638,49 @@ model WatchedAuthor { @@index([authorAsin]) @@map("watched_authors") } + +// ============================================================================ +// USER HOME SECTION TABLE +// Per-user configurable home page sections (popular, new_releases, category) +// Documentation: documentation/features/home-sections.md +// ============================================================================ + +model UserHomeSection { + id String @id @default(uuid()) + userId String @map("user_id") + sectionType String @map("section_type") // 'popular' | 'new_releases' | 'category' + categoryId String? @map("category_id") // Audible category node ID (only for type 'category') + categoryName String? @map("category_name") // Display name (only for type 'category') + sortOrder Int @map("sort_order") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + // Relations + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, sectionType, categoryId]) + @@index([userId]) + @@index([sortOrder]) + @@map("user_home_sections") +} + +// ============================================================================ +// AUDIBLE CACHE CATEGORY TABLE +// Join table linking AudibleCache entries to Audible categories with ranking +// Documentation: documentation/features/home-sections.md +// ============================================================================ + +model AudibleCacheCategory { + id String @id @default(uuid()) + asin String + categoryId String @map("category_id") + rank Int + lastSyncedAt DateTime @default(now()) @map("last_synced_at") + createdAt DateTime @default(now()) @map("created_at") + + @@unique([asin, categoryId]) + @@index([categoryId]) + @@index([asin]) + @@index([categoryId, rank]) + @@map("audible_cache_categories") +} diff --git a/public/placeholder_cover.svg b/public/placeholder_cover.svg new file mode 100644 index 0000000..5778281 --- /dev/null +++ b/public/placeholder_cover.svg @@ -0,0 +1,22 @@ + + \ No newline at end of file diff --git a/src/app/admin/components/ReportedIssuesSection.tsx b/src/app/admin/components/ReportedIssuesSection.tsx index 975baa9..6245186 100644 --- a/src/app/admin/components/ReportedIssuesSection.tsx +++ b/src/app/admin/components/ReportedIssuesSection.tsx @@ -114,23 +114,13 @@ export function ReportedIssuesSection({ issues }: ReportedIssuesSectionProps) {