diff --git a/internal/database/models.go b/internal/database/models.go index ef4861d..80abb98 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -27,6 +27,13 @@ type Anime struct { Airing sql.NullBool `json:"airing"` } +type NotificationPreference struct { + ID string `json:"id"` + UserID string `json:"user_id"` + NotifyNewEpisodes bool `json:"notify_new_episodes"` + CreatedAt time.Time `json:"created_at"` +} + type Session struct { ID string `json:"id"` UserID string `json:"user_id"` @@ -42,10 +49,12 @@ type User struct { } type WatchListEntry struct { - ID string `json:"id"` - UserID string `json:"user_id"` - AnimeID int64 `json:"anime_id"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + UserID string `json:"user_id"` + AnimeID int64 `json:"anime_id"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CurrentEpisode sql.NullInt64 `json:"current_episode"` + LastEpisodeAt sql.NullTime `json:"last_episode_at"` } diff --git a/internal/database/querier.go b/internal/database/querier.go index e5dd7c6..eed23f7 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -20,6 +20,7 @@ type Querier interface { GetUserByUsername(ctx context.Context, username string) (User, error) GetUserWatchList(ctx context.Context, userID string) ([]GetUserWatchListRow, error) GetWatchListEntry(ctx context.Context, arg GetWatchListEntryParams) (WatchListEntry, error) + GetWatchingAnime(ctx context.Context, userID string) ([]GetWatchingAnimeRow, error) UpsertAnime(ctx context.Context, arg UpsertAnimeParams) (Anime, error) UpsertWatchListEntry(ctx context.Context, arg UpsertWatchListEntryParams) (WatchListEntry, error) } diff --git a/internal/database/queries.sql b/internal/database/queries.sql index e35f9f9..4aff98c 100644 --- a/internal/database/queries.sql +++ b/internal/database/queries.sql @@ -38,10 +38,11 @@ RETURNING *; SELECT * FROM anime WHERE id = ? LIMIT 1; -- name: UpsertWatchListEntry :one -INSERT INTO watch_list_entry (id, user_id, anime_id, status, updated_at) -VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) +INSERT INTO watch_list_entry (id, user_id, anime_id, status, current_episode, updated_at) +VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT (user_id, anime_id) DO UPDATE SET status = excluded.status, + current_episode = excluded.current_episode, updated_at = CURRENT_TIMESTAMP RETURNING *; @@ -65,3 +66,16 @@ ORDER BY e.updated_at DESC; -- name: DeleteWatchListEntry :exec DELETE FROM watch_list_entry WHERE user_id = ? AND anime_id = ?; + +-- name: GetWatchingAnime :many +SELECT + e.*, + a.title_original, + a.title_english, + a.title_japanese, + a.image_url, + a.airing +FROM watch_list_entry e +JOIN anime a ON e.anime_id = a.id +WHERE e.user_id = ? AND e.status = 'watching' AND a.airing = 1 +ORDER BY e.updated_at DESC; diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 735d106..81b533b 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -161,7 +161,7 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, const getUserWatchList = `-- name: GetUserWatchList :many SELECT - e.id, e.user_id, e.anime_id, e.status, e.created_at, e.updated_at, + e.id, e.user_id, e.anime_id, e.status, e.created_at, e.updated_at, e.current_episode, e.last_episode_at, a.title_original, a.title_english, a.title_japanese, @@ -174,17 +174,19 @@ ORDER BY e.updated_at DESC ` type GetUserWatchListRow struct { - ID string `json:"id"` - UserID string `json:"user_id"` - AnimeID int64 `json:"anime_id"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - TitleOriginal string `json:"title_original"` - TitleEnglish sql.NullString `json:"title_english"` - TitleJapanese sql.NullString `json:"title_japanese"` - ImageUrl string `json:"image_url"` - Airing sql.NullBool `json:"airing"` + ID string `json:"id"` + UserID string `json:"user_id"` + AnimeID int64 `json:"anime_id"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CurrentEpisode sql.NullInt64 `json:"current_episode"` + LastEpisodeAt sql.NullTime `json:"last_episode_at"` + TitleOriginal string `json:"title_original"` + TitleEnglish sql.NullString `json:"title_english"` + TitleJapanese sql.NullString `json:"title_japanese"` + ImageUrl string `json:"image_url"` + Airing sql.NullBool `json:"airing"` } func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUserWatchListRow, error) { @@ -203,6 +205,8 @@ func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUse &i.Status, &i.CreatedAt, &i.UpdatedAt, + &i.CurrentEpisode, + &i.LastEpisodeAt, &i.TitleOriginal, &i.TitleEnglish, &i.TitleJapanese, @@ -223,7 +227,7 @@ func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUse } const getWatchListEntry = `-- name: GetWatchListEntry :one -SELECT id, user_id, anime_id, status, created_at, updated_at FROM watch_list_entry +SELECT id, user_id, anime_id, status, created_at, updated_at, current_episode, last_episode_at FROM watch_list_entry WHERE user_id = ? AND anime_id = ? LIMIT 1 ` @@ -242,10 +246,79 @@ func (q *Queries) GetWatchListEntry(ctx context.Context, arg GetWatchListEntryPa &i.Status, &i.CreatedAt, &i.UpdatedAt, + &i.CurrentEpisode, + &i.LastEpisodeAt, ) return i, err } +const getWatchingAnime = `-- name: GetWatchingAnime :many +SELECT + e.id, e.user_id, e.anime_id, e.status, e.created_at, e.updated_at, e.current_episode, e.last_episode_at, + a.title_original, + a.title_english, + a.title_japanese, + a.image_url, + a.airing +FROM watch_list_entry e +JOIN anime a ON e.anime_id = a.id +WHERE e.user_id = ? AND e.status = 'watching' AND a.airing = 1 +ORDER BY e.updated_at DESC +` + +type GetWatchingAnimeRow struct { + ID string `json:"id"` + UserID string `json:"user_id"` + AnimeID int64 `json:"anime_id"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CurrentEpisode sql.NullInt64 `json:"current_episode"` + LastEpisodeAt sql.NullTime `json:"last_episode_at"` + TitleOriginal string `json:"title_original"` + TitleEnglish sql.NullString `json:"title_english"` + TitleJapanese sql.NullString `json:"title_japanese"` + ImageUrl string `json:"image_url"` + Airing sql.NullBool `json:"airing"` +} + +func (q *Queries) GetWatchingAnime(ctx context.Context, userID string) ([]GetWatchingAnimeRow, error) { + rows, err := q.db.QueryContext(ctx, getWatchingAnime, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetWatchingAnimeRow + for rows.Next() { + var i GetWatchingAnimeRow + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.AnimeID, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + &i.CurrentEpisode, + &i.LastEpisodeAt, + &i.TitleOriginal, + &i.TitleEnglish, + &i.TitleJapanese, + &i.ImageUrl, + &i.Airing, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const upsertAnime = `-- name: UpsertAnime :one INSERT INTO anime (id, title_original, title_english, title_japanese, image_url, airing) VALUES (?, ?, ?, ?, ?, ?) @@ -290,19 +363,21 @@ func (q *Queries) UpsertAnime(ctx context.Context, arg UpsertAnimeParams) (Anime } const upsertWatchListEntry = `-- name: UpsertWatchListEntry :one -INSERT INTO watch_list_entry (id, user_id, anime_id, status, updated_at) -VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) +INSERT INTO watch_list_entry (id, user_id, anime_id, status, current_episode, updated_at) +VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT (user_id, anime_id) DO UPDATE SET status = excluded.status, + current_episode = excluded.current_episode, updated_at = CURRENT_TIMESTAMP -RETURNING id, user_id, anime_id, status, created_at, updated_at +RETURNING id, user_id, anime_id, status, created_at, updated_at, current_episode, last_episode_at ` type UpsertWatchListEntryParams struct { - ID string `json:"id"` - UserID string `json:"user_id"` - AnimeID int64 `json:"anime_id"` - Status string `json:"status"` + ID string `json:"id"` + UserID string `json:"user_id"` + AnimeID int64 `json:"anime_id"` + Status string `json:"status"` + CurrentEpisode sql.NullInt64 `json:"current_episode"` } func (q *Queries) UpsertWatchListEntry(ctx context.Context, arg UpsertWatchListEntryParams) (WatchListEntry, error) { @@ -311,6 +386,7 @@ func (q *Queries) UpsertWatchListEntry(ctx context.Context, arg UpsertWatchListE arg.UserID, arg.AnimeID, arg.Status, + arg.CurrentEpisode, ) var i WatchListEntry err := row.Scan( @@ -320,6 +396,8 @@ func (q *Queries) UpsertWatchListEntry(ctx context.Context, arg UpsertWatchListE &i.Status, &i.CreatedAt, &i.UpdatedAt, + &i.CurrentEpisode, + &i.LastEpisodeAt, ) return i, err } diff --git a/internal/features/anime/handler.go b/internal/features/anime/handler.go index 55035d5..5a0fe40 100644 --- a/internal/features/anime/handler.go +++ b/internal/features/anime/handler.go @@ -145,6 +145,62 @@ func (h *Handler) HandleAPIAnimeRelations(w http.ResponseWriter, r *http.Request templates.AnimeRelationsList(relations).Render(r.Context(), w) } +// HandleAPIAnime routes anime API requests +func (h *Handler) HandleAPIAnime(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path[len("/api/anime/"):] + + // Parse: {id}/relations or {id}/recommendations + parts := splitPath(path) + if len(parts) < 2 { + http.Error(w, "invalid path", http.StatusBadRequest) + return + } + + id, err := strconv.Atoi(parts[0]) + if err != nil || id <= 0 { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + + switch parts[1] { + case "relations": + relations := h.svc.GetRelations(id) + templates.AnimeRelationsList(relations).Render(r.Context(), w) + case "recommendations": + recs, err := h.svc.GetRecommendations(id) + if err != nil { + log.Printf("recommendations error for %d: %v", id, err) + http.Error(w, "Failed to fetch recommendations", http.StatusInternalServerError) + return + } + if len(recs) > 10 { + recs = recs[:10] + } + templates.AnimeRecommendations(recs).Render(r.Context(), w) + default: + http.Error(w, "not found", http.StatusNotFound) + } +} + +func splitPath(path string) []string { + var parts []string + var current string + for _, c := range path { + if c == '/' { + if current != "" { + parts = append(parts, current) + current = "" + } + } else { + current += string(c) + } + } + if current != "" { + parts = append(parts, current) + } + return parts +} + func (h *Handler) HandleQuickSearch(w http.ResponseWriter, r *http.Request) { query := r.URL.Query().Get("q") if query == "" { @@ -231,3 +287,46 @@ func (h *Handler) HandleAPIDiscoverUpcoming(w http.ResponseWriter, r *http.Reque templates.DiscoverItems(res.Animes, "upcoming", page+1, res.HasNextPage).Render(r.Context(), w) } + +func (h *Handler) HandleSchedule(w http.ResponseWriter, r *http.Request) { + templates.Schedule().Render(r.Context(), w) +} + +func (h *Handler) HandleAPISchedule(w http.ResponseWriter, r *http.Request) { + day := r.URL.Query().Get("day") + if day == "" { + day = "monday" + } + + res, err := h.svc.GetSchedule(day) + if err != nil { + log.Printf("schedule error for %s: %v", day, err) + http.Error(w, "Failed to fetch schedule", http.StatusInternalServerError) + return + } + + res.Animes = deduplicateAnimes(res.Animes) + + templates.ScheduleDay(day, res.Animes).Render(r.Context(), w) +} + +func (h *Handler) HandleNotifications(w http.ResponseWriter, r *http.Request) { + userID := "" + if user, ok := r.Context().Value(middleware.UserContextKey).(*database.User); ok && user != nil { + userID = user.ID + } + + if userID == "" { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + watching, err := h.svc.GetWatchingAnime(r.Context(), userID) + if err != nil { + log.Printf("watching anime error: %v", err) + http.Error(w, "Failed to fetch watching anime", http.StatusInternalServerError) + return + } + + templates.Notifications(watching).Render(r.Context(), w) +} diff --git a/internal/features/anime/service.go b/internal/features/anime/service.go index 7e60522..3f6313e 100644 --- a/internal/features/anime/service.go +++ b/internal/features/anime/service.go @@ -6,6 +6,7 @@ import ( "mal/internal/database" "mal/internal/jikan" + "mal/internal/templates" ) type Service struct { @@ -59,3 +60,34 @@ func (s *Service) GetAnimeDetails(ctx context.Context, id int, userID string) (j func (s *Service) GetRelations(id int) []jikan.RelationEntry { return s.jikanClient.GetFullRelations(id) } + +func (s *Service) GetSchedule(day string) (jikan.ScheduleResult, error) { + return s.jikanClient.GetSchedule(day) +} + +func (s *Service) GetRecommendations(animeID int) ([]jikan.Anime, error) { + return s.jikanClient.GetRecommendations(animeID) +} + +func (s *Service) GetWatchingAnime(ctx context.Context, userID string) ([]templates.WatchingAnimeWithDetails, error) { + rows, err := s.db.GetWatchingAnime(ctx, userID) + if err != nil { + return nil, fmt.Errorf("failed to get watching anime: %w", err) + } + + var result []templates.WatchingAnimeWithDetails + for _, row := range rows { + anime, err := s.jikanClient.GetAnimeByID(int(row.AnimeID)) + if err != nil { + // Skip if we can't fetch anime details + continue + } + + result = append(result, templates.WatchingAnimeWithDetails{ + Entry: row, + Anime: anime, + }) + } + + return result, nil +} diff --git a/internal/features/watchlist/service.go b/internal/features/watchlist/service.go index b15f38b..0fc3518 100644 --- a/internal/features/watchlist/service.go +++ b/internal/features/watchlist/service.go @@ -48,10 +48,11 @@ func (s *Service) AddEntry(ctx context.Context, userID string, req AddRequest) e entryID := uuid.New().String() _, err = s.db.UpsertWatchListEntry(ctx, database.UpsertWatchListEntryParams{ - ID: entryID, - UserID: userID, - AnimeID: req.AnimeID, - Status: req.Status, + ID: entryID, + UserID: userID, + AnimeID: req.AnimeID, + Status: req.Status, + CurrentEpisode: sql.NullInt64{Int64: 0, Valid: false}, }) if err != nil { return fmt.Errorf("failed to update watchlist: %w", err) @@ -152,10 +153,11 @@ func (s *Service) Import(ctx context.Context, userID string, export ExportData) } _, err = s.db.UpsertWatchListEntry(ctx, database.UpsertWatchListEntryParams{ - ID: uuid.New().String(), - UserID: userID, - AnimeID: entry.AnimeID, - Status: entry.Status, + ID: uuid.New().String(), + UserID: userID, + AnimeID: entry.AnimeID, + Status: entry.Status, + CurrentEpisode: sql.NullInt64{Int64: 0, Valid: false}, }) if err != nil { continue diff --git a/internal/jikan/client.go b/internal/jikan/client.go index 24cbdd0..b5f0f38 100644 --- a/internal/jikan/client.go +++ b/internal/jikan/client.go @@ -18,6 +18,8 @@ type Client struct { upcomingCache *expirable.LRU[int, TopAnimeResult] animeCache *expirable.LRU[int, Anime] relationsCache *expirable.LRU[int, JikanRelationsResponse] + scheduleCache *expirable.LRU[string, ScheduleResult] + recsCache *expirable.LRU[int, []Anime] } func NewClient() *Client { @@ -27,6 +29,8 @@ func NewClient() *Client { upcomingCache := expirable.NewLRU[int, TopAnimeResult](100, nil, time.Hour*1) animeCache := expirable.NewLRU[int, Anime](1000, nil, time.Hour*24) relationsCache := expirable.NewLRU[int, JikanRelationsResponse](1000, nil, time.Hour*24) + scheduleCache := expirable.NewLRU[string, ScheduleResult](50, nil, time.Hour*1) + recsCache := expirable.NewLRU[int, []Anime](500, nil, time.Hour*24) return &Client{ httpClient: &http.Client{Timeout: 10 * time.Second}, @@ -37,6 +41,8 @@ func NewClient() *Client { upcomingCache: upcomingCache, animeCache: animeCache, relationsCache: relationsCache, + scheduleCache: scheduleCache, + recsCache: recsCache, } } diff --git a/internal/jikan/recommendations.go b/internal/jikan/recommendations.go new file mode 100644 index 0000000..b38bb50 --- /dev/null +++ b/internal/jikan/recommendations.go @@ -0,0 +1,61 @@ +package jikan + +import "fmt" + +// RecommendationEntry represents a single recommendation +type RecommendationEntry struct { + Entry struct { + MalID int `json:"mal_id"` + URL string `json:"url"` + Images struct { + Webp struct { + LargeImageURL string `json:"large_image_url"` + } `json:"webp"` + } `json:"images"` + Title string `json:"title"` + } `json:"entry"` + Votes int `json:"votes"` +} + +type RecommendationsResponse struct { + Data []RecommendationEntry `json:"data"` +} + +// GetRecommendations fetches recommendations for an anime +func (c *Client) GetRecommendations(animeID int) ([]Anime, error) { + if cached, ok := c.recsCache.Get(animeID); ok { + return cached, nil + } + + var result RecommendationsResponse + reqURL := fmt.Sprintf("%s/anime/%d/recommendations", c.baseURL, animeID) + if err := c.fetchWithRetry(reqURL, &result); err != nil { + return nil, err + } + + // Convert to Anime slice (partial data) + animes := make([]Anime, 0, len(result.Data)) + for _, rec := range result.Data { + animes = append(animes, Anime{ + MalID: rec.Entry.MalID, + Title: rec.Entry.Title, + Images: struct { + Jpg struct { + LargeImageURL string `json:"large_image_url"` + } `json:"jpg"` + Webp struct { + LargeImageURL string `json:"large_image_url"` + } `json:"webp"` + }{ + Webp: struct { + LargeImageURL string `json:"large_image_url"` + }{ + LargeImageURL: rec.Entry.Images.Webp.LargeImageURL, + }, + }, + }) + } + + c.recsCache.Add(animeID, animes) + return animes, nil +} diff --git a/internal/jikan/seasons.go b/internal/jikan/seasons.go index ffed9c6..acfb212 100644 --- a/internal/jikan/seasons.go +++ b/internal/jikan/seasons.go @@ -1,6 +1,56 @@ package jikan -import "fmt" +import ( + "fmt" + "strings" +) + +// ScheduleResult contains anime grouped by day +type ScheduleResult struct { + Animes []Anime + HasNextPage bool +} + +// GetSchedule fetches anime airing on a specific day +// day can be: monday, tuesday, wednesday, thursday, friday, saturday, sunday, unknown, other +func (c *Client) GetSchedule(day string) (ScheduleResult, error) { + day = strings.ToLower(day) + cacheKey := fmt.Sprintf("schedule_%s", day) + + if cached, ok := c.scheduleCache.Get(cacheKey); ok { + return cached, nil + } + + var result TopAnimeResponse + reqURL := fmt.Sprintf("%s/schedules?filter=%s&sfw=true", c.baseURL, day) + if err := c.fetchWithRetry(reqURL, &result); err != nil { + return ScheduleResult{}, err + } + + res := ScheduleResult{ + Animes: result.Data, + HasNextPage: result.Pagination.HasNextPage, + } + + c.scheduleCache.Add(cacheKey, res) + return res, nil +} + +// GetFullSchedule fetches all days at once +func (c *Client) GetFullSchedule() (map[string][]Anime, error) { + days := []string{"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"} + schedule := make(map[string][]Anime) + + for _, day := range days { + res, err := c.GetSchedule(day) + if err != nil { + return nil, fmt.Errorf("failed to fetch %s schedule: %w", day, err) + } + schedule[day] = res.Animes + } + + return schedule, nil +} // GetSeasonsNow fetches currently airing anime func (c *Client) GetSeasonsNow(page int) (TopAnimeResult, error) { diff --git a/internal/server/routes.go b/internal/server/routes.go index 6960da0..2265e4b 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -35,6 +35,9 @@ func NewRouter(cfg Config) http.Handler { // Anime / Search / Catalog mux.HandleFunc("/", animeHandler.HandleCatalog) mux.HandleFunc("/discover", animeHandler.HandleDiscover) + mux.HandleFunc("/schedule", animeHandler.HandleSchedule) + mux.HandleFunc("/notifications", animeHandler.HandleNotifications) + mux.HandleFunc("/api/schedule", animeHandler.HandleAPISchedule) mux.HandleFunc("/api/discover/airing", animeHandler.HandleAPIDiscoverAiring) mux.HandleFunc("/api/discover/upcoming", animeHandler.HandleAPIDiscoverUpcoming) mux.HandleFunc("/search", animeHandler.HandleSearch) @@ -42,7 +45,7 @@ func NewRouter(cfg Config) http.Handler { mux.HandleFunc("/api/search-quick", animeHandler.HandleQuickSearch) mux.HandleFunc("/api/catalog", animeHandler.HandleAPICatalog) mux.HandleFunc("/anime/", animeHandler.HandleAnimeDetails) - mux.HandleFunc("/api/anime/", animeHandler.HandleAPIAnimeRelations) + mux.HandleFunc("/api/anime/", animeHandler.HandleAPIAnime) // Auth Endpoints mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/templates/anime.templ b/internal/templates/anime.templ index 9cc63d0..ae23d65 100644 --- a/internal/templates/anime.templ +++ b/internal/templates/anime.templ @@ -56,6 +56,14 @@ templ AnimeDetails(anime jikan.Anime, currentStatus string) { loading relations +
+
+
+
+
+ loading recommendations +
+