feat: add in-memory media info cache and YouTube search feature
- Add in-memory LRU cache (200 entries, 30 min TTL) to yt-dlp Client for GetMediaInfo and GetPlaylistInfo results, avoiding redundant yt-dlp calls for the same URL within a session - Add YouTube (/search) and YouTube Music (/music) search with paginated results (5 per page), user selects a result to start the download flow - Add SearchState type, new search.go handler, search results keyboard with < > navigation and New Search button - Add Search button to main menu keyboard - Update help text with new commands
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -20,6 +21,36 @@ import (
|
||||
type Client struct {
|
||||
binaryPath string
|
||||
cookiesFile string
|
||||
cache map[string]*cachedMediaInfo
|
||||
cacheMu sync.Mutex
|
||||
}
|
||||
|
||||
type cachedMediaInfo struct {
|
||||
info *domain.MediaInfo
|
||||
ts time.Time
|
||||
}
|
||||
|
||||
const cacheTTL = 30 * time.Minute
|
||||
const cacheMaxEntries = 200
|
||||
|
||||
func (c *Client) getCached(url string) *domain.MediaInfo {
|
||||
c.cacheMu.Lock()
|
||||
defer c.cacheMu.Unlock()
|
||||
entry, ok := c.cache[url]
|
||||
if !ok || time.Since(entry.ts) > cacheTTL {
|
||||
delete(c.cache, url)
|
||||
return nil
|
||||
}
|
||||
return entry.info
|
||||
}
|
||||
|
||||
func (c *Client) setCache(url string, info *domain.MediaInfo) {
|
||||
c.cacheMu.Lock()
|
||||
defer c.cacheMu.Unlock()
|
||||
if len(c.cache) >= cacheMaxEntries {
|
||||
c.cache = make(map[string]*cachedMediaInfo)
|
||||
}
|
||||
c.cache[url] = &cachedMediaInfo{info: info, ts: time.Now()}
|
||||
}
|
||||
|
||||
// NewClient creates a new yt-dlp client. It checks for the binary.
|
||||
@@ -29,7 +60,11 @@ func NewClient(cookiesFile string) (*Client, error) {
|
||||
return nil, fmt.Errorf("yt-dlp not found in PATH: %w", err)
|
||||
}
|
||||
slog.Info("yt-dlp found", "path", path)
|
||||
return &Client{binaryPath: path, cookiesFile: cookiesFile}, nil
|
||||
return &Client{
|
||||
binaryPath: path,
|
||||
cookiesFile: cookiesFile,
|
||||
cache: make(map[string]*cachedMediaInfo),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type ytdlpFormat struct {
|
||||
@@ -70,6 +105,11 @@ type ytdlpPlaylistEntry struct {
|
||||
|
||||
// GetMediaInfo fetches format information for a given URL.
|
||||
func (c *Client) GetMediaInfo(url string) (*domain.MediaInfo, error) {
|
||||
if cached := c.getCached(url); cached != nil {
|
||||
slog.Debug("cache hit", "url", url)
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"-J", "--no-download",
|
||||
"--no-warnings",
|
||||
@@ -106,6 +146,7 @@ func (c *Client) GetMediaInfo(url string) (*domain.MediaInfo, error) {
|
||||
if mi.IsPlaylist {
|
||||
mi.PlaylistCount = info.PlaylistCount
|
||||
mi.PlaylistEntries = c.parsePlaylistEntries(info.Entries)
|
||||
c.setCache(url, mi)
|
||||
return mi, nil
|
||||
}
|
||||
|
||||
@@ -124,11 +165,17 @@ func (c *Client) GetMediaInfo(url string) (*domain.MediaInfo, error) {
|
||||
}
|
||||
mi.EstimatedSize = maxSize
|
||||
|
||||
c.setCache(url, mi)
|
||||
return mi, nil
|
||||
}
|
||||
|
||||
// GetPlaylistInfo fetches a flat playlist listing (fast, no format detail).
|
||||
func (c *Client) GetPlaylistInfo(url string) (*domain.MediaInfo, error) {
|
||||
if cached := c.getCached(url); cached != nil {
|
||||
slog.Debug("cache hit", "url", url)
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"-J", "--flat-playlist", "--no-download",
|
||||
"--no-warnings",
|
||||
@@ -159,6 +206,7 @@ func (c *Client) GetPlaylistInfo(url string) (*domain.MediaInfo, error) {
|
||||
PlaylistEntries: c.parsePlaylistEntries(info.Entries),
|
||||
}
|
||||
|
||||
c.setCache(url, mi)
|
||||
return mi, nil
|
||||
}
|
||||
|
||||
@@ -376,6 +424,36 @@ func (c *Client) SearchYoutube(query string) (url, title string, err error) {
|
||||
return info.Entries[0].URL, info.Entries[0].Title, nil
|
||||
}
|
||||
|
||||
// Search does a yt-dlp search and returns entries.
|
||||
func (c *Client) Search(query, source string, limit int) ([]domain.PlaylistEntry, error) {
|
||||
searchPrefix := "ytsearch"
|
||||
if source == "ytmusic" {
|
||||
searchPrefix = "ytmusicsearch"
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"--flat-playlist", "-J", "--no-download",
|
||||
"--no-warnings",
|
||||
fmt.Sprintf("%s%d:%s", searchPrefix, limit, query),
|
||||
}
|
||||
if c.cookiesFile != "" {
|
||||
args = append(args, "--cookies", c.cookiesFile)
|
||||
}
|
||||
|
||||
slog.Info("searching", "query", query, "source", source)
|
||||
out, err := c.run(args)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search failed: %w", err)
|
||||
}
|
||||
|
||||
var info ytdlpInfo
|
||||
if err := json.Unmarshal(out, &info); err != nil {
|
||||
return nil, fmt.Errorf("parse search result: %w", err)
|
||||
}
|
||||
|
||||
return c.parsePlaylistEntries(info.Entries), nil
|
||||
}
|
||||
|
||||
func (c *Client) run(args []string) ([]byte, error) {
|
||||
cmd := exec.Command(c.binaryPath, args...)
|
||||
var stderr bytes.Buffer
|
||||
|
||||
Reference in New Issue
Block a user