The Sink port carries only a Summary, so video title/url/published_at would never reach the store. UpsertVideo (new file, store.go untouched) persists them and returns the durable videos.id UUID, idempotent on (user_id, provider, provider_video_id). The run loop uses that id as v.ID, so it equals summaries.video_id and SeenVideoIDs dedup survives restarts. subscription_id stays NULL: the YouTube resource id is not a UUID (Stage 0). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
78 lines
2.5 KiB
Go
78 lines
2.5 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"gitea.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")
|
|
}
|
|
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return "", fmt.Errorf("store: begin: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx) //nolint:errcheck // no-op after Commit
|
|
|
|
// 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)
|
|
}
|
|
|
|
provider := string(v.Provider)
|
|
if provider == "" {
|
|
provider = string(domain.ProviderYouTube)
|
|
}
|
|
|
|
var id string
|
|
if err := tx.QueryRow(ctx,
|
|
`INSERT INTO videos (user_id, provider, provider_video_id, title, url, published_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
ON CONFLICT (user_id, provider, provider_video_id) DO UPDATE SET
|
|
title = EXCLUDED.title,
|
|
url = EXCLUDED.url,
|
|
published_at = EXCLUDED.published_at
|
|
RETURNING id`,
|
|
v.UserID, provider, v.ProviderVideoID, v.Title, v.URL, nullTime(v.PublishedAt),
|
|
).Scan(&id); err != nil {
|
|
return "", fmt.Errorf("store: upsert video: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return "", fmt.Errorf("store: commit: %w", 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
|
|
}
|