From f884dc989c8c945af42bb3bcbc3a442529d2e027 Mon Sep 17 00:00:00 2001 From: HallowDem <75336799+Cytrogen@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:55:26 -0500 Subject: [PATCH] Initial commit: YTM - YouTube Music Downloader CLI tool for searching and downloading music from YouTube Music to a local Navidrome library via InnerTube API and yt-dlp. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 2 + README.org | 120 ++++++++++++++++++++++ config.go | 78 ++++++++++++++ go.mod | 7 ++ go.sum | 4 + innertube.go | 200 ++++++++++++++++++++++++++++++++++++ main.go | 281 +++++++++++++++++++++++++++++++++++++++++++++++++++ types.go | 59 +++++++++++ ytdlp.go | 110 ++++++++++++++++++++ 9 files changed, 861 insertions(+) create mode 100644 .gitignore create mode 100644 README.org create mode 100644 config.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 innertube.go create mode 100644 main.go create mode 100644 types.go create mode 100644 ytdlp.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..0a79861202116fbc9ed9e46887a9c30188e6cbb0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.exe +config.toml diff --git a/README.org b/README.org new file mode 100644 index 0000000000000000000000000000000000000000..24ecd638477792c693f11a5080c693bb5e4e4376 --- /dev/null +++ b/README.org @@ -0,0 +1,120 @@ +#+TITLE: YTM - YouTube Music 下载器 +#+AUTHOR: Cytrogen +#+DESCRIPTION: 从 YouTube Music 搜索并下载音乐的命令行工具 + +* 概述 + +YTM 是一个命令行交互式工具,用于从 YouTube Music 搜索、浏览并下载音乐到本地 Navidrome 音乐库。通过 YouTube Music InnerTube API 实现搜索和元数据解析,调用 =yt-dlp= 执行实际音频下载。 + +* 功能 + +- 通过 InnerTube API 按艺术家或专辑搜索 +- 浏览艺术家的完整发行列表并选择下载 +- 自动提取最高音质音频 +- 嵌入封面和元数据 +- 按可配置的分类文件夹整理下载内容 +- 单曲自动放入 =Singles/= 子文件夹 +- 基于 TOML 的配置文件 + +* 依赖 + +- Go 1.25+(用于构建) +- [[https://github.com/yt-dlp/yt-dlp][yt-dlp]] 已安装并在 PATH 中可用(或通过 =config.toml= 配置路径) + +* 构建 + +#+begin_src bash +cd ytm +go build -o ytm.exe . +#+end_src + +* 使用 + +#+begin_src bash +./ytm +#+end_src + +** 交互命令 + +| 输入 | 操作 | +|----------+----------------------------| +| =help= | 显示帮助信息 | +| =q= | 退出程序 | +| 数字 | 选择单项(如 =1=) | +| 逗号分隔 | 选择多项(如 =1,3,5=) | +| =all= | 选择全部 | +| 回车 | 接受默认值 | + +** 工作流程 + +1. 输入搜索关键词 +2. 选择搜索模式(艺术家/专辑) +3. 从结果列表中选择要下载的项目 +4. 选择音乐分类和文件夹名 +5. 等待下载完成 + +* 配置 + +首次运行时,会在可执行文件所在目录自动生成 =config.toml= 配置文件: + +#+begin_src toml +# YTM 配置文件 + +# 音乐下载根目录 +music_root = "D:/Music/" + +# 音乐分类(对应根目录下的子文件夹) +categories = ["C-Rock", "J-Pop", "K-Pop", "Other", "Game Music"] + +# yt-dlp 可执行文件路径 +ytdlp_path = "yt-dlp" + +# HTTP 请求超时(秒) +http_timeout = 30 + +# 搜索结果最大数量 +max_artist_results = 8 +max_album_results = 10 +#+end_src + +| 键 | 说明 | 默认值 | +|----------------------+--------------------------+------------------| +| =music_root= | 音乐下载根目录 | =D:/Music/= | +| =categories= | 根目录下的分类子文件夹 | 5 个预设分类 | +| =ytdlp_path= | yt-dlp 可执行文件路径 | =yt-dlp= | +| =http_timeout= | HTTP 请求超时(秒) | =30= | +| =max_artist_results= | 搜索返回的最大艺术家数量 | =8= | +| =max_album_results= | 搜索返回的最大专辑数量 | =10= | + +* 目录结构 + +下载内容按以下结构整理: + +#+begin_example +{music_root}/ +├── C-Rock/ +│ └── {艺术家}/ +│ ├── {专辑}/ +│ │ ├── 01. 曲目一.opus +│ │ └── 02. 曲目二.opus +│ └── Singles/ +│ └── 单曲.opus +├── J-Pop/ +├── K-Pop/ +├── Other/ +└── Game Music/ +#+end_example + +* 项目结构 + +#+begin_example +ytm/ +├── go.mod +├── go.sum +├── config.toml # 首次运行时自动生成 +├── config.go # 配置加载 +├── types.go # 数据类型与常量 +├── innertube.go # YouTube Music InnerTube API 客户端 +├── ytdlp.go # yt-dlp 封装 +└── main.go # 入口与交互主循环 +#+end_example diff --git a/config.go b/config.go new file mode 100644 index 0000000000000000000000000000000000000000..1ee668292c35f9f9e240b0dd4a3bcb9dcc01622d --- /dev/null +++ b/config.go @@ -0,0 +1,78 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/BurntSushi/toml" +) + +type Config struct { + MusicRoot string `toml:"music_root"` + Categories []string `toml:"categories"` + YtdlpPath string `toml:"ytdlp_path"` + HTTPTimeout int `toml:"http_timeout"` + MaxArtistResults int `toml:"max_artist_results"` + MaxAlbumResults int `toml:"max_album_results"` +} + +var cfg *Config + +func defaultConfig() Config { + return Config{ + MusicRoot: "D:/Music/", + Categories: []string{"C-Rock", "J-Pop", "K-Pop", "Other", "Game Music"}, + YtdlpPath: "yt-dlp", + HTTPTimeout: 30, + MaxArtistResults: 8, + MaxAlbumResults: 10, + } +} + +const defaultConfigTOML = `# YTM 配置文件 + +# 音乐下载根目录 +music_root = "D:/Music/" + +# 音乐分类(对应根目录下的子文件夹) +categories = ["C-Rock", "J-Pop", "K-Pop", "Other", "Game Music"] + +# yt-dlp 可执行文件路径 +ytdlp_path = "yt-dlp" + +# HTTP 请求超时(秒) +http_timeout = 30 + +# 搜索结果最大数量 +max_artist_results = 8 +max_album_results = 10 +` + +func configPath() string { + exe, err := os.Executable() + if err != nil { + return "config.toml" + } + return filepath.Join(filepath.Dir(exe), "config.toml") +} + +func loadConfig() *Config { + c := defaultConfig() + path := configPath() + + if _, err := os.Stat(path); os.IsNotExist(err) { + if err := os.WriteFile(path, []byte(defaultConfigTOML), 0644); err == nil { + fmt.Printf("已生成默认配置文件: %s\n", path) + } + return &c + } + + if _, err := toml.DecodeFile(path, &c); err != nil { + fmt.Printf("读取配置文件失败: %v,使用默认配置\n", err) + d := defaultConfig() + return &d + } + + return &c +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000000000000000000000000000000000000..8bdaebeee75631d4c14a570bd4d5e68999dfc9f2 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module ytm + +go 1.25.1 + +require golang.org/x/text v0.34.0 + +require github.com/BurntSushi/toml v1.6.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000000000000000000000000000000000000..38aa1b481e14bf25195896175fbc389fb5d81bb0 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= diff --git a/innertube.go b/innertube.go new file mode 100644 index 0000000000000000000000000000000000000000..7dbca282252a62c4db7f932c389ec09db30ca004 --- /dev/null +++ b/innertube.go @@ -0,0 +1,200 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +var httpClient *http.Client + +func initHTTPClient() { + httpClient = &http.Client{Timeout: time.Duration(cfg.HTTPTimeout) * time.Second} +} + +func postInnerTube(endpoint string, extraFields map[string]any) ([]byte, error) { + body := map[string]any{ + "context": map[string]any{ + "client": map[string]any{ + "clientName": "WEB_REMIX", + "clientVersion": "1.20260225.01.00", + }, + }, + } + for k, v := range extraFields { + body[k] = v + } + + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + + url := InnerTubeBase + "/" + endpoint + "?prettyPrint=false" + resp, err := httpClient.Post(url, "application/json", bytes.NewReader(data)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, endpoint) + } + + return io.ReadAll(resp.Body) +} + +func SearchArtists(query string) ([]Artist, error) { + data, err := postInnerTube("search", map[string]any{ + "query": query, + "params": "EgWKAQIIAWoKEAMQBBAJEAoQBQ==", + }) + if err != nil { + return nil, err + } + + ids := reChannelID.FindAllString(string(data), -1) + + seen := make(map[string]bool) + var unique []string + for _, id := range ids { + if !seen[id] { + seen[id] = true + unique = append(unique, id) + } + if len(unique) >= cfg.MaxArtistResults { + break + } + } + + var artists []Artist + for _, id := range unique { + name, mpreIDs, err := BrowseChannel(id) + if err != nil { + continue + } + if len(mpreIDs) == 0 { + continue + } + artists = append(artists, Artist{ + ID: id, + Name: name, + ReleaseCount: len(mpreIDs), + }) + } + return artists, nil +} + +func BrowseChannel(channelID string) (string, []string, error) { + data, err := postInnerTube("browse", map[string]any{ + "browseId": channelID, + }) + if err != nil { + return "", nil, err + } + + text := string(data) + + name := "" + if loc := reHeaderTag.FindStringIndex(text); loc != nil { + // Extract first "text":"..." after musicImmersiveHeaderRenderer + if m := reTitle.FindStringSubmatch(text[loc[1]:]); m != nil { + name = strings.TrimSpace(m[1]) + } + } + if name == "" { + // Fallback to first text match + if m := reTitle.FindStringSubmatch(text); m != nil { + name = m[1] + } + } + + mpreIDs := reMPREID.FindAllString(text, -1) + seen := make(map[string]bool) + var unique []string + for _, id := range mpreIDs { + if !seen[id] { + seen[id] = true + unique = append(unique, id) + } + } + + return name, unique, nil +} + +func ResolveMPRE(mpreID string) (Release, error) { + data, err := postInnerTube("browse", map[string]any{ + "browseId": mpreID, + }) + if err != nil { + return Release{}, err + } + + text := string(data) + + olakID := "" + if m := reOLAKID.FindString(text); m != "" { + olakID = m + } + if olakID == "" { + return Release{}, fmt.Errorf("no OLAK ID found for %s", mpreID) + } + + albumTitle := "" + if m := reTitle.FindStringSubmatch(text); m != nil { + albumTitle = m[1] + } + + artist := "" + if m := reStrapline.FindStringSubmatch(text); m != nil { + artist = strings.TrimSpace(m[1]) + } + + return Release{ + MPREID: mpreID, + OLAKID: olakID, + Title: albumTitle, + Artist: artist, + }, nil +} + +func SearchAlbums(query string) ([]Release, error) { + data, err := postInnerTube("search", map[string]any{ + "query": query, + "params": "EgWKAQIYAWoKEAMQBBAJEAoQBQ==", + }) + if err != nil { + return nil, err + } + + ids := reMPREID.FindAllString(string(data), -1) + seen := make(map[string]bool) + var unique []string + for _, id := range ids { + if !seen[id] { + seen[id] = true + unique = append(unique, id) + } + if len(unique) >= cfg.MaxAlbumResults { + break + } + } + + return ResolveAllReleases(unique), nil +} + +func ResolveAllReleases(mpreIDs []string) []Release { + var releases []Release + for _, id := range mpreIDs { + r, err := ResolveMPRE(id) + if err != nil { + continue + } + releases = append(releases, r) + } + return releases +} diff --git a/main.go b/main.go new file mode 100644 index 0000000000000000000000000000000000000000..c561eb6478f25322802892fe3e3e4c14284516ec --- /dev/null +++ b/main.go @@ -0,0 +1,281 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "strconv" + "strings" +) + +func readLine(reader *bufio.Reader) string { + line, _ := reader.ReadString('\n') + return strings.TrimSpace(line) +} + +func promptChoice(reader *bufio.Reader, max int) int { + for { + s := readLine(reader) + n, err := strconv.Atoi(s) + if err == nil && n >= 0 && n <= max { + return n + } + fmt.Printf(" 请输入 0-%d: ", max) + } +} + +func promptReleaseSelection(reader *bufio.Reader, max int) []int { + for { + s := readLine(reader) + if strings.ToLower(s) == "all" { + result := make([]int, max) + for i := range result { + result[i] = i + 1 + } + return result + } + + parts := strings.Split(s, ",") + var result []int + valid := true + for _, p := range parts { + p = strings.TrimSpace(p) + n, err := strconv.Atoi(p) + if err != nil || n < 1 || n > max { + valid = false + break + } + result = append(result, n) + } + if valid && len(result) > 0 { + return result + } + fmt.Printf(" 请输入 1-%d (逗号分隔) 或 'all': ", max) + } +} + +func promptCategory(reader *bufio.Reader) string { + fmt.Println("\n分类:") + for i, cat := range cfg.Categories { + fmt.Printf(" [%d] %s\n", i+1, cat) + } + fmt.Printf("\n> 选择分类 [1]: ") + s := readLine(reader) + if s == "" { + return cfg.Categories[0] + } + n, err := strconv.Atoi(s) + if err != nil || n < 1 || n > len(cfg.Categories) { + return cfg.Categories[0] + } + return cfg.Categories[n-1] +} + +func promptArtistFolder(reader *bufio.Reader, defaultName string) string { + fmt.Printf("> 艺术家文件夹名 [%s]: ", defaultName) + s := readLine(reader) + if s == "" { + return defaultName + } + return s +} + +func printHelp() { + fmt.Println(`YTM - YouTube Music Downloader +从 YouTube Music 搜索并下载音乐到 Navidrome 音乐库 + +使用方法: + 1. 输入关键词搜索艺术家或专辑 + 2. 选择搜索模式(艺术家/专辑) + 3. 从列表中选择要下载的项目 + 4. 选择音乐分类和文件夹名 + 5. 等待下载完成 + +命令: + help 显示此帮助信息 + q 退出程序 + +输入格式: + 数字 选择单项 (如: 1) + 逗号分隔 选择多项 (如: 1,3,5) + all 选择全部 + 回车 接受默认值 [方括号内]`) + fmt.Printf("\n下载目录: %s{分类}/{艺术家}/{专辑}/\n", cfg.MusicRoot) + fmt.Printf("分类: %s\n", strings.Join(cfg.Categories, " | ")) + fmt.Println("单曲保存到: {艺术家}/Singles/") + fmt.Println() +} + +func fetchTrackCounts(releases []Release) { + fmt.Println("获取曲目信息...") + for i := range releases { + tracks, err := ListPlaylistTracks(releases[i].OLAKID) + if err == nil { + releases[i].TrackCount = len(tracks) + releases[i].Tracks = tracks + } else { + releases[i].TrackCount = 0 + } + } +} + +func displayReleases(label string, releases []Release, showArtist bool) { + fmt.Printf("\n%s:\n", label) + for i, r := range releases { + trackLabel := "track" + if r.TrackCount != 1 { + trackLabel = "tracks" + } + if showArtist && r.Artist != "" { + fmt.Printf(" [%d] %s - %s (%d %s)\n", i+1, r.Artist, r.Title, r.TrackCount, trackLabel) + } else { + fmt.Printf(" [%d] %s (%d %s)\n", i+1, r.Title, r.TrackCount, trackLabel) + } + } +} + +func downloadReleases(reader *bufio.Reader, releases []Release, defaultArtist string) { + fmt.Printf("\n> 选择要下载的 (逗号分隔, 或 'all'): ") + selections := promptReleaseSelection(reader, len(releases)) + + category := promptCategory(reader) + artistFolder := promptArtistFolder(reader, defaultArtist) + artistFolder = sanitizePath(artistFolder) + + basePath := cfg.MusicRoot + category + "/" + artistFolder + + fmt.Printf("\n开始下载到 %s/ ...\n", basePath) + + totalTracks := 0 + for idx, sel := range selections { + r := releases[sel-1] + fmt.Printf(" [%d/%d] %s (%d tracks) ", idx+1, len(selections), r.Title, r.TrackCount) + + var dlErr error + if r.TrackCount == 1 && len(r.Tracks) == 1 { + singlesDir := basePath + "/Singles" + os.MkdirAll(singlesDir, 0755) + dlErr = DownloadSingle(r.Tracks[0].VideoID, singlesDir) + } else { + albumDir := basePath + "/" + sanitizePath(r.Title) + os.MkdirAll(albumDir, 0755) + dlErr = DownloadPlaylist(r.OLAKID, albumDir) + } + + if dlErr != nil { + fmt.Printf("x\n 错误: %v\n", dlErr) + } else { + fmt.Println("OK") + totalTracks += r.TrackCount + } + } + + fmt.Printf("\n下载完成! 共 %d 首曲目\n\n", totalTracks) +} + +func main() { + // Set Windows console to UTF-8 + exec.Command("cmd", "/C", "chcp", "65001").Run() + + cfg = loadConfig() + initHTTPClient() + + printHelp() + + reader := bufio.NewReader(os.Stdin) + + for { + fmt.Print("> 输入搜索关键词 (q 退出): ") + query := readLine(reader) + if query == "" { + continue + } + if query == "q" || query == "Q" { + fmt.Println("再见!") + return + } + if strings.ToLower(query) == "help" { + printHelp() + continue + } + + // Choose search mode + fmt.Println("\n搜索模式:") + fmt.Println(" [1] 按艺术家搜索") + fmt.Println(" [2] 按专辑搜索") + fmt.Printf("\n> 选择模式 [1]: ") + modeStr := readLine(reader) + mode := 1 + if modeStr == "2" { + mode = 2 + } + + if mode == 2 { + // Album search + fmt.Printf("\n搜索专辑 \"%s\" ...\n", query) + releases, err := SearchAlbums(query) + if err != nil { + fmt.Printf("搜索失败: %v\n\n", err) + continue + } + if len(releases) == 0 { + fmt.Println("未找到专辑\n") + continue + } + + fetchTrackCounts(releases) + displayReleases("搜索结果", releases, true) + + // Use artist from first release as default + defaultArtist := "" + if len(releases) > 0 { + defaultArtist = releases[0].Artist + } + downloadReleases(reader, releases, defaultArtist) + } else { + // Artist search + fmt.Printf("\n搜索艺术家 \"%s\" ...\n", query) + artists, err := SearchArtists(query) + if err != nil { + fmt.Printf("搜索失败: %v\n\n", err) + continue + } + if len(artists) == 0 { + fmt.Println("未找到艺术家\n") + continue + } + + fmt.Println("\n搜索结果:") + for i, a := range artists { + fmt.Printf(" [%d] %s (%s) - %d releases\n", i+1, a.Name, a.ID, a.ReleaseCount) + } + fmt.Println(" [0] 重新搜索") + fmt.Printf("\n> 选择艺术家 [1]: ") + + choice := promptChoice(reader, len(artists)) + if choice == 0 { + continue + } + artist := artists[choice-1] + + // Browse channel to get MPRE IDs + fmt.Printf("\n获取 %s 的发行列表...\n", artist.Name) + _, mpreIDs, err := BrowseChannel(artist.ID) + if err != nil { + fmt.Printf("获取发行列表失败: %v\n\n", err) + continue + } + + releases := ResolveAllReleases(mpreIDs) + if len(releases) == 0 { + fmt.Println("未找到可用发行\n") + continue + } + + fetchTrackCounts(releases) + displayReleases(artist.Name+" 的发行列表", releases, false) + downloadReleases(reader, releases, artist.Name) + } + } +} diff --git a/types.go b/types.go new file mode 100644 index 0000000000000000000000000000000000000000..a2e63c92c4d725c31e958e76bc098be714ce2fa4 --- /dev/null +++ b/types.go @@ -0,0 +1,59 @@ +package main + +import ( + "regexp" + "strings" +) + +const InnerTubeBase = "https://music.youtube.com/youtubei/v1" + +type Artist struct { + ID string + Name string + ReleaseCount int +} + +type Release struct { + MPREID string + OLAKID string + Title string + Artist string + TrackCount int + Tracks []Track +} + +type Track struct { + Index int + Title string + VideoID string +} + +var ( + reChannelID = regexp.MustCompile(`UC[a-zA-Z0-9_-]{22}`) + reMPREID = regexp.MustCompile(`MPREb_[a-zA-Z0-9_-]+`) + reOLAKID = regexp.MustCompile(`OLAK5uy_[a-zA-Z0-9_-]+`) + reTitle = regexp.MustCompile(`"text"\s*:\s*"((?:[^"\\]|\\.)*)"`) + reHeaderTag = regexp.MustCompile(`"musicImmersiveHeaderRenderer"`) + reStrapline = regexp.MustCompile(`"straplineTextOne":\{"runs":\[\{"text":"((?:[^"\\]|\\.)*)"`) +) + +var windowsIllegal = strings.NewReplacer( + "<", "_", + ">", "_", + ":", "_", + "\"", "_", + "/", "_", + "\\", "_", + "|", "_", + "?", "_", + "*", "_", +) + +func sanitizePath(name string) string { + s := windowsIllegal.Replace(name) + s = strings.TrimSpace(s) + if len(s) > 100 { + s = s[:100] + } + return s +} diff --git a/ytdlp.go b/ytdlp.go new file mode 100644 index 0000000000000000000000000000000000000000..dd7af366b3868d479744dc8e9ae23a024dfcdfa5 --- /dev/null +++ b/ytdlp.go @@ -0,0 +1,110 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + + "golang.org/x/text/encoding/simplifiedchinese" + "golang.org/x/text/transform" +) + +func isAcceptableError(err error) bool { + if exitErr, ok := err.(*exec.ExitError); ok { + return exitErr.ExitCode() == 1 + } + return false +} + +func runYtdlp(args []string) ([]byte, error) { + cmd := exec.Command(cfg.YtdlpPath, args...) + out, err := cmd.Output() + if err != nil && !isAcceptableError(err) { + return nil, fmt.Errorf("yt-dlp error: %w", err) + } + + decoder := simplifiedchinese.GBK.NewDecoder() + decoded, _, decErr := transform.Bytes(decoder, out) + if decErr != nil { + return out, nil + } + return decoded, nil +} + +func runYtdlpStreaming(args []string) error { + cmd := exec.Command(cfg.YtdlpPath, args...) + + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + cmd.Stderr = os.Stderr + + if err := cmd.Start(); err != nil { + return err + } + + reader := transform.NewReader(stdout, simplifiedchinese.GBK.NewDecoder()) + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + fmt.Println(scanner.Text()) + } + + err = cmd.Wait() + if err != nil && !isAcceptableError(err) { + return fmt.Errorf("yt-dlp error: %w", err) + } + return nil +} + +func ListPlaylistTracks(olakID string) ([]Track, error) { + out, err := runYtdlp([]string{ + "--flat-playlist", + "--print", "%(playlist_index)s\t%(title)s\t%(id)s", + "https://www.youtube.com/playlist?list=" + olakID, + }) + if err != nil { + return nil, err + } + + var tracks []Track + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.SplitN(line, "\t", 3) + if len(parts) < 3 { + continue + } + idx, _ := strconv.Atoi(parts[0]) + tracks = append(tracks, Track{ + Index: idx, + Title: parts[1], + VideoID: parts[2], + }) + } + return tracks, nil +} + +func DownloadPlaylist(olakID, outputDir string) error { + return runYtdlpStreaming([]string{ + "-x", "--audio-quality", "0", + "--embed-thumbnail", "--embed-metadata", + "-o", outputDir + "/%(playlist_index)s. %(title)s.%(ext)s", + "https://www.youtube.com/playlist?list=" + olakID, + }) +} + +func DownloadSingle(videoID, outputDir string) error { + return runYtdlpStreaming([]string{ + "-x", "--audio-quality", "0", + "--embed-thumbnail", "--embed-metadata", + "-o", outputDir + "/%(title)s.%(ext)s", + "https://www.youtube.com/watch?v=" + videoID, + }) +}