~cytrogen/ytm

f884dc989c8c945af42bb3bcbc3a442529d2e027 — HallowDem a month ago master
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 <noreply@anthropic.com>
9 files changed, 861 insertions(+), 0 deletions(-)

A .gitignore
A README.org
A config.go
A go.mod
A go.sum
A innertube.go
A main.go
A types.go
A ytdlp.go
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,
	})
}