feat: save continue watching progress
This commit is contained in:
@@ -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"`
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
13
migrations/011_add_continue_watching.sql
Normal file
13
migrations/011_add_continue_watching.sql
Normal 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);
|
||||||
Reference in New Issue
Block a user