feat: add batch watchlist and command palette db queries
This commit is contained in:
160
internal/db/command_palette.go
Normal file
160
internal/db/command_palette.go
Normal file
@@ -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 + "%"
|
||||
}
|
||||
114
internal/db/command_palette_test.go
Normal file
114
internal/db/command_palette_test.go
Normal file
@@ -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
|
||||
}
|
||||
58
internal/db/watchlist_ids.go
Normal file
58
internal/db/watchlist_ids.go
Normal file
@@ -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
|
||||
}
|
||||
43
internal/db/watchlist_ids_test.go
Normal file
43
internal/db/watchlist_ids_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user