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:
2026-06-09 21:43:28 +02:00
co-authored by Claude Opus 4.8
parent 59050c4db6
commit 2907801aca
2 changed files with 104 additions and 0 deletions
+45
View File
@@ -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 {