Skip to content

Commit 865675e

Browse files
explodedclaude
andcommitted
Expand catalog beyond hardcoded shows with TMDB integration
- Auto-seed ~200 popular shows from TMDB on first startup - AI verdicts can now recommend any TV show, not just catalog entries - Add TMDB search to catalog so users can find and add any series - Real-time catalog stat counters, profile taste summary, improved verdict loading Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 316c34d commit 865675e

18 files changed

Lines changed: 920 additions & 90 deletions

File tree

cmd/wtw/main.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,10 +163,10 @@ func main() {
163163
tmdbClient.StartRefreshLoop(context.Background())
164164

165165
// Recommendation engine
166-
engine := recommend.NewEngine(queries, os.Getenv("ANTHROPIC_API_KEY"))
166+
engine := recommend.NewEngine(queries, os.Getenv("ANTHROPIC_API_KEY"), tmdbClient)
167167

168168
// Handlers
169-
h := handlers.New(queries, store, engine)
169+
h := handlers.New(queries, store, engine, tmdbClient)
170170

171171
// Routes
172172
mux := http.NewServeMux()
@@ -181,6 +181,8 @@ func main() {
181181
mux.HandleFunc("GET /onboarding", middleware.RequireAuth(store, h.Onboarding))
182182
mux.HandleFunc("GET /catalog", middleware.RequireAuth(store, h.Catalog))
183183
mux.HandleFunc("GET /catalog/filter", middleware.RequireAuth(store, h.CatalogFilter))
184+
mux.HandleFunc("GET /catalog/tmdb-search", middleware.RequireAuth(store, h.CatalogSearchTMDB))
185+
mux.HandleFunc("POST /catalog/add-tmdb/{tmdb_id}", middleware.RequireAuth(store, h.CatalogAddTMDB))
184186
mux.HandleFunc("GET /recs", middleware.RequireAuth(store, h.Recs))
185187
mux.HandleFunc("GET /recs/refresh", middleware.RequireAuth(store, h.RecsRefresh))
186188
mux.HandleFunc("POST /recs/new-verdict", middleware.RequireAuth(store, h.RecsNewVerdict))

internal/db/queries.sql

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,3 +144,16 @@ SELECT s.* FROM shows s
144144
JOIN ratings r1 ON r1.show_id = s.id AND r1.user_id = ? AND r1.rating IN ('liked', 'favourite')
145145
JOIN ratings r2 ON r2.show_id = s.id AND r2.user_id = ? AND r2.rating IN ('liked', 'favourite')
146146
ORDER BY s.popularity DESC;
147+
148+
-- name: InsertShow :exec
149+
INSERT OR IGNORE INTO shows (id, tmdb_id, title, year, poster_path, genre, synopsis, popularity)
150+
VALUES (?, ?, ?, ?, ?, ?, ?, ?);
151+
152+
-- name: GetShowByTmdbID :one
153+
SELECT * FROM shows WHERE tmdb_id = ?;
154+
155+
-- name: GetShowByTitle :one
156+
SELECT * FROM shows WHERE LOWER(title) = LOWER(?) LIMIT 1;
157+
158+
-- name: CountShows :one
159+
SELECT COUNT(*) FROM shows;

internal/db/queries.sql.go

Lines changed: 83 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/db/slug.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package db
2+
3+
import (
4+
"regexp"
5+
"strings"
6+
)
7+
8+
var (
9+
nonAlphaNum = regexp.MustCompile(`[^a-z0-9-]`)
10+
multipleHyphen = regexp.MustCompile(`-{2,}`)
11+
)
12+
13+
func Slugify(title string) string {
14+
s := strings.ToLower(title)
15+
s = strings.ReplaceAll(s, " ", "-")
16+
s = nonAlphaNum.ReplaceAllString(s, "")
17+
s = multipleHyphen.ReplaceAllString(s, "-")
18+
s = strings.Trim(s, "-")
19+
return s
20+
}

internal/handlers/catalog.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
package handlers
22

33
import (
4+
"database/sql"
45
"net/http"
6+
"strconv"
57
"strings"
68

79
"wtw/internal/middleware"
10+
"wtw/internal/tmdb"
811
)
912

1013
type CatalogData struct {
@@ -115,3 +118,60 @@ func (h *Handler) CatalogFilter(w http.ResponseWriter, r *http.Request) {
115118

116119
renderPartial(w, "poster-grid", filtered)
117120
}
121+
122+
type TMDBSearchResult struct {
123+
tmdb.TVResult
124+
AlreadyInDB bool
125+
}
126+
127+
func (h *Handler) CatalogSearchTMDB(w http.ResponseWriter, r *http.Request) {
128+
query := r.URL.Query().Get("q")
129+
if query == "" {
130+
w.WriteHeader(http.StatusBadRequest)
131+
return
132+
}
133+
134+
results, err := h.tmdbClient.SearchTVMulti(r.Context(), query)
135+
if err != nil {
136+
renderPartial(w, "tmdb-results", []TMDBSearchResult{})
137+
return
138+
}
139+
140+
var out []TMDBSearchResult
141+
for _, res := range results {
142+
alreadyIn := false
143+
if res.TmdbID > 0 {
144+
_, err := h.queries.GetShowByTmdbID(r.Context(), sql.NullInt64{Int64: res.TmdbID, Valid: true})
145+
if err == nil {
146+
alreadyIn = true
147+
}
148+
}
149+
out = append(out, TMDBSearchResult{TVResult: res, AlreadyInDB: alreadyIn})
150+
}
151+
152+
renderPartial(w, "tmdb-results", out)
153+
}
154+
155+
func (h *Handler) CatalogAddTMDB(w http.ResponseWriter, r *http.Request) {
156+
tmdbIDStr := r.PathValue("tmdb_id")
157+
tmdbID, err := strconv.ParseInt(tmdbIDStr, 10, 64)
158+
if err != nil {
159+
http.Error(w, "invalid tmdb_id", http.StatusBadRequest)
160+
return
161+
}
162+
163+
show, err := h.tmdbClient.EnsureShow(r.Context(), tmdbID)
164+
if err != nil {
165+
http.Error(w, "failed to add show", http.StatusInternalServerError)
166+
return
167+
}
168+
169+
userID := middleware.GetUserID(r.Context())
170+
ratings, _ := h.queries.GetUserRatings(r.Context(), userID)
171+
ratingMap := make(map[string]string)
172+
for _, rating := range ratings {
173+
ratingMap[rating.ShowID] = rating.Rating
174+
}
175+
176+
renderPartial(w, "poster-card", ShowWithRating{Show: *show, Rating: ratingMap[show.ID]})
177+
}

internal/handlers/profile.go

Lines changed: 110 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"database/sql"
55
"fmt"
66
"net/http"
7+
"sort"
78
"strconv"
89
"strings"
910

@@ -39,13 +40,111 @@ type ProfileData struct {
3940
TopShows []db.Show
4041
ShowCount int
4142
MemberSince string
43+
TasteSummary string
4244
ActivePartners []ActivePartner
4345
PendingPartners []PendingPartner
4446
IncomingRequests []IncomingRequest
4547
PartnerError string
4648
PartnerSuccess string
4749
}
4850

51+
func buildTasteSummary(ratings []db.GetUserRatingsWithShowsRow) string {
52+
if len(ratings) == 0 {
53+
return ""
54+
}
55+
56+
likedGenres := map[string]int{}
57+
dislikedGenres := map[string]int{}
58+
var totalLiked, totalDisliked int
59+
60+
for _, r := range ratings {
61+
genre := r.Genre.String
62+
if genre == "" {
63+
continue
64+
}
65+
switch r.Rating {
66+
case "favourite", "liked":
67+
likedGenres[genre]++
68+
totalLiked++
69+
case "disliked":
70+
dislikedGenres[genre]++
71+
totalDisliked++
72+
}
73+
}
74+
75+
if totalLiked == 0 {
76+
return ""
77+
}
78+
79+
type genreCount struct {
80+
name string
81+
count int
82+
}
83+
ranked := make([]genreCount, 0, len(likedGenres))
84+
for g, c := range likedGenres {
85+
ranked = append(ranked, genreCount{g, c})
86+
}
87+
sort.Slice(ranked, func(i, j int) bool {
88+
if ranked[i].count != ranked[j].count {
89+
return ranked[i].count > ranked[j].count
90+
}
91+
return ranked[i].name < ranked[j].name
92+
})
93+
94+
// Top genres the user enjoys
95+
topN := ranked
96+
if len(topN) > 3 {
97+
topN = topN[:3]
98+
}
99+
names := make([]string, len(topN))
100+
for i, g := range topN {
101+
names[i] = strings.ToLower(g.name)
102+
}
103+
104+
var parts []string
105+
switch len(names) {
106+
case 1:
107+
parts = append(parts, fmt.Sprintf("You're drawn to %s", names[0]))
108+
case 2:
109+
parts = append(parts, fmt.Sprintf("You're drawn to %s and %s", names[0], names[1]))
110+
default:
111+
parts = append(parts, fmt.Sprintf("You're drawn to %s, %s, and %s", names[0], names[1], names[2]))
112+
}
113+
114+
if len(ranked) > 4 {
115+
parts[0] += ", with broad taste across many genres"
116+
}
117+
118+
// Genres the user tends to avoid
119+
if totalDisliked > 0 {
120+
avoided := make([]genreCount, 0, len(dislikedGenres))
121+
for g, c := range dislikedGenres {
122+
if likedGenres[g] == 0 || c > likedGenres[g] {
123+
avoided = append(avoided, genreCount{g, c})
124+
}
125+
}
126+
sort.Slice(avoided, func(i, j int) bool {
127+
return avoided[i].count > avoided[j].count
128+
})
129+
if len(avoided) > 2 {
130+
avoided = avoided[:2]
131+
}
132+
if len(avoided) > 0 {
133+
avoidNames := make([]string, len(avoided))
134+
for i, g := range avoided {
135+
avoidNames[i] = strings.ToLower(g.name)
136+
}
137+
if len(avoidNames) == 1 {
138+
parts = append(parts, fmt.Sprintf("and tend to skip %s", avoidNames[0]))
139+
} else {
140+
parts = append(parts, fmt.Sprintf("and tend to skip %s and %s", avoidNames[0], avoidNames[1]))
141+
}
142+
}
143+
}
144+
145+
return strings.Join(parts, ", ") + "."
146+
}
147+
49148
func (h *Handler) Profile(w http.ResponseWriter, r *http.Request) {
50149
userID := middleware.GetUserID(r.Context())
51150
userEmail := middleware.GetUserEmail(r.Context())
@@ -64,17 +163,19 @@ func (h *Handler) Profile(w http.ResponseWriter, r *http.Request) {
64163
}
65164

66165
user, _ := h.queries.GetUserByID(r.Context(), userID)
166+
ratingsWithShows, _ := h.queries.GetUserRatingsWithShows(r.Context(), userID)
67167

68168
data := ProfileData{
69-
PageData: h.basePageDataWithPartners(r, "profile"),
70-
Favourite: favourite,
71-
Liked: liked,
72-
Disliked: disliked,
73-
Total: counts.Total,
74-
LikedShows: likedShows,
75-
TopShows: topShows,
76-
ShowCount: len(shows),
77-
MemberSince: user.CreatedAt.Format("January 2006"),
169+
PageData: h.basePageDataWithPartners(r, "profile"),
170+
Favourite: favourite,
171+
Liked: liked,
172+
Disliked: disliked,
173+
Total: counts.Total,
174+
LikedShows: likedShows,
175+
TopShows: topShows,
176+
ShowCount: len(shows),
177+
MemberSince: user.CreatedAt.Format("January 2006"),
178+
TasteSummary: buildTasteSummary(ratingsWithShows),
78179
}
79180

80181
// Active partnerships (as owner)

0 commit comments

Comments
 (0)