1045 lines
24 KiB
Go
Executable file
1045 lines
24 KiB
Go
Executable file
package services
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
)
|
|
|
|
type VideoData struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Uploader string `json:"uploader"`
|
|
ChannelID string `json:"channel_id"`
|
|
UploaderID string `json:"uploader_id"`
|
|
Thumbnail string `json:"thumbnail"`
|
|
ViewCount int64 `json:"view_count"`
|
|
UploadDate string `json:"upload_date"`
|
|
Duration string `json:"duration"`
|
|
Description string `json:"description"`
|
|
StreamURL string `json:"stream_url,omitempty"`
|
|
}
|
|
|
|
type VideoFormat struct {
|
|
FormatID string `json:"format_id"`
|
|
FormatNote string `json:"format_note"`
|
|
Ext string `json:"ext"`
|
|
Resolution string `json:"resolution"`
|
|
Filesize int64 `json:"filesize"`
|
|
VCodec string `json:"vcodec"`
|
|
ACodec string `json:"acodec"`
|
|
Type string `json:"type"` // "video", "audio", or "both"
|
|
}
|
|
|
|
type YtDlpEntry struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Uploader string `json:"uploader"`
|
|
Channel string `json:"channel"`
|
|
ChannelID string `json:"channel_id"`
|
|
UploaderID string `json:"uploader_id"`
|
|
ViewCount int64 `json:"view_count"`
|
|
UploadDate string `json:"upload_date"`
|
|
Duration interface{} `json:"duration"` // Can be float64 or int
|
|
Description string `json:"description"`
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
func sanitizeVideoData(entry YtDlpEntry) VideoData {
|
|
uploader := entry.Uploader
|
|
if uploader == "" {
|
|
uploader = entry.Channel
|
|
}
|
|
if uploader == "" {
|
|
uploader = "Unknown"
|
|
}
|
|
|
|
var durationStr string
|
|
if d, ok := entry.Duration.(float64); ok && d > 0 {
|
|
hours := int(d) / 3600
|
|
mins := (int(d) % 3600) / 60
|
|
secs := int(d) % 60
|
|
if hours > 0 {
|
|
durationStr = fmt.Sprintf("%d:%02d:%02d", hours, mins, secs)
|
|
} else {
|
|
durationStr = fmt.Sprintf("%d:%02d", mins, secs)
|
|
}
|
|
}
|
|
|
|
thumbnail := ""
|
|
if entry.ID != "" {
|
|
thumbnail = fmt.Sprintf("https://i.ytimg.com/vi/%s/hqdefault.jpg", entry.ID)
|
|
}
|
|
|
|
return VideoData{
|
|
ID: entry.ID,
|
|
Title: entry.Title,
|
|
Uploader: uploader,
|
|
ChannelID: entry.ChannelID,
|
|
UploaderID: entry.UploaderID,
|
|
Thumbnail: thumbnail,
|
|
ViewCount: entry.ViewCount,
|
|
UploadDate: entry.UploadDate,
|
|
Duration: durationStr,
|
|
Description: entry.Description,
|
|
}
|
|
}
|
|
|
|
// RunYtDlp securely executes yt-dlp with the given arguments and returns JSON output
|
|
func RunYtDlp(args ...string) ([]byte, error) {
|
|
cmdArgs := append([]string{
|
|
"--dump-json",
|
|
"--no-warnings",
|
|
"--quiet",
|
|
"--force-ipv4",
|
|
"--ignore-errors",
|
|
"--user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
}, args...)
|
|
|
|
binPath := "yt-dlp"
|
|
// Check common install paths if yt-dlp is not in PATH
|
|
if _, err := exec.LookPath("yt-dlp"); err != nil {
|
|
fallbacks := []string{
|
|
os.ExpandEnv("$HOME/Library/Python/3.14/bin/yt-dlp"),
|
|
os.ExpandEnv("$HOME/Library/Python/3.13/bin/yt-dlp"),
|
|
os.ExpandEnv("$HOME/Library/Python/3.12/bin/yt-dlp"),
|
|
os.ExpandEnv("$HOME/Library/Python/3.11/bin/yt-dlp"),
|
|
os.ExpandEnv("$HOME/.local/bin/yt-dlp"),
|
|
"/usr/local/bin/yt-dlp",
|
|
"/opt/homebrew/bin/yt-dlp",
|
|
}
|
|
for _, fb := range fallbacks {
|
|
if _, err := os.Stat(fb); err == nil {
|
|
binPath = fb
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
cmd := exec.Command(binPath, cmdArgs...)
|
|
|
|
var out bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cmd.Stdout = &out
|
|
cmd.Stderr = &stderr
|
|
|
|
err := cmd.Run()
|
|
if err != nil {
|
|
log.Printf("yt-dlp error: %v, stderr: %s", err, stderr.String())
|
|
return nil, err
|
|
}
|
|
|
|
return out.Bytes(), nil
|
|
}
|
|
|
|
func SearchVideos(query string, limit int) ([]VideoData, error) {
|
|
searchQuery := fmt.Sprintf("ytsearch%d:%s", limit, query)
|
|
|
|
args := []string{
|
|
"--flat-playlist",
|
|
searchQuery,
|
|
}
|
|
|
|
out, err := RunYtDlp(args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var results []VideoData
|
|
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
|
for _, line := range lines {
|
|
if line == "" {
|
|
continue
|
|
}
|
|
var entry YtDlpEntry
|
|
if err := json.Unmarshal([]byte(line), &entry); err == nil {
|
|
if entry.ID != "" {
|
|
results = append(results, sanitizeVideoData(entry))
|
|
}
|
|
}
|
|
}
|
|
|
|
return results, nil
|
|
}
|
|
|
|
func GetVideoInfo(videoID string) (*VideoData, error) {
|
|
url := fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoID)
|
|
|
|
args := []string{
|
|
"--format", "bestvideo+bestaudio/best",
|
|
"--skip-download",
|
|
"--no-playlist",
|
|
url,
|
|
}
|
|
|
|
out, err := RunYtDlp(args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var entry YtDlpEntry
|
|
if err := json.Unmarshal(out, &entry); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
data := sanitizeVideoData(entry)
|
|
data.StreamURL = entry.URL
|
|
|
|
return &data, nil
|
|
}
|
|
|
|
type QualityFormat struct {
|
|
FormatID string `json:"format_id"`
|
|
Label string `json:"label"`
|
|
Resolution string `json:"resolution"`
|
|
Height int `json:"height"`
|
|
URL string `json:"url"`
|
|
AudioURL string `json:"audio_url,omitempty"`
|
|
IsHLS bool `json:"is_hls"`
|
|
VCodec string `json:"vcodec"`
|
|
ACodec string `json:"acodec"`
|
|
Filesize int64 `json:"filesize"`
|
|
HasAudio bool `json:"has_audio"`
|
|
}
|
|
|
|
func GetVideoQualities(videoID string) ([]QualityFormat, error) {
|
|
url := fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoID)
|
|
|
|
cmdArgs := append([]string{
|
|
"--dump-json",
|
|
"--no-warnings",
|
|
"--quiet",
|
|
"--force-ipv4",
|
|
"--no-playlist",
|
|
"--user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
}, url)
|
|
|
|
binPath := "yt-dlp"
|
|
if _, err := exec.LookPath("yt-dlp"); err != nil {
|
|
fallbacks := []string{
|
|
os.ExpandEnv("$HOME/Library/Python/3.14/bin/yt-dlp"),
|
|
os.ExpandEnv("$HOME/Library/Python/3.13/bin/yt-dlp"),
|
|
os.ExpandEnv("$HOME/Library/Python/3.12/bin/yt-dlp"),
|
|
os.ExpandEnv("$HOME/.local/bin/yt-dlp"),
|
|
"/usr/local/bin/yt-dlp",
|
|
"/opt/homebrew/bin/yt-dlp",
|
|
"/config/.local/bin/yt-dlp",
|
|
}
|
|
for _, fb := range fallbacks {
|
|
if _, err := os.Stat(fb); err == nil {
|
|
binPath = fb
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
cmd := exec.Command(binPath, cmdArgs...)
|
|
|
|
var out bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cmd.Stdout = &out
|
|
cmd.Stderr = &stderr
|
|
|
|
err := cmd.Run()
|
|
if err != nil {
|
|
log.Printf("yt-dlp error: %v, stderr: %s", err, stderr.String())
|
|
return nil, err
|
|
}
|
|
|
|
var raw struct {
|
|
Formats []struct {
|
|
FormatID string `json:"format_id"`
|
|
FormatNote string `json:"format_note"`
|
|
Ext string `json:"ext"`
|
|
Resolution string `json:"resolution"`
|
|
Width interface{} `json:"width"`
|
|
Height interface{} `json:"height"`
|
|
URL string `json:"url"`
|
|
ManifestURL string `json:"manifest_url"`
|
|
VCodec string `json:"vcodec"`
|
|
ACodec string `json:"acodec"`
|
|
Filesize interface{} `json:"filesize"`
|
|
} `json:"formats"`
|
|
}
|
|
|
|
if err := json.Unmarshal(out.Bytes(), &raw); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var qualities []QualityFormat
|
|
seen := make(map[int]int) // height -> index in qualities
|
|
|
|
for _, f := range raw.Formats {
|
|
if f.VCodec == "none" || f.URL == "" {
|
|
continue
|
|
}
|
|
|
|
var height int
|
|
switch v := f.Height.(type) {
|
|
case float64:
|
|
height = int(v)
|
|
case int:
|
|
height = v
|
|
}
|
|
|
|
if height == 0 {
|
|
continue
|
|
}
|
|
|
|
hasAudio := f.ACodec != "none" && f.ACodec != ""
|
|
|
|
var filesize int64
|
|
switch v := f.Filesize.(type) {
|
|
case float64:
|
|
filesize = int64(v)
|
|
case int64:
|
|
filesize = v
|
|
}
|
|
|
|
isHLS := f.ManifestURL != "" || strings.Contains(f.URL, ".m3u8") || strings.Contains(f.URL, "manifest")
|
|
|
|
label := f.FormatNote
|
|
if label == "" {
|
|
switch height {
|
|
case 2160:
|
|
label = "4K"
|
|
case 1440:
|
|
label = "1440p"
|
|
case 1080:
|
|
label = "1080p"
|
|
case 720:
|
|
label = "720p"
|
|
case 480:
|
|
label = "480p"
|
|
case 360:
|
|
label = "360p"
|
|
default:
|
|
label = fmt.Sprintf("%dp", height)
|
|
}
|
|
}
|
|
|
|
streamURL := f.URL
|
|
if f.ManifestURL != "" {
|
|
streamURL = f.ManifestURL
|
|
}
|
|
|
|
qf := QualityFormat{
|
|
FormatID: f.FormatID,
|
|
Label: label,
|
|
Resolution: f.Resolution,
|
|
Height: height,
|
|
URL: streamURL,
|
|
IsHLS: isHLS,
|
|
VCodec: f.VCodec,
|
|
ACodec: f.ACodec,
|
|
Filesize: filesize,
|
|
HasAudio: hasAudio,
|
|
}
|
|
|
|
// Prefer formats with audio, otherwise just add
|
|
if idx, exists := seen[height]; exists {
|
|
// Replace if this one has audio and the existing one doesn't
|
|
if hasAudio && !qualities[idx].HasAudio {
|
|
qualities[idx] = qf
|
|
}
|
|
} else {
|
|
seen[height] = len(qualities)
|
|
qualities = append(qualities, qf)
|
|
}
|
|
}
|
|
|
|
// Sort by height descending
|
|
for i := range qualities {
|
|
for j := i + 1; j < len(qualities); j++ {
|
|
if qualities[j].Height > qualities[i].Height {
|
|
qualities[i], qualities[j] = qualities[j], qualities[i]
|
|
}
|
|
}
|
|
}
|
|
|
|
return qualities, nil
|
|
}
|
|
|
|
func GetBestAudioURL(videoID string) (string, error) {
|
|
url := fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoID)
|
|
|
|
cmdArgs := []string{
|
|
"--dump-json",
|
|
"--no-warnings",
|
|
"--quiet",
|
|
"--force-ipv4",
|
|
"--no-playlist",
|
|
"--user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
url,
|
|
}
|
|
|
|
binPath := "yt-dlp"
|
|
if _, err := exec.LookPath("yt-dlp"); err != nil {
|
|
fallbacks := []string{
|
|
os.ExpandEnv("$HOME/.local/bin/yt-dlp"),
|
|
"/usr/local/bin/yt-dlp",
|
|
"/opt/homebrew/bin/yt-dlp",
|
|
"/config/.local/bin/yt-dlp",
|
|
}
|
|
for _, fb := range fallbacks {
|
|
if _, err := os.Stat(fb); err == nil {
|
|
binPath = fb
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
cmd := exec.Command(binPath, cmdArgs...)
|
|
|
|
var out bytes.Buffer
|
|
cmd.Stdout = &out
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var raw struct {
|
|
Formats []struct {
|
|
FormatID string `json:"format_id"`
|
|
URL string `json:"url"`
|
|
VCodec string `json:"vcodec"`
|
|
ACodec string `json:"acodec"`
|
|
ABR interface{} `json:"abr"`
|
|
FormatNote string `json:"format_note"`
|
|
} `json:"formats"`
|
|
}
|
|
|
|
if err := json.Unmarshal(out.Bytes(), &raw); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Find best audio-only stream (prefer highest ABR)
|
|
var bestAudio string
|
|
var bestABR float64
|
|
for _, f := range raw.Formats {
|
|
if f.VCodec == "none" && f.ACodec != "none" && f.URL != "" {
|
|
var abr float64
|
|
switch v := f.ABR.(type) {
|
|
case float64:
|
|
abr = v
|
|
case int:
|
|
abr = float64(v)
|
|
}
|
|
if abr > bestABR {
|
|
bestABR = abr
|
|
bestAudio = f.URL
|
|
}
|
|
}
|
|
}
|
|
|
|
return bestAudio, nil
|
|
}
|
|
|
|
func GetVideoQualitiesWithAudio(videoID string) ([]QualityFormat, string, error) {
|
|
qualities, err := GetVideoQualities(videoID)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
|
|
// Get best audio URL
|
|
audioURL, err := GetBestAudioURL(videoID)
|
|
if err != nil {
|
|
log.Printf("Warning: could not get audio URL: %v", err)
|
|
}
|
|
|
|
// Attach audio URL to qualities without audio
|
|
for i := range qualities {
|
|
if !qualities[i].HasAudio && audioURL != "" {
|
|
qualities[i].AudioURL = audioURL
|
|
}
|
|
}
|
|
|
|
return qualities, audioURL, nil
|
|
}
|
|
|
|
// GetFullStreamData runs a single yt-dlp command to fetch all essential information at once
|
|
// This avoids doing 3 separate slow calls for video info, qualities, and best audio.
|
|
func GetFullStreamData(videoID string) (*VideoData, []QualityFormat, string, error) {
|
|
urlStr := fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoID)
|
|
|
|
cmdArgs := []string{
|
|
"--dump-json",
|
|
"--no-warnings",
|
|
"--quiet",
|
|
"--force-ipv4",
|
|
"--no-playlist",
|
|
"--user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
urlStr,
|
|
}
|
|
|
|
binPath := "yt-dlp"
|
|
if _, err := exec.LookPath("yt-dlp"); err != nil {
|
|
fallbacks := []string{
|
|
os.ExpandEnv("$HOME/Library/Python/3.14/bin/yt-dlp"),
|
|
os.ExpandEnv("$HOME/Library/Python/3.13/bin/yt-dlp"),
|
|
os.ExpandEnv("$HOME/Library/Python/3.12/bin/yt-dlp"),
|
|
os.ExpandEnv("$HOME/.local/bin/yt-dlp"),
|
|
"/usr/local/bin/yt-dlp",
|
|
"/opt/homebrew/bin/yt-dlp",
|
|
"/config/.local/bin/yt-dlp",
|
|
}
|
|
for _, fb := range fallbacks {
|
|
if _, err := os.Stat(fb); err == nil {
|
|
binPath = fb
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
cmd := exec.Command(binPath, cmdArgs...)
|
|
|
|
var out bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cmd.Stdout = &out
|
|
cmd.Stderr = &stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
log.Printf("yt-dlp error in GetFullStreamData: %v, stderr: %s", err, stderr.String())
|
|
return nil, nil, "", err
|
|
}
|
|
|
|
// Unmarshal common metadata
|
|
var entry YtDlpEntry
|
|
if err := json.Unmarshal(out.Bytes(), &entry); err != nil {
|
|
return nil, nil, "", err
|
|
}
|
|
|
|
videoData := sanitizeVideoData(entry)
|
|
videoData.StreamURL = entry.URL
|
|
|
|
// Unmarshal formats specifically
|
|
var raw struct {
|
|
Formats []struct {
|
|
FormatID string `json:"format_id"`
|
|
FormatNote string `json:"format_note"`
|
|
Ext string `json:"ext"`
|
|
Resolution string `json:"resolution"`
|
|
Width interface{} `json:"width"`
|
|
Height interface{} `json:"height"`
|
|
URL string `json:"url"`
|
|
ManifestURL string `json:"manifest_url"`
|
|
VCodec string `json:"vcodec"`
|
|
ACodec string `json:"acodec"`
|
|
Filesize interface{} `json:"filesize"`
|
|
ABR interface{} `json:"abr"`
|
|
} `json:"formats"`
|
|
}
|
|
|
|
if err := json.Unmarshal(out.Bytes(), &raw); err != nil {
|
|
return nil, nil, "", err
|
|
}
|
|
|
|
var qualities []QualityFormat
|
|
seen := make(map[int]int) // height -> index in qualities
|
|
var bestAudio string
|
|
var bestABR float64
|
|
|
|
for _, f := range raw.Formats {
|
|
// Determine if it's the best audio
|
|
if f.VCodec == "none" && f.ACodec != "none" && f.URL != "" {
|
|
var abr float64
|
|
switch v := f.ABR.(type) {
|
|
case float64:
|
|
abr = v
|
|
case int:
|
|
abr = float64(v)
|
|
}
|
|
if bestAudio == "" || abr > bestABR {
|
|
bestABR = abr
|
|
bestAudio = f.URL
|
|
}
|
|
}
|
|
|
|
if f.VCodec == "none" || f.URL == "" {
|
|
continue
|
|
}
|
|
|
|
var height int
|
|
switch v := f.Height.(type) {
|
|
case float64:
|
|
height = int(v)
|
|
case int:
|
|
height = v
|
|
}
|
|
|
|
if height == 0 {
|
|
continue
|
|
}
|
|
|
|
hasAudio := f.ACodec != "none" && f.ACodec != ""
|
|
|
|
var filesize int64
|
|
switch v := f.Filesize.(type) {
|
|
case float64:
|
|
filesize = int64(v)
|
|
case int64:
|
|
filesize = v
|
|
}
|
|
|
|
isHLS := f.ManifestURL != "" || strings.Contains(f.URL, ".m3u8") || strings.Contains(f.URL, "manifest")
|
|
|
|
label := f.FormatNote
|
|
if label == "" {
|
|
switch height {
|
|
case 2160:
|
|
label = "4K"
|
|
case 1440:
|
|
label = "1440p"
|
|
case 1080:
|
|
label = "1080p"
|
|
case 720:
|
|
label = "720p"
|
|
case 480:
|
|
label = "480p"
|
|
case 360:
|
|
label = "360p"
|
|
default:
|
|
label = fmt.Sprintf("%dp", height)
|
|
}
|
|
}
|
|
|
|
streamURL := f.URL
|
|
if f.ManifestURL != "" {
|
|
streamURL = f.ManifestURL
|
|
}
|
|
|
|
qf := QualityFormat{
|
|
FormatID: f.FormatID,
|
|
Label: label,
|
|
Resolution: f.Resolution,
|
|
Height: height,
|
|
URL: streamURL,
|
|
IsHLS: isHLS,
|
|
VCodec: f.VCodec,
|
|
ACodec: f.ACodec,
|
|
Filesize: filesize,
|
|
HasAudio: hasAudio,
|
|
}
|
|
|
|
// Prefer formats with audio, otherwise just add
|
|
if idx, exists := seen[height]; exists {
|
|
// Replace if this one has audio and the existing one doesn't
|
|
if hasAudio && !qualities[idx].HasAudio {
|
|
qualities[idx] = qf
|
|
}
|
|
} else {
|
|
seen[height] = len(qualities)
|
|
qualities = append(qualities, qf)
|
|
}
|
|
}
|
|
|
|
// Sort by height descending
|
|
for i := range qualities {
|
|
for j := i + 1; j < len(qualities); j++ {
|
|
if qualities[j].Height > qualities[i].Height {
|
|
qualities[i], qualities[j] = qualities[j], qualities[i]
|
|
}
|
|
}
|
|
}
|
|
|
|
// Attach audio URL to qualities without audio
|
|
for i := range qualities {
|
|
if !qualities[i].HasAudio && bestAudio != "" {
|
|
qualities[i].AudioURL = bestAudio
|
|
}
|
|
}
|
|
|
|
return &videoData, qualities, bestAudio, nil
|
|
}
|
|
|
|
func GetStreamURLForQuality(videoID string, height int) (string, error) {
|
|
qualities, err := GetVideoQualities(videoID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
for _, q := range qualities {
|
|
if q.Height == height {
|
|
return q.URL, nil
|
|
}
|
|
}
|
|
|
|
if len(qualities) > 0 {
|
|
return qualities[0].URL, nil
|
|
}
|
|
|
|
return "", fmt.Errorf("no suitable quality found")
|
|
}
|
|
|
|
func GetRelatedVideos(title, uploader string, limit int) ([]VideoData, error) {
|
|
query := title
|
|
if uploader != "" {
|
|
query = uploader + " " + title
|
|
}
|
|
// Limit query length to avoid issues
|
|
if len(query) > 100 {
|
|
query = query[:100]
|
|
}
|
|
return SearchVideos(query, limit)
|
|
}
|
|
|
|
type DownloadInfo struct {
|
|
URL string `json:"url"`
|
|
Title string `json:"title"`
|
|
Ext string `json:"ext"`
|
|
}
|
|
|
|
func GetDownloadURL(videoID string, formatID string) (*DownloadInfo, error) {
|
|
url := fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoID)
|
|
|
|
formatArgs := "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best"
|
|
if formatID != "" {
|
|
formatArgs = formatID
|
|
if !strings.Contains(formatID, "+") && !strings.Contains(formatID, "best") {
|
|
// If it's just a video format, we might want to try adding audio, but for simple direct download links,
|
|
// let's stick to what the user requested or what yt-dlp gives for that ID.
|
|
formatArgs = formatID + "+bestaudio/best"
|
|
}
|
|
}
|
|
|
|
args := []string{
|
|
"--format", formatArgs,
|
|
"--dump-json",
|
|
"--no-playlist",
|
|
url,
|
|
}
|
|
|
|
out, err := RunYtDlp(args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var raw map[string]interface{}
|
|
if err := json.Unmarshal(out, &raw); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
downloadURL, _ := raw["url"].(string)
|
|
title, _ := raw["title"].(string)
|
|
ext, _ := raw["ext"].(string)
|
|
|
|
if downloadURL == "" {
|
|
formats, ok := raw["formats"].([]interface{})
|
|
if ok && len(formats) > 0 {
|
|
// Try to find the first mp4 format that is not m3u8
|
|
for i := len(formats) - 1; i >= 0; i-- {
|
|
fmtMap, ok := formats[i].(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
fUrl, _ := fmtMap["url"].(string)
|
|
fExt, _ := fmtMap["ext"].(string)
|
|
if fUrl != "" && !strings.Contains(fUrl, ".m3u8") && fExt == "mp4" {
|
|
downloadURL = fUrl
|
|
ext = fExt
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if title == "" {
|
|
title = "video"
|
|
}
|
|
if ext == "" {
|
|
ext = "mp4"
|
|
}
|
|
|
|
return &DownloadInfo{
|
|
URL: downloadURL,
|
|
Title: title,
|
|
Ext: ext,
|
|
}, nil
|
|
}
|
|
|
|
func GetVideoFormats(videoID string) ([]VideoFormat, error) {
|
|
url := fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoID)
|
|
|
|
args := []string{
|
|
"--dump-json",
|
|
"--no-playlist",
|
|
url,
|
|
}
|
|
|
|
out, err := RunYtDlp(args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var raw struct {
|
|
Formats []struct {
|
|
FormatID string `json:"format_id"`
|
|
FormatNote string `json:"format_note"`
|
|
Ext string `json:"ext"`
|
|
Resolution string `json:"resolution"`
|
|
Filesize float64 `json:"filesize"`
|
|
VCodec string `json:"vcodec"`
|
|
ACodec string `json:"acodec"`
|
|
} `json:"formats"`
|
|
}
|
|
|
|
if err := json.Unmarshal(out, &raw); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var formats []VideoFormat
|
|
for _, f := range raw.Formats {
|
|
// Filter out storyboards and other non-media formats
|
|
if strings.Contains(f.FormatID, "sb") || f.VCodec == "none" && f.ACodec == "none" {
|
|
continue
|
|
}
|
|
|
|
fType := "both"
|
|
if f.VCodec == "none" {
|
|
fType = "audio"
|
|
} else if f.ACodec == "none" {
|
|
fType = "video"
|
|
}
|
|
|
|
formats = append(formats, VideoFormat{
|
|
FormatID: f.FormatID,
|
|
FormatNote: f.FormatNote,
|
|
Ext: f.Ext,
|
|
Resolution: f.Resolution,
|
|
Filesize: int64(f.Filesize),
|
|
VCodec: f.VCodec,
|
|
ACodec: f.ACodec,
|
|
Type: fType,
|
|
})
|
|
}
|
|
|
|
return formats, nil
|
|
}
|
|
|
|
type ChannelInfo struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
SubscriberCount int64 `json:"subscriber_count"`
|
|
Avatar string `json:"avatar"`
|
|
}
|
|
|
|
func GetChannelInfo(channelID string) (*ChannelInfo, error) {
|
|
url := fmt.Sprintf("https://www.youtube.com/channel/%s", channelID)
|
|
if strings.HasPrefix(channelID, "@") {
|
|
url = fmt.Sprintf("https://www.youtube.com/%s", channelID)
|
|
}
|
|
|
|
// Fetch 1 video with full metadata to extract channel info
|
|
args := []string{
|
|
url + "/videos",
|
|
"--dump-json",
|
|
"--playlist-end", "1",
|
|
"--no-warnings",
|
|
"--quiet",
|
|
}
|
|
|
|
out, err := RunYtDlp(args...)
|
|
if err != nil || len(out) == 0 {
|
|
return nil, fmt.Errorf("failed to get channel info: %v", err)
|
|
}
|
|
|
|
// Parse the first video's JSON
|
|
var raw map[string]interface{}
|
|
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
|
if len(lines) == 0 {
|
|
return nil, fmt.Errorf("no output from yt-dlp")
|
|
}
|
|
|
|
if err := json.Unmarshal([]byte(lines[0]), &raw); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
title, _ := raw["channel"].(string)
|
|
if title == "" {
|
|
title, _ = raw["uploader"].(string)
|
|
}
|
|
if title == "" {
|
|
title = channelID
|
|
}
|
|
|
|
cID, _ := raw["channel_id"].(string)
|
|
if cID == "" {
|
|
cID = channelID
|
|
}
|
|
|
|
subCountFloat, _ := raw["channel_follower_count"].(float64)
|
|
|
|
// Create an avatar based on the first letter of the channel title
|
|
avatarStr := "?"
|
|
if len(title) > 0 {
|
|
avatarStr = strings.ToUpper(string(title[0]))
|
|
}
|
|
|
|
return &ChannelInfo{
|
|
ID: cID,
|
|
Title: title,
|
|
SubscriberCount: int64(subCountFloat),
|
|
Avatar: avatarStr, // Simple fallback for now
|
|
}, nil
|
|
}
|
|
|
|
func GetChannelVideos(channelID string, limit int) ([]VideoData, error) {
|
|
url := fmt.Sprintf("https://www.youtube.com/channel/%s", channelID)
|
|
if strings.HasPrefix(channelID, "@") {
|
|
url = fmt.Sprintf("https://www.youtube.com/%s", channelID)
|
|
}
|
|
|
|
args := []string{
|
|
url + "/videos",
|
|
"--flat-playlist",
|
|
fmt.Sprintf("--playlist-end=%d", limit),
|
|
}
|
|
|
|
out, err := RunYtDlp(args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var results []VideoData
|
|
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
|
for _, line := range lines {
|
|
if line == "" {
|
|
continue
|
|
}
|
|
var entry YtDlpEntry
|
|
if err := json.Unmarshal([]byte(line), &entry); err == nil {
|
|
if entry.ID != "" {
|
|
results = append(results, sanitizeVideoData(entry))
|
|
}
|
|
}
|
|
}
|
|
|
|
return results, nil
|
|
}
|
|
|
|
type Comment struct {
|
|
ID string `json:"id"`
|
|
Text string `json:"text"`
|
|
Author string `json:"author"`
|
|
AuthorID string `json:"author_id"`
|
|
AuthorThumb string `json:"author_thumbnail"`
|
|
Likes int `json:"likes"`
|
|
IsReply bool `json:"is_reply"`
|
|
Parent string `json:"parent"`
|
|
Timestamp string `json:"timestamp"`
|
|
}
|
|
|
|
func GetComments(videoID string, limit int) ([]Comment, error) {
|
|
url := fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoID)
|
|
|
|
cmdArgs := []string{
|
|
"--dump-json",
|
|
"--no-download",
|
|
"--no-playlist",
|
|
"--write-comments",
|
|
fmt.Sprintf("--comment-limit=%d", limit),
|
|
url,
|
|
}
|
|
|
|
cmdArgs = append([]string{
|
|
"--no-warnings",
|
|
"--quiet",
|
|
"--user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
}, cmdArgs...)
|
|
|
|
binPath := "yt-dlp"
|
|
if _, err := exec.LookPath("yt-dlp"); err != nil {
|
|
fallbacks := []string{
|
|
os.ExpandEnv("$HOME/Library/Python/3.14/bin/yt-dlp"),
|
|
os.ExpandEnv("$HOME/Library/Python/3.13/bin/yt-dlp"),
|
|
os.ExpandEnv("$HOME/Library/Python/3.12/bin/yt-dlp"),
|
|
os.ExpandEnv("$HOME/.local/bin/yt-dlp"),
|
|
"/usr/local/bin/yt-dlp",
|
|
"/opt/homebrew/bin/yt-dlp",
|
|
"/config/.local/bin/yt-dlp",
|
|
}
|
|
for _, fb := range fallbacks {
|
|
if _, err := os.Stat(fb); err == nil {
|
|
binPath = fb
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
cmd := exec.Command(binPath, cmdArgs...)
|
|
|
|
var out bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cmd.Stdout = &out
|
|
cmd.Stderr = &stderr
|
|
|
|
err := cmd.Run()
|
|
if err != nil {
|
|
log.Printf("yt-dlp comments error: %v, stderr: %s", err, stderr.String())
|
|
return nil, err
|
|
}
|
|
|
|
var raw struct {
|
|
Comments []struct {
|
|
ID string `json:"id"`
|
|
Text string `json:"text"`
|
|
Author string `json:"author"`
|
|
AuthorID string `json:"author_id"`
|
|
AuthorThumb string `json:"author_thumbnail"`
|
|
Likes int `json:"like_count"`
|
|
IsReply bool `json:"is_reply"`
|
|
Parent string `json:"parent"`
|
|
Timestamp int64 `json:"timestamp"`
|
|
} `json:"comments"`
|
|
}
|
|
|
|
if err := json.Unmarshal(out.Bytes(), &raw); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var comments []Comment
|
|
for _, c := range raw.Comments {
|
|
timestamp := ""
|
|
if c.Timestamp > 0 {
|
|
timestamp = formatCommentTime(c.Timestamp)
|
|
}
|
|
comments = append(comments, Comment{
|
|
ID: c.ID,
|
|
Text: c.Text,
|
|
Author: c.Author,
|
|
AuthorID: c.AuthorID,
|
|
AuthorThumb: c.AuthorThumb,
|
|
Likes: c.Likes,
|
|
IsReply: c.IsReply,
|
|
Parent: c.Parent,
|
|
Timestamp: timestamp,
|
|
})
|
|
}
|
|
|
|
return comments, nil
|
|
}
|
|
|
|
func formatCommentTime(timestamp int64) string {
|
|
now := float64(timestamp)
|
|
then := float64(0)
|
|
diff := int((now - then) / 1000)
|
|
|
|
if diff < 60 {
|
|
return "just now"
|
|
} else if diff < 3600 {
|
|
return fmt.Sprintf("%dm ago", diff/60)
|
|
} else if diff < 86400 {
|
|
return fmt.Sprintf("%dh ago", diff/3600)
|
|
} else if diff < 604800 {
|
|
return fmt.Sprintf("%dd ago", diff/86400)
|
|
} else if diff < 2592000 {
|
|
return fmt.Sprintf("%dw ago", diff/604800)
|
|
} else if diff < 31536000 {
|
|
return fmt.Sprintf("%dmo ago", diff/2592000)
|
|
}
|
|
return fmt.Sprintf("%dy ago", diff/31536000)
|
|
}
|