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:
2026-06-25 22:58:20 +03:30
parent 66a8cd1128
commit 3290bb55c0
6 changed files with 246 additions and 3 deletions

View File

@@ -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