feat: add top pick for you section to homepage

This commit is contained in:
2026-06-05 16:15:13 +02:00
committed by Milas Holsting
parent 8e66581f6c
commit e235f36a45
5 changed files with 67 additions and 46 deletions

View File

@@ -1,16 +1,19 @@
# Recommendation Architecture # Recommendation Architecture
This document defines the long-term shape of the `For You` discovery system. This document defines the long-term shape of the `Top Pick for You`
recommendation system.
The goal is to keep the current implementation simple enough to operate inside The goal is to keep the current implementation simple enough to operate inside
the existing Go application while preserving a clean path toward a larger the existing Go application while preserving a clean path toward a larger
recommender system. recommender system.
## Current Serving Model ## Current Serving Model
The current `For You` implementation is a bounded hybrid ranker: The current `Top Pick for You` implementation is a bounded hybrid ranker:
- builds weighted seeds from user watch history - builds weighted seeds from user watch history
- uses Jikan recommendation edges as collaborative candidates - uses Jikan recommendation edges as collaborative candidates
- uses watchlist-derived genres, themes, studios, and demographics as profile
search candidates
- excludes anime already present in the watchlist - excludes anime already present in the watchlist
- boosts candidates that match user taste signals - boosts candidates that match user taste signals
- reranks the final list to reduce genre pileups - reranks the final list to reduce genre pileups
@@ -19,10 +22,11 @@ The online request path stays intentionally small:
1. load recent watchlist state 1. load recent watchlist state
2. derive strong seeds 2. derive strong seeds
3. fetch bounded candidate set 3. build a weighted taste profile from those seeds
4. score candidates 4. fetch bounded collaborative and profile-search candidate sets
5. rerank for diversity 5. score candidates
6. return top results 6. rerank for diversity
7. return top results
## Target System Shape ## Target System Shape

View File

@@ -1,8 +1,4 @@
import { import { isJstTimezone, normalizeWeekday, parseHHMM } from "./shared/broadcast";
isJstTimezone,
normalizeWeekday,
parseHHMM,
} from "./shared/broadcast";
export {}; export {};

View File

@@ -74,6 +74,7 @@
</template> </template>
<script type="module" src="/dist/static/dropdown.js" defer></script> <script type="module" src="/dist/static/dropdown.js" defer></script>
<script type="module" src="/dist/static/discover.js" defer></script> <script type="module" src="/dist/static/discover.js" defer></script>
<script type="module" src="/dist/static/top_pick_carousel.js" defer></script>
<script type="module" src="/dist/static/anime.js" defer></script> <script type="module" src="/dist/static/anime.js" defer></script>
<script type="module" src="/dist/static/timezone.js" defer></script> <script type="module" src="/dist/static/timezone.js" defer></script>
<script type="module" src="/dist/static/player/main.js" defer></script> <script type="module" src="/dist/static/player/main.js" defer></script>

View File

@@ -34,17 +34,6 @@
</div> </div>
</section> </section>
{{/* For You Section */}}
<section id="discover-for-you-section" class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<h2 class="text-base font-normal text-foreground">For You</h2>
<span class="text-sm text-foreground-muted">Based on your watchlist</span>
</div>
<div hx-get="/api/discover/for-you" hx-trigger="load" hx-target="#discover-for-you-section" hx-swap="outerHTML">
{{template "discover_skeleton"}}
</div>
</section>
{{/* Upcoming Section */}} {{/* Upcoming Section */}}
<section class="flex flex-col gap-4"> <section class="flex flex-col gap-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
@@ -85,30 +74,6 @@
</div> </div>
{{end}} {{end}}
{{define "discover_row"}}
<div class="grid gap-4 [grid-template-columns:repeat(auto-fit,minmax(160px,1fr))]">
{{range .Animes}}
{{template "anime_card" dict "Anime" . "WithActions" true "IsWatchlist" (index $.WatchlistMap .MalID)}}
{{end}}
</div>
{{end}}
{{define "discover_for_you_section"}}
{{if gt (len .Animes) 0}}
<section id="discover-for-you-section" class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<h2 class="text-base font-normal text-foreground">For You</h2>
<span class="text-sm text-foreground-muted">Based on your watchlist</span>
</div>
<div class="grid gap-4 [grid-template-columns:repeat(auto-fit,minmax(160px,1fr))]">
{{range .Animes}}
{{template "anime_card" dict "Anime" . "WithActions" true "IsWatchlist" (index $.WatchlistMap .MalID)}}
{{end}}
</div>
</section>
{{end}}
{{end}}
{{define "discover_skeleton"}} {{define "discover_skeleton"}}
<div class="discover-grid"> <div class="discover-grid">
{{range (seq 8)}} {{range (seq 8)}}

View File

