feat: save continue watching progress

This commit is contained in:
2026-04-18 23:42:08 +02:00
parent a2dad9410f
commit dea66f2f6a
7 changed files with 265 additions and 16 deletions

View File

@@ -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"`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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