Files
wehub-resource-sync ead81af521
Deploy to GitHub Pages / deploy (push) Failing after 0s
CI / go (push) Has been cancelled
CI / build (darwin, macos-14) (push) Has been cancelled
CI / build (windows, windows-2025) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:31:13 +08:00

175 lines
4.5 KiB
Go

package ytmusic
import (
"context"
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
"github.com/bjarneo/cliamp/internal/appdir"
"google.golang.org/api/youtube/v3"
)
// musicCategoryID is the YouTube video category for Music.
const musicCategoryID = "10"
// classificationCache maps playlist ID → true if the playlist is music.
type classificationCache struct {
Music map[string]bool `json:"music"` // playlist ID → is music
}
// classificationCachePath returns the path to the classification cache file.
func classificationCachePath() string {
dir, err := appdir.Dir()
if err != nil {
return ""
}
return filepath.Join(dir, "ytmusic_classification.json")
}
// loadClassification loads cached playlist classifications from disk.
func loadClassification() map[string]bool {
data, err := os.ReadFile(classificationCachePath())
if err != nil {
return nil
}
var cache classificationCache
if err := json.Unmarshal(data, &cache); err != nil {
return nil
}
return cache.Music
}
// saveClassification writes playlist classifications to disk.
func saveClassification(music map[string]bool) {
cache := classificationCache{Music: music}
data, _ := json.MarshalIndent(cache, "", " ")
path := classificationCachePath()
os.MkdirAll(filepath.Dir(path), 0o700)
os.WriteFile(path, data, 0o600)
}
// classifyPlaylists determines which playlists contain music content by
// sampling one video from each and checking its category.
// Returns a map of playlist ID → true (music) / false (not music).
// Results are cached to disk to avoid repeated API calls.
func classifyPlaylists(ctx context.Context, svc *youtube.Service, playlists []playlistEntry, existing map[string]bool) map[string]bool {
cached := existing
if cached == nil {
cached = loadClassification()
}
if cached == nil {
cached = make(map[string]bool)
}
// Find playlists that need classification.
var toClassify []playlistEntry
for _, pl := range playlists {
if _, ok := cached[pl.ID]; !ok {
toClassify = append(toClassify, pl)
}
}
if len(toClassify) == 0 {
return cached
}
// Sample one video ID from each playlist (parallel, max 10 concurrent).
type sampleResult struct {
playlistID string
videoID string
}
sampleCh := make(chan sampleResult, len(toClassify))
sem := make(chan struct{}, 10) // concurrency limit
var wg sync.WaitGroup
for _, pl := range toClassify {
wg.Go(func() {
sem <- struct{}{}
defer func() { <-sem }()
resp, err := svc.PlaylistItems.List([]string{"contentDetails"}).
PlaylistId(pl.ID).
MaxResults(1).
Context(ctx).
Do()
if err != nil || len(resp.Items) == 0 {
sampleCh <- sampleResult{playlistID: pl.ID}
return
}
sampleCh <- sampleResult{
playlistID: pl.ID,
videoID: resp.Items[0].ContentDetails.VideoId,
}
})
}
wg.Wait()
close(sampleCh)
// Collect video IDs for batch category lookup.
videoToPlaylist := make(map[string]string) // videoID → playlistID
var videoIDs []string
for s := range sampleCh {
if s.videoID != "" {
videoToPlaylist[s.videoID] = s.playlistID
videoIDs = append(videoIDs, s.videoID)
} else {
// No video found — default to non-music.
cached[s.playlistID] = false
}
}
// Batch fetch video categories.
for i := 0; i < len(videoIDs); i += youtubeAPIBatchSize {
end := min(i+youtubeAPIBatchSize, len(videoIDs))
batch := videoIDs[i:end]
vResp, err := svc.Videos.List([]string{"snippet"}).
Id(batch...).
Context(ctx).
Do()
if err != nil {
// On error, default unclassified to non-music.
for _, vid := range batch {
if plID, ok := videoToPlaylist[vid]; ok {
cached[plID] = false
}
}
continue
}
for _, v := range vResp.Items {
plID := videoToPlaylist[v.Id]
cached[plID] = (v.Snippet.CategoryId == musicCategoryID)
}
}
// Mark any remaining unclassified as non-music.
for _, pl := range toClassify {
if _, ok := cached[pl.ID]; !ok {
cached[pl.ID] = false
}
}
saveClassification(cached)
return cached
}
// playlistEntry is a minimal playlist descriptor for classification.
type playlistEntry struct {
ID string `json:"id"`
Name string `json:"name"`
TrackCount int `json:"track_count"`
}
// classifyWithTimeout runs classification with a timeout.
func classifyWithTimeout(svc *youtube.Service, playlists []playlistEntry, timeout time.Duration, existing map[string]bool) map[string]bool {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return classifyPlaylists(ctx, svc, playlists, existing)
}