feat: save continue watching progress
This commit is contained in:
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user