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)
+);