From c4bd5cc39538b038645cce3d387c85dc7bede751 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Wed, 20 May 2026 17:03:05 +0200 Subject: [PATCH] feat: add batch watchlist and command palette db queries --- internal/db/command_palette.go | 160 ++++++++++++++++++++++++++++ internal/db/command_palette_test.go | 114 ++++++++++++++++++++ internal/db/watchlist_ids.go | 58 ++++++++++ internal/db/watchlist_ids_test.go | 43 ++++++++ 4 files changed, 375 insertions(+) create mode 100644 internal/db/command_palette.go create mode 100644 internal/db/command_palette_test.go create mode 100644 internal/db/watchlist_ids.go create mode 100644 internal/db/watchlist_ids_test.go diff --git a/internal/db/command_palette.go b/internal/db/command_palette.go new file mode 100644 index 0000000..6e98644 --- /dev/null +++ b/internal/db/command_palette.go @@ -0,0 +1,160 @@ +package db + +import ( + "context" + "strings" +) + +func (q *Queries) GetCommandPaletteContinueWatching(ctx context.Context, userID string, query string, limit int64) ([]GetContinueWatchingEntriesRow, error) { + if userID == "" { + return nil, nil + } + if limit <= 0 { + limit = 5 + } + + needle, pattern := commandPalettePattern(query) + rows, err := q.db.QueryContext(ctx, ` +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 = ? + AND ( + ? = '' + OR lower(a.title_original) LIKE ? + OR lower(coalesce(a.title_english, '')) LIKE ? + OR lower(coalesce(a.title_japanese, '')) LIKE ? + OR lower('Continue watching') LIKE ? + ) +ORDER BY c.updated_at DESC +LIMIT ?`, userID, needle, pattern, pattern, pattern, pattern, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + items := make([]GetContinueWatchingEntriesRow, 0, limit) + for rows.Next() { + var item GetContinueWatchingEntriesRow + if err := rows.Scan( + &item.ID, + &item.UserID, + &item.AnimeID, + &item.CurrentEpisode, + &item.CurrentTimeSeconds, + &item.DurationSeconds, + &item.CreatedAt, + &item.UpdatedAt, + &item.TitleOriginal, + &item.TitleEnglish, + &item.TitleJapanese, + &item.ImageUrl, + &item.AnimeDurationSeconds, + ); err != nil { + return nil, err + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, err + } + + return items, nil +} + +func (q *Queries) GetCommandPaletteWatchlist(ctx context.Context, userID string, query string, limit int64) ([]GetUserWatchListRow, error) { + if userID == "" { + return nil, nil + } + if limit <= 0 { + limit = 5 + } + + needle, pattern := commandPalettePattern(query) + rows, err := q.db.QueryContext(ctx, ` +SELECT + e.id, + e.user_id, + e.anime_id, + e.status, + e.created_at, + e.updated_at, + e.current_episode, + e.last_episode_at, + e.current_time_seconds, + a.title_original, + a.title_english, + a.title_japanese, + a.image_url, + a.airing +FROM watch_list_entry e +JOIN anime a ON e.anime_id = a.id +WHERE e.user_id = ? + AND e.status IN ('watching', 'plan_to_watch') + AND ( + ? = '' + OR lower(a.title_original) LIKE ? + OR lower(coalesce(a.title_english, '')) LIKE ? + OR lower(coalesce(a.title_japanese, '')) LIKE ? + OR lower(e.status) LIKE ? + ) +ORDER BY + CASE e.status + WHEN 'watching' THEN 0 + WHEN 'plan_to_watch' THEN 1 + ELSE 2 + END, + e.updated_at DESC +LIMIT ?`, userID, needle, pattern, pattern, pattern, pattern, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + items := make([]GetUserWatchListRow, 0, limit) + for rows.Next() { + var item GetUserWatchListRow + if err := rows.Scan( + &item.ID, + &item.UserID, + &item.AnimeID, + &item.Status, + &item.CreatedAt, + &item.UpdatedAt, + &item.CurrentEpisode, + &item.LastEpisodeAt, + &item.CurrentTimeSeconds, + &item.TitleOriginal, + &item.TitleEnglish, + &item.TitleJapanese, + &item.ImageUrl, + &item.Airing, + ); err != nil { + return nil, err + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, err + } + + return items, nil +} + +func commandPalettePattern(query string) (string, string) { + needle := strings.ToLower(strings.TrimSpace(query)) + return needle, "%" + needle + "%" +} diff --git a/internal/db/command_palette_test.go b/internal/db/command_palette_test.go new file mode 100644 index 0000000..3332b1f --- /dev/null +++ b/internal/db/command_palette_test.go @@ -0,0 +1,114 @@ +package db + +import ( + "context" + "database/sql" + "testing" + + _ "github.com/mattn/go-sqlite3" +) + +func TestGetCommandPaletteContinueWatchingFiltersAndLimits(t *testing.T) { + sqlDB := openCommandPaletteTestDB(t) + + got, err := New(sqlDB).GetCommandPaletteContinueWatching(context.Background(), "user-a", "continue", 1) + if err != nil { + t.Fatalf("GetCommandPaletteContinueWatching: %v", err) + } + if len(got) != 1 || got[0].AnimeID != 20 { + t.Fatalf("continue rows = %+v, want latest anime 20 only", got) + } + + got, err = New(sqlDB).GetCommandPaletteContinueWatching(context.Background(), "user-a", "nar", 5) + if err != nil { + t.Fatalf("GetCommandPaletteContinueWatching filtered: %v", err) + } + if len(got) != 1 || got[0].AnimeID != 10 { + t.Fatalf("filtered continue rows = %+v, want anime 10", got) + } +} + +func TestGetCommandPaletteWatchlistFiltersAndOrders(t *testing.T) { + sqlDB := openCommandPaletteTestDB(t) + + got, err := New(sqlDB).GetCommandPaletteWatchlist(context.Background(), "user-a", "", 5) + if err != nil { + t.Fatalf("GetCommandPaletteWatchlist: %v", err) + } + if len(got) != 2 { + t.Fatalf("watchlist rows len = %d, want 2", len(got)) + } + if got[0].AnimeID != 10 || got[1].AnimeID != 20 { + t.Fatalf("watchlist order = [%d %d], want watching anime 10 before plan anime 20", got[0].AnimeID, got[1].AnimeID) + } + + got, err = New(sqlDB).GetCommandPaletteWatchlist(context.Background(), "user-a", "plan", 5) + if err != nil { + t.Fatalf("GetCommandPaletteWatchlist filtered: %v", err) + } + if len(got) != 1 || got[0].AnimeID != 20 { + t.Fatalf("filtered watchlist rows = %+v, want anime 20", got) + } +} + +func openCommandPaletteTestDB(t *testing.T) *sql.DB { + t.Helper() + + sqlDB, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + + _, err = sqlDB.Exec(` + CREATE TABLE anime ( + id INTEGER PRIMARY KEY, + title_original TEXT NOT NULL, + title_english TEXT, + title_japanese TEXT, + image_url TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + airing BOOLEAN DEFAULT 0, + duration_seconds REAL + ); + CREATE TABLE watch_list_entry ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + anime_id INTEGER NOT NULL, + status TEXT NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + current_episode INTEGER, + last_episode_at DATETIME, + current_time_seconds REAL NOT NULL DEFAULT 0 + ); + CREATE TABLE continue_watching_entry ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + anime_id INTEGER NOT NULL, + current_episode INTEGER, + current_time_seconds REAL NOT NULL DEFAULT 0, + duration_seconds REAL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ); + INSERT INTO anime (id, title_original, title_english, title_japanese, image_url, airing, duration_seconds) VALUES + (10, 'Naruto', NULL, NULL, 'naruto.jpg', 0, 1440), + (20, 'Frieren', 'Frieren: Beyond Journey''s End', NULL, 'frieren.jpg', 0, 1440), + (30, 'Dropped Show', NULL, NULL, 'dropped.jpg', 0, 1440); + INSERT INTO watch_list_entry (id, user_id, anime_id, status, created_at, updated_at, current_episode, current_time_seconds) VALUES + ('w1', 'user-a', 10, 'watching', '2026-01-01 00:00:00', '2026-01-01 00:00:00', 3, 0), + ('w2', 'user-a', 20, 'plan_to_watch', '2026-01-02 00:00:00', '2026-01-03 00:00:00', 0, 0), + ('w3', 'user-a', 30, 'dropped', '2026-01-04 00:00:00', '2026-01-04 00:00:00', 0, 0), + ('w4', 'user-b', 10, 'watching', '2026-01-05 00:00:00', '2026-01-05 00:00:00', 1, 0); + INSERT INTO continue_watching_entry (id, user_id, anime_id, current_episode, current_time_seconds, duration_seconds, created_at, updated_at) VALUES + ('c1', 'user-a', 10, 4, 120, 1440, '2026-01-01 00:00:00', '2026-01-01 00:00:00'), + ('c2', 'user-a', 20, 1, 60, 1440, '2026-01-02 00:00:00', '2026-01-03 00:00:00'), + ('c3', 'user-b', 10, 1, 30, 1440, '2026-01-04 00:00:00', '2026-01-04 00:00:00'); + `) + if err != nil { + t.Fatalf("seed command palette db: %v", err) + } + + return sqlDB +} diff --git a/internal/db/watchlist_ids.go b/internal/db/watchlist_ids.go new file mode 100644 index 0000000..3145881 --- /dev/null +++ b/internal/db/watchlist_ids.go @@ -0,0 +1,58 @@ +package db + +import ( + "context" + "strings" +) + +func (q *Queries) GetUserWatchlistAnimeIDs(ctx context.Context, userID string, animeIDs []int64) ([]int64, error) { + animeIDs = uniquePositiveIDs(animeIDs) + if userID == "" || len(animeIDs) == 0 { + return nil, nil + } + + placeholders := strings.TrimRight(strings.Repeat("?,", len(animeIDs)), ",") + query := "SELECT anime_id FROM watch_list_entry WHERE user_id = ? AND anime_id IN (" + placeholders + ") ORDER BY anime_id" + + args := make([]any, 0, len(animeIDs)+1) + args = append(args, userID) + for _, animeID := range animeIDs { + args = append(args, animeID) + } + + rows, err := q.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + matches := make([]int64, 0, len(animeIDs)) + for rows.Next() { + var animeID int64 + if err := rows.Scan(&animeID); err != nil { + return nil, err + } + matches = append(matches, animeID) + } + if err := rows.Err(); err != nil { + return nil, err + } + + return matches, nil +} + +func uniquePositiveIDs(ids []int64) []int64 { + seen := make(map[int64]struct{}, len(ids)) + unique := make([]int64, 0, len(ids)) + for _, id := range ids { + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + unique = append(unique, id) + } + return unique +} diff --git a/internal/db/watchlist_ids_test.go b/internal/db/watchlist_ids_test.go new file mode 100644 index 0000000..06ce863 --- /dev/null +++ b/internal/db/watchlist_ids_test.go @@ -0,0 +1,43 @@ +package db + +import ( + "context" + "database/sql" + "reflect" + "testing" + + _ "github.com/mattn/go-sqlite3" +) + +func TestGetUserWatchlistAnimeIDsFiltersRequestedIDs(t *testing.T) { + sqlDB, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + defer sqlDB.Close() + + _, err = sqlDB.Exec(` + CREATE TABLE watch_list_entry ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + anime_id INTEGER NOT NULL + ); + INSERT INTO watch_list_entry (id, user_id, anime_id) VALUES + ('1', 'user-a', 10), + ('2', 'user-a', 20), + ('3', 'user-b', 30); + `) + if err != nil { + t.Fatalf("seed watchlist: %v", err) + } + + got, err := New(sqlDB).GetUserWatchlistAnimeIDs(context.Background(), "user-a", []int64{0, 10, 10, 30, 20}) + if err != nil { + t.Fatalf("GetUserWatchlistAnimeIDs: %v", err) + } + + want := []int64{10, 20} + if !reflect.DeepEqual(got, want) { + t.Fatalf("ids = %v, want %v", got, want) + } +}