feat: add upcoming seasons sync and notification for sequels
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ type WatchingAnimeWithDetails struct {
|
||||
Anime jikan.Anime
|
||||
}
|
||||
|
||||
templ Notifications(watching []WatchingAnimeWithDetails) {
|
||||
templ Notifications(watching []WatchingAnimeWithDetails, upcomingSeasons []database.GetUpcomingSeasonsRow) {
|
||||
@Layout("mal - notifications") {
|
||||
<div class="notifications-page">
|
||||
<h1>upcoming episodes</h1>
|
||||
@@ -27,10 +27,56 @@ templ Notifications(watching []WatchingAnimeWithDetails) {
|
||||
}
|
||||
</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>
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
<a href={ templ.URL(fmt.Sprintf("/anime/%d", item.Entry.AnimeID)) } class="notification-card">
|
||||
<div class="notification-image">
|
||||
|
||||
@@ -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, "</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 {
|
||||
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, "<a href=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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 {
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if item.Entry.ImageUrl != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<img src=\"")
|
||||
if item.ImageUrl != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<img src=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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 {
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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 {
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} 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 {
|
||||
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 {
|
||||
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, "</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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if item.Anime.Broadcast.String != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<span class=\"notification-broadcast\">")
|
||||
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, "</span> ")
|
||||
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, "<span class=\"notification-progress\">")
|
||||
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, "</span></div></div></a>")
|
||||
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, "</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, 18, "<span class=\"notification-progress\">")
|
||||
}()
|
||||
}
|
||||
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, "<a href=\"")
|
||||
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 {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/notifications.templ`, Line: 81, Col: 66}
|
||||
}
|
||||
_, 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, 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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
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 {
|
||||
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))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
142
internal/worker/relations.go
Normal file
142
internal/worker/relations.go
Normal 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)
|
||||
}
|
||||
}
|
||||
9
migrations/005_add_anime_relations.sql
Normal file
9
migrations/005_add_anime_relations.sql
Normal 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)
|
||||
);
|
||||
Reference in New Issue
Block a user