From 3b45500b7b57766a1bf9c1b14e00e6106c38098b Mon Sep 17 00:00:00 2001 From: mkelvers Date: Wed, 8 Apr 2026 13:35:44 +0200 Subject: [PATCH] feat: add upcoming seasons sync and notification for sequels --- cmd/server/main.go | 6 + internal/database/models.go | 22 +- internal/database/querier.go | 4 + internal/database/queries.sql | 31 +++ internal/database/queries.sql.go | 140 ++++++++++- internal/features/anime/handler.go | 9 +- internal/features/anime/service.go | 9 +- internal/templates/notifications.templ | 48 +++- internal/templates/notifications_templ.go | 290 ++++++++++++++++------ internal/worker/relations.go | 142 +++++++++++ migrations/005_add_anime_relations.sql | 9 + 11 files changed, 625 insertions(+), 85 deletions(-) create mode 100644 internal/worker/relations.go create mode 100644 migrations/005_add_anime_relations.sql diff --git a/cmd/server/main.go b/cmd/server/main.go index f052b7f..7d5c4ca 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -8,10 +8,12 @@ import ( _ "github.com/mattn/go-sqlite3" + "context" "mal/internal/database" "mal/internal/features/auth" "mal/internal/jikan" "mal/internal/server" + "mal/internal/worker" ) func main() { @@ -36,6 +38,10 @@ func main() { authService := auth.NewService(queries) jikanClient := jikan.NewClient() + // Start background workers + relationsWorker := worker.New(queries, jikanClient) + go relationsWorker.Start(context.Background()) + app := server.Config{ DB: queries, JikanClient: jikanClient, diff --git a/internal/database/models.go b/internal/database/models.go index 80abb98..00aaab4 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -18,13 +18,21 @@ type Account struct { } type Anime struct { - ID int64 `json:"id"` - TitleOriginal string `json:"title_original"` - ImageUrl string `json:"image_url"` - CreatedAt time.Time `json:"created_at"` - TitleEnglish sql.NullString `json:"title_english"` - TitleJapanese sql.NullString `json:"title_japanese"` - Airing sql.NullBool `json:"airing"` + ID int64 `json:"id"` + TitleOriginal string `json:"title_original"` + ImageUrl string `json:"image_url"` + CreatedAt time.Time `json:"created_at"` + TitleEnglish sql.NullString `json:"title_english"` + TitleJapanese sql.NullString `json:"title_japanese"` + Airing sql.NullBool `json:"airing"` + Status sql.NullString `json:"status"` + RelationsSyncedAt sql.NullTime `json:"relations_synced_at"` +} + +type AnimeRelation struct { + AnimeID int64 `json:"anime_id"` + RelatedAnimeID int64 `json:"related_anime_id"` + RelationType string `json:"relation_type"` } type NotificationPreference struct { diff --git a/internal/database/querier.go b/internal/database/querier.go index eed23f7..b0c57d8 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -15,13 +15,17 @@ type Querier interface { DeleteUserSessions(ctx context.Context, userID string) error DeleteWatchListEntry(ctx context.Context, arg DeleteWatchListEntryParams) error GetAnime(ctx context.Context, id int64) (Anime, error) + GetAnimeNeedingRelationSync(ctx context.Context) ([]GetAnimeNeedingRelationSyncRow, error) GetSession(ctx context.Context, id string) (Session, error) + GetUpcomingSeasons(ctx context.Context, userID string) ([]GetUpcomingSeasonsRow, error) GetUser(ctx context.Context, id string) (User, error) 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) + UpdateAnimeStatus(ctx context.Context, arg UpdateAnimeStatusParams) error UpsertAnime(ctx context.Context, arg UpsertAnimeParams) (Anime, error) + UpsertAnimeRelation(ctx context.Context, arg UpsertAnimeRelationParams) error UpsertWatchListEntry(ctx context.Context, arg UpsertWatchListEntryParams) (WatchListEntry, error) } diff --git a/internal/database/queries.sql b/internal/database/queries.sql index 15ed9c3..e01a9f8 100644 --- a/internal/database/queries.sql +++ b/internal/database/queries.sql @@ -79,3 +79,34 @@ FROM watch_list_entry e JOIN anime a ON e.anime_id = a.id WHERE e.user_id = ? AND e.status IN ('watching', 'plan_to_watch') AND a.airing = 1 ORDER BY e.updated_at DESC; +-- name: UpsertAnimeRelation :exec +INSERT INTO anime_relation (anime_id, related_anime_id, relation_type) +VALUES (?, ?, ?) +ON CONFLICT (anime_id, related_anime_id) DO UPDATE SET + relation_type = excluded.relation_type; + +-- name: UpdateAnimeStatus :exec +UPDATE anime SET status = ?, relations_synced_at = CURRENT_TIMESTAMP WHERE id = ?; + +-- name: GetAnimeNeedingRelationSync :many +SELECT a.id, a.title_original +FROM watch_list_entry w +JOIN anime a ON w.anime_id = a.id +WHERE w.status IN ('completed', 'watching') + AND (a.relations_synced_at IS NULL OR a.relations_synced_at < datetime('now', '-7 days')) +GROUP BY a.id +LIMIT 50; + +-- name: GetUpcomingSeasons :many +SELECT DISTINCT + related.*, + a.title_original AS prequel_title +FROM watch_list_entry w +JOIN anime_relation r ON w.anime_id = r.anime_id +JOIN anime a ON w.anime_id = a.id +JOIN anime related ON r.related_anime_id = related.id +WHERE w.user_id = ? + AND w.status IN ('completed', 'watching') + AND r.relation_type = 'Sequel' + AND related.status IN ('Not yet aired', 'Currently Airing') +ORDER BY related.id DESC; diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index d11447a..4424e75 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -93,7 +93,7 @@ func (q *Queries) DeleteWatchListEntry(ctx context.Context, arg DeleteWatchListE } const getAnime = `-- name: GetAnime :one -SELECT id, title_original, image_url, created_at, title_english, title_japanese, airing FROM anime WHERE id = ? LIMIT 1 +SELECT id, title_original, image_url, created_at, title_english, title_japanese, airing, status, relations_synced_at FROM anime WHERE id = ? LIMIT 1 ` func (q *Queries) GetAnime(ctx context.Context, id int64) (Anime, error) { @@ -107,10 +107,50 @@ func (q *Queries) GetAnime(ctx context.Context, id int64) (Anime, error) { &i.TitleEnglish, &i.TitleJapanese, &i.Airing, + &i.Status, + &i.RelationsSyncedAt, ) return i, err } +const getAnimeNeedingRelationSync = `-- name: GetAnimeNeedingRelationSync :many +SELECT a.id, a.title_original +FROM watch_list_entry w +JOIN anime a ON w.anime_id = a.id +WHERE w.status IN ('completed', 'watching') + AND (a.relations_synced_at IS NULL OR a.relations_synced_at < datetime('now', '-7 days')) +GROUP BY a.id +LIMIT 50 +` + +type GetAnimeNeedingRelationSyncRow struct { + ID int64 `json:"id"` + TitleOriginal string `json:"title_original"` +} + +func (q *Queries) GetAnimeNeedingRelationSync(ctx context.Context) ([]GetAnimeNeedingRelationSyncRow, error) { + rows, err := q.db.QueryContext(ctx, getAnimeNeedingRelationSync) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetAnimeNeedingRelationSyncRow + for rows.Next() { + var i GetAnimeNeedingRelationSyncRow + if err := rows.Scan(&i.ID, &i.TitleOriginal); 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 getSession = `-- name: GetSession :one SELECT id, user_id, expires_at, created_at FROM session WHERE id = ? LIMIT 1 ` @@ -127,6 +167,68 @@ func (q *Queries) GetSession(ctx context.Context, id string) (Session, error) { return i, err } +const getUpcomingSeasons = `-- name: GetUpcomingSeasons :many +SELECT DISTINCT + related.id, related.title_original, related.image_url, related.created_at, related.title_english, related.title_japanese, related.airing, related.status, related.relations_synced_at, + a.title_original AS prequel_title +FROM watch_list_entry w +JOIN anime_relation r ON w.anime_id = r.anime_id +JOIN anime a ON w.anime_id = a.id +JOIN anime related ON r.related_anime_id = related.id +WHERE w.user_id = ? + AND w.status IN ('completed', 'watching') + AND r.relation_type = 'Sequel' + AND related.status IN ('Not yet aired', 'Currently Airing') +ORDER BY related.id DESC +` + +type GetUpcomingSeasonsRow struct { + ID int64 `json:"id"` + TitleOriginal string `json:"title_original"` + ImageUrl string `json:"image_url"` + CreatedAt time.Time `json:"created_at"` + TitleEnglish sql.NullString `json:"title_english"` + TitleJapanese sql.NullString `json:"title_japanese"` + Airing sql.NullBool `json:"airing"` + Status sql.NullString `json:"status"` + RelationsSyncedAt sql.NullTime `json:"relations_synced_at"` + PrequelTitle string `json:"prequel_title"` +} + +func (q *Queries) GetUpcomingSeasons(ctx context.Context, userID string) ([]GetUpcomingSeasonsRow, error) { + rows, err := q.db.QueryContext(ctx, getUpcomingSeasons, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetUpcomingSeasonsRow + for rows.Next() { + var i GetUpcomingSeasonsRow + if err := rows.Scan( + &i.ID, + &i.TitleOriginal, + &i.ImageUrl, + &i.CreatedAt, + &i.TitleEnglish, + &i.TitleJapanese, + &i.Airing, + &i.Status, + &i.RelationsSyncedAt, + &i.PrequelTitle, + ); 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 getUser = `-- name: GetUser :one SELECT id, username, password_hash, created_at FROM user WHERE id = ? LIMIT 1 ` @@ -319,6 +421,20 @@ func (q *Queries) GetWatchingAnime(ctx context.Context, userID string) ([]GetWat return items, nil } +const updateAnimeStatus = `-- name: UpdateAnimeStatus :exec +UPDATE anime SET status = ?, relations_synced_at = CURRENT_TIMESTAMP WHERE id = ? +` + +type UpdateAnimeStatusParams struct { + Status sql.NullString `json:"status"` + ID int64 `json:"id"` +} + +func (q *Queries) UpdateAnimeStatus(ctx context.Context, arg UpdateAnimeStatusParams) error { + _, err := q.db.ExecContext(ctx, updateAnimeStatus, arg.Status, arg.ID) + return err +} + const upsertAnime = `-- name: UpsertAnime :one INSERT INTO anime (id, title_original, title_english, title_japanese, image_url, airing) VALUES (?, ?, ?, ?, ?, ?) @@ -328,7 +444,7 @@ ON CONFLICT (id) DO UPDATE SET title_japanese = excluded.title_japanese, image_url = excluded.image_url, airing = excluded.airing -RETURNING id, title_original, image_url, created_at, title_english, title_japanese, airing +RETURNING id, title_original, image_url, created_at, title_english, title_japanese, airing, status, relations_synced_at ` type UpsertAnimeParams struct { @@ -358,10 +474,30 @@ func (q *Queries) UpsertAnime(ctx context.Context, arg UpsertAnimeParams) (Anime &i.TitleEnglish, &i.TitleJapanese, &i.Airing, + &i.Status, + &i.RelationsSyncedAt, ) return i, err } +const upsertAnimeRelation = `-- name: UpsertAnimeRelation :exec +INSERT INTO anime_relation (anime_id, related_anime_id, relation_type) +VALUES (?, ?, ?) +ON CONFLICT (anime_id, related_anime_id) DO UPDATE SET + relation_type = excluded.relation_type +` + +type UpsertAnimeRelationParams struct { + AnimeID int64 `json:"anime_id"` + RelatedAnimeID int64 `json:"related_anime_id"` + RelationType string `json:"relation_type"` +} + +func (q *Queries) UpsertAnimeRelation(ctx context.Context, arg UpsertAnimeRelationParams) error { + _, err := q.db.ExecContext(ctx, upsertAnimeRelation, arg.AnimeID, arg.RelatedAnimeID, arg.RelationType) + return err +} + const upsertWatchListEntry = `-- name: UpsertWatchListEntry :one INSERT INTO watch_list_entry (id, user_id, anime_id, status, current_episode, updated_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) diff --git a/internal/features/anime/handler.go b/internal/features/anime/handler.go index 5a0fe40..9af903c 100644 --- a/internal/features/anime/handler.go +++ b/internal/features/anime/handler.go @@ -328,5 +328,12 @@ func (h *Handler) HandleNotifications(w http.ResponseWriter, r *http.Request) { return } - templates.Notifications(watching).Render(r.Context(), w) + upcomingSeasons, err := h.svc.GetUpcomingSeasons(r.Context(), userID) + if err != nil { + log.Printf("upcoming seasons error: %v", err) + http.Error(w, "Failed to fetch upcoming seasons", http.StatusInternalServerError) + return + } + + templates.Notifications(watching, upcomingSeasons).Render(r.Context(), w) } diff --git a/internal/features/anime/service.go b/internal/features/anime/service.go index 3f6313e..691beb8 100644 --- a/internal/features/anime/service.go +++ b/internal/features/anime/service.go @@ -82,7 +82,6 @@ func (s *Service) GetWatchingAnime(ctx context.Context, userID string) ([]templa // Skip if we can't fetch anime details continue } - result = append(result, templates.WatchingAnimeWithDetails{ Entry: row, Anime: anime, @@ -91,3 +90,11 @@ func (s *Service) GetWatchingAnime(ctx context.Context, userID string) ([]templa return result, nil } + +func (s *Service) GetUpcomingSeasons(ctx context.Context, userID string) ([]database.GetUpcomingSeasonsRow, error) { + rows, err := s.db.GetUpcomingSeasons(ctx, userID) + if err != nil { + return nil, fmt.Errorf("failed to get upcoming seasons: %w", err) + } + return rows, nil +} diff --git a/internal/templates/notifications.templ b/internal/templates/notifications.templ index 825d007..601b913 100644 --- a/internal/templates/notifications.templ +++ b/internal/templates/notifications.templ @@ -9,7 +9,7 @@ type WatchingAnimeWithDetails struct { Anime jikan.Anime } -templ Notifications(watching []WatchingAnimeWithDetails) { +templ Notifications(watching []WatchingAnimeWithDetails, upcomingSeasons []database.GetUpcomingSeasonsRow) { @Layout("mal - notifications") {

upcoming episodes

@@ -27,10 +27,56 @@ templ Notifications(watching []WatchingAnimeWithDetails) { }
} + +

upcoming seasons

+

because you've watched prequels

+ + if len(upcomingSeasons) == 0 { +
+

no upcoming seasons for anime you've watched

+

as you watch more shows, new seasons will appear here

+
+ } else { +
+ for _, item := range upcomingSeasons { + @UpcomingSeasonCard(item) + } +
+ } } } +templ UpcomingSeasonCard(item database.GetUpcomingSeasonsRow) { + +
+ if item.ImageUrl != "" { + { + } else { +
no image
+ } +
+
+
+ { displaySeasonTitle(item) } +
+
+ because you watched { item.PrequelTitle } +
+
+
+} + +func displaySeasonTitle(entry database.GetUpcomingSeasonsRow) string { + if entry.TitleEnglish.Valid && entry.TitleEnglish.String != "" { + return entry.TitleEnglish.String + } + if entry.TitleJapanese.Valid && entry.TitleJapanese.String != "" { + return entry.TitleJapanese.String + } + return entry.TitleOriginal +} + templ NotificationCard(item WatchingAnimeWithDetails) {
diff --git a/internal/templates/notifications_templ.go b/internal/templates/notifications_templ.go index 498da55..e762af3 100644 --- a/internal/templates/notifications_templ.go +++ b/internal/templates/notifications_templ.go @@ -17,7 +17,7 @@ type WatchingAnimeWithDetails struct { Anime jikan.Anime } -func Notifications(watching []WatchingAnimeWithDetails) templ.Component { +func Notifications(watching []WatchingAnimeWithDetails, upcomingSeasons []database.GetUpcomingSeasonsRow) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -75,7 +75,32 @@ func Notifications(watching []WatchingAnimeWithDetails) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

upcoming seasons

because you've watched prequels

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(upcomingSeasons) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "

no upcoming seasons for anime you've watched

as you watch more shows, new seasons will appear here

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, item := range upcomingSeasons { + templ_7745c5c3_Err = UpcomingSeasonCard(item).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -89,7 +114,7 @@ func Notifications(watching []WatchingAnimeWithDetails) templ.Component { }) } -func NotificationCard(item WatchingAnimeWithDetails) templ.Component { +func UpcomingSeasonCard(item database.GetUpcomingSeasonsRow) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -110,146 +135,265 @@ func NotificationCard(item WatchingAnimeWithDetails) templ.Component { templ_7745c5c3_Var3 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" class=\"notification-card\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if item.Entry.ImageUrl != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"")") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" loading=\"lazy\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
no image
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
no image
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var7 string - templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(displayTitle(item.Entry)) + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(displaySeasonTitle(item)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/notifications.templ`, Line: 45, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/notifications.templ`, Line: 61, Col: 30} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
because you watched ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if item.Anime.Broadcast.String != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var8 string - templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.Anime.Broadcast.String) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/notifications.templ`, Line: 49, Col: 71} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.PrequelTitle) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/notifications.templ`, Line: 64, Col: 125} } - if item.Anime.Episodes > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if item.Entry.CurrentEpisode.Valid { - var templ_7745c5c3_Var9 string - templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d / %d eps", item.Entry.CurrentEpisode.Int64, item.Anime.Episodes)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/notifications.templ`, Line: 54, Col: 89} + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func displaySeasonTitle(entry database.GetUpcomingSeasonsRow) string { + if entry.TitleEnglish.Valid && entry.TitleEnglish.String != "" { + return entry.TitleEnglish.String + } + if entry.TitleJapanese.Valid && entry.TitleJapanese.String != "" { + return entry.TitleJapanese.String + } + return entry.TitleOriginal +} + +func NotificationCard(item WatchingAnimeWithDetails) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else { - var templ_7745c5c3_Var10 string - templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("0 / %d eps", item.Anime.Episodes)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/notifications.templ`, Line: 56, Col: 55} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else if item.Entry.CurrentEpisode.Valid && item.Entry.CurrentEpisode.Int64 > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "") + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var9 := templ.GetChildren(ctx) + if templ_7745c5c3_Var9 == nil { + templ_7745c5c3_Var9 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if item.Entry.ImageUrl != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" alt=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(displayTitle(item.Entry)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/notifications.templ`, Line: 84, Col: 67} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" loading=\"lazy\">") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
no image
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(displayTitle(item.Entry)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/notifications.templ`, Line: 91, Col: 30} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if item.Anime.Broadcast.String != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(item.Anime.Broadcast.String) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/notifications.templ`, Line: 95, Col: 71} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if item.Anime.Episodes > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if item.Entry.CurrentEpisode.Valid { + var templ_7745c5c3_Var15 string + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d / %d eps", item.Entry.CurrentEpisode.Int64, item.Anime.Episodes)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/notifications.templ`, Line: 100, Col: 89} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("0 / %d eps", item.Anime.Episodes)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/notifications.templ`, Line: 102, Col: 55} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if item.Entry.CurrentEpisode.Valid && item.Entry.CurrentEpisode.Int64 > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var17 string + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d eps watched", item.Entry.CurrentEpisode.Int64)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/notifications.templ`, Line: 107, Col: 70} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/internal/worker/relations.go b/internal/worker/relations.go new file mode 100644 index 0000000..f32e342 --- /dev/null +++ b/internal/worker/relations.go @@ -0,0 +1,142 @@ +package worker + +import ( + "context" + "database/sql" + "log" + "time" + + "mal/internal/database" + "mal/internal/jikan" +) + +type Worker struct { + db *database.Queries + client *jikan.Client +} + +func New(db *database.Queries, client *jikan.Client) *Worker { + return &Worker{ + db: db, + client: client, + } +} + +func (w *Worker) Start(ctx context.Context) { + log.Println("Starting relations sync worker...") + ticker := time.NewTicker(2 * time.Minute) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + w.syncRelations(ctx) + } + } +} + +func (w *Worker) syncRelations(ctx context.Context) { + // Find up to 50 anime that need their relations synced + animes, err := w.db.GetAnimeNeedingRelationSync(ctx) + if err != nil { + log.Printf("worker: failed to get anime needing sync: %v", err) + return + } + + for _, a := range animes { + log.Printf("worker: syncing relations for anime %d (%s)", a.ID, a.TitleOriginal) + + relations, err := w.client.GetRelationsData(int(a.ID)) + if err != nil { + log.Printf("worker: failed to fetch relations for %d: %v", a.ID, err) + continue + } + + for _, rel := range relations.Data { + for _, entry := range rel.Entry { + if entry.Type == "anime" { + // We just insert the relation. + err := w.db.UpsertAnimeRelation(ctx, database.UpsertAnimeRelationParams{ + AnimeID: a.ID, + RelatedAnimeID: int64(entry.MalID), + RelationType: rel.Relation, + }) + if err != nil { + log.Printf("worker: failed to insert relation %d -> %d: %v", a.ID, entry.MalID, err) + } + + // If it's a Sequel, we should also make sure the related anime is tracked + if rel.Relation == "Sequel" { + w.ensureAnimeExistsAndStatusUpdated(ctx, entry.MalID) + } + } + } + } + + // Also update the status of the anime itself so we know if it's Not yet aired, etc. + animeDetails, err := w.client.GetAnimeByID(int(a.ID)) + if err == nil { + err = w.db.UpdateAnimeStatus(ctx, database.UpdateAnimeStatusParams{ + Status: sql.NullString{String: animeDetails.Status, Valid: true}, + ID: a.ID, + }) + if err != nil { + log.Printf("worker: failed to update status for %d: %v", a.ID, err) + } + } + + // Sleep briefly to respect Jikan's 3 req/sec rate limit + time.Sleep(400 * time.Millisecond) + } +} + +func (w *Worker) ensureAnimeExistsAndStatusUpdated(ctx context.Context, malID int) { + // check if we have it + _, err := w.db.GetAnime(ctx, int64(malID)) + if err != nil { + // we don't have it, let's fetch it + animeDetails, err := w.client.GetAnimeByID(malID) + if err != nil { + log.Printf("worker: failed to fetch related anime %d: %v", malID, err) + return + } + + _, err = w.db.UpsertAnime(ctx, database.UpsertAnimeParams{ + ID: int64(animeDetails.MalID), + TitleOriginal: animeDetails.Title, + TitleEnglish: sql.NullString{String: animeDetails.TitleEnglish, Valid: animeDetails.TitleEnglish != ""}, + TitleJapanese: sql.NullString{String: animeDetails.TitleJapanese, Valid: animeDetails.TitleJapanese != ""}, + ImageUrl: animeDetails.ImageURL(), + Airing: sql.NullBool{Bool: animeDetails.Airing, Valid: true}, + }) + if err != nil { + log.Printf("worker: failed to insert related anime %d: %v", malID, err) + return + } + + err = w.db.UpdateAnimeStatus(ctx, database.UpdateAnimeStatusParams{ + Status: sql.NullString{String: animeDetails.Status, Valid: true}, + ID: int64(animeDetails.MalID), + }) + if err != nil { + log.Printf("worker: failed to update status for related anime %d: %v", malID, err) + } + + time.Sleep(400 * time.Millisecond) + } else { + // We have it, but maybe status is outdated. Fetching every time might be too much, + // but since it's a Sequel to something they watched, we could fetch it. + // For now, let's just let the worker naturally pick it up if it gets added to watchlist, + // OR we can explicitly fetch its details to keep sequels up to date. + animeDetails, err := w.client.GetAnimeByID(malID) + if err == nil { + w.db.UpdateAnimeStatus(ctx, database.UpdateAnimeStatusParams{ + Status: sql.NullString{String: animeDetails.Status, Valid: true}, + ID: int64(animeDetails.MalID), + }) + } + time.Sleep(400 * time.Millisecond) + } +} diff --git a/migrations/005_add_anime_relations.sql b/migrations/005_add_anime_relations.sql new file mode 100644 index 0000000..11a82ee --- /dev/null +++ b/migrations/005_add_anime_relations.sql @@ -0,0 +1,9 @@ +ALTER TABLE anime ADD COLUMN status TEXT DEFAULT ''; +ALTER TABLE anime ADD COLUMN relations_synced_at DATETIME; + +CREATE TABLE IF NOT EXISTS anime_relation ( + anime_id INTEGER NOT NULL REFERENCES anime(id) ON DELETE CASCADE, + related_anime_id INTEGER NOT NULL, + relation_type TEXT NOT NULL, + PRIMARY KEY (anime_id, related_anime_id) +);