A => .gitignore +2 -0
@@ 1,2 @@
+*.exe
+config.toml
A => README.org +120 -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
A => config.go +78 -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
+}
A => go.mod +7 -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
A => go.sum +4 -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=
A => innertube.go +200 -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
+}
A => main.go +281 -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)
+ }
+ }
+}
A => types.go +59 -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
+}
A => ytdlp.go +110 -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,
+ })
+}