diff --git a/internal/database/models.go b/internal/database/models.go index 2c4edf4..4885d02 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -44,6 +44,16 @@ type AnimeRelation struct { RelationType string `json:"relation_type"` } +type ContinueWatchingEntry struct { + ID string `json:"id"` + UserID string `json:"user_id"` + AnimeID int64 `json:"anime_id"` + CurrentEpisode sql.NullInt64 `json:"current_episode"` + CurrentTimeSeconds float64 `json:"current_time_seconds"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + type JikanCache struct { Key string `json:"key"` Data string `json:"data"` diff --git a/internal/database/querier.go b/internal/database/querier.go index dcf66db..211ea44 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -13,6 +13,7 @@ type Querier interface { CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) DeleteAnimeFetchRetry(ctx context.Context, animeID int64) error + DeleteContinueWatchingEntry(ctx context.Context, arg DeleteContinueWatchingEntryParams) error DeleteExpiredJikanCache(ctx context.Context) error DeleteSession(ctx context.Context, id string) error DeleteUserSessions(ctx context.Context, userID string) error @@ -20,6 +21,8 @@ type Querier interface { EnqueueAnimeFetchRetry(ctx context.Context, arg EnqueueAnimeFetchRetryParams) error GetAnime(ctx context.Context, id int64) (Anime, error) GetAnimeNeedingRelationSync(ctx context.Context) ([]GetAnimeNeedingRelationSyncRow, error) + GetContinueWatchingEntries(ctx context.Context, userID string) ([]GetContinueWatchingEntriesRow, error) + GetContinueWatchingEntry(ctx context.Context, arg GetContinueWatchingEntryParams) (ContinueWatchingEntry, error) GetDueAnimeFetchRetries(ctx context.Context, limit int64) ([]AnimeFetchRetry, error) GetJikanCache(ctx context.Context, key string) (string, error) GetJikanCacheStale(ctx context.Context, key string) (string, error) @@ -39,6 +42,7 @@ type Querier interface { UpdateUserPasswordAndRecoveryKeyHash(ctx context.Context, arg UpdateUserPasswordAndRecoveryKeyHashParams) error UpsertAnime(ctx context.Context, arg UpsertAnimeParams) (Anime, error) UpsertAnimeRelation(ctx context.Context, arg UpsertAnimeRelationParams) error + UpsertContinueWatchingEntry(ctx context.Context, arg UpsertContinueWatchingEntryParams) (ContinueWatchingEntry, error) UpsertWatchListEntry(ctx context.Context, arg UpsertWatchListEntryParams) (WatchListEntry, error) } diff --git a/internal/database/queries.sql b/internal/database/queries.sql index 355077e..ee1c530 100644 --- a/internal/database/queries.sql +++ b/internal/database/queries.sql @@ -62,6 +62,41 @@ SET current_episode = ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ? AND anime_id = ?; +-- name: UpsertContinueWatchingEntry :one +INSERT INTO continue_watching_entry (id, user_id, anime_id, current_episode, current_time_seconds, updated_at) +VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) +ON CONFLICT (user_id, anime_id) DO UPDATE SET + current_episode = excluded.current_episode, + current_time_seconds = excluded.current_time_seconds, + updated_at = CURRENT_TIMESTAMP +RETURNING *; + +-- name: GetContinueWatchingEntry :one +SELECT * FROM continue_watching_entry +WHERE user_id = ? AND anime_id = ? LIMIT 1; + +-- name: GetContinueWatchingEntries :many +SELECT + c.id, + c.user_id, + c.anime_id, + c.current_episode, + c.current_time_seconds, + c.created_at, + c.updated_at, + a.title_original, + a.title_english, + a.title_japanese, + a.image_url +FROM continue_watching_entry c +JOIN anime a ON c.anime_id = a.id +WHERE c.user_id = ? +ORDER BY c.updated_at DESC; + +-- name: DeleteContinueWatchingEntry :exec +DELETE FROM continue_watching_entry +WHERE user_id = ? AND anime_id = ?; + -- name: GetWatchListEntry :one SELECT * FROM watch_list_entry WHERE user_id = ? AND anime_id = ? LIMIT 1; diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index ec5b07c..b658d3c 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -89,6 +89,21 @@ func (q *Queries) DeleteAnimeFetchRetry(ctx context.Context, animeID int64) erro return err } +const deleteContinueWatchingEntry = `-- name: DeleteContinueWatchingEntry :exec +DELETE FROM continue_watching_entry +WHERE user_id = ? AND anime_id = ? +` + +type DeleteContinueWatchingEntryParams struct { + UserID string `json:"user_id"` + AnimeID int64 `json:"anime_id"` +} + +func (q *Queries) DeleteContinueWatchingEntry(ctx context.Context, arg DeleteContinueWatchingEntryParams) error { + _, err := q.db.ExecContext(ctx, deleteContinueWatchingEntry, arg.UserID, arg.AnimeID) + return err +} + const deleteExpiredJikanCache = `-- name: DeleteExpiredJikanCache :exec DELETE FROM jikan_cache WHERE expires_at <= CURRENT_TIMESTAMP ` @@ -225,6 +240,99 @@ func (q *Queries) GetAnimeNeedingRelationSync(ctx context.Context) ([]GetAnimeNe return items, nil } +const getContinueWatchingEntries = `-- name: GetContinueWatchingEntries :many +SELECT + c.id, + c.user_id, + c.anime_id, + c.current_episode, + c.current_time_seconds, + c.created_at, + c.updated_at, + a.title_original, + a.title_english, + a.title_japanese, + a.image_url +FROM continue_watching_entry c +JOIN anime a ON c.anime_id = a.id +WHERE c.user_id = ? +ORDER BY c.updated_at DESC +` + +type GetContinueWatchingEntriesRow struct { + ID string `json:"id"` + UserID string `json:"user_id"` + AnimeID int64 `json:"anime_id"` + CurrentEpisode sql.NullInt64 `json:"current_episode"` + CurrentTimeSeconds float64 `json:"current_time_seconds"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + TitleOriginal string `json:"title_original"` + TitleEnglish sql.NullString `json:"title_english"` + TitleJapanese sql.NullString `json:"title_japanese"` + ImageUrl string `json:"image_url"` +} + +func (q *Queries) GetContinueWatchingEntries(ctx context.Context, userID string) ([]GetContinueWatchingEntriesRow, error) { + rows, err := q.db.QueryContext(ctx, getContinueWatchingEntries, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetContinueWatchingEntriesRow + for rows.Next() { + var i GetContinueWatchingEntriesRow + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.AnimeID, + &i.CurrentEpisode, + &i.CurrentTimeSeconds, + &i.CreatedAt, + &i.UpdatedAt, + &i.TitleOriginal, + &i.TitleEnglish, + &i.TitleJapanese, + &i.ImageUrl, + ); 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 getContinueWatchingEntry = `-- name: GetContinueWatchingEntry :one +SELECT id, user_id, anime_id, current_episode, current_time_seconds, created_at, updated_at FROM continue_watching_entry +WHERE user_id = ? AND anime_id = ? LIMIT 1 +` + +type GetContinueWatchingEntryParams struct { + UserID string `json:"user_id"` + AnimeID int64 `json:"anime_id"` +} + +func (q *Queries) GetContinueWatchingEntry(ctx context.Context, arg GetContinueWatchingEntryParams) (ContinueWatchingEntry, error) { + row := q.db.QueryRowContext(ctx, getContinueWatchingEntry, arg.UserID, arg.AnimeID) + var i ContinueWatchingEntry + err := row.Scan( + &i.ID, + &i.UserID, + &i.AnimeID, + &i.CurrentEpisode, + &i.CurrentTimeSeconds, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const getDueAnimeFetchRetries = `-- name: GetDueAnimeFetchRetries :many SELECT anime_id, attempts, next_retry_at, last_error, created_at, updated_at FROM anime_fetch_retry @@ -779,6 +887,45 @@ func (q *Queries) UpsertAnimeRelation(ctx context.Context, arg UpsertAnimeRelati return err } +const upsertContinueWatchingEntry = `-- name: UpsertContinueWatchingEntry :one +INSERT INTO continue_watching_entry (id, user_id, anime_id, current_episode, current_time_seconds, updated_at) +VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) +ON CONFLICT (user_id, anime_id) DO UPDATE SET + current_episode = excluded.current_episode, + current_time_seconds = excluded.current_time_seconds, + updated_at = CURRENT_TIMESTAMP +RETURNING id, user_id, anime_id, current_episode, current_time_seconds, created_at, updated_at +` + +type UpsertContinueWatchingEntryParams struct { + ID string `json:"id"` + UserID string `json:"user_id"` + AnimeID int64 `json:"anime_id"` + CurrentEpisode sql.NullInt64 `json:"current_episode"` + CurrentTimeSeconds float64 `json:"current_time_seconds"` +} + +func (q *Queries) UpsertContinueWatchingEntry(ctx context.Context, arg UpsertContinueWatchingEntryParams) (ContinueWatchingEntry, error) { + row := q.db.QueryRowContext(ctx, upsertContinueWatchingEntry, + arg.ID, + arg.UserID, + arg.AnimeID, + arg.CurrentEpisode, + arg.CurrentTimeSeconds, + ) + var i ContinueWatchingEntry + err := row.Scan( + &i.ID, + &i.UserID, + &i.AnimeID, + &i.CurrentEpisode, + &i.CurrentTimeSeconds, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const upsertWatchListEntry = `-- name: UpsertWatchListEntry :one INSERT INTO watch_list_entry (id, user_id, anime_id, status, current_episode, current_time_seconds, updated_at) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) diff --git a/internal/features/playback/handler.go b/internal/features/playback/handler.go index bbd498d..5281e66 100644 --- a/internal/features/playback/handler.go +++ b/internal/features/playback/handler.go @@ -12,6 +12,8 @@ import ( "strings" "time" + "github.com/google/uuid" + "mal/internal/database" "mal/internal/jikan" "mal/internal/shared/middleware" @@ -88,15 +90,15 @@ func (h *Handler) HandleWatchPage(w http.ResponseWriter, r *http.Request) { // Convert playback.WatchPageData to templates.WatchPageData pageData := templates.WatchPageData{ - MalID: data.MalID, - Title: data.Title, - CurrentEpisode: data.CurrentEpisode, + MalID: data.MalID, + Title: data.Title, + CurrentEpisode: data.CurrentEpisode, StartTimeSeconds: data.StartTimeSeconds, - CurrentStatus: data.CurrentStatus, - InitialMode: data.InitialMode, - AvailableModes: data.AvailableModes, - ModeSources: convertModeSources(data.ModeSources), - Segments: convertSegments(data.Segments), + CurrentStatus: data.CurrentStatus, + InitialMode: data.InitialMode, + AvailableModes: data.AvailableModes, + ModeSources: convertModeSources(data.ModeSources), + Segments: convertSegments(data.Segments), } templates.WatchPage(anime, pageData).Render(r.Context(), w) @@ -244,21 +246,49 @@ func (h *Handler) HandleSaveProgress(w http.ResponseWriter, r *http.Request) { return } - if _, err := h.svc.db.GetWatchListEntry(r.Context(), database.GetWatchListEntryParams{ - UserID: user.ID, - AnimeID: int64(payload.MalID), - }); err != nil { - http.Error(w, "watchlist entry not found", http.StatusNotFound) - return + animeID := int64(payload.MalID) + + if _, err := h.svc.db.GetAnime(r.Context(), animeID); err != nil { + anime, fetchErr := h.jikanClient.GetAnimeByID(r.Context(), payload.MalID) + if fetchErr != nil { + log.Printf("save progress failed to fetch anime user_id=%s mal_id=%d err=%v", user.ID, payload.MalID, fetchErr) + http.Error(w, "failed to save progress", http.StatusInternalServerError) + return + } + + if _, upsertErr := h.svc.db.UpsertAnime(r.Context(), database.UpsertAnimeParams{ + ID: animeID, + TitleOriginal: anime.Title, + TitleEnglish: sql.NullString{String: anime.TitleEnglish, Valid: anime.TitleEnglish != ""}, + TitleJapanese: sql.NullString{String: anime.TitleJapanese, Valid: anime.TitleJapanese != ""}, + ImageUrl: anime.ImageURL(), + Airing: sql.NullBool{Bool: anime.Airing, Valid: true}, + }); upsertErr != nil { + log.Printf("save progress failed to upsert anime user_id=%s mal_id=%d err=%v", user.ID, payload.MalID, upsertErr) + http.Error(w, "failed to save progress", http.StatusInternalServerError) + return + } } if err := h.svc.db.SaveWatchProgress(r.Context(), database.SaveWatchProgressParams{ CurrentEpisode: sql.NullInt64{Int64: int64(payload.Episode), Valid: true}, CurrentTimeSeconds: timeSeconds, UserID: user.ID, - AnimeID: int64(payload.MalID), + AnimeID: animeID, }); err != nil { - log.Printf("save progress failed user_id=%s mal_id=%d err=%v", user.ID, payload.MalID, err) + if err.Error() != "sql: no rows in result set" { + log.Printf("save watchlist progress skipped user_id=%s mal_id=%d err=%v", user.ID, payload.MalID, err) + } + } + + if _, err := h.svc.db.UpsertContinueWatchingEntry(r.Context(), database.UpsertContinueWatchingEntryParams{ + ID: uuid.New().String(), + UserID: user.ID, + AnimeID: animeID, + CurrentEpisode: sql.NullInt64{Int64: int64(payload.Episode), Valid: true}, + CurrentTimeSeconds: timeSeconds, + }); err != nil { + log.Printf("save continue watching failed user_id=%s mal_id=%d err=%v", user.ID, payload.MalID, err) http.Error(w, "failed to save progress", http.StatusInternalServerError) return } diff --git a/internal/features/playback/service.go b/internal/features/playback/service.go index d4b7804..ab72865 100644 --- a/internal/features/playback/service.go +++ b/internal/features/playback/service.go @@ -110,6 +110,16 @@ func (s *Service) BuildWatchPageData(ctx context.Context, malID int, title strin startTimeSeconds = entry.CurrentTimeSeconds } } + + if startTimeSeconds <= 0 { + continueEntry, continueErr := s.db.GetContinueWatchingEntry(ctx, database.GetContinueWatchingEntryParams{ + UserID: userID, + AnimeID: int64(malID), + }) + if continueErr == nil && continueEntry.CurrentEpisode.Valid && strconv.FormatInt(continueEntry.CurrentEpisode.Int64, 10) == normalizedEpisode && continueEntry.CurrentTimeSeconds > 0 { + startTimeSeconds = continueEntry.CurrentTimeSeconds + } + } } watchTitle := strings.TrimSpace(resolvedTitle) diff --git a/migrations/011_add_continue_watching.sql b/migrations/011_add_continue_watching.sql new file mode 100644 index 0000000..d12b2af --- /dev/null +++ b/migrations/011_add_continue_watching.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS continue_watching_entry ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE, + anime_id INTEGER NOT NULL REFERENCES anime(id) ON DELETE CASCADE, + current_episode INTEGER, + current_time_seconds REAL NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, anime_id) +); + +CREATE INDEX IF NOT EXISTS idx_continue_watching_user_updated +ON continue_watching_entry(user_id, updated_at DESC);