feat: add upcoming seasons sync and notification for sequels

This commit is contained in:
2026-04-08 13:35:44 +02:00
parent 437ec70d8c
commit 3b45500b7b
11 changed files with 625 additions and 85 deletions

View File

@@ -8,10 +8,12 @@ import (
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
"context"
"mal/internal/database" "mal/internal/database"
"mal/internal/features/auth" "mal/internal/features/auth"
"mal/internal/jikan" "mal/internal/jikan"
"mal/internal/server" "mal/internal/server"
"mal/internal/worker"
) )
func main() { func main() {
@@ -36,6 +38,10 @@ func main() {
authService := auth.NewService(queries) authService := auth.NewService(queries)
jikanClient := jikan.NewClient() jikanClient := jikan.NewClient()
// Start background workers
relationsWorker := worker.New(queries, jikanClient)
go relationsWorker.Start(context.Background())
app := server.Config{ app := server.Config{
DB: queries, DB: queries,
JikanClient: jikanClient, JikanClient: jikanClient,

View File

@@ -18,13 +18,21 @@ type Account struct {
} }
type Anime struct { type Anime struct {
ID int64 `json:"id"` ID int64 `json:"id"`
TitleOriginal string `json:"title_original"` TitleOriginal string `json:"title_original"`
ImageUrl string `json:"image_url"` ImageUrl string `json:"image_url"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
TitleEnglish sql.NullString `json:"title_english"` TitleEnglish sql.NullString `json:"title_english"`
TitleJapanese sql.NullString `json:"title_japanese"` TitleJapanese sql.NullString `json:"title_japanese"`
Airing sql.NullBool `json:"airing"` 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 { type NotificationPreference struct {

View File

@@ -15,13 +15,17 @@ type Querier interface {
DeleteUserSessions(ctx context.Context, userID string) error DeleteUserSessions(ctx context.Context, userID string) error
DeleteWatchListEntry(ctx context.Context, arg DeleteWatchListEntryParams) error DeleteWatchListEntry(ctx context.Context, arg DeleteWatchListEntryParams) error
GetAnime(ctx context.Context, id int64) (Anime, error) GetAnime(ctx context.Context, id int64) (Anime, error)
GetAnimeNeedingRelationSync(ctx context.Context) ([]GetAnimeNeedingRelationSyncRow, error)
GetSession(ctx context.Context, id string) (Session, 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) GetUser(ctx context.Context, id string) (User, error)
GetUserByUsername(ctx context.Context, username string) (User, error) GetUserByUsername(ctx context.Context, username string) (User, error)
GetUserWatchList(ctx context.Context, userID string) ([]GetUserWatchListRow, error) GetUserWatchList(ctx context.Context, userID string) ([]GetUserWatchListRow, error)
GetWatchListEntry(ctx context.Context, arg GetWatchListEntryParams) (WatchListEntry, error) GetWatchListEntry(ctx context.Context, arg GetWatchListEntryParams) (WatchListEntry, error)
GetWatchingAnime(ctx context.Context, userID string) ([]GetWatchingAnimeRow, error) GetWatchingAnime(ctx context.Context, userID string) ([]GetWatchingAnimeRow, error)
UpdateAnimeStatus(ctx context.Context, arg UpdateAnimeStatusParams) error
UpsertAnime(ctx context.Context, arg UpsertAnimeParams) (Anime, error) UpsertAnime(ctx context.Context, arg UpsertAnimeParams) (Anime, error)
UpsertAnimeRelation(ctx context.Context, arg UpsertAnimeRelationParams) error
UpsertWatchListEntry(ctx context.Context, arg UpsertWatchListEntryParams) (WatchListEntry, error) UpsertWatchListEntry(ctx context.Context, arg UpsertWatchListEntryParams) (WatchListEntry, error)
} }

View File

@@ -79,3 +79,34 @@ FROM watch_list_entry e
JOIN anime a ON e.anime_id = a.id 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 WHERE e.user_id = ? AND e.status IN ('watching', 'plan_to_watch') AND a.airing = 1
ORDER BY e.updated_at DESC; 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;

View File

@@ -93,7 +93,7 @@ func (q *Queries) DeleteWatchListEntry(ctx context.Context, arg DeleteWatchListE
} }
const getAnime = `-- name: GetAnime :one 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) { 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.TitleEnglish,
&i.TitleJapanese, &i.TitleJapanese,
&i.Airing, &i.Airing,
&i.Status,
&i.RelationsSyncedAt,
) )
return i, err 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 const getSession = `-- name: GetSession :one
SELECT id, user_id, expires_at, created_at FROM session WHERE id = ? LIMIT 1 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 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 const getUser = `-- name: GetUser :one
SELECT id, username, password_hash, created_at FROM user WHERE id = ? LIMIT 1 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 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 const upsertAnime = `-- name: UpsertAnime :one
INSERT INTO anime (id, title_original, title_english, title_japanese, image_url, airing) INSERT INTO anime (id, title_original, title_english, title_japanese, image_url, airing)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
@@ -328,7 +444,7 @@ ON CONFLICT (id) DO UPDATE SET
title_japanese = excluded.title_japanese, title_japanese = excluded.title_japanese,
image_url = excluded.image_url, image_url = excluded.image_url,
airing = excluded.airing 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 { type UpsertAnimeParams struct {
@@ -358,10 +474,30 @@ func (q *Queries) UpsertAnime(ctx context.Context, arg UpsertAnimeParams) (Anime
&i.TitleEnglish, &i.TitleEnglish,
&i.TitleJapanese, &i.TitleJapanese,
&i.Airing, &i.Airing,
&i.Status,
&i.RelationsSyncedAt,
) )
return i, err 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 const upsertWatchListEntry = `-- name: UpsertWatchListEntry :one
INSERT INTO watch_list_entry (id, user_id, anime_id, status, current_episode, updated_at) INSERT INTO watch_list_entry (id, user_id, anime_id, status, current_episode, updated_at)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)

View File

@@ -328,5 +328,12 @@ func (h *Handler) HandleNotifications(w http.ResponseWriter, r *http.Request) {
return 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)
} }

View File

@@ -82,7 +82,6 @@ func (s *Service) GetWatchingAnime(ctx context.Context, userID string) ([]templa
// Skip if we can't fetch anime details // Skip if we can't fetch anime details
continue continue
} }
result = append(result, templates.WatchingAnimeWithDetails{ result = append(result, templates.WatchingAnimeWithDetails{
Entry: row, Entry: row,
Anime: anime, Anime: anime,
@@ -91,3 +90,11 @@ func (s *Service) GetWatchingAnime(ctx context.Context, userID string) ([]templa
return result, nil 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
}

View File

@@ -9,7 +9,7 @@ type WatchingAnimeWithDetails struct {
Anime jikan.Anime Anime jikan.Anime
} }
templ Notifications(watching []WatchingAnimeWithDetails) { templ Notifications(watching []WatchingAnimeWithDetails, upcomingSeasons []database.GetUpcomingSeasonsRow) {
@Layout("mal - notifications") { @Layout("mal - notifications") {
<div class="notifications-page"> <div class="notifications-page">
<h1>upcoming episodes</h1> <h1>upcoming episodes</h1>
@@ -27,10 +27,56 @@ templ Notifications(watching []WatchingAnimeWithDetails) {
} }
</div> </div>
} }
<h1 style="margin-top: var(--space-2xl);">upcoming seasons</h1>
<p class="notifications-subtitle">because you've watched prequels</p>
if len(upcomingSeasons) == 0 {
<div class="no-notifications">
<p>no upcoming seasons for anime you've watched</p>
<p class="hint">as you watch more shows, new seasons will appear here</p>
</div>
} else {
<div class="notifications-list">
for _, item := range upcomingSeasons {
@UpcomingSeasonCard(item)
}
</div>
}
</div> </div>
} }
} }
templ UpcomingSeasonCard(item database.GetUpcomingSeasonsRow) {
<a href={ templ.URL(fmt.Sprintf("/anime/%d", item.ID)) } class="notification-card">
<div class="notification-image">
if item.ImageUrl != "" {
<img src={ item.ImageUrl } alt={ displaySeasonTitle(item) } loading="lazy"/>
} else {
<div class="no-image">no image</div>
}
</div>
<div class="notification-content">
<div class="notification-title">
{ displaySeasonTitle(item) }
</div>
<div class="notification-meta">
<span class="notification-broadcast" style="color: var(--text-muted) !important;">because you watched { item.PrequelTitle }</span>
</div>
</div>
</a>
}
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) { templ NotificationCard(item WatchingAnimeWithDetails) {
<a href={ templ.URL(fmt.Sprintf("/anime/%d", item.Entry.AnimeID)) } class="notification-card"> <a href={ templ.URL(fmt.Sprintf("/anime/%d", item.Entry.AnimeID)) } class="notification-card">
<div class="notification-image"> <div class="notification-image">

View File

@@ -17,7 +17,7 @@ type WatchingAnimeWithDetails struct {
Anime jikan.Anime 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) { 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 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -75,7 +75,32 @@ func Notifications(watching []WatchingAnimeWithDetails) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<h1 style=\"margin-top: var(--space-2xl);\">upcoming seasons</h1><p class=\"notifications-subtitle\">because you've watched prequels</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(upcomingSeasons) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"no-notifications\"><p>no upcoming seasons for anime you've watched</p><p class=\"hint\">as you watch more shows, new seasons will appear here</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"notifications-list\">")
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, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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) { 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 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 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 templ_7745c5c3_Var3 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<a href=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<a href=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var4 templ.SafeURL var templ_7745c5c3_Var4 templ.SafeURL
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL(fmt.Sprintf("/anime/%d", item.Entry.AnimeID))) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL(fmt.Sprintf("/anime/%d", item.ID)))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/notifications.templ`, Line: 35, Col: 66} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/notifications.templ`, Line: 51, Col: 55}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" class=\"notification-card\"><div class=\"notification-image\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" class=\"notification-card\"><div class=\"notification-image\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if item.Entry.ImageUrl != "" { if item.ImageUrl != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<img src=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<img src=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.Entry.ImageUrl) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.ImageUrl)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/notifications.templ`, Line: 38, Col: 34} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/notifications.templ`, Line: 54, Col: 28}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" alt=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" alt=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(displayTitle(item.Entry)) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(displaySeasonTitle(item))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/notifications.templ`, Line: 38, Col: 67} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/notifications.templ`, Line: 54, Col: 61}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" loading=\"lazy\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" loading=\"lazy\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} else { } else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"no-image\">no image</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<div class=\"no-image\">no image</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div><div class=\"notification-content\"><div class=\"notification-title\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div><div class=\"notification-content\"><div class=\"notification-title\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var7 string 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 { 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)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div><div class=\"notification-meta\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div><div class=\"notification-meta\"><span class=\"notification-broadcast\" style=\"color: var(--text-muted) !important;\">because you watched ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if item.Anime.Broadcast.String != "" { var templ_7745c5c3_Var8 string
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<span class=\"notification-broadcast\">") templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.PrequelTitle)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/notifications.templ`, Line: 64, Col: 125}
}
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, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} }
if item.Anime.Episodes > 0 { _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<span class=\"notification-progress\">") if templ_7745c5c3_Err != nil {
if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err
return templ_7745c5c3_Err }
} templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</span></div></div></a>")
if item.Entry.CurrentEpisode.Valid { if templ_7745c5c3_Err != nil {
var templ_7745c5c3_Var9 string return templ_7745c5c3_Err
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 nil
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/notifications.templ`, Line: 54, Col: 89} })
}
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 ctx = templ.InitializeContext(ctx)
} templ_7745c5c3_Var9 := templ.GetChildren(ctx)
} else { if templ_7745c5c3_Var9 == nil {
var templ_7745c5c3_Var10 string templ_7745c5c3_Var9 = templ.NopComponent
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("0 / %d eps", item.Anime.Episodes)) }
if templ_7745c5c3_Err != nil { ctx = templ.ClearChildren(ctx)
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/notifications.templ`, Line: 56, Col: 55} templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<a href=\"")
} if templ_7745c5c3_Err != nil {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) return templ_7745c5c3_Err
if templ_7745c5c3_Err != nil { }
return templ_7745c5c3_Err var templ_7745c5c3_Var10 templ.SafeURL
} templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL(fmt.Sprintf("/anime/%d", item.Entry.AnimeID)))
} if templ_7745c5c3_Err != nil {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</span>") return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/notifications.templ`, Line: 81, Col: 66}
if templ_7745c5c3_Err != nil { }
return templ_7745c5c3_Err _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
} if templ_7745c5c3_Err != nil {
} else if item.Entry.CurrentEpisode.Valid && item.Entry.CurrentEpisode.Int64 > 0 { return templ_7745c5c3_Err
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<span class=\"notification-progress\">") }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" class=\"notification-card\"><div class=\"notification-image\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if item.Entry.ImageUrl != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<img src=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d eps watched", item.Entry.CurrentEpisode.Int64)) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.Entry.ImageUrl)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/notifications.templ`, Line: 61, Col: 70} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/notifications.templ`, Line: 84, Col: 34}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</span>") 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, "<div class=\"no-image\">no image</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div></div></a>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</div><div class=\"notification-content\"><div class=\"notification-title\">")
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, "</div><div class=\"notification-meta\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if item.Anime.Broadcast.String != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<span class=\"notification-broadcast\">")
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, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if item.Anime.Episodes > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<span class=\"notification-progress\">")
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, "</span>")
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, "<span class=\"notification-progress\">")
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, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</div></div></a>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }

View File

@@ -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)
}
}

View File

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