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"` 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 { type JikanCache struct {
Key string `json:"key"` Key string `json:"key"`
Data string `json:"data"` Data string `json:"data"`

View File

@@ -13,6 +13,7 @@ type Querier interface {
CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error)
CreateUser(ctx context.Context, arg CreateUserParams) (User, error) CreateUser(ctx context.Context, arg CreateUserParams) (User, error)
DeleteAnimeFetchRetry(ctx context.Context, animeID int64) error DeleteAnimeFetchRetry(ctx context.Context, animeID int64) error
DeleteContinueWatchingEntry(ctx context.Context, arg DeleteContinueWatchingEntryParams) error
DeleteExpiredJikanCache(ctx context.Context) error DeleteExpiredJikanCache(ctx context.Context) error
DeleteSession(ctx context.Context, id string) error DeleteSession(ctx context.Context, id string) error
DeleteUserSessions(ctx context.Context, userID string) error DeleteUserSessions(ctx context.Context, userID string) error
@@ -20,6 +21,8 @@ type Querier interface {
EnqueueAnimeFetchRetry(ctx context.Context, arg EnqueueAnimeFetchRetryParams) error EnqueueAnimeFetchRetry(ctx context.Context, arg EnqueueAnimeFetchRetryParams) error
GetAnime(ctx context.Context, id int64) (Anime, error) GetAnime(ctx context.Context, id int64) (Anime, error)
GetAnimeNeedingRelationSync(ctx context.Context) ([]GetAnimeNeedingRelationSyncRow, 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) GetDueAnimeFetchRetries(ctx context.Context, limit int64) ([]AnimeFetchRetry, error)
GetJikanCache(ctx context.Context, key string) (string, error) GetJikanCache(ctx context.Context, key string) (string, error)
GetJikanCacheStale(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 UpdateUserPasswordAndRecoveryKeyHash(ctx context.Context, arg UpdateUserPasswordAndRecoveryKeyHashParams) error
UpsertAnime(ctx context.Context, arg UpsertAnimeParams) (Anime, error) UpsertAnime(ctx context.Context, arg UpsertAnimeParams) (Anime, error)
UpsertAnimeRelation(ctx context.Context, arg UpsertAnimeRelationParams) error UpsertAnimeRelation(ctx context.Context, arg UpsertAnimeRelationParams) error
UpsertContinueWatchingEntry(ctx context.Context, arg UpsertContinueWatchingEntryParams) (ContinueWatchingEntry, error)
UpsertWatchListEntry(ctx context.Context, arg UpsertWatchListEntryParams) (WatchListEntry, error) UpsertWatchListEntry(ctx context.Context, arg UpsertWatchListEntryParams) (WatchListEntry, error)
} }

View File

@@ -62,6 +62,41 @@ SET current_episode = ?,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE user_id = ? AND anime_id = ?; 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 -- name: GetWatchListEntry :one
SELECT * FROM watch_list_entry SELECT * FROM watch_list_entry
WHERE user_id = ? AND anime_id = ? LIMIT 1; 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 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 const deleteExpiredJikanCache = `-- name: DeleteExpiredJikanCache :exec
DELETE FROM jikan_cache WHERE expires_at <= CURRENT_TIMESTAMP DELETE FROM jikan_cache WHERE expires_at <= CURRENT_TIMESTAMP
` `
@@ -225,6 +240,99 @@ func (q *Queries) GetAnimeNeedingRelationSync(ctx context.Context) ([]GetAnimeNe
return items, nil 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 const getDueAnimeFetchRetries = `-- name: GetDueAnimeFetchRetries :many
SELECT anime_id, attempts, next_retry_at, last_error, created_at, updated_at SELECT anime_id, attempts, next_retry_at, last_error, created_at, updated_at
FROM anime_fetch_retry FROM anime_fetch_retry
@@ -779,6 +887,45 @@ func (q *Queries) UpsertAnimeRelation(ctx context.Context, arg UpsertAnimeRelati
return err 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 const upsertWatchListEntry = `-- name: UpsertWatchListEntry :one
INSERT INTO watch_list_entry (id, user_id, anime_id, status, current_episode, current_time_seconds, updated_at) INSERT INTO watch_list_entry (id, user_id, anime_id, status, current_episode, current_time_seconds, updated_at)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)

View File

@@ -12,6 +12,8 @@ import (
"strings" "strings"
"time" "time"
"github.com/google/uuid"
"mal/internal/database" "mal/internal/database"
"mal/internal/jikan" "mal/internal/jikan"
"mal/internal/shared/middleware" "mal/internal/shared/middleware"
@@ -88,15 +90,15 @@ func (h *Handler) HandleWatchPage(w http.ResponseWriter, r *http.Request) {
// Convert playback.WatchPageData to templates.WatchPageData // Convert playback.WatchPageData to templates.WatchPageData
pageData := templates.WatchPageData{ pageData := templates.WatchPageData{
MalID: data.MalID, MalID: data.MalID,
Title: data.Title, Title: data.Title,
CurrentEpisode: data.CurrentEpisode, CurrentEpisode: data.CurrentEpisode,
StartTimeSeconds: data.StartTimeSeconds, StartTimeSeconds: data.StartTimeSeconds,
CurrentStatus: data.CurrentStatus, CurrentStatus: data.CurrentStatus,
InitialMode: data.InitialMode, InitialMode: data.InitialMode,
AvailableModes: data.AvailableModes, AvailableModes: data.AvailableModes,
ModeSources: convertModeSources(data.ModeSources), ModeSources: convertModeSources(data.ModeSources),
Segments: convertSegments(data.Segments), Segments: convertSegments(data.Segments),
} }
templates.WatchPage(anime, pageData).Render(r.Context(), w) templates.WatchPage(anime, pageData).Render(r.Context(), w)
@@ -244,21 +246,49 @@ func (h *Handler) HandleSaveProgress(w http.ResponseWriter, r *http.Request) {
return return
} }
if _, err := h.svc.db.GetWatchListEntry(r.Context(), database.GetWatchListEntryParams{ animeID := int64(payload.MalID)
UserID: user.ID,
AnimeID: int64(payload.MalID), if _, err := h.svc.db.GetAnime(r.Context(), animeID); err != nil {
}); err != nil { anime, fetchErr := h.jikanClient.GetAnimeByID(r.Context(), payload.MalID)
http.Error(w, "watchlist entry not found", http.StatusNotFound) if fetchErr != nil {
return 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{ if err := h.svc.db.SaveWatchProgress(r.Context(), database.SaveWatchProgressParams{
CurrentEpisode: sql.NullInt64{Int64: int64(payload.Episode), Valid: true}, CurrentEpisode: sql.NullInt64{Int64: int64(payload.Episode), Valid: true},
CurrentTimeSeconds: timeSeconds, CurrentTimeSeconds: timeSeconds,
UserID: user.ID, UserID: user.ID,
AnimeID: int64(payload.MalID), AnimeID: animeID,
}); err != nil { }); 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) http.Error(w, "failed to save progress", http.StatusInternalServerError)
return return
} }

View File

@@ -110,6 +110,16 @@ func (s *Service) BuildWatchPageData(ctx context.Context, malID int, title strin
startTimeSeconds = entry.CurrentTimeSeconds 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) 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);