Files
bjarneo--cliamp/external/ytmusic/provider.go
T
2026-08-20 23:17:16 +02:00

609 lines
16 KiB
Go

package ytmusic
import (
"context"
"fmt"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/bjarneo/cliamp/playlist"
"google.golang.org/api/youtube/v3"
)
// itemInfo holds metadata for a single video in a playlist.
type itemInfo struct {
videoID string
title string
channel string
}
// youtubeAPIBatchSize is the maximum number of items per YouTube Data API request.
const youtubeAPIBatchSize = 50
// baseProvider holds shared state for YouTube and YouTube Music providers.
// Both providers share the same OAuth session and track cache.
type baseProvider struct {
session *Session
clientID string
clientSecret string
hasCookies bool // true when cookies_from is configured
mu sync.Mutex
trackCache map[string][]playlist.Track // playlist ID -> cached tracks
allPlaylists []playlistEntry // cached raw playlist list
classified map[string]bool // playlist ID -> is music (from classify.go)
disk *ytCache // lazy-loaded disk cache
authCancel context.CancelFunc // cancels any in-progress OAuth flow
}
func newBase(session *Session, clientID, clientSecret string, hasCookies bool) *baseProvider {
return &baseProvider{
session: session,
clientID: clientID,
clientSecret: clientSecret,
hasCookies: hasCookies,
trackCache: make(map[string][]playlist.Track),
}
}
// ensureDiskCache lazily loads the disk cache. Must be called under mu.
func (b *baseProvider) ensureDiskCache() *ytCache {
if b.disk == nil {
b.disk = loadYTCache(oauthCacheScope(strings.TrimSpace(b.clientID)))
}
return b.disk
}
// initSession creates a session if one doesn't exist yet. If interactive is
// false, only stored credentials are tried (returning ErrNeedsAuth on failure).
// If interactive is true, a browser-based OAuth flow is started. Any previous
// in-progress OAuth flow is cancelled first to free the callback port.
func (b *baseProvider) initSession(interactive bool) error {
b.mu.Lock()
if b.session != nil {
b.mu.Unlock()
return nil
}
// Cancel any previous in-progress auth attempt so the old listener
// on CallbackPort is released before we try to bind again.
if b.authCancel != nil {
b.authCancel()
b.authCancel = nil
}
clientID := b.clientID
clientSecret := b.clientSecret
b.mu.Unlock()
if clientID == "" {
return fmt.Errorf("ytmusic: no client ID available")
}
var sess *Session
var err error
if interactive {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
b.mu.Lock()
b.authCancel = cancel
b.mu.Unlock()
sess, err = NewSession(ctx, clientID, clientSecret)
b.mu.Lock()
b.authCancel = nil
b.mu.Unlock()
cancel()
} else {
sess, err = NewSessionSilent(context.Background(), clientID, clientSecret)
}
if err != nil {
if !interactive {
return playlist.ErrNeedsAuth
}
return err
}
b.mu.Lock()
if b.session == nil {
b.session = sess
// Authentication may have created or rotated the stored refresh token.
b.disk = nil
}
b.mu.Unlock()
return nil
}
func (b *baseProvider) ensureSession() error { return b.initSession(false) }
func (b *baseProvider) authenticate() error { return b.initSession(true) }
// refresh clears playlist/track caches so the next call re-fetches from the
// API. Classification is preserved — it's on disk in a separate file, rarely
// changes, and re-classifying costs API quota.
func (b *baseProvider) refresh() {
b.mu.Lock()
b.allPlaylists = nil
b.classified = nil
clear(b.trackCache)
dc := b.ensureDiskCache()
dc.clear()
snap := dc.snapshot()
b.mu.Unlock()
saveSnapshot(snap)
}
func (b *baseProvider) close() {
b.mu.Lock()
defer b.mu.Unlock()
if b.authCancel != nil {
b.authCancel()
b.authCancel = nil
}
if b.session != nil {
b.session.Close()
b.session = nil
}
}
// fetchAndClassify loads all playlists and classifies them as music vs non-music.
// Results are cached in-memory for the session and on disk for fast startup.
// If fresh disk cache exists with full classification, no API calls or auth are needed.
func (b *baseProvider) fetchAndClassify() error {
b.mu.Lock()
if b.allPlaylists != nil {
b.mu.Unlock()
return nil
}
// Try disk cache first — no session/auth needed if data is fresh.
dc := b.ensureDiskCache()
if dc.playlistsFresh() {
classified := loadClassification()
if classified != nil {
allClassified := true
for _, pl := range dc.Playlists {
if _, ok := classified[pl.ID]; !ok {
allClassified = false
break
}
}
if allClassified {
b.allPlaylists = dc.Playlists
b.classified = classified
b.mu.Unlock()
return nil
}
}
}
b.mu.Unlock()
// Disk cache stale or incomplete — fetch from API.
if err := b.ensureSession(); err != nil {
return err
}
b.mu.Lock()
sess := b.session
b.mu.Unlock()
if sess == nil {
return fmt.Errorf("ytmusic: session unavailable")
}
svc := sess.Service()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var all []playlistEntry
seen := make(map[string]bool)
pageToken := ""
for {
call := svc.Playlists.List([]string{"snippet", "contentDetails"}).
Mine(true).
MaxResults(50).
Context(ctx)
if pageToken != "" {
call = call.PageToken(pageToken)
}
resp, err := call.Do()
if err != nil {
return fmt.Errorf("ytmusic: list playlists: %w", err)
}
for _, item := range resp.Items {
count := int(item.ContentDetails.ItemCount)
if count <= 0 || seen[item.Id] {
continue
}
seen[item.Id] = true
all = append(all, playlistEntry{
ID: item.Id,
Name: item.Snippet.Title,
TrackCount: count,
})
}
if resp.NextPageToken == "" {
break
}
pageToken = resp.NextPageToken
}
// Classify playlists (parallel, with disk cache).
// Pass nil to let classifyPlaylists load from disk itself —
// the earlier loadClassification() was skipped because cache was stale.
classified := classifyWithTimeout(svc, all, 60*time.Second, nil)
b.mu.Lock()
if b.allPlaylists != nil {
// Another goroutine completed fetchAndClassify while we were fetching.
b.mu.Unlock()
return nil
}
b.allPlaylists = all
b.classified = classified
// Persist playlists to disk cache.
dc = b.ensureDiskCache()
dc.setPlaylists(all)
snap := dc.snapshot()
b.mu.Unlock()
// Save outside the lock — disk I/O shouldn't block other goroutines.
saveSnapshot(snap)
return nil
}
// filteredPlaylists returns playlists filtered by music classification.
func (b *baseProvider) filteredPlaylists(wantMusic bool) []playlist.PlaylistInfo {
b.mu.Lock()
defer b.mu.Unlock()
var result []playlist.PlaylistInfo
for _, pl := range b.allPlaylists {
isMusic, ok := b.classified[pl.ID]
if !ok {
isMusic = false
}
if isMusic == wantMusic {
result = append(result, playlist.PlaylistInfo{
ID: pl.ID,
Name: pl.Name,
TrackCount: pl.TrackCount,
})
}
}
return result
}
// tracks fetches tracks for a playlist (shared between both providers).
// Checks in-memory cache, then disk cache, then fetches from API.
func (b *baseProvider) tracks(playlistID string) ([]playlist.Track, error) {
b.mu.Lock()
if cached, ok := b.trackCache[playlistID]; ok {
b.mu.Unlock()
return cached, nil
}
// Check disk cache — avoids API call and auth if fresh.
dc := b.ensureDiskCache()
if tracks, ok := dc.tracksFresh(playlistID); ok {
b.trackCache[playlistID] = tracks
b.mu.Unlock()
return tracks, nil
}
b.mu.Unlock()
// Disk cache miss — fetch from API.
if err := b.ensureSession(); err != nil {
return nil, err
}
b.mu.Lock()
sess := b.session
b.mu.Unlock()
if sess == nil {
return nil, fmt.Errorf("ytmusic: session unavailable")
}
svc := sess.Service()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
var items []itemInfo
pageToken := ""
for {
call := svc.PlaylistItems.List([]string{"snippet", "contentDetails"}).
PlaylistId(playlistID).
MaxResults(50).
Context(ctx)
if pageToken != "" {
call = call.PageToken(pageToken)
}
resp, err := call.Do()
if err != nil {
return nil, fmt.Errorf("ytmusic: list playlist items: %w", err)
}
for _, item := range resp.Items {
vid := item.ContentDetails.VideoId
if vid == "" {
continue
}
title := item.Snippet.Title
if title == "Private video" || title == "Deleted video" {
continue
}
channel := item.Snippet.VideoOwnerChannelTitle
if !b.hasCookies && (channel == "Music Library Uploads" || channel == "") {
continue
}
items = append(items, itemInfo{
videoID: vid,
title: title,
channel: channel,
})
}
if resp.NextPageToken == "" {
break
}
pageToken = resp.NextPageToken
}
durations := b.fetchDurations(ctx, svc, items)
var tracks []playlist.Track
for _, it := range items {
tracks = append(tracks, playlist.Track{
Path: "https://music.youtube.com/watch?v=" + it.videoID,
Title: it.title,
Artist: cleanChannelName(it.channel),
Stream: false,
DurationSecs: durations[it.videoID],
})
}
// Persist to in-memory and disk cache.
b.mu.Lock()
b.trackCache[playlistID] = tracks
dc = b.ensureDiskCache()
dc.setTracks(playlistID, tracks)
snap := dc.snapshot()
b.mu.Unlock()
// Save outside the lock — disk I/O shouldn't block other goroutines.
saveSnapshot(snap)
return tracks, nil
}
func (b *baseProvider) fetchDurations(ctx context.Context, svc *youtube.Service, items []itemInfo) map[string]int {
var mu sync.Mutex
durations := make(map[string]int)
var wg sync.WaitGroup
sem := make(chan struct{}, 5) // limit concurrent API calls
for i := 0; i < len(items); i += youtubeAPIBatchSize {
end := min(i+youtubeAPIBatchSize, len(items))
batch := items[i:end]
wg.Go(func() {
sem <- struct{}{}
defer func() { <-sem }()
var ids []string
for _, it := range batch {
ids = append(ids, it.videoID)
}
vResp, err := svc.Videos.List([]string{"contentDetails"}).
Id(ids...).
Context(ctx).
Do()
if err != nil {
return
}
mu.Lock()
for _, v := range vResp.Items {
durations[v.Id] = parseISO8601Duration(v.ContentDetails.Duration)
}
mu.Unlock()
})
}
wg.Wait()
return durations
}
// Special auto-generated playlist IDs exposed by the YouTube Data API.
const (
playlistIDLikedMusic = "LM" // YouTube Music liked songs
playlistIDLikedVideos = "LL" // YouTube liked videos
)
// playlistCounts fetches item counts for the given playlist IDs in a single
// API call. IDs not exposed by the API are omitted from the result.
// Returns an empty map when no session is available (e.g. disk-cache-only boot).
func (b *baseProvider) playlistCounts(ids ...string) map[string]int {
out := make(map[string]int, len(ids))
if len(ids) == 0 {
return out
}
b.mu.Lock()
sess := b.session
b.mu.Unlock()
if sess == nil {
return out
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
resp, err := sess.Service().Playlists.List([]string{"contentDetails"}).
Id(ids...).
Context(ctx).
Do()
if err != nil {
return out
}
for _, item := range resp.Items {
out[item.Id] = int(item.ContentDetails.ItemCount)
}
return out
}
// ─── YouTube Music Provider ────────────────────────────────────────────────
// YouTubeMusicProvider shows playlists classified as music content.
type YouTubeMusicProvider struct {
base *baseProvider
}
func (p *YouTubeMusicProvider) Name() string { return "YouTube Music" }
func (p *YouTubeMusicProvider) Authenticate() error { return p.base.authenticate() }
func (p *YouTubeMusicProvider) Close() { p.base.close() }
func (p *YouTubeMusicProvider) Refresh() { p.base.refresh() }
func (p *YouTubeMusicProvider) Tracks(id string) ([]playlist.Track, error) {
return p.base.tracks(id)
}
func (p *YouTubeMusicProvider) Playlists() ([]playlist.PlaylistInfo, error) {
if err := p.base.fetchAndClassify(); err != nil {
return nil, err
}
counts := p.base.playlistCounts(playlistIDLikedMusic)
all := []playlist.PlaylistInfo{{
ID: playlistIDLikedMusic,
Name: "Liked Music",
TrackCount: counts[playlistIDLikedMusic],
}}
all = append(all, p.base.filteredPlaylists(true)...)
return all, nil
}
// ─── YouTube Provider ──────────────────────────────────────────────────────
// YouTubeProvider shows playlists classified as non-music (video) content.
type YouTubeProvider struct {
base *baseProvider
}
func (p *YouTubeProvider) Name() string { return "YouTube" }
func (p *YouTubeProvider) Authenticate() error { return p.base.authenticate() }
func (p *YouTubeProvider) Close() { /* shared base; closed via music provider */ }
func (p *YouTubeProvider) Refresh() { p.base.refresh() }
func (p *YouTubeProvider) Tracks(id string) ([]playlist.Track, error) {
return p.base.tracks(id)
}
func (p *YouTubeProvider) Playlists() ([]playlist.PlaylistInfo, error) {
if err := p.base.fetchAndClassify(); err != nil {
return nil, err
}
counts := p.base.playlistCounts(playlistIDLikedVideos)
all := []playlist.PlaylistInfo{{
ID: playlistIDLikedVideos,
Name: "Liked Videos",
TrackCount: counts[playlistIDLikedVideos],
}}
all = append(all, p.base.filteredPlaylists(false)...)
return all, nil
}
// ─── YouTube All Provider ──────────────────────────────────────────────────
// YouTubeAllProvider shows all playlists regardless of classification.
type YouTubeAllProvider struct {
base *baseProvider
}
func (p *YouTubeAllProvider) Name() string { return "YouTube (All)" }
func (p *YouTubeAllProvider) Authenticate() error { return p.base.authenticate() }
func (p *YouTubeAllProvider) Close() { /* shared base; closed via music provider */ }
func (p *YouTubeAllProvider) Refresh() { p.base.refresh() }
func (p *YouTubeAllProvider) Tracks(id string) ([]playlist.Track, error) {
return p.base.tracks(id)
}
func (p *YouTubeAllProvider) Playlists() ([]playlist.PlaylistInfo, error) {
if err := p.base.fetchAndClassify(); err != nil {
return nil, err
}
counts := p.base.playlistCounts(playlistIDLikedMusic, playlistIDLikedVideos)
all := []playlist.PlaylistInfo{
{ID: playlistIDLikedMusic, Name: "Liked Music", TrackCount: counts[playlistIDLikedMusic]},
{ID: playlistIDLikedVideos, Name: "Liked Videos", TrackCount: counts[playlistIDLikedVideos]},
}
b := p.base
b.mu.Lock()
for _, pl := range b.allPlaylists {
all = append(all, playlist.PlaylistInfo{
ID: pl.ID,
Name: pl.Name,
TrackCount: pl.TrackCount,
})
}
b.mu.Unlock()
return all, nil
}
// ─── Constructor ───────────────────────────────────────────────────────────
// Providers holds the YouTube Music, YouTube, and YouTube All providers,
// sharing a single OAuth session.
type Providers struct {
Music *YouTubeMusicProvider
Video *YouTubeProvider
All *YouTubeAllProvider
}
// New creates all three YouTube providers with a shared session.
func New(session *Session, clientID, clientSecret string, hasCookies bool) Providers {
base := newBase(session, clientID, clientSecret, hasCookies)
return Providers{
Music: &YouTubeMusicProvider{base: base},
Video: &YouTubeProvider{base: base},
All: &YouTubeAllProvider{base: base},
}
}
// ─── Helpers ───────────────────────────────────────────────────────────────
var iso8601Re = regexp.MustCompile(`P(?:(\d+)D)?T?(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?`)
func parseISO8601Duration(d string) int {
m := iso8601Re.FindStringSubmatch(d)
if m == nil {
return 0
}
var total int
if m[1] != "" {
v, _ := strconv.Atoi(m[1])
total += v * 86400
}
if m[2] != "" {
v, _ := strconv.Atoi(m[2])
total += v * 3600
}
if m[3] != "" {
v, _ := strconv.Atoi(m[3])
total += v * 60
}
if m[4] != "" {
v, _ := strconv.Atoi(m[4])
total += v
}
return total
}
func cleanChannelName(name string) string {
name = strings.TrimSuffix(name, " - Topic")
return name
}