feat: track watchlist completions

This commit is contained in:
2026-06-29 13:39:27 +02:00
parent 748c823172
commit 1e6ac5e854
6 changed files with 395 additions and 122 deletions

View File

@@ -7,6 +7,7 @@ import (
"testing" "testing"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
"github.com/pressly/goose/v3"
) )
func TestRunMigrationsCreatesHotPathIndexes(t *testing.T) { func TestRunMigrationsCreatesHotPathIndexes(t *testing.T) {
@@ -45,6 +46,116 @@ func TestRunMigrationsCreatesHotPathIndexes(t *testing.T) {
} }
} }
func TestWatchlistCompletionDateFollowsCompletedStatus(t *testing.T) {
sqlDB := newMigratedTestDB(t)
defer closeTestDB(t, sqlDB)
ctx := context.Background()
mustExecTestSQL(t, sqlDB, `INSERT INTO user (id, username, password_hash) VALUES ('user-1', 'alice', 'hash')`)
mustExecTestSQL(t, sqlDB, `INSERT INTO anime (id, title_original, image_url) VALUES (1, 'Anime', 'image.jpg')`)
queries := db.New(sqlDB)
upsertTestWatchlistStatus(ctx, t, queries, "completed")
completedAt, estimated := testWatchlistCompletion(ctx, t, sqlDB)
if !completedAt.Valid {
t.Fatalf("completed status should record completed_at")
}
if estimated {
t.Fatalf("new completion date should be exact")
}
upsertTestWatchlistStatus(ctx, t, queries, "watching")
completedAt, estimated = testWatchlistCompletion(ctx, t, sqlDB)
if completedAt.Valid || estimated {
t.Fatalf("watching status completion = %v estimated=%v, want empty", completedAt, estimated)
}
upsertTestWatchlistStatus(ctx, t, queries, "completed")
completedAt, estimated = testWatchlistCompletion(ctx, t, sqlDB)
if !completedAt.Valid || estimated {
t.Fatalf("re-completed status completion = %v estimated=%v, want exact date", completedAt, estimated)
}
}
func TestCompletionDateMigrationMarksHistoricalEstimates(t *testing.T) {
sqlDB, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
defer closeTestDB(t, sqlDB)
sqlDB.SetMaxOpenConns(1)
goose.SetBaseFS(migrationsFS)
goose.SetLogger(goose.NopLogger())
if err := goose.SetDialect("sqlite3"); err != nil {
t.Fatalf("set goose dialect: %v", err)
}
if err := goose.UpTo(sqlDB, "migrations", 25); err != nil {
t.Fatalf("migrate to version 25: %v", err)
}
ctx := context.Background()
mustExecTestSQL(t, sqlDB, `INSERT INTO user (id, username, password_hash) VALUES ('user-1', 'alice', 'hash')`)
mustExecTestSQL(t, sqlDB, `INSERT INTO anime (id, title_original, image_url) VALUES (1, 'Audited', '1.jpg'), (2, 'Estimated', '2.jpg')`)
mustExecTestSQL(t, sqlDB, `
INSERT INTO watch_list_entry (id, user_id, anime_id, status, updated_at)
VALUES
('entry-1', 'user-1', 1, 'completed', '2026-06-20 20:00:00'),
('entry-2', 'user-1', 2, 'completed', '2026-06-21 21:00:00')`)
mustExecTestSQL(t, sqlDB, `
INSERT INTO audit_log (id, occurred_at, user_id, action, resource_type, resource_id)
VALUES ('audit-1', '2026-06-19 19:00:00', 'user-1', 'watch_completed', 'anime', '1')`)
if err := goose.UpTo(sqlDB, "migrations", 26); err != nil {
t.Fatalf("migrate to version 26: %v", err)
}
assertHistoricalCompletion(ctx, t, sqlDB, 1, "2026-06-19T19:00:00Z", false)
assertHistoricalCompletion(ctx, t, sqlDB, 2, "2026-06-21T21:00:00Z", true)
}
func mustExecTestSQL(t *testing.T, sqlDB *sql.DB, query string) {
t.Helper()
if _, err := sqlDB.ExecContext(context.Background(), query); err != nil {
t.Fatalf("execute test SQL: %v", err)
}
}
func upsertTestWatchlistStatus(ctx context.Context, t *testing.T, queries *db.Queries, status string) {
t.Helper()
_, err := queries.UpsertWatchListEntry(ctx, db.UpsertWatchListEntryParams{
ID: "entry-1",
UserID: "user-1",
AnimeID: 1,
Status: status,
})
if err != nil {
t.Fatalf("upsert %s watchlist entry: %v", status, err)
}
}
func testWatchlistCompletion(ctx context.Context, t *testing.T, sqlDB *sql.DB) (sql.NullTime, bool) {
t.Helper()
var completedAt sql.NullTime
var estimated bool
if err := sqlDB.QueryRowContext(ctx, `SELECT completed_at, completed_at_estimated FROM watch_list_entry WHERE user_id = 'user-1' AND anime_id = 1`).Scan(&completedAt, &estimated); err != nil {
t.Fatalf("query completion date: %v", err)
}
return completedAt, estimated
}
func assertHistoricalCompletion(ctx context.Context, t *testing.T, sqlDB *sql.DB, animeID int64, wantTime string, wantEstimated bool) {
t.Helper()
var completedAt string
var estimated bool
if err := sqlDB.QueryRowContext(ctx, `SELECT completed_at, completed_at_estimated FROM watch_list_entry WHERE anime_id = ?`, animeID).Scan(&completedAt, &estimated); err != nil {
t.Fatalf("query completion for anime %d: %v", animeID, err)
}
if completedAt != wantTime || estimated != wantEstimated {
t.Fatalf("anime %d completion = %q estimated=%v, want %q estimated=%v", animeID, completedAt, estimated, wantTime, wantEstimated)
}
}
func TestCleanupExpiredJikanCache(t *testing.T) { func TestCleanupExpiredJikanCache(t *testing.T) {
sqlDB := newMigratedTestDB(t) sqlDB := newMigratedTestDB(t)
defer closeTestDB(t, sqlDB) defer closeTestDB(t, sqlDB)

View File

@@ -0,0 +1,32 @@
-- +goose Up
ALTER TABLE watch_list_entry ADD COLUMN completed_at DATETIME;
ALTER TABLE watch_list_entry ADD COLUMN completed_at_estimated BOOLEAN NOT NULL DEFAULT 0;
UPDATE watch_list_entry
SET completed_at = COALESCE(
(
SELECT MAX(a.occurred_at)
FROM audit_log a
WHERE a.user_id = watch_list_entry.user_id
AND a.action = 'watch_completed'
AND a.resource_type = 'anime'
AND a.resource_id = CAST(watch_list_entry.anime_id AS TEXT)
),
watch_list_entry.updated_at
),
completed_at_estimated = CASE
WHEN EXISTS (
SELECT 1
FROM audit_log a
WHERE a.user_id = watch_list_entry.user_id
AND a.action = 'watch_completed'
AND a.resource_type = 'anime'
AND a.resource_id = CAST(watch_list_entry.anime_id AS TEXT)
) THEN 0
ELSE 1
END
WHERE status = 'completed';
-- +goose Down
ALTER TABLE watch_list_entry DROP COLUMN completed_at_estimated;
ALTER TABLE watch_list_entry DROP COLUMN completed_at;

View File

@@ -103,6 +103,38 @@ type JikanCache struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
type RecommendationEvent struct {
ID string `json:"id"`
UserID string `json:"user_id"`
AnimeID sql.NullInt64 `json:"anime_id"`
EventType string `json:"event_type"`
Source sql.NullString `json:"source"`
MetadataJson sql.NullString `json:"metadata_json"`
OccurredAt time.Time `json:"occurred_at"`
CreatedAt time.Time `json:"created_at"`
}
type RecommendationImpression struct {
ID string `json:"id"`
UserID string `json:"user_id"`
AnimeID int64 `json:"anime_id"`
Rail string `json:"rail"`
Position int64 `json:"position"`
RequestID sql.NullString `json:"request_id"`
MetadataJson sql.NullString `json:"metadata_json"`
OccurredAt time.Time `json:"occurred_at"`
CreatedAt time.Time `json:"created_at"`
}
type RecommendationProfileSnapshot struct {
UserID string `json:"user_id"`
ProfileJson string `json:"profile_json"`
SourceWindowStart sql.NullTime `json:"source_window_start"`
SourceWindowEnd sql.NullTime `json:"source_window_end"`
ComputedAt time.Time `json:"computed_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Session struct { type Session struct {
ID string `json:"id"` ID string `json:"id"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
@@ -140,4 +172,6 @@ type WatchListEntry struct {
CurrentEpisode sql.NullInt64 `json:"current_episode"` CurrentEpisode sql.NullInt64 `json:"current_episode"`
LastEpisodeAt sql.NullTime `json:"last_episode_at"` LastEpisodeAt sql.NullTime `json:"last_episode_at"`
CurrentTimeSeconds float64 `json:"current_time_seconds"` CurrentTimeSeconds float64 `json:"current_time_seconds"`
CompletedAt sql.NullTime `json:"completed_at"`
CompletedAtEstimated bool `json:"completed_at_estimated"`
} }

View File

@@ -32,8 +32,8 @@ type Querier interface {
GetEpisodeAvailabilityCache(ctx context.Context, animeID int64) (EpisodeAvailabilityCache, error) GetEpisodeAvailabilityCache(ctx context.Context, animeID int64) (EpisodeAvailabilityCache, error)
GetEpisodeProviderMapping(ctx context.Context, arg GetEpisodeProviderMappingParams) (EpisodeProviderMapping, error) GetEpisodeProviderMapping(ctx context.Context, arg GetEpisodeProviderMappingParams) (EpisodeProviderMapping, error)
GetJikanCache(ctx context.Context, key string) (string, error) GetJikanCache(ctx context.Context, key string) (string, error)
GetJikanCacheStats(ctx context.Context) (GetJikanCacheStatsRow, error)
GetJikanCacheStale(ctx context.Context, key string) (string, error) GetJikanCacheStale(ctx context.Context, key string) (string, error)
GetJikanCacheStats(ctx context.Context) (GetJikanCacheStatsRow, error)
GetSession(ctx context.Context, id string) (Session, error) GetSession(ctx context.Context, id string) (Session, error)
GetTrackedAiringAnimeIDsDueForEpisodeRefresh(ctx context.Context, limit int64) ([]int64, error) GetTrackedAiringAnimeIDsDueForEpisodeRefresh(ctx context.Context, limit int64) ([]int64, error)
GetUpcomingSeasons(ctx context.Context, userID string) ([]GetUpcomingSeasonsRow, error) GetUpcomingSeasons(ctx context.Context, userID string) ([]GetUpcomingSeasonsRow, error)

View File

@@ -68,12 +68,32 @@ RETURNING *;
SELECT * FROM anime WHERE id = ? LIMIT 1; SELECT * FROM anime WHERE id = ? LIMIT 1;
-- name: UpsertWatchListEntry :one -- 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, completed_at, completed_at_estimated, updated_at)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) VALUES (
sqlc.arg(id),
sqlc.arg(user_id),
sqlc.arg(anime_id),
sqlc.arg(status),
sqlc.arg(current_episode),
sqlc.arg(current_time_seconds),
CASE WHEN sqlc.arg(status) = 'completed' THEN CURRENT_TIMESTAMP END,
0,
CURRENT_TIMESTAMP
)
ON CONFLICT (user_id, anime_id) DO UPDATE SET ON CONFLICT (user_id, anime_id) DO UPDATE SET
status = excluded.status, status = excluded.status,
current_episode = excluded.current_episode, current_episode = excluded.current_episode,
current_time_seconds = excluded.current_time_seconds, current_time_seconds = excluded.current_time_seconds,
completed_at = CASE
WHEN excluded.status != 'completed' THEN NULL
WHEN watch_list_entry.status = 'completed' THEN COALESCE(watch_list_entry.completed_at, CURRENT_TIMESTAMP)
ELSE CURRENT_TIMESTAMP
END,
completed_at_estimated = CASE
WHEN excluded.status = 'completed' AND watch_list_entry.status = 'completed'
THEN watch_list_entry.completed_at_estimated
ELSE 0
END,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
RETURNING *; RETURNING *;
@@ -148,7 +168,20 @@ WHERE user_id = ? AND anime_id = ? LIMIT 1;
-- name: GetUserWatchList :many -- name: GetUserWatchList :many
SELECT SELECT
e.*, 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,
e.completed_at,
e.completed_at_estimated,
c.current_episode AS playback_current_episode,
c.current_time_seconds AS playback_current_time_seconds,
c.updated_at AS playback_updated_at,
a.title_original, a.title_original,
a.title_english, a.title_english,
a.title_japanese, a.title_japanese,
@@ -156,6 +189,7 @@ SELECT
a.airing a.airing
FROM watch_list_entry e FROM watch_list_entry e
JOIN anime a ON e.anime_id = a.id JOIN anime a ON e.anime_id = a.id
LEFT JOIN continue_watching_entry c ON c.user_id = e.user_id AND c.anime_id = e.anime_id
WHERE e.user_id = ? WHERE e.user_id = ?
ORDER BY e.updated_at DESC; ORDER BY e.updated_at DESC;

View File

@@ -246,6 +246,7 @@ func (q *Queries) GetAllCachedAnime(ctx context.Context) ([]string, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close()
var items []string var items []string
for rows.Next() { for rows.Next() {
var data string var data string
@@ -318,6 +319,7 @@ func (q *Queries) GetAnimeNeedingRelationSync(ctx context.Context) ([]GetAnimeNe
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close()
var items []GetAnimeNeedingRelationSyncRow var items []GetAnimeNeedingRelationSyncRow
for rows.Next() { for rows.Next() {
var i GetAnimeNeedingRelationSyncRow var i GetAnimeNeedingRelationSyncRow
@@ -353,6 +355,7 @@ func (q *Queries) GetAuditLogsForUser(ctx context.Context, arg GetAuditLogsForUs
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close()
var items []AuditLog var items []AuditLog
for rows.Next() { for rows.Next() {
var i AuditLog var i AuditLog
@@ -380,6 +383,70 @@ func (q *Queries) GetAuditLogsForUser(ctx context.Context, arg GetAuditLogsForUs
return items, nil 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
}
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.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 getContinueWatchingEntries = `-- name: GetContinueWatchingEntries :many const getContinueWatchingEntries = `-- name: GetContinueWatchingEntries :many
SELECT SELECT
c.id, c.id,
@@ -422,69 +489,7 @@ func (q *Queries) GetContinueWatchingEntries(ctx context.Context, userID string)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var items []GetContinueWatchingEntriesRow defer rows.Close()
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 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 var items []GetContinueWatchingEntriesRow
for rows.Next() { for rows.Next() {
var i GetContinueWatchingEntriesRow var i GetContinueWatchingEntriesRow
@@ -555,6 +560,7 @@ func (q *Queries) GetDueAnimeFetchRetries(ctx context.Context, limit int64) ([]A
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close()
var items []AnimeFetchRetry var items []AnimeFetchRetry
for rows.Next() { for rows.Next() {
var i AnimeFetchRetry var i AnimeFetchRetry
@@ -639,6 +645,18 @@ func (q *Queries) GetJikanCache(ctx context.Context, key string) (string, error)
return data, err return data, err
} }
const getJikanCacheStale = `-- name: GetJikanCacheStale :one
SELECT data FROM jikan_cache
WHERE key = ? AND datetime(expires_at) > datetime(CURRENT_TIMESTAMP, '-14 days') LIMIT 1
`
func (q *Queries) GetJikanCacheStale(ctx context.Context, key string) (string, error) {
row := q.db.QueryRowContext(ctx, getJikanCacheStale, key)
var data string
err := row.Scan(&data)
return data, err
}
const getJikanCacheStats = `-- name: GetJikanCacheStats :one const getJikanCacheStats = `-- name: GetJikanCacheStats :one
SELECT SELECT
COUNT(*) AS total_rows, COUNT(*) AS total_rows,
@@ -660,18 +678,6 @@ func (q *Queries) GetJikanCacheStats(ctx context.Context) (GetJikanCacheStatsRow
return i, err return i, err
} }
const getJikanCacheStale = `-- name: GetJikanCacheStale :one
SELECT data FROM jikan_cache
WHERE key = ? AND datetime(expires_at) > datetime(CURRENT_TIMESTAMP, '-14 days') LIMIT 1
`
func (q *Queries) GetJikanCacheStale(ctx context.Context, key string) (string, error) {
row := q.db.QueryRowContext(ctx, getJikanCacheStale, key)
var data string
err := row.Scan(&data)
return data, err
}
const getSession = `-- name: GetSession :one const getSession = `-- name: GetSession :one
SELECT id, user_id, expires_at, created_at FROM session WHERE id = ? LIMIT 1 SELECT id, user_id, expires_at, created_at FROM session WHERE id = ? LIMIT 1
` `
@@ -716,6 +722,7 @@ func (q *Queries) GetTrackedAiringAnimeIDsDueForEpisodeRefresh(ctx context.Conte
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close()
var items []int64 var items []int64
for rows.Next() { for rows.Next() {
var anime_id int64 var anime_id int64
@@ -792,6 +799,7 @@ func (q *Queries) GetUpcomingSeasons(ctx context.Context, userID string) ([]GetU
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close()
var items []GetUpcomingSeasonsRow var items []GetUpcomingSeasonsRow
for rows.Next() { for rows.Next() {
var i GetUpcomingSeasonsRow var i GetUpcomingSeasonsRow
@@ -857,7 +865,20 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User,
const getUserWatchList = `-- name: GetUserWatchList :many const getUserWatchList = `-- name: GetUserWatchList :many
SELECT 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, 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,
e.completed_at,
e.completed_at_estimated,
c.current_episode AS playback_current_episode,
c.current_time_seconds AS playback_current_time_seconds,
c.updated_at AS playback_updated_at,
a.title_original, a.title_original,
a.title_english, a.title_english,
a.title_japanese, a.title_japanese,
@@ -865,6 +886,7 @@ SELECT
a.airing a.airing
FROM watch_list_entry e FROM watch_list_entry e
JOIN anime a ON e.anime_id = a.id JOIN anime a ON e.anime_id = a.id
LEFT JOIN continue_watching_entry c ON c.user_id = e.user_id AND c.anime_id = e.anime_id
WHERE e.user_id = ? WHERE e.user_id = ?
ORDER BY e.updated_at DESC ORDER BY e.updated_at DESC
` `
@@ -879,6 +901,11 @@ type GetUserWatchListRow struct {
CurrentEpisode sql.NullInt64 `json:"current_episode"` CurrentEpisode sql.NullInt64 `json:"current_episode"`
LastEpisodeAt sql.NullTime `json:"last_episode_at"` LastEpisodeAt sql.NullTime `json:"last_episode_at"`
CurrentTimeSeconds float64 `json:"current_time_seconds"` CurrentTimeSeconds float64 `json:"current_time_seconds"`
CompletedAt sql.NullTime `json:"completed_at"`
CompletedAtEstimated bool `json:"completed_at_estimated"`
PlaybackCurrentEpisode sql.NullInt64 `json:"playback_current_episode"`
PlaybackCurrentTimeSeconds sql.NullFloat64 `json:"playback_current_time_seconds"`
PlaybackUpdatedAt sql.NullTime `json:"playback_updated_at"`
TitleOriginal string `json:"title_original"` TitleOriginal string `json:"title_original"`
TitleEnglish sql.NullString `json:"title_english"` TitleEnglish sql.NullString `json:"title_english"`
TitleJapanese sql.NullString `json:"title_japanese"` TitleJapanese sql.NullString `json:"title_japanese"`
@@ -891,6 +918,7 @@ func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUse
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close()
var items []GetUserWatchListRow var items []GetUserWatchListRow
for rows.Next() { for rows.Next() {
var i GetUserWatchListRow var i GetUserWatchListRow
@@ -904,6 +932,11 @@ func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUse
&i.CurrentEpisode, &i.CurrentEpisode,
&i.LastEpisodeAt, &i.LastEpisodeAt,
&i.CurrentTimeSeconds, &i.CurrentTimeSeconds,
&i.CompletedAt,
&i.CompletedAtEstimated,
&i.PlaybackCurrentEpisode,
&i.PlaybackCurrentTimeSeconds,
&i.PlaybackUpdatedAt,
&i.TitleOriginal, &i.TitleOriginal,
&i.TitleEnglish, &i.TitleEnglish,
&i.TitleJapanese, &i.TitleJapanese,
@@ -924,7 +957,7 @@ func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUse
} }
const getWatchListEntry = `-- name: GetWatchListEntry :one const getWatchListEntry = `-- name: GetWatchListEntry :one
SELECT id, user_id, anime_id, status, created_at, updated_at, current_episode, last_episode_at, current_time_seconds FROM watch_list_entry SELECT id, user_id, anime_id, status, created_at, updated_at, current_episode, last_episode_at, current_time_seconds, completed_at, completed_at_estimated FROM watch_list_entry
WHERE user_id = ? AND anime_id = ? LIMIT 1 WHERE user_id = ? AND anime_id = ? LIMIT 1
` `
@@ -946,13 +979,15 @@ func (q *Queries) GetWatchListEntry(ctx context.Context, arg GetWatchListEntryPa
&i.CurrentEpisode, &i.CurrentEpisode,
&i.LastEpisodeAt, &i.LastEpisodeAt,
&i.CurrentTimeSeconds, &i.CurrentTimeSeconds,
&i.CompletedAt,
&i.CompletedAtEstimated,
) )
return i, err return i, err
} }
const getWatchingAnime = `-- name: GetWatchingAnime :many const getWatchingAnime = `-- name: GetWatchingAnime :many
SELECT 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, 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, e.completed_at, e.completed_at_estimated,
a.title_original, a.title_original,
a.title_english, a.title_english,
a.title_japanese, a.title_japanese,
@@ -974,6 +1009,8 @@ type GetWatchingAnimeRow struct {
CurrentEpisode sql.NullInt64 `json:"current_episode"` CurrentEpisode sql.NullInt64 `json:"current_episode"`
LastEpisodeAt sql.NullTime `json:"last_episode_at"` LastEpisodeAt sql.NullTime `json:"last_episode_at"`
CurrentTimeSeconds float64 `json:"current_time_seconds"` CurrentTimeSeconds float64 `json:"current_time_seconds"`
CompletedAt sql.NullTime `json:"completed_at"`
CompletedAtEstimated bool `json:"completed_at_estimated"`
TitleOriginal string `json:"title_original"` TitleOriginal string `json:"title_original"`
TitleEnglish sql.NullString `json:"title_english"` TitleEnglish sql.NullString `json:"title_english"`
TitleJapanese sql.NullString `json:"title_japanese"` TitleJapanese sql.NullString `json:"title_japanese"`
@@ -986,6 +1023,7 @@ func (q *Queries) GetWatchingAnime(ctx context.Context, userID string) ([]GetWat
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close()
var items []GetWatchingAnimeRow var items []GetWatchingAnimeRow
for rows.Next() { for rows.Next() {
var i GetWatchingAnimeRow var i GetWatchingAnimeRow
@@ -999,6 +1037,8 @@ func (q *Queries) GetWatchingAnime(ctx context.Context, userID string) ([]GetWat
&i.CurrentEpisode, &i.CurrentEpisode,
&i.LastEpisodeAt, &i.LastEpisodeAt,
&i.CurrentTimeSeconds, &i.CurrentTimeSeconds,
&i.CompletedAt,
&i.CompletedAtEstimated,
&i.TitleOriginal, &i.TitleOriginal,
&i.TitleEnglish, &i.TitleEnglish,
&i.TitleJapanese, &i.TitleJapanese,
@@ -1362,14 +1402,34 @@ func (q *Queries) UpsertEpisodeProviderMapping(ctx context.Context, arg UpsertEp
} }
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, completed_at, completed_at_estimated, updated_at)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) VALUES (
?1,
?2,
?3,
?4,
?5,
?6,
CASE WHEN ?4 = 'completed' THEN CURRENT_TIMESTAMP END,
0,
CURRENT_TIMESTAMP
)
ON CONFLICT (user_id, anime_id) DO UPDATE SET ON CONFLICT (user_id, anime_id) DO UPDATE SET
status = excluded.status, status = excluded.status,
current_episode = excluded.current_episode, current_episode = excluded.current_episode,
current_time_seconds = excluded.current_time_seconds, current_time_seconds = excluded.current_time_seconds,
completed_at = CASE
WHEN excluded.status != 'completed' THEN NULL
WHEN watch_list_entry.status = 'completed' THEN COALESCE(watch_list_entry.completed_at, CURRENT_TIMESTAMP)
ELSE CURRENT_TIMESTAMP
END,
completed_at_estimated = CASE
WHEN excluded.status = 'completed' AND watch_list_entry.status = 'completed'
THEN watch_list_entry.completed_at_estimated
ELSE 0
END,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
RETURNING id, user_id, anime_id, status, created_at, updated_at, current_episode, last_episode_at, current_time_seconds RETURNING id, user_id, anime_id, status, created_at, updated_at, current_episode, last_episode_at, current_time_seconds, completed_at, completed_at_estimated
` `
type UpsertWatchListEntryParams struct { type UpsertWatchListEntryParams struct {
@@ -1401,6 +1461,8 @@ func (q *Queries) UpsertWatchListEntry(ctx context.Context, arg UpsertWatchListE
&i.CurrentEpisode, &i.CurrentEpisode,
&i.LastEpisodeAt, &i.LastEpisodeAt,
&i.CurrentTimeSeconds, &i.CurrentTimeSeconds,
&i.CompletedAt,
&i.CompletedAtEstimated,
) )
return i, err return i, err
} }