Infra ADR-0004 renamed the Gitea host. Bulk replace across go.mod and all .go import paths. Build and tests pass unchanged. Closes #20 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dt6aHEDWRjkK14Voi6HnGh
163 lines
5.7 KiB
Go
163 lines
5.7 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"git.d-ma.be/mathias/tapir/internal/domain"
|
|
)
|
|
|
|
// UpsertVideo persists a video's metadata and returns its durable store id (the
|
|
// videos.id UUID). It is idempotent on (user_id, provider, provider_video_id):
|
|
// the same provider video for a user always resolves to the same row and the
|
|
// same returned id, so the run loop can use that id as the stable dedup key
|
|
// across restarts (it matches summaries.video_id once a summary exists).
|
|
//
|
|
// This lives in a separate file from store.go on purpose: the Sink port only
|
|
// carries a domain.Summary (no title/channel), so video metadata is persisted
|
|
// here, out of the delivery path, to keep the reader's rows readable.
|
|
//
|
|
// subscription_id is intentionally left NULL at Stage 0: the YouTube
|
|
// Subscription.ID is a provider resource id, not the UUID that column expects,
|
|
// and the subscriptions table is not part of this slice (data-model.md).
|
|
func (s *Store) UpsertVideo(ctx context.Context, v domain.Video) (string, error) {
|
|
if v.UserID == "" {
|
|
return "", fmt.Errorf("store: upsert video: empty user id")
|
|
}
|
|
if v.ProviderVideoID == "" {
|
|
return "", fmt.Errorf("store: upsert video: empty provider video id")
|
|
}
|
|
|
|
provider := string(v.Provider)
|
|
if provider == "" {
|
|
provider = string(domain.ProviderYouTube)
|
|
}
|
|
|
|
var id string
|
|
if err := s.withUser(ctx, v.UserID, func(tx pgx.Tx) error {
|
|
// Ensure the owning user exists (FK target) — same as the Deliver path.
|
|
if _, err := tx.Exec(ctx,
|
|
`INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO NOTHING`,
|
|
v.UserID); err != nil {
|
|
return fmt.Errorf("store: upsert user: %w", err)
|
|
}
|
|
|
|
if err := tx.QueryRow(ctx,
|
|
`INSERT INTO videos (user_id, provider, provider_video_id, title, url, published_at, channel_title, duration_s)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
ON CONFLICT (user_id, provider, provider_video_id) DO UPDATE SET
|
|
title = EXCLUDED.title,
|
|
url = EXCLUDED.url,
|
|
published_at = EXCLUDED.published_at,
|
|
channel_title = COALESCE(NULLIF(EXCLUDED.channel_title, ''), videos.channel_title),
|
|
duration_s = COALESCE(EXCLUDED.duration_s, videos.duration_s)
|
|
RETURNING id`,
|
|
v.UserID, provider, v.ProviderVideoID, v.Title, v.URL, nullTime(v.PublishedAt), v.ChannelTitle, nullDuration(v.DurationSeconds),
|
|
).Scan(&id); err != nil {
|
|
return fmt.Errorf("store: upsert video: %w", err)
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return "", err
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// nullTime maps the zero time to NULL so an unknown published_at is stored as
|
|
// SQL NULL rather than year 0001.
|
|
func nullTime(t time.Time) *time.Time {
|
|
if t.IsZero() {
|
|
return nil
|
|
}
|
|
return &t
|
|
}
|
|
|
|
// nullDuration maps an unknown duration (0) to SQL NULL so the upsert's
|
|
// COALESCE(EXCLUDED.duration_s, videos.duration_s) preserves a previously-known
|
|
// value instead of clobbering it with 0 (ADR-028; the channel_title backfill
|
|
// stance, migration 014).
|
|
func nullDuration(seconds int) *int {
|
|
if seconds <= 0 {
|
|
return nil
|
|
}
|
|
return &seconds
|
|
}
|
|
|
|
// OnboardBurstVideoIDs returns up to limit of the user's unsummarized videos for
|
|
// the connect-time onboarding burst (ADR-028), newest-first but quality-aware: a
|
|
// video is excluded when its duration is KNOWN and outside [minSeconds, maxSeconds]
|
|
// — dropping Shorts (below min) and multi-hour livestream VODs (above max) that
|
|
// would waste a scarce caption fetch on a poor first impression. A NULL/unknown
|
|
// duration is kept (degrade-open) but ranked AFTER known-good rows, so a freshly
|
|
// enriched good pick wins when both exist. minSeconds<=0 / maxSeconds<=0 each
|
|
// disable that bound (0/0 == pure newest-first, the reversibility lever).
|
|
// RLS-scoped via withUser; limit <= 0 returns nil.
|
|
func (s *Store) OnboardBurstVideoIDs(ctx context.Context, userID string, limit, minSeconds, maxSeconds int) ([]string, error) {
|
|
if limit <= 0 {
|
|
return nil, nil
|
|
}
|
|
var ids []string
|
|
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
|
rows, err := tx.Query(ctx,
|
|
`SELECT v.id
|
|
FROM videos v
|
|
WHERE v.user_id = $1
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM summaries su
|
|
WHERE su.user_id = v.user_id AND su.video_id = v.id)
|
|
AND NOT (
|
|
v.duration_s IS NOT NULL
|
|
AND ( ($3 > 0 AND v.duration_s < $3)
|
|
OR ($4 > 0 AND v.duration_s > $4) ))
|
|
ORDER BY (v.duration_s IS NOT NULL) DESC,
|
|
v.published_at DESC NULLS LAST, v.seen_at DESC
|
|
LIMIT $2`, userID, limit, minSeconds, maxSeconds)
|
|
if err != nil {
|
|
return fmt.Errorf("store: onboard burst videos: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err != nil {
|
|
return fmt.Errorf("store: scan onboard burst video: %w", err)
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
return rows.Err()
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
// DistinctChannels returns the user's distinct, non-empty source channel titles
|
|
// (the channels they have videos from), alphabetically — the option list for the
|
|
// feed's channel filter. RLS-scoped via withUser.
|
|
func (s *Store) DistinctChannels(ctx context.Context, userID string) ([]string, error) {
|
|
var out []string
|
|
if err := s.withUser(ctx, userID, func(tx pgx.Tx) error {
|
|
rows, err := tx.Query(ctx,
|
|
`SELECT DISTINCT channel_title FROM videos
|
|
WHERE user_id = $1 AND channel_title IS NOT NULL AND channel_title <> ''
|
|
ORDER BY channel_title`, userID)
|
|
if err != nil {
|
|
return fmt.Errorf("store: distinct channels: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var c string
|
|
if err := rows.Scan(&c); err != nil {
|
|
return fmt.Errorf("store: scan channel: %w", err)
|
|
}
|
|
out = append(out, c)
|
|
}
|
|
return rows.Err()
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|