@@ -5,6 +5,10 @@
<div hx-get="/api/catalog/continue" hx-trigger="load" hx-swap="outerHTML"> <div hx-get="/api/catalog/continue" hx-trigger="load" hx-swap="outerHTML">
{{template "continue_watching_skeleton"}} {{template "continue_watching_skeleton"}}
</div> </div>
<div hx-get="/api/catalog/top-pick" hx-trigger="load" hx-swap="outerHTML">
{{template "top_pick_for_you_skeleton"}}
</div>
{{end}} {{end}}
<section class="w-full"> <section class="w-full">
@@ -36,6 +40,37 @@
{{end}} {{end}}
{{define "top_pick_for_you_section"}}
{{if gt (len .Animes) 0}}
<section id="top-pick-for-you-section" class="w-full" data-top-pick-carousel>
<div class="mb-4 flex items-end justify-between gap-3">
<h2 class="min-w-0 text-base font-normal text-foreground">Top Pick for You</h2>
<a href="/discover" class="group flex items-center gap-1 text-sm text-foreground-muted transition-colors hover:text-foreground">
Explore more
<svg xmlns="http://www.w3.org/2000/svg" class="size-4 transition-transform group-hover:translate-x-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg>
</a>
</div>
<div class="relative overflow-hidden">
<div data-top-pick-track tabindex="0" class="grid min-w-0 grid-flow-col auto-cols-[calc((100%_-_0.75rem)/2.18)] gap-3 overflow-x-auto scroll-smooth pb-1 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden md:auto-cols-[calc((100%_-_2rem)/3.35)] md:gap-4 lg:auto-cols-[calc((100%_-_3rem)/4.35)] xl:auto-cols-[calc((100%_-_4rem)/5.35)] 2xl:auto-cols-[calc((100%_-_5rem)/6.35)]">
{{range .Animes}}
<div data-top-pick-item>
{{template "anime_card" dict "Anime" . "WithActions" true "IsWatchlist" (index $.WatchlistMap .MalID)}}
</div>
{{end}}
</div>
<div data-top-pick-prev-fade class="pointer-events-none absolute inset-y-0 left-0 z-[70] hidden w-20 bg-linear-to-r from-background via-background/80 to-transparent" aria-hidden="true"></div>
<div data-top-pick-next-fade class="pointer-events-none absolute inset-y-0 right-0 z-[70] w-20 bg-linear-to-l from-background via-background/80 to-transparent" aria-hidden="true"></div>
<button type="button" data-unstyled-button data-top-pick-prev class="pointer-events-auto absolute left-0 top-[42%] z-[80] hidden h-16 w-14 -translate-y-1/2 cursor-pointer items-center justify-center text-foreground-muted transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent" aria-label="Scroll top picks left" aria-hidden="true">
<svg class="pointer-events-auto size-10 drop-shadow-[0_1px_2px_rgba(0,0,0,0.45)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 18 9 12 15 6"></polyline></svg>
</button>
<button type="button" data-unstyled-button data-top-pick-next class="pointer-events-auto absolute right-0 top-[42%] z-[80] inline-flex h-16 w-14 -translate-y-1/2 cursor-pointer items-center justify-center text-foreground-muted transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent" aria-label="Scroll top picks right" aria-hidden="false">
<svg class="pointer-events-auto size-10 drop-shadow-[0_1px_2px_rgba(0,0,0,0.45)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"></polyline></svg>
</button>
</div>
</section>
{{end}}
{{end}}
{{define "catalog_section"}} {{define "catalog_section"}}
{{if eq .Section "Continue"}} {{if eq .Section "Continue"}}
{{if .ContinueWatching}} {{if .ContinueWatching}}
@@ -61,6 +96,26 @@
</div> </div>
{{end}} {{end}}
{{define "top_pick_for_you_skeleton"}}
<section id="top-pick-for-you-section" class="w-full">
<div class="mb-4 flex items-end justify-between gap-3">
<div class="skeleton h-5 w-36"></div>
<div class="skeleton skeleton-subtle h-4 w-24"></div>
</div>
<div class="relative overflow-hidden">
<div class="grid min-w-0 grid-flow-col auto-cols-[calc((100%_-_0.75rem)/2.18)] gap-3 overflow-hidden pb-1 md:auto-cols-[calc((100%_-_2rem)/3.35)] md:gap-4 lg:auto-cols-[calc((100%_-_3rem)/4.35)] xl:auto-cols-[calc((100%_-_4rem)/5.35)] 2xl:auto-cols-[calc((100%_-_5rem)/6.35)]">
{{range (seq 6)}}
<div class="flex flex-col gap-2">
<div class="skeleton aspect-2/3 w-full"></div>
<div class="skeleton h-4 w-3/4"></div>
<div class="skeleton skeleton-subtle h-3 w-1/2"></div>
</div>
{{end}}
</div>
</div>
</section>
{{end}}
{{define "continue_watching_skeleton"}} {{define "continue_watching_skeleton"}}
<section id="continue-watching-section" class="w-full"> <section id="continue-watching-section" class="w-full">
<h2 class="mb-3 text-base font-normal text-foreground">Continue Watching</h2> <h2 class="mb-3 text-base font-normal text-foreground">Continue Watching</h2>