feat(youtube): VideoByID for arbitrary-video metadata (paste-a-URL)
videos.list (part=snippet) for a single id, including channels the user does not follow. Data API call (1 quota unit), NOT the rate-limited caption path — ungated metadata; only the later transcript fetch hits globalFetchGate. Returns a subscription-less domain.Video scoped to the user, or ErrVideoNotFound for a deleted/private/typo'd id. Foundation for Feature 2. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ package youtube
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -31,6 +32,10 @@ import (
|
|||||||
"gitea.d-ma.be/mathias/tapir/internal/ports"
|
"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
|
// defaultBaseURL is the YouTube Data API v3 root. Overridable via Config.BaseURL
|
||||||
// (tests point it at an httptest server).
|
// (tests point it at an httptest server).
|
||||||
const defaultBaseURL = "https://www.googleapis.com/youtube/v3"
|
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
|
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:
|
// uploadsPlaylistID derives a channel's uploads playlist id at zero API cost:
|
||||||
// a standard channel id "UCxxxx" maps to uploads playlist "UUxxxx". Returns
|
// 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
|
// ok=false for ids that don't follow this convention (caller falls back to
|
||||||
@@ -342,6 +378,15 @@ type playlistItemListResponse struct {
|
|||||||
} `json:"items"`
|
} `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 {
|
type channelListResponse struct {
|
||||||
Items []struct {
|
Items []struct {
|
||||||
ContentDetails struct {
|
ContentDetails struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user