609 lines
16 KiB
Go
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
|
|
}
|