From 538a3f0138f3da2e5ae3ec6a0822cf94c0d8d431 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Sat, 27 Jun 2026 15:26:31 +0200 Subject: [PATCH] feat: add continue watching carousel query --- internal/db/continue_watching_test.go | 93 +++++++++++++++++++++++++++ internal/db/querier.go | 1 + internal/db/queries.sql | 21 ++++++ internal/db/queries.sql.go | 63 ++++++++++++++++++ 4 files changed, 178 insertions(+) create mode 100644 internal/db/continue_watching_test.go diff --git a/internal/db/continue_watching_test.go b/internal/db/continue_watching_test.go new file mode 100644 index 00000000..8e516660 --- /dev/null +++ b/internal/db/continue_watching_test.go @@ -0,0 +1,93 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "testing" + + _ "github.com/mattn/go-sqlite3" +) + +func TestGetContinueWatchingCarouselEntriesLimitsNewestFirst(t *testing.T) { + ctx := context.Background() + sqlDB := newContinueWatchingTestDB(ctx, t) + defer closeContinueWatchingTestDB(t, sqlDB) + seedContinueWatchingRows(ctx, t, sqlDB, 30) + + rows, err := New(sqlDB).GetContinueWatchingCarouselEntries(ctx, GetContinueWatchingCarouselEntriesParams{ + UserID: "user-1", + Limit: 24, + }) + if err != nil { + t.Fatalf("GetContinueWatchingCarouselEntries: %v", err) + } + if len(rows) != 24 { + t.Fatalf("len(rows) = %d, want 24", len(rows)) + } + if rows[0].AnimeID != 30 { + t.Fatalf("first AnimeID = %d, want 30", rows[0].AnimeID) + } + if rows[len(rows)-1].AnimeID != 7 { + t.Fatalf("last AnimeID = %d, want 7", rows[len(rows)-1].AnimeID) + } +} + +func newContinueWatchingTestDB(ctx context.Context, t *testing.T) *sql.DB { + t.Helper() + + sqlDB, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + + _, err = sqlDB.ExecContext(ctx, ` +CREATE TABLE anime ( + id INTEGER PRIMARY KEY, + title_original TEXT NOT NULL, + title_english TEXT, + title_japanese TEXT, + image_url TEXT NOT NULL, + duration_seconds REAL +); +CREATE TABLE continue_watching_entry ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + anime_id INTEGER NOT NULL REFERENCES anime(id), + current_episode INTEGER, + current_time_seconds REAL NOT NULL DEFAULT 0, + duration_seconds REAL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, anime_id) +);`) + if err != nil { + closeContinueWatchingTestDB(t, sqlDB) + t.Fatalf("create schema: %v", err) + } + + return sqlDB +} + +func closeContinueWatchingTestDB(t *testing.T, sqlDB *sql.DB) { + t.Helper() + + if err := sqlDB.Close(); err != nil { + t.Errorf("close sqlite: %v", err) + } +} + +func seedContinueWatchingRows(ctx context.Context, t *testing.T, sqlDB *sql.DB, totalRows int) { + t.Helper() + + for i := 1; i <= totalRows; i++ { + if _, err := sqlDB.ExecContext(ctx, `INSERT INTO anime (id, title_original, image_url) VALUES (?, ?, ?)`, i, fmt.Sprintf("Anime %02d", i), "image.jpg"); err != nil { + t.Fatalf("insert anime %d: %v", i, err) + } + if _, err := sqlDB.ExecContext(ctx, ` +INSERT INTO continue_watching_entry (id, user_id, anime_id, current_episode, current_time_seconds, updated_at) +VALUES (?, ?, ?, ?, ?, datetime('2026-01-01', printf('+%d minutes', ?)))`, fmt.Sprintf("cw-%02d", i), "user-1", i, i, 0, i); err != nil { + t.Fatalf("insert continue watching %d: %v", i, err) + } + } +} diff --git a/internal/db/querier.go b/internal/db/querier.go index 745263ef..515f3c9d 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -25,6 +25,7 @@ type Querier interface { GetAnime(ctx context.Context, id int64) (Anime, error) GetAnimeNeedingRelationSync(ctx context.Context) ([]GetAnimeNeedingRelationSyncRow, error) GetAuditLogsForUser(ctx context.Context, arg GetAuditLogsForUserParams) ([]AuditLog, error) + GetContinueWatchingCarouselEntries(ctx context.Context, arg GetContinueWatchingCarouselEntriesParams) ([]GetContinueWatchingEntriesRow, 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) diff --git a/internal/db/queries.sql b/internal/db/queries.sql index 1d52b6e1..95f39cef 100644 --- a/internal/db/queries.sql +++ b/internal/db/queries.sql @@ -117,6 +117,27 @@ JOIN anime a ON c.anime_id = a.id WHERE c.user_id = ? ORDER BY c.updated_at DESC; +-- name: GetContinueWatchingCarouselEntries :many +SELECT + c.id, + c.user_id, + c.anime_id, + c.current_episode, + c.current_time_seconds, + c.duration_seconds, + c.created_at, + c.updated_at, + a.title_original, + a.title_english, + a.title_japanese, + a.image_url, + a.duration_seconds as anime_duration_seconds +FROM continue_watching_entry c +JOIN anime a ON c.anime_id = a.id +WHERE c.user_id = ? +ORDER BY c.updated_at DESC +LIMIT ?; + -- name: DeleteContinueWatchingEntry :exec DELETE FROM continue_watching_entry WHERE user_id = ? AND anime_id = ?; diff --git a/internal/db/queries.sql.go b/internal/db/queries.sql.go index bbbaf96a..68788458 100644 --- a/internal/db/queries.sql.go +++ b/internal/db/queries.sql.go @@ -453,6 +453,69 @@ func (q *Queries) GetContinueWatchingEntries(ctx context.Context, userID string) return items, nil } +const getContinueWatchingCarouselEntries = `-- name: GetContinueWatchingCarouselEntries :many +SELECT + c.id, + c.user_id, + c.anime_id, + c.current_episode, + c.current_time_seconds, + c.duration_seconds, + c.created_at, + c.updated_at, + a.title_original, + a.title_english, + a.title_japanese, + a.image_url, + a.duration_seconds as anime_duration_seconds +FROM continue_watching_entry c +JOIN anime a ON c.anime_id = a.id +WHERE c.user_id = ? +ORDER BY c.updated_at DESC +LIMIT ? +` + +type GetContinueWatchingCarouselEntriesParams struct { + UserID string `json:"user_id"` + Limit int64 `json:"limit"` +} + +func (q *Queries) GetContinueWatchingCarouselEntries(ctx context.Context, arg GetContinueWatchingCarouselEntriesParams) ([]GetContinueWatchingEntriesRow, error) { + rows, err := q.db.QueryContext(ctx, getContinueWatchingCarouselEntries, arg.UserID, arg.Limit) + if err != nil { + return nil, err + } + var items []GetContinueWatchingEntriesRow + for rows.Next() { + var i GetContinueWatchingEntriesRow + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.AnimeID, + &i.CurrentEpisode, + &i.CurrentTimeSeconds, + &i.DurationSeconds, + &i.CreatedAt, + &i.UpdatedAt, + &i.TitleOriginal, + &i.TitleEnglish, + &i.TitleJapanese, + &i.ImageUrl, + &i.AnimeDurationSeconds, + ); 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, duration_seconds FROM continue_watching_entry WHERE user_id = ? AND anime_id = ? LIMIT 1