diff --git a/internal/adapters/youtube/videobyid_test.go b/internal/adapters/youtube/videobyid_test.go new file mode 100644 index 0000000..f436b4e --- /dev/null +++ b/internal/adapters/youtube/videobyid_test.go @@ -0,0 +1,59 @@ +package youtube + +import ( + "context" + "errors" + "net/http" + "testing" + + "gitea.d-ma.be/mathias/tapir/internal/domain" +) + +func TestVideoByID(t *testing.T) { + const id = "dQw4w9WgXcQ" + a, secrets := newTestAdapter(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/videos" { + t.Errorf("unexpected path %q (must use videos.list)", r.URL.Path) + } + if got := r.URL.Query().Get("id"); got != id { + t.Errorf("expected id=%s, got %q", id, got) + } + if got := r.URL.Query().Get("part"); got != "snippet" { + t.Errorf("expected part=snippet, got %q", got) + } + _, _ = w.Write([]byte(`{"items":[{"snippet":{"title":"Never Gonna Give You Up","publishedAt":"2026-05-20T09:00:00Z"}}]}`)) + }) + + v, err := a.VideoByID(context.Background(), "u1", id) + if err != nil { + t.Fatalf("VideoByID: %v", err) + } + if v.UserID != "u1" { + t.Errorf("UserID = %q, want u1", v.UserID) + } + if v.ProviderVideoID != id || v.Title != "Never Gonna Give You Up" { + t.Errorf("unexpected video: %+v", v) + } + if v.Provider != domain.ProviderYouTube || v.URL != "https://www.youtube.com/watch?v="+id { + t.Errorf("video not wired correctly: %+v", v) + } + if v.PublishedAt.IsZero() { + t.Errorf("expected publishedAt parsed, got zero") + } + if v.SubscriptionID != "" { + t.Errorf("a pasted video must have no subscription, got %q", v.SubscriptionID) + } + if secrets.byRef == nil { + t.Errorf("token must be resolved by reference through the SecretStore") + } +} + +func TestVideoByIDNotFound(t *testing.T) { + a, _ := newTestAdapter(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"items":[]}`)) + }) + _, err := a.VideoByID(context.Background(), "u1", "missingvid0") + if !errors.Is(err, ErrVideoNotFound) { + t.Fatalf("VideoByID for missing id = %v, want ErrVideoNotFound", err) + } +} diff --git a/internal/adapters/youtube/youtube.go b/internal/adapters/youtube/youtube.go index 361d815..954fb04 100644 --- a/internal/adapters/youtube/youtube.go +++ b/internal/adapters/youtube/youtube.go @@ -18,6 +18,7 @@ package youtube import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -31,6 +32,10 @@ import ( "gitea.d-ma.be/mathias/tapir/internal/ports" ) +// ErrVideoNotFound is returned by VideoByID when an id resolves to no video +// (deleted, private, or a typo'd paste). Callers map it to an honest user message. +var ErrVideoNotFound = errors.New("youtube: video not found") + // defaultBaseURL is the YouTube Data API v3 root. Overridable via Config.BaseURL // (tests point it at an httptest server). const defaultBaseURL = "https://www.googleapis.com/youtube/v3" @@ -244,6 +249,37 @@ func (a *Adapter) NewVideos(ctx context.Context, sub domain.Subscription) ([]dom return videos, nil } +// VideoByID fetches a single video's metadata (videos.list, snippet) for an +// arbitrary video id — including channels the user does not follow (paste-a-URL, +// Feature 2). This is a Data API call (1 quota unit), NOT the rate-limited +// caption path, so it is not gated: only the later transcript fetch goes through +// globalFetchGate. UserID is set on the result and SubscriptionID is left empty +// (a pasted video has no subscription parent). Returns ErrVideoNotFound when the +// id resolves to no video. +func (a *Adapter) VideoByID(ctx context.Context, userID, videoID string) (domain.Video, error) { + client, err := a.httpClient(ctx, a.cfg.TokenSecretRef) + if err != nil { + return domain.Video{}, err + } + q := url.Values{"part": {"snippet"}, "id": {videoID}} + var resp videoListResponse + if err := a.getJSON(ctx, client, "/videos", q, &resp); err != nil { + return domain.Video{}, fmt.Errorf("video by id %q: %w", videoID, err) + } + if len(resp.Items) == 0 { + return domain.Video{}, fmt.Errorf("video %q: %w", videoID, ErrVideoNotFound) + } + it := resp.Items[0] + return domain.Video{ + UserID: userID, + Provider: domain.ProviderYouTube, + ProviderVideoID: videoID, + Title: it.Snippet.Title, + URL: "https://www.youtube.com/watch?v=" + videoID, + PublishedAt: it.Snippet.PublishedAt, + }, nil +} + // uploadsPlaylistID derives a channel's uploads playlist id at zero API cost: // a standard channel id "UCxxxx" maps to uploads playlist "UUxxxx". Returns // ok=false for ids that don't follow this convention (caller falls back to @@ -342,6 +378,15 @@ type playlistItemListResponse struct { } `json:"items"` } +type videoListResponse struct { + Items []struct { + Snippet struct { + Title string `json:"title"` + PublishedAt time.Time `json:"publishedAt"` + } `json:"snippet"` + } `json:"items"` +} + type channelListResponse struct { Items []struct { ContentDetails struct {