init
This commit is contained in:
7
extractor/builtins/builtins.go
Normal file
7
extractor/builtins/builtins.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package builtins
|
||||
|
||||
import (
|
||||
_ "git.nobrain.org/r4/dischord/extractor/spotify"
|
||||
_ "git.nobrain.org/r4/dischord/extractor/youtube"
|
||||
_ "git.nobrain.org/r4/dischord/extractor/ytdl"
|
||||
)
|
||||
208
extractor/extractor.go
Normal file
208
extractor/extractor.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package extractor
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNoSearchResults = errors.New("no search provider available")
|
||||
ErrNoSearchProvider = errors.New("no search provider available")
|
||||
ErrNoSuggestionProvider = errors.New("no search suggestion provider available")
|
||||
)
|
||||
|
||||
var (
|
||||
providers []provider
|
||||
extractors []extractor
|
||||
searchers []searcher
|
||||
suggestors []suggestor
|
||||
defaultConfig Config
|
||||
)
|
||||
|
||||
func Extract(cfg Config, input string) ([]Data, error) {
|
||||
if err := cfg.CheckTypes(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, e := range extractors {
|
||||
if e.Matches(cfg[e.name], input) {
|
||||
data, err := e.Extract(cfg[e.name], input)
|
||||
if err != nil {
|
||||
return nil, &Error{e.name, err}
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
d, err := Search(cfg, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(d) == 0 {
|
||||
return nil, ErrNoSearchResults
|
||||
}
|
||||
return []Data{d[0]}, nil
|
||||
}
|
||||
|
||||
func Search(cfg Config, input string) ([]Data, error) {
|
||||
if err := cfg.CheckTypes(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, s := range searchers {
|
||||
data, err := s.Search(cfg[s.name], input)
|
||||
if err != nil {
|
||||
return nil, &Error{s.name, err}
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
return nil, ErrNoSearchProvider
|
||||
}
|
||||
|
||||
func Suggest(cfg Config, input string) ([]string, error) {
|
||||
if err := cfg.CheckTypes(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, s := range suggestors {
|
||||
data, err := s.Suggest(cfg[s.name], input)
|
||||
if err != nil {
|
||||
return nil, &Error{s.name, err}
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
return nil, ErrNoSuggestionProvider
|
||||
}
|
||||
|
||||
type Error struct {
|
||||
ProviderName string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
return "extractor[" + e.ProviderName + "]: " + e.Err.Error()
|
||||
}
|
||||
|
||||
type provider struct {
|
||||
Provider
|
||||
name string
|
||||
}
|
||||
|
||||
type extractor struct {
|
||||
Extractor
|
||||
name string
|
||||
}
|
||||
|
||||
type searcher struct {
|
||||
Searcher
|
||||
name string
|
||||
}
|
||||
|
||||
type suggestor struct {
|
||||
Suggestor
|
||||
name string
|
||||
}
|
||||
|
||||
type Config map[string]ProviderConfig
|
||||
|
||||
func DefaultConfig() Config {
|
||||
if defaultConfig == nil {
|
||||
cfg := make(Config)
|
||||
for _, e := range providers {
|
||||
cfg[e.name] = e.DefaultConfig()
|
||||
}
|
||||
return cfg
|
||||
} else {
|
||||
return defaultConfig
|
||||
}
|
||||
}
|
||||
|
||||
func (cfg Config) CheckTypes() error {
|
||||
for provider, pCfg := range cfg {
|
||||
if pCfg == nil {
|
||||
return fmt.Errorf("extractor config for %v is nil", provider)
|
||||
}
|
||||
for k, v := range pCfg {
|
||||
got, expected := reflect.TypeOf(v), reflect.TypeOf(DefaultConfig()[provider][k])
|
||||
if got != expected {
|
||||
return &ConfigTypeError{
|
||||
Provider: provider,
|
||||
Key: k,
|
||||
Expected: expected,
|
||||
Got: got,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ConfigTypeError struct {
|
||||
Provider string
|
||||
Key string
|
||||
Expected reflect.Type
|
||||
Got reflect.Type
|
||||
}
|
||||
|
||||
func (e *ConfigTypeError) Error() string {
|
||||
expectedName := "nil"
|
||||
if e.Expected != nil {
|
||||
expectedName = e.Expected.Name()
|
||||
}
|
||||
gotName := "nil"
|
||||
if e.Got != nil {
|
||||
gotName = e.Got.Name()
|
||||
}
|
||||
return "extractor config type error: "+e.Provider+"."+e.Key+": expected "+expectedName+" but got "+gotName
|
||||
}
|
||||
|
||||
type ProviderConfig map[string]any
|
||||
|
||||
type Provider interface {
|
||||
DefaultConfig() ProviderConfig
|
||||
}
|
||||
|
||||
type Extractor interface {
|
||||
Provider
|
||||
Matches(cfg ProviderConfig, input string) bool
|
||||
Extract(cfg ProviderConfig, input string) ([]Data, error)
|
||||
}
|
||||
|
||||
func AddExtractor(name string, e Extractor) {
|
||||
providers = append(providers, provider{e, name})
|
||||
extractors = append(extractors, extractor{e, name})
|
||||
}
|
||||
|
||||
type Searcher interface {
|
||||
Provider
|
||||
Search(cfg ProviderConfig, input string) ([]Data, error)
|
||||
}
|
||||
|
||||
func AddSearcher(name string, s Searcher) {
|
||||
providers = append(providers, provider{s, name})
|
||||
searchers = append(searchers, searcher{s, name})
|
||||
}
|
||||
|
||||
type Suggestor interface {
|
||||
Provider
|
||||
Suggest(cfg ProviderConfig, input string) ([]string, error)
|
||||
}
|
||||
|
||||
func AddSuggestor(name string, s Suggestor) {
|
||||
providers = append(providers, provider{s, name})
|
||||
suggestors = append(suggestors, suggestor{s, name})
|
||||
}
|
||||
|
||||
type Data struct {
|
||||
// Each instance of this struct should be reconstructable by calling
|
||||
// Extract() on the SourceUrl
|
||||
// String values are "" if not present
|
||||
SourceUrl string
|
||||
StreamUrl string // may expire, see Expires
|
||||
Title string
|
||||
PlaylistUrl string
|
||||
PlaylistTitle string
|
||||
Description string
|
||||
Uploader string
|
||||
Duration int // in seconds; -1 if unknown
|
||||
Expires time.Time // when StreamUrl expires
|
||||
OfficialArtist bool // only for sites that have non-music (e.g. YouTube); search results only
|
||||
}
|
||||
220
extractor/extractor_test.go
Normal file
220
extractor/extractor_test.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package extractor_test
|
||||
|
||||
import (
|
||||
"git.nobrain.org/r4/dischord/extractor"
|
||||
_ "git.nobrain.org/r4/dischord/extractor/builtins"
|
||||
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"testing"
|
||||
)
|
||||
|
||||
var extractorTestCfg = extractor.DefaultConfig()
|
||||
|
||||
func validYtStreamUrl(strmUrl string) bool {
|
||||
u, err := url.Parse(strmUrl)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
q, err := url.ParseQuery(u.RawQuery)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
looksOk := u.Scheme == "https" &&
|
||||
strings.HasSuffix(u.Host, ".googlevideo.com") &&
|
||||
u.Path == "/videoplayback" &&
|
||||
q.Has("expire") &&
|
||||
q.Has("id")
|
||||
if !looksOk {
|
||||
return false
|
||||
}
|
||||
resp, err := http.Get(strmUrl)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return resp.StatusCode == 200
|
||||
}
|
||||
|
||||
func verifySearchResult(t *testing.T, data []extractor.Data, targetUrl string) {
|
||||
if len(data) == 0 {
|
||||
t.Fatalf("Expected search results but got none")
|
||||
}
|
||||
first := data[0]
|
||||
if first.SourceUrl != targetUrl {
|
||||
t.Fatalf("Invalid search result: expected '%v' but got '%v'", targetUrl, first.SourceUrl)
|
||||
}
|
||||
strmData, err := extractor.Extract(extractorTestCfg, first.SourceUrl)
|
||||
if err != nil {
|
||||
t.Fatalf("Error retrieving video data: %v", err)
|
||||
}
|
||||
if len(strmData) != 1 {
|
||||
t.Fatalf("Expected exactly one extraction result")
|
||||
}
|
||||
if !validYtStreamUrl(strmData[0].StreamUrl) {
|
||||
t.Fatalf("Invalid YouTube stream URL: got '%v'", strmData[0].StreamUrl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearch(t *testing.T) {
|
||||
extractor.Extract(extractorTestCfg, "https://open.spotify.com/track/22z9GL53FudbuFJqa43Nzj")
|
||||
|
||||
data, err := extractor.Search(extractorTestCfg, "nilered turns water into wine like jesus")
|
||||
if err != nil {
|
||||
t.Fatalf("Error searching YouTube: %v", err)
|
||||
}
|
||||
verifySearchResult(t, data, "https://www.youtube.com/watch?v=tAU0FX1d044")
|
||||
}
|
||||
|
||||
func TestSearchPlaylist(t *testing.T) {
|
||||
data, err := extractor.Search(extractorTestCfg, "instant regret clicking this playlist epic donut dude")
|
||||
if err != nil {
|
||||
t.Fatalf("Error searching YouTube: %v", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Fatalf("Expected search results but got none")
|
||||
}
|
||||
target := "https://www.youtube.com/playlist?list=PLv3TTBr1W_9tppikBxAE_G6qjWdBljBHJ"
|
||||
if data[0].PlaylistUrl != target {
|
||||
t.Fatalf("Invalid search result: expected '%v' but got '%v'", target, data[0].SourceUrl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchSuggestions(t *testing.T) {
|
||||
sug, err := extractor.Suggest(extractorTestCfg, "a")
|
||||
if err != nil {
|
||||
t.Fatalf("Error: %v", err)
|
||||
}
|
||||
if len(sug) == 0 {
|
||||
t.Fatalf("Function didn't return any suggestions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchIntegrityWeirdCharacters(t *testing.T) {
|
||||
data, err := extractor.Extract(extractorTestCfg, "test lol | # !@#%&(*)!&*!äöfáßö®©œæ %% %3 %32")
|
||||
if err != nil {
|
||||
t.Fatalf("Error searching YouTube: %v", err)
|
||||
}
|
||||
if len(data) != 1 {
|
||||
t.Fatalf("Expected exactly one URL but got %v", len(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestYoutubeMusicVideo(t *testing.T) {
|
||||
data, err := extractor.Extract(extractorTestCfg, "https://www.youtube.com/watch?v=dQw4w9WgXcQ")
|
||||
if err != nil {
|
||||
t.Fatalf("Error searching YouTube: %v", err)
|
||||
}
|
||||
if len(data) != 1 {
|
||||
t.Fatalf("Expected exactly one URL but got %v", len(data))
|
||||
}
|
||||
if !validYtStreamUrl(data[0].StreamUrl) {
|
||||
t.Fatalf("Invalid YouTube stream URL: got '%v'", data[0].StreamUrl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestYoutubeMusicVideoMulti(t *testing.T) {
|
||||
for i := 0; i < 10; i++ {
|
||||
TestYoutubeMusicVideo(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestYoutubePlaylist(t *testing.T) {
|
||||
cfg := extractor.DefaultConfig()
|
||||
cfg["YouTube"]["Require direct playlist URL"] = "true"
|
||||
|
||||
url := "https://www.youtube.com/watch?v=jdUXfsMTv7o&list=PLdImBTpIvHA1xN1Dfw2Ec5NQ5d-LF3ZP5"
|
||||
pUrl := "https://www.youtube.com/playlist?list=PLdImBTpIvHA1xN1Dfw2Ec5NQ5d-LF3ZP5"
|
||||
|
||||
data, err := extractor.Extract(cfg, url)
|
||||
if err != nil {
|
||||
t.Fatalf("Error: %v", err)
|
||||
}
|
||||
if len(data) != 1 {
|
||||
t.Fatalf("Expected only a single video")
|
||||
}
|
||||
if data[0].PlaylistTitle != "" {
|
||||
t.Fatalf("Did not expect a playlist")
|
||||
}
|
||||
|
||||
data, err = extractor.Extract(cfg, pUrl)
|
||||
if err != nil {
|
||||
t.Fatalf("Error: %v", err)
|
||||
}
|
||||
if len(data) != 14 {
|
||||
t.Fatalf("Invalid playlist item count: got '%v'", len(data))
|
||||
}
|
||||
|
||||
data, err = extractor.Extract(extractorTestCfg, url)
|
||||
if err != nil {
|
||||
t.Fatalf("Error: %v", err)
|
||||
}
|
||||
if len(data) != 14 {
|
||||
t.Fatalf("Invalid playlist item count: got '%v'", len(data))
|
||||
}
|
||||
if data[0].Title != "Why I use Linux" {
|
||||
t.Fatalf("Invalid title of first item: got '%v'", data[0].Title)
|
||||
}
|
||||
if data[0].Duration != 70 {
|
||||
t.Fatalf("Invalid duration of first item: got '%v'", data[0].Duration)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpotifyTrack(t *testing.T) {
|
||||
data, err := extractor.Extract(extractorTestCfg, "https://open.spotify.com/track/7HjaeqTHY6QlwPY0MEjuMF")
|
||||
if err != nil {
|
||||
t.Fatalf("Error: %v", err)
|
||||
}
|
||||
if len(data) != 1 {
|
||||
t.Fatalf("Expected exactly one URL but got %v", len(data))
|
||||
}
|
||||
if data[0].Title != "Infected Mushroom, Ninet Tayeb - Black Velvet" {
|
||||
t.Fatalf("Invalid song title: %v", data[0].Title)
|
||||
}
|
||||
if data[0].Uploader != "Infected Mushroom, Ninet Tayeb" {
|
||||
t.Fatalf("Invalid artists: %v", data[0].Uploader)
|
||||
}
|
||||
if !validYtStreamUrl(data[0].StreamUrl) {
|
||||
t.Fatalf("Invalid YouTube stream URL: got '%v'", data[0].StreamUrl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpotifyAlbum(t *testing.T) {
|
||||
data, err := extractor.Extract(extractorTestCfg, "https://open.spotify.com/album/6YEjK95sgoXQn1yGbYjHsp")
|
||||
if err != nil {
|
||||
t.Fatalf("Error: %v", err)
|
||||
}
|
||||
if len(data) != 11 {
|
||||
t.Fatalf("Expected exactly 11 tracks but got %v", len(data))
|
||||
}
|
||||
if data[0].Title != "Infected Mushroom, Ninet Tayeb - Black Velvet" {
|
||||
t.Fatalf("Invalid title of first item: got '%v'", data[0].Title)
|
||||
}
|
||||
if data[0].Uploader != "Infected Mushroom, Ninet Tayeb" {
|
||||
t.Fatalf("Invalid artists in first item: %v", data[0].Uploader)
|
||||
}
|
||||
if data[1].Title != "Infected Mushroom - While I'm in the Mood" {
|
||||
t.Fatalf("Invalid title of second item: got '%v'", data[1].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestYoutubeDl(t *testing.T) {
|
||||
data, err := extractor.Extract(extractorTestCfg, "https://soundcloud.com/pendulum/sets/hold-your-colour-1")
|
||||
if err != nil {
|
||||
t.Fatalf("Error: %v", err)
|
||||
}
|
||||
if len(data) != 14 {
|
||||
t.Fatalf("Invalid playlist item count: got '%v'", len(data))
|
||||
}
|
||||
if data[0].Title != "Prelude" {
|
||||
t.Fatalf("Invalid title of first item: got '%v'", data[0].Title)
|
||||
}
|
||||
if data[1].Title != "Slam" {
|
||||
t.Fatalf("Invalid title of second item: got '%v'", data[1].Title)
|
||||
}
|
||||
if data[0].PlaylistTitle != "Hold Your Colour" {
|
||||
t.Fatalf("Invalid playlist title: got '%v'", data[0].PlaylistTitle)
|
||||
}
|
||||
}
|
||||
96
extractor/spotify/providers.go
Normal file
96
extractor/spotify/providers.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package spotify
|
||||
|
||||
import (
|
||||
"git.nobrain.org/r4/dischord/extractor"
|
||||
"git.nobrain.org/r4/dischord/extractor/youtube"
|
||||
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
extractor.AddExtractor("spotify", NewExtractor())
|
||||
}
|
||||
|
||||
type matchType int
|
||||
|
||||
const (
|
||||
matchTypeNone matchType = iota
|
||||
matchTypeTrack
|
||||
matchTypeAlbum
|
||||
matchTypePlaylist
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidInput = errors.New("invalid input")
|
||||
)
|
||||
|
||||
func matches(input string) (string, matchType) {
|
||||
u, err := url.Parse(input)
|
||||
if err != nil {
|
||||
return "", matchTypeNone
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return "", matchTypeNone
|
||||
}
|
||||
if u.Host != "open.spotify.com" {
|
||||
return "", matchTypeNone
|
||||
}
|
||||
sp := strings.Split(u.Path, "/")
|
||||
if len(sp) != 3 || sp[0] != "" {
|
||||
return "", matchTypeNone
|
||||
}
|
||||
switch sp[1] {
|
||||
case "track":
|
||||
return sp[2], matchTypeTrack
|
||||
case "album":
|
||||
return sp[2], matchTypeAlbum
|
||||
case "playlist":
|
||||
return sp[2], matchTypePlaylist
|
||||
}
|
||||
return "", matchTypeNone
|
||||
}
|
||||
|
||||
type Extractor struct {
|
||||
ytSearcher *youtube.Searcher
|
||||
ytSearcherConfig extractor.ProviderConfig
|
||||
ytExtractor *youtube.Extractor
|
||||
ytExtractorConfig extractor.ProviderConfig
|
||||
token apiToken
|
||||
}
|
||||
|
||||
func NewExtractor() *Extractor {
|
||||
extractor := &Extractor{}
|
||||
extractor.ytSearcher = &youtube.Searcher{}
|
||||
extractor.ytSearcherConfig = extractor.ytSearcher.DefaultConfig()
|
||||
extractor.ytExtractor = &youtube.Extractor{}
|
||||
extractor.ytExtractorConfig = extractor.ytExtractor.DefaultConfig()
|
||||
return extractor
|
||||
}
|
||||
|
||||
func (e *Extractor) DefaultConfig() extractor.ProviderConfig {
|
||||
return extractor.ProviderConfig{}
|
||||
}
|
||||
|
||||
func (e *Extractor) Matches(cfg extractor.ProviderConfig, input string) bool {
|
||||
_, m := matches(input)
|
||||
return m != matchTypeNone
|
||||
}
|
||||
|
||||
func (e *Extractor) Extract(cfg extractor.ProviderConfig, input string) ([]extractor.Data, error) {
|
||||
id, m := matches(input)
|
||||
switch m {
|
||||
case matchTypeTrack:
|
||||
d, err := getTrack(e, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []extractor.Data{d}, nil
|
||||
case matchTypeAlbum:
|
||||
return getAlbum(e, id)
|
||||
case matchTypePlaylist:
|
||||
return getPlaylist(e, id)
|
||||
}
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
378
extractor/spotify/spotify.go
Normal file
378
extractor/spotify/spotify.go
Normal file
@@ -0,0 +1,378 @@
|
||||
package spotify
|
||||
|
||||
import (
|
||||
"git.nobrain.org/r4/dischord/extractor"
|
||||
exutil "git.nobrain.org/r4/dischord/extractor/util"
|
||||
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrGettingSessionData = errors.New("unable to get session data")
|
||||
ErrInvalidTrackData = errors.New("invalid track data")
|
||||
ErrTrackNotFound = errors.New("unable to find track on YouTube")
|
||||
ErrUnableToGetYoutubeStream = errors.New("unable to get YouTube stream")
|
||||
ErrDecodingApiResponse = errors.New("error decoding API response")
|
||||
)
|
||||
|
||||
// distance between two integers
|
||||
func iDist(a, b int) int {
|
||||
if a > b {
|
||||
return a - b
|
||||
} else {
|
||||
return b - a
|
||||
}
|
||||
}
|
||||
|
||||
func containsIgnoreCase(s, substr string) bool {
|
||||
return strings.Contains(strings.ToUpper(s), strings.ToUpper(substr))
|
||||
}
|
||||
|
||||
type sessionData struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
AccessTokenExpirationTimestampMs int64 `json:"accessTokenExpirationTimestampMs"`
|
||||
}
|
||||
|
||||
type apiToken struct {
|
||||
token string
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
func updateApiToken(token *apiToken) error {
|
||||
if time.Now().Before(token.expires) {
|
||||
// Token already up-to-date
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get new token
|
||||
var data sessionData
|
||||
var funcErr error
|
||||
err := exutil.GetHTMLScriptFunc("https://open.spotify.com", false, func(code string) bool {
|
||||
if strings.HasPrefix(code, "{\"accessToken\":\"") {
|
||||
// Parse session data
|
||||
if err := json.Unmarshal([]byte(code), &data); err != nil {
|
||||
funcErr = err
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if funcErr != nil {
|
||||
return funcErr
|
||||
}
|
||||
*token = apiToken{
|
||||
token: data.AccessToken,
|
||||
expires: time.UnixMilli(data.AccessTokenExpirationTimestampMs),
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type trackData struct {
|
||||
Artists []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"artists"`
|
||||
DurationMs int `json:"duration_ms"`
|
||||
ExternalUrls struct {
|
||||
Spotify string `json:"spotify"`
|
||||
} `json:"external_urls"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (d trackData) artistsString() (res string) {
|
||||
for i, v := range d.Artists {
|
||||
if i != 0 {
|
||||
res += ", "
|
||||
}
|
||||
res += v.Name
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (d trackData) titleString() string {
|
||||
return d.artistsString() + " - " + d.Name
|
||||
}
|
||||
|
||||
func getTrack(e *Extractor, trackId string) (extractor.Data, error) {
|
||||
if err := updateApiToken(&e.token); err != nil {
|
||||
return extractor.Data{}, err
|
||||
}
|
||||
|
||||
// Make API request for track info
|
||||
req, err := http.NewRequest("GET", "https://api.spotify.com/v1/tracks/"+trackId, nil)
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
req.Header.Add("Authorization", "Bearer "+e.token.token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return extractor.Data{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Parse track info
|
||||
var data trackData
|
||||
dec := json.NewDecoder(resp.Body)
|
||||
if err := dec.Decode(&data); err != nil {
|
||||
return extractor.Data{}, ErrDecodingApiResponse
|
||||
}
|
||||
|
||||
if len(data.Artists) == 0 {
|
||||
return extractor.Data{}, ErrInvalidTrackData
|
||||
}
|
||||
|
||||
// Search for track on YouTube
|
||||
results, err := e.ytSearcher.Search(e.ytSearcherConfig, data.Name+" - "+data.artistsString())
|
||||
if err != nil {
|
||||
return extractor.Data{}, err
|
||||
}
|
||||
if len(results) == 0 {
|
||||
return extractor.Data{}, ErrTrackNotFound
|
||||
}
|
||||
|
||||
// Lower is better
|
||||
score := func(ytd extractor.Data, resIdx int) (score int) {
|
||||
// This function determines the likelihood of a given YouTube video
|
||||
// belonging to the Spotify song.
|
||||
// It may look pretty complicated, but here's the gist:
|
||||
// - lower scores are better
|
||||
// - the general formula is: resIdx - matchAccuracy / penalty
|
||||
// where 'resIdx' is the position in the search results,
|
||||
// 'matchAccuracy' is how well the video superficially matches
|
||||
// with the Spotify song (title, artists, duration) and 'penalty'
|
||||
// measures the hints pointing to the current video being the
|
||||
// wrong one (awfully wrong duration, instrumental version, remix
|
||||
// etc.)
|
||||
// - if the video is from an official artist channel, that makes the
|
||||
// penalty points even more credible, so they're squared
|
||||
// - accuracy and penalty points are multiplicative; this makes them
|
||||
// have exponentially more weight the more they are given
|
||||
|
||||
matchAccuracy := 1.0
|
||||
matchPenalty := 1.0
|
||||
sqrPenalty := false
|
||||
if ytd.OfficialArtist || strings.HasSuffix(ytd.Uploader, " - Topic") {
|
||||
matchAccuracy *= 4.0
|
||||
sqrPenalty = true
|
||||
}
|
||||
if containsIgnoreCase(ytd.Title, data.Name) {
|
||||
matchAccuracy *= 4.0
|
||||
}
|
||||
matchingArtists := 0.0
|
||||
firstMatches := false
|
||||
for i, artist := range data.Artists {
|
||||
if containsIgnoreCase(ytd.Uploader, artist.Name) ||
|
||||
containsIgnoreCase(ytd.Title, artist.Name) {
|
||||
matchingArtists += 1.0
|
||||
if i == 0 {
|
||||
firstMatches = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if firstMatches {
|
||||
matchAccuracy *= 2.0
|
||||
}
|
||||
matchAccuracy *= 2.0 * (matchingArtists / float64(len(data.Artists)))
|
||||
durationDist := iDist(ytd.Duration, data.DurationMs/1000)
|
||||
if durationDist <= 5 {
|
||||
matchAccuracy *= 8.0
|
||||
} else if durationDist >= 300 {
|
||||
matchPenalty *= 16.0
|
||||
}
|
||||
spotiArtists := data.artistsString()
|
||||
onlyYtTitleContains := func(s string) bool {
|
||||
return !containsIgnoreCase(data.Name, s) &&
|
||||
!containsIgnoreCase(spotiArtists, s) &&
|
||||
containsIgnoreCase(ytd.Title, s)
|
||||
}
|
||||
if onlyYtTitleContains("instrumental") || onlyYtTitleContains("cover") ||
|
||||
onlyYtTitleContains("live") || onlyYtTitleContains("album") {
|
||||
matchPenalty *= 8.0
|
||||
}
|
||||
if onlyYtTitleContains("remix") || onlyYtTitleContains("rmx") {
|
||||
matchPenalty *= 8.0
|
||||
} else if onlyYtTitleContains("mix") {
|
||||
matchPenalty *= 6.0
|
||||
}
|
||||
if onlyYtTitleContains("vip") {
|
||||
matchPenalty *= 6.0
|
||||
}
|
||||
totalPenalty := matchPenalty
|
||||
if sqrPenalty {
|
||||
totalPenalty *= totalPenalty
|
||||
}
|
||||
return resIdx - int(matchAccuracy/totalPenalty)
|
||||
}
|
||||
|
||||
// Select the result with the lowest (best) score
|
||||
lowestIdx := -1
|
||||
lowest := 2147483647
|
||||
for i, v := range results {
|
||||
score := score(v, i)
|
||||
//fmt.Println(i, score, v)
|
||||
if score < lowest {
|
||||
lowestIdx = i
|
||||
lowest = score
|
||||
}
|
||||
}
|
||||
|
||||
ytData, err := e.ytExtractor.Extract(e.ytExtractorConfig, results[lowestIdx].SourceUrl)
|
||||
if err != nil {
|
||||
return extractor.Data{}, err
|
||||
}
|
||||
|
||||
if len(ytData) != 1 {
|
||||
return extractor.Data{}, ErrUnableToGetYoutubeStream
|
||||
}
|
||||
|
||||
return extractor.Data{
|
||||
SourceUrl: data.ExternalUrls.Spotify,
|
||||
StreamUrl: ytData[0].StreamUrl,
|
||||
Title: data.titleString(),
|
||||
Uploader: data.artistsString(),
|
||||
Duration: ytData[0].Duration,
|
||||
Expires: ytData[0].Expires,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type playlistData struct {
|
||||
ExternalUrls struct {
|
||||
Spotify string `json:"spotify"`
|
||||
} `json:"external_urls"`
|
||||
Name string `json:"name"`
|
||||
Tracks struct {
|
||||
Items []struct {
|
||||
Track trackData `json:"track"`
|
||||
} `json:"items"`
|
||||
Next string `json:"next"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
|
||||
func getPlaylist(e *Extractor, playlistId string) ([]extractor.Data, error) {
|
||||
if err := updateApiToken(&e.token); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var data playlistData
|
||||
trackOnlyReq := false
|
||||
reqUrl := "https://api.spotify.com/v1/playlists/" + playlistId
|
||||
var res []extractor.Data
|
||||
for {
|
||||
// Make API request for playlist info
|
||||
req, err := http.NewRequest("GET", reqUrl, nil)
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
req.Header.Add("Authorization", "Bearer "+e.token.token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Parse playlist info
|
||||
dec := json.NewDecoder(resp.Body)
|
||||
if trackOnlyReq {
|
||||
// JSON decoder doesn't always overwrite the set value
|
||||
data.Tracks.Next = ""
|
||||
data.Tracks.Items = nil
|
||||
|
||||
err = dec.Decode(&data.Tracks)
|
||||
} else {
|
||||
err = dec.Decode(&data)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, ErrDecodingApiResponse
|
||||
}
|
||||
|
||||
for _, v := range data.Tracks.Items {
|
||||
res = append(res, extractor.Data{
|
||||
SourceUrl: v.Track.ExternalUrls.Spotify,
|
||||
Title: v.Track.titleString(),
|
||||
Uploader: v.Track.artistsString(),
|
||||
PlaylistUrl: data.ExternalUrls.Spotify,
|
||||
PlaylistTitle: data.Name,
|
||||
})
|
||||
}
|
||||
|
||||
if data.Tracks.Next == "" {
|
||||
break
|
||||
} else {
|
||||
reqUrl = data.Tracks.Next
|
||||
trackOnlyReq = true
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
type albumData struct {
|
||||
ExternalUrls struct {
|
||||
Spotify string `json:"spotify"`
|
||||
} `json:"external_urls"`
|
||||
Name string `json:"name"`
|
||||
Tracks struct {
|
||||
Items []trackData `json:"items"`
|
||||
Next string `json:"next"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
|
||||
func getAlbum(e *Extractor, albumId string) ([]extractor.Data, error) {
|
||||
// This function is pretty much copied from getPlaylist, with minor
|
||||
// modifications
|
||||
|
||||
if err := updateApiToken(&e.token); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var data albumData
|
||||
trackOnlyReq := false
|
||||
reqUrl := "https://api.spotify.com/v1/albums/" + albumId
|
||||
var res []extractor.Data
|
||||
for {
|
||||
// Make API request for album info
|
||||
req, err := http.NewRequest("GET", reqUrl, nil)
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
req.Header.Add("Authorization", "Bearer "+e.token.token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Parse album info
|
||||
dec := json.NewDecoder(resp.Body)
|
||||
if trackOnlyReq {
|
||||
// JSON decoder doesn't always overwrite the set value
|
||||
data.Tracks.Next = ""
|
||||
data.Tracks.Items = nil
|
||||
|
||||
err = dec.Decode(&data.Tracks)
|
||||
} else {
|
||||
err = dec.Decode(&data)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, ErrDecodingApiResponse
|
||||
}
|
||||
|
||||
for _, v := range data.Tracks.Items {
|
||||
res = append(res, extractor.Data{
|
||||
SourceUrl: v.ExternalUrls.Spotify,
|
||||
Title: v.titleString(),
|
||||
Uploader: v.artistsString(),
|
||||
PlaylistUrl: data.ExternalUrls.Spotify,
|
||||
PlaylistTitle: data.Name,
|
||||
})
|
||||
}
|
||||
|
||||
if data.Tracks.Next == "" {
|
||||
break
|
||||
} else {
|
||||
reqUrl = data.Tracks.Next
|
||||
trackOnlyReq = true
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
59
extractor/util/util.go
Normal file
59
extractor/util/util.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"golang.org/x/net/html"
|
||||
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Retrieve JavaScript embedded in HTML
|
||||
func GetHTMLScriptFunc(url string, readCodeLineByLine bool, codeFunc func(code string) bool) error {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
z := html.NewTokenizer(resp.Body)
|
||||
isScript := false
|
||||
|
||||
for {
|
||||
tt := z.Next()
|
||||
|
||||
switch tt {
|
||||
case html.ErrorToken:
|
||||
return z.Err()
|
||||
case html.TextToken:
|
||||
if codeFunc != nil && isScript {
|
||||
t := string(z.Text())
|
||||
if readCodeLineByLine {
|
||||
// NOTE: a bufio line scanner doesn't work (bufio.Scanner: token too long); maybe this is a bug
|
||||
// Iterate over each line in the script
|
||||
ls := 0 // line start
|
||||
le := 0 // line end
|
||||
for ls < len(t) {
|
||||
if le == len(t) || t[le] == '\n' {
|
||||
ln := t[ls:le]
|
||||
|
||||
if !codeFunc(ln) {
|
||||
return nil
|
||||
}
|
||||
|
||||
ls = le + 1
|
||||
}
|
||||
le++
|
||||
}
|
||||
} else {
|
||||
if !codeFunc(t) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
case html.StartTagToken, html.EndTagToken:
|
||||
tn, _ := z.TagName()
|
||||
if string(tn) == "script" {
|
||||
isScript = tt == html.StartTagToken
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
102
extractor/youtube/providers.go
Normal file
102
extractor/youtube/providers.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package youtube
|
||||
|
||||
import (
|
||||
"git.nobrain.org/r4/dischord/extractor"
|
||||
|
||||
"errors"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func init() {
|
||||
extractor.AddExtractor("youtube", &Extractor{})
|
||||
extractor.AddSearcher("youtube-search", &Searcher{})
|
||||
extractor.AddSuggestor("youtube-search-suggestions", &Suggestor{})
|
||||
}
|
||||
|
||||
type matchType int
|
||||
|
||||
const (
|
||||
matchTypeNone matchType = iota
|
||||
matchTypeVideo
|
||||
matchTypePlaylist
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidInput = errors.New("invalid input")
|
||||
)
|
||||
|
||||
func matches(requireDirectPlaylistUrl bool, input string) matchType {
|
||||
u, err := url.Parse(input)
|
||||
if err != nil {
|
||||
return matchTypeNone
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return matchTypeNone
|
||||
}
|
||||
q, err := url.ParseQuery(u.RawQuery)
|
||||
if err != nil {
|
||||
return matchTypeNone
|
||||
}
|
||||
switch u.Host {
|
||||
case "www.youtube.com", "youtube.com":
|
||||
if u.Path != "/watch" && u.Path != "/playlist" {
|
||||
return matchTypeNone
|
||||
}
|
||||
if q.Has("list") && (!requireDirectPlaylistUrl || u.Path == "/playlist") {
|
||||
return matchTypePlaylist
|
||||
}
|
||||
return matchTypeVideo
|
||||
case "youtu.be":
|
||||
return matchTypeVideo
|
||||
default:
|
||||
return matchTypeNone
|
||||
}
|
||||
}
|
||||
|
||||
type Extractor struct {
|
||||
decryptor decryptor
|
||||
}
|
||||
|
||||
func (e *Extractor) DefaultConfig() extractor.ProviderConfig {
|
||||
return extractor.ProviderConfig{
|
||||
"require-direct-playlist-url": false,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Extractor) Matches(cfg extractor.ProviderConfig, input string) bool {
|
||||
return matches(cfg["require-direct-playlist-url"].(bool), input) != matchTypeNone
|
||||
}
|
||||
|
||||
func (e *Extractor) Extract(cfg extractor.ProviderConfig, input string) ([]extractor.Data, error) {
|
||||
switch matches(cfg["require-direct-playlist-url"].(bool), input) {
|
||||
case matchTypeVideo:
|
||||
d, err := getVideo(&e.decryptor, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []extractor.Data{d}, nil
|
||||
case matchTypePlaylist:
|
||||
return getPlaylist(input)
|
||||
}
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
|
||||
type Searcher struct{}
|
||||
|
||||
func (s *Searcher) DefaultConfig() extractor.ProviderConfig {
|
||||
return extractor.ProviderConfig{}
|
||||
}
|
||||
|
||||
func (s *Searcher) Search(cfg extractor.ProviderConfig, input string) ([]extractor.Data, error) {
|
||||
return getSearch(input)
|
||||
}
|
||||
|
||||
type Suggestor struct{}
|
||||
|
||||
func (s *Suggestor) DefaultConfig() extractor.ProviderConfig {
|
||||
return extractor.ProviderConfig{}
|
||||
}
|
||||
|
||||
func (s *Suggestor) Suggest(cfg extractor.ProviderConfig, input string) ([]string, error) {
|
||||
return getSearchSuggestions(input)
|
||||
}
|
||||
58
extractor/youtube/util.go
Normal file
58
extractor/youtube/util.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package youtube
|
||||
|
||||
import (
|
||||
"golang.org/x/net/html"
|
||||
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func getHTMLScriptFunc(url string, readCodeLineByLine bool, codeFunc func(code string) bool) error {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
z := html.NewTokenizer(resp.Body)
|
||||
isScript := false
|
||||
|
||||
for {
|
||||
tt := z.Next()
|
||||
|
||||
switch tt {
|
||||
case html.ErrorToken:
|
||||
return z.Err()
|
||||
case html.TextToken:
|
||||
if codeFunc != nil && isScript {
|
||||
t := string(z.Text())
|
||||
if readCodeLineByLine {
|
||||
// NOTE: a bufio line scanner doesn't work (bufio.Scanner: token too long); maybe this is a bug
|
||||
// Iterate over each line in the script
|
||||
ls := 0 // line start
|
||||
le := 0 // line end
|
||||
for ls < len(t) {
|
||||
if le == len(t) || t[le] == '\n' {
|
||||
ln := t[ls:le]
|
||||
|
||||
if !codeFunc(ln) {
|
||||
return nil
|
||||
}
|
||||
|
||||
ls = le + 1
|
||||
}
|
||||
le++
|
||||
}
|
||||
} else {
|
||||
if !codeFunc(t) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
case html.StartTagToken, html.EndTagToken:
|
||||
tn, _ := z.TagName()
|
||||
if string(tn) == "script" {
|
||||
isScript = tt == html.StartTagToken
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
416
extractor/youtube/youtube.go
Normal file
416
extractor/youtube/youtube.go
Normal file
@@ -0,0 +1,416 @@
|
||||
package youtube
|
||||
|
||||
import (
|
||||
"git.nobrain.org/r4/dischord/extractor"
|
||||
exutil "git.nobrain.org/r4/dischord/extractor/util"
|
||||
"git.nobrain.org/r4/dischord/util"
|
||||
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNoSuitableFormat = errors.New("no suitable audio-only format found")
|
||||
ErrGettingUrlFromSignatureCipher = errors.New("error getting URL from signature cipher")
|
||||
ErrDecryptFunctionBroken = errors.New("signature decryptor function is broken (perhaps the extractor is out of date)")
|
||||
ErrMalformedJson = errors.New("malformed JSON")
|
||||
)
|
||||
|
||||
type playerData struct {
|
||||
StreamingData struct {
|
||||
ExpiresInSeconds string `json:"expiresInSeconds"`
|
||||
Formats []struct {
|
||||
Url string `json:"url"`
|
||||
SignatureCipher string `json:"signatureCipher"`
|
||||
MimeType string `json:"mimeType"`
|
||||
Bitrate int `json:"bitrate"`
|
||||
ApproxDurationMs string `json:"approxDurationMs"`
|
||||
AudioSampleRate string `json:"audioSampleRate"`
|
||||
AudioChannels int `json:"audioChannels"`
|
||||
} `json:"formats"`
|
||||
AdaptiveFormats []struct {
|
||||
Url string `json:"url"`
|
||||
SignatureCipher string `json:"signatureCipher"`
|
||||
MimeType string `json:"mimeType"`
|
||||
Bitrate int `json:"bitrate"`
|
||||
ApproxDurationMs string `json:"approxDurationMs"`
|
||||
AudioSampleRate string `json:"audioSampleRate"`
|
||||
AudioChannels int `json:"audioChannels"`
|
||||
} `json:"adaptiveFormats"`
|
||||
} `json:"streamingData"`
|
||||
VideoDetails struct {
|
||||
VideoId string `json:"videoId"`
|
||||
Title string `json:"title"`
|
||||
LengthSeconds string `json:"lengthSeconds"`
|
||||
ShortDescription string `json:"shortDescription"`
|
||||
Author string `json:"author"`
|
||||
} `json:"videoDetails"`
|
||||
}
|
||||
|
||||
func getVideo(decryptor *decryptor, vUrl string) (extractor.Data, error) {
|
||||
try := func() (extractor.Data, error) {
|
||||
// Get JSON string from YouTube
|
||||
v, err := getJSVar(vUrl, "ytInitialPlayerResponse")
|
||||
if err != nil {
|
||||
return extractor.Data{}, err
|
||||
}
|
||||
|
||||
// Parse player data scraped from YouTube
|
||||
var data playerData
|
||||
if err := json.Unmarshal([]byte(v), &data); err != nil {
|
||||
return extractor.Data{}, err
|
||||
}
|
||||
|
||||
// Get audio format with maximum bitrate
|
||||
maxBr := -1
|
||||
for i, f := range data.StreamingData.AdaptiveFormats {
|
||||
if strings.HasPrefix(f.MimeType, "audio/") {
|
||||
if maxBr == -1 || f.Bitrate > data.StreamingData.AdaptiveFormats[maxBr].Bitrate {
|
||||
maxBr = i
|
||||
}
|
||||
}
|
||||
}
|
||||
if maxBr == -1 {
|
||||
return extractor.Data{}, ErrNoSuitableFormat
|
||||
}
|
||||
|
||||
duration, err := strconv.Atoi(data.VideoDetails.LengthSeconds)
|
||||
if err != nil {
|
||||
duration = -1
|
||||
}
|
||||
expires, err := strconv.Atoi(data.StreamingData.ExpiresInSeconds)
|
||||
if err != nil {
|
||||
return extractor.Data{}, err
|
||||
}
|
||||
|
||||
ft := data.StreamingData.AdaptiveFormats[maxBr]
|
||||
var resUrl string
|
||||
if ft.Url != "" {
|
||||
resUrl = ft.Url
|
||||
} else {
|
||||
// For music, YouTube makes getting the resource URL a bit trickier
|
||||
q, err := url.ParseQuery(ft.SignatureCipher)
|
||||
if err != nil {
|
||||
return extractor.Data{}, ErrGettingUrlFromSignatureCipher
|
||||
}
|
||||
sig := q.Get("s")
|
||||
sigParam := q.Get("sp")
|
||||
baseUrl := q.Get("url")
|
||||
sigDecrypted, err := decryptor.decrypt(sig)
|
||||
if err != nil {
|
||||
return extractor.Data{}, err
|
||||
}
|
||||
resUrl = baseUrl + "&" + sigParam + "=" + sigDecrypted
|
||||
}
|
||||
|
||||
return extractor.Data{
|
||||
SourceUrl: vUrl,
|
||||
StreamUrl: resUrl,
|
||||
Title: data.VideoDetails.Title,
|
||||
Description: data.VideoDetails.ShortDescription,
|
||||
Uploader: data.VideoDetails.Author,
|
||||
Duration: duration,
|
||||
Expires: time.Now().Add(time.Duration(expires) * time.Second),
|
||||
}, nil
|
||||
}
|
||||
|
||||
isOk := func(strmUrl string) bool {
|
||||
resp, err := http.Get(strmUrl)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return resp.StatusCode == 200
|
||||
}
|
||||
|
||||
// Sometimes we just get an invalid stream URL, and I didn't find anything
|
||||
// simple to do about it, so we just try the stream URL we get and repeat
|
||||
// if it's invalid
|
||||
for tries := 0; tries < 10; tries++ {
|
||||
data, err := try()
|
||||
if err != nil {
|
||||
return extractor.Data{}, err
|
||||
}
|
||||
if isOk(data.StreamUrl) {
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
|
||||
return extractor.Data{}, ErrDecryptFunctionBroken
|
||||
}
|
||||
|
||||
type playlistVideoData struct {
|
||||
Contents struct {
|
||||
TwoColumnWatchNextResults struct {
|
||||
Playlist struct {
|
||||
Playlist struct {
|
||||
Title string `json:"title"`
|
||||
Contents []struct {
|
||||
PlaylistPanelVideoRenderer struct {
|
||||
NavigationEndpoint struct {
|
||||
WatchEndpoint struct {
|
||||
VideoId string `json:"videoId"`
|
||||
Index int `json:"index"`
|
||||
} `json:"watchEndpoint"`
|
||||
} `json:"navigationEndpoint"`
|
||||
Title struct {
|
||||
SimpleText string `json:"simpleText"`
|
||||
} `json:"title"`
|
||||
ShortBylineText struct {
|
||||
Runs []struct {
|
||||
Text string `json:"text"` // uploader name
|
||||
} `json:"runs"`
|
||||
} `json:"shortBylineText"`
|
||||
LengthText struct {
|
||||
SimpleText string `json:"simpleText"`
|
||||
} `json:"lengthText"`
|
||||
} `json:"playlistPanelVideoRenderer"`
|
||||
} `json:"contents"`
|
||||
} `json:"playlist"`
|
||||
} `json:"playlist"`
|
||||
} `json:"twoColumnWatchNextResults"`
|
||||
} `json:"contents"`
|
||||
}
|
||||
|
||||
// Only gets superficial data, the actual stream URL must be extracted from SourceUrl
|
||||
func getPlaylist(pUrl string) ([]extractor.Data, error) {
|
||||
u, err := url.Parse(pUrl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q, err := url.ParseQuery(u.RawQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
listId := q.Get("list")
|
||||
vidId := ""
|
||||
index := 0
|
||||
|
||||
var res []extractor.Data
|
||||
|
||||
// This loop uses the playlist sidebar: each video played in the context
|
||||
// of a playlist loads 100 or so of the following videos' infos, which we
|
||||
// add to the returned slice; then we take the last retrieved video's infos
|
||||
// and use its sidebar and so on
|
||||
for {
|
||||
vUrl := "https://www.youtube.com/watch?v=" + vidId + "&list=" + listId + "&index=" + strconv.Itoa(index+1)
|
||||
|
||||
// Get JSON string from YouTube
|
||||
v, err := getJSVar(vUrl, "ytInitialData")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse playlist data scraped from YouTube
|
||||
var data playlistVideoData
|
||||
if err := json.Unmarshal([]byte(v), &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
added := false
|
||||
for _, v := range data.Contents.TwoColumnWatchNextResults.Playlist.Playlist.Contents {
|
||||
vidId = v.PlaylistPanelVideoRenderer.NavigationEndpoint.WatchEndpoint.VideoId
|
||||
index = v.PlaylistPanelVideoRenderer.NavigationEndpoint.WatchEndpoint.Index
|
||||
|
||||
if index == len(res) {
|
||||
srcUrl := "https://www.youtube.com/watch?v=" + vidId
|
||||
|
||||
bylineText := v.PlaylistPanelVideoRenderer.ShortBylineText
|
||||
if len(bylineText.Runs) == 0 {
|
||||
return nil, ErrMalformedJson
|
||||
}
|
||||
uploader := bylineText.Runs[0].Text
|
||||
|
||||
length, err := util.ParseDurationSeconds(v.PlaylistPanelVideoRenderer.LengthText.SimpleText)
|
||||
if err != nil {
|
||||
length = -1
|
||||
}
|
||||
|
||||
res = append(res, extractor.Data{
|
||||
SourceUrl: srcUrl,
|
||||
Title: v.PlaylistPanelVideoRenderer.Title.SimpleText,
|
||||
PlaylistUrl: "https://www.youtube.com/playlist?list=" + listId,
|
||||
PlaylistTitle: data.Contents.TwoColumnWatchNextResults.Playlist.Playlist.Title,
|
||||
Uploader: uploader,
|
||||
Duration: length,
|
||||
})
|
||||
|
||||
added = true
|
||||
}
|
||||
}
|
||||
|
||||
if !added {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
type searchData struct {
|
||||
Contents struct {
|
||||
TwoColumnSearchResultsRenderer struct {
|
||||
PrimaryContents struct {
|
||||
SectionListRenderer struct {
|
||||
Contents []struct {
|
||||
ItemSectionRenderer struct {
|
||||
Contents []struct {
|
||||
PlaylistRenderer struct {
|
||||
PlaylistId string `json:"playlistId"`
|
||||
Title struct {
|
||||
SimpleText string `json:"simpleText"`
|
||||
} `json:"title"`
|
||||
} `json:"playlistRenderer"`
|
||||
VideoRenderer struct {
|
||||
VideoId string `json:"videoId"`
|
||||
Title struct {
|
||||
Runs []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"runs"`
|
||||
} `json:"title"`
|
||||
LongBylineText struct {
|
||||
Runs []struct {
|
||||
Text string `json:"text"` // uploader name
|
||||
} `json:"runs"`
|
||||
} `json:"longBylineText"`
|
||||
LengthText struct {
|
||||
SimpleText string `json:"simpleText"`
|
||||
} `json:"lengthText"`
|
||||
OwnerBadges []struct {
|
||||
MetadataBadgeRenderer struct {
|
||||
Style string `json:"style"`
|
||||
} `json:"metadataBadgeRenderer"`
|
||||
} `json:"OwnerBadges"`
|
||||
} `json:"videoRenderer"`
|
||||
} `json:"contents"`
|
||||
} `json:"itemSectionRenderer"`
|
||||
} `json:"contents"`
|
||||
} `json:"sectionListRenderer"`
|
||||
} `json:"primaryContents"`
|
||||
} `json:"twoColumnSearchResultsRenderer"`
|
||||
} `json:"contents"`
|
||||
}
|
||||
|
||||
// Only gets superficial data, the actual stream URL must be extracted from SourceUrl
|
||||
func getSearch(query string) ([]extractor.Data, error) {
|
||||
// Get JSON string from YouTube
|
||||
sanitizedQuery := url.QueryEscape(strings.ReplaceAll(query, " ", "+"))
|
||||
queryUrl := "https://www.youtube.com/results?search_query=" + sanitizedQuery
|
||||
v, err := getJSVar(queryUrl, "ytInitialData")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse search data scraped from YouTube
|
||||
var data searchData
|
||||
if err := json.Unmarshal([]byte(v), &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var res []extractor.Data
|
||||
for _, v0 := range data.Contents.TwoColumnSearchResultsRenderer.PrimaryContents.SectionListRenderer.Contents {
|
||||
for _, v1 := range v0.ItemSectionRenderer.Contents {
|
||||
if v1.VideoRenderer.VideoId != "" {
|
||||
titleRuns := v1.VideoRenderer.Title.Runs
|
||||
if len(titleRuns) == 0 {
|
||||
return nil, ErrMalformedJson
|
||||
}
|
||||
title := titleRuns[0].Text
|
||||
|
||||
bylineText := v1.VideoRenderer.LongBylineText
|
||||
if len(bylineText.Runs) == 0 {
|
||||
return nil, ErrMalformedJson
|
||||
}
|
||||
uploader := bylineText.Runs[0].Text
|
||||
|
||||
length, err := util.ParseDurationSeconds(v1.VideoRenderer.LengthText.SimpleText)
|
||||
if err != nil {
|
||||
length = -1
|
||||
}
|
||||
|
||||
badges := v1.VideoRenderer.OwnerBadges
|
||||
|
||||
res = append(res, extractor.Data{
|
||||
SourceUrl: "https://www.youtube.com/watch?v=" + v1.VideoRenderer.VideoId,
|
||||
Title: title,
|
||||
Duration: length,
|
||||
Uploader: uploader,
|
||||
OfficialArtist: len(badges) != 0 && badges[0].MetadataBadgeRenderer.Style == "BADGE_STYLE_TYPE_VERIFIED_ARTIST",
|
||||
})
|
||||
} else if v1.PlaylistRenderer.PlaylistId != "" {
|
||||
res = append(res, extractor.Data{
|
||||
PlaylistUrl: "https://www.youtube.com/playlist?list=" + v1.PlaylistRenderer.PlaylistId,
|
||||
PlaylistTitle: v1.PlaylistRenderer.Title.SimpleText,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func getSearchSuggestions(query string) ([]string, error) {
|
||||
url := "https://suggestqueries-clients6.youtube.com/complete/search?client=youtube&ds=yt&q=" + url.QueryEscape(query)
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw = []byte(strings.TrimSuffix(strings.TrimPrefix(string(raw), "window.google.ac.h("), ")"))
|
||||
|
||||
var data []any
|
||||
if err := json.Unmarshal(raw, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(data) != 3 {
|
||||
return nil, ErrMalformedJson
|
||||
}
|
||||
rawSuggestions, ok := data[1].([]any)
|
||||
if !ok {
|
||||
return nil, ErrMalformedJson
|
||||
}
|
||||
|
||||
var res []string
|
||||
for _, v := range rawSuggestions {
|
||||
rawSuggestion, ok := v.([]any)
|
||||
if !ok || len(rawSuggestion) != 3 {
|
||||
return nil, ErrMalformedJson
|
||||
}
|
||||
suggestion, ok := rawSuggestion[0].(string)
|
||||
if !ok {
|
||||
return nil, ErrMalformedJson
|
||||
}
|
||||
res = append(res, suggestion)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Gets a constant JavaScript variable's value from a URL and a variable name
|
||||
// (variable format must be: var someVarName = {"somekey": "lol"};)
|
||||
func getJSVar(url, varName string) (string, error) {
|
||||
match := "var " + varName + " = "
|
||||
|
||||
var res string
|
||||
err := exutil.GetHTMLScriptFunc(url, true, func(code string) bool {
|
||||
if strings.HasPrefix(code, match) {
|
||||
res = strings.TrimRight(code[len(match):], ";")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
245
extractor/youtube/youtube_decrypt.go
Normal file
245
extractor/youtube/youtube_decrypt.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package youtube
|
||||
|
||||
import (
|
||||
exutil "git.nobrain.org/r4/dischord/extractor/util"
|
||||
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDecryptGettingFunctionName = errors.New("error getting signature decryption function name")
|
||||
ErrDecryptGettingFunction = errors.New("error getting signature decryption function")
|
||||
ErrDecryptGettingOpTable = errors.New("error getting signature decryption operation table")
|
||||
ErrGettingBaseJs = errors.New("unable to get base.js")
|
||||
)
|
||||
|
||||
type decryptorOp struct {
|
||||
fn func(a *string, b int)
|
||||
arg int
|
||||
}
|
||||
|
||||
type decryptor struct {
|
||||
// base.js version ID, used for caching
|
||||
versionId string
|
||||
// The actual decryption algorithm can be split up into a list of known
|
||||
// operations
|
||||
ops []decryptorOp
|
||||
}
|
||||
|
||||
func (d *decryptor) decrypt(input string) (string, error) {
|
||||
if err := updateDecryptor(d); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
s := input
|
||||
for _, op := range d.ops {
|
||||
op.fn(&s, op.arg)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
type configData struct {
|
||||
PlayerJsUrl string `json:"PLAYER_JS_URL"`
|
||||
}
|
||||
|
||||
func updateDecryptor(d *decryptor) error {
|
||||
prefix := "(function() {window.ytplayer={};\nytcfg.set("
|
||||
endStr := ");"
|
||||
// Get base.js URL
|
||||
var url string
|
||||
var funcErr error
|
||||
err := exutil.GetHTMLScriptFunc("https://www.youtube.com", false, func(code string) bool {
|
||||
if strings.HasPrefix(code, prefix) {
|
||||
// Cut out the JSON part
|
||||
code = code[len(prefix):]
|
||||
end := strings.Index(code, endStr)
|
||||
if end == -1 {
|
||||
funcErr = ErrGettingBaseJs
|
||||
return false
|
||||
}
|
||||
|
||||
// Parse config data
|
||||
var data configData
|
||||
if err := json.Unmarshal([]byte(code[:end]), &data); err != nil {
|
||||
funcErr = ErrGettingBaseJs
|
||||
return false
|
||||
}
|
||||
|
||||
url = "https://www.youtube.com" + data.PlayerJsUrl
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if funcErr != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get base.js version ID
|
||||
sp := strings.SplitN(strings.TrimPrefix(url, "/s/player/"), "/", 2)
|
||||
if len(sp) != 2 {
|
||||
return ErrGettingBaseJs
|
||||
}
|
||||
verId := sp[0]
|
||||
|
||||
if d.versionId == verId {
|
||||
// Decryptor already up-to-date
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get base.js contents
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return ErrGettingBaseJs
|
||||
}
|
||||
|
||||
// Copy contents to buffer
|
||||
buf := new(strings.Builder)
|
||||
_, err = io.Copy(buf, resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get decryption operations
|
||||
ops, err := getDecryptOps(buf.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
d.versionId = verId
|
||||
d.ops = ops
|
||||
return nil
|
||||
}
|
||||
|
||||
var decryptFunctionNameRegexp = regexp.MustCompile(`[a-zA-Z]*&&\([a-zA-Z]*=([a-zA-Z]*)\(decodeURIComponent\([a-zA-Z]*\)\),[a-zA-Z]*\.set\([a-zA-Z]*,encodeURIComponent\([a-zA-Z]*\)\)\)`)
|
||||
|
||||
func getDecryptFunction(baseJs string) (string, error) {
|
||||
idx := decryptFunctionNameRegexp.FindSubmatchIndex([]byte(baseJs))
|
||||
if len(idx) != 4 {
|
||||
return "", ErrDecryptGettingFunctionName
|
||||
}
|
||||
fnName := baseJs[idx[2]:idx[3]]
|
||||
|
||||
startMatch := fnName + `=function(a){a=a.split("");`
|
||||
endMatch := `;return a.join("")};`
|
||||
start := strings.Index(baseJs, startMatch)
|
||||
if start == -1 {
|
||||
return "", ErrDecryptGettingFunction
|
||||
}
|
||||
fn := baseJs[start+len(startMatch):]
|
||||
end := strings.Index(fn, endMatch)
|
||||
if start == -1 {
|
||||
return "", ErrDecryptGettingFunction
|
||||
}
|
||||
return fn[:end], nil
|
||||
}
|
||||
|
||||
func getDecryptOps(baseJs string) ([]decryptorOp, error) {
|
||||
// Extract main decryptor function JS
|
||||
decrFn, err := getDecryptFunction(baseJs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get decyptor operation JS
|
||||
var ops string
|
||||
{
|
||||
sp := strings.SplitN(decrFn, ".", 2)
|
||||
if len(sp) != 2 {
|
||||
return nil, ErrDecryptGettingOpTable
|
||||
}
|
||||
opsObjName := sp[0]
|
||||
|
||||
startMatch := `var ` + opsObjName + `={`
|
||||
endMatch := `};`
|
||||
start := strings.Index(baseJs, startMatch)
|
||||
if start == -1 {
|
||||
return nil, ErrDecryptGettingOpTable
|
||||
}
|
||||
ops = baseJs[start+len(startMatch):]
|
||||
end := strings.Index(ops, endMatch)
|
||||
if start == -1 {
|
||||
return nil, ErrDecryptGettingOpTable
|
||||
}
|
||||
ops = ops[:end]
|
||||
}
|
||||
|
||||
// Make a decryptor operation table that associates the operation
|
||||
// names with a specific action on an input string
|
||||
opTable := make(map[string]func(a *string, b int))
|
||||
{
|
||||
lns := strings.Split(ops, "\n")
|
||||
if len(lns) != 3 {
|
||||
return nil, ErrDecryptGettingOpTable
|
||||
}
|
||||
for _, ln := range lns {
|
||||
sp := strings.Split(ln, ":")
|
||||
if len(sp) != 2 {
|
||||
return nil, ErrDecryptGettingOpTable
|
||||
}
|
||||
name := sp[0]
|
||||
fn := sp[1]
|
||||
switch {
|
||||
case strings.HasPrefix(fn, `function(a){a.reverse()}`):
|
||||
opTable[name] = func(a *string, b int) {
|
||||
// Reverse a
|
||||
var res string
|
||||
for _, c := range *a {
|
||||
res = string(c) + res
|
||||
}
|
||||
*a = res
|
||||
}
|
||||
case strings.HasPrefix(fn, `function(a,b){var c=a[0];a[0]=a[b%a.length];a[b%a.length]=c}`):
|
||||
opTable[name] = func(a *string, b int) {
|
||||
// Swap a[0] and a[b % len(a)]
|
||||
c := []byte(*a)
|
||||
c[0], c[b%len(*a)] = c[b%len(*a)], c[0]
|
||||
*a = string(c)
|
||||
}
|
||||
case strings.HasPrefix(fn, `function(a,b){a.splice(0,b)}`):
|
||||
opTable[name] = func(a *string, b int) {
|
||||
// Slice off all elements of a up to a[b]
|
||||
*a = (*a)[b:]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse all operations in the main decryptor function and return them in
|
||||
// order
|
||||
var res []decryptorOp
|
||||
for _, fn := range strings.Split(decrFn, ";") {
|
||||
sp := strings.SplitN(fn, ".", 2)
|
||||
if len(sp) != 2 {
|
||||
return nil, ErrDecryptGettingOpTable
|
||||
}
|
||||
sp = strings.SplitN(sp[1], "(", 2)
|
||||
if len(sp) != 2 {
|
||||
return nil, ErrDecryptGettingOpTable
|
||||
}
|
||||
name := sp[0]
|
||||
argS := strings.TrimSuffix(strings.TrimPrefix(sp[1], "a,"), ")")
|
||||
arg, err := strconv.Atoi(argS)
|
||||
if err != nil {
|
||||
return nil, ErrDecryptGettingOpTable
|
||||
}
|
||||
callableOp, exists := opTable[name]
|
||||
if !exists {
|
||||
return nil, ErrDecryptGettingOpTable
|
||||
}
|
||||
res = append(res, decryptorOp{callableOp, arg})
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
35
extractor/ytdl/providers.go
Normal file
35
extractor/ytdl/providers.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package ytdl
|
||||
|
||||
import (
|
||||
"git.nobrain.org/r4/dischord/extractor"
|
||||
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
extractor.AddExtractor("youtube-dl", &Extractor{})
|
||||
}
|
||||
|
||||
type Extractor struct{}
|
||||
|
||||
func (e *Extractor) DefaultConfig() extractor.ProviderConfig {
|
||||
return extractor.ProviderConfig{
|
||||
"youtube-dl-path": "youtube-dl",
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Extractor) Matches(cfg extractor.ProviderConfig, input string) bool {
|
||||
return strings.HasPrefix(input, "http://") || strings.HasPrefix(input, "https://")
|
||||
}
|
||||
|
||||
func (e *Extractor) Extract(cfg extractor.ProviderConfig, input string) ([]extractor.Data, error) {
|
||||
var res []extractor.Data
|
||||
dch, errch := ytdlGet(cfg["youtube-dl-path"].(string), input)
|
||||
for v := range dch {
|
||||
res = append(res, v)
|
||||
}
|
||||
for err := range errch {
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
135
extractor/ytdl/ytdl.go
Normal file
135
extractor/ytdl/ytdl.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package ytdl
|
||||
|
||||
import (
|
||||
"git.nobrain.org/r4/dischord/extractor"
|
||||
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnsupportedUrl = errors.New("unsupported URL")
|
||||
)
|
||||
|
||||
// A very reduced version of the JSON structure returned by youtube-dl
|
||||
type ytdlMetadata struct {
|
||||
Title string `json:"title"`
|
||||
Extractor string `json:"extractor"`
|
||||
Duration float32 `json:"duration"`
|
||||
WebpageUrl string `json:"webpage_url"`
|
||||
Playlist string `json:"playlist"`
|
||||
Uploader string `json:"uploader"`
|
||||
Description string `json:"description"`
|
||||
Formats []struct {
|
||||
Url string `json:"url"`
|
||||
Format string `json"format"`
|
||||
VCodec string `json:"vcodec"`
|
||||
} `json:"formats"`
|
||||
}
|
||||
|
||||
// Gradually sends all audio URLs through the string channel. If an error occurs, it is sent through the
|
||||
// error channel. Both channels are closed after either an error occurs or all URLs have been output.
|
||||
func ytdlGet(youtubeDLPath, input string) (<-chan extractor.Data, <-chan error) {
|
||||
out := make(chan extractor.Data)
|
||||
errch := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
defer close(out)
|
||||
defer close(errch)
|
||||
|
||||
// Set youtube-dl args
|
||||
var ytdlArgs []string
|
||||
ytdlArgs = append(ytdlArgs, "-j", input)
|
||||
|
||||
// Prepare command for execution
|
||||
cmd := exec.Command(youtubeDLPath, ytdlArgs...)
|
||||
cmd.Env = []string{"LC_ALL=en_US.UTF-8"} // Youtube-dl doesn't recognize some chars if LC_ALL=C or not set at all
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
errch <- err
|
||||
return
|
||||
}
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
errch <- err
|
||||
return
|
||||
}
|
||||
|
||||
// Catch any errors put out by youtube-dl
|
||||
stderrReadDoneCh := make(chan struct{})
|
||||
var ytdlError string
|
||||
go func() {
|
||||
sc := bufio.NewScanner(stderr)
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if strings.HasPrefix(line, "ERROR: ") {
|
||||
ytdlError = strings.TrimPrefix(line, "ERROR: ")
|
||||
}
|
||||
}
|
||||
stderrReadDoneCh <- struct{}{}
|
||||
}()
|
||||
|
||||
// Start youtube-dl
|
||||
if err := cmd.Start(); err != nil {
|
||||
errch <- err
|
||||
return
|
||||
}
|
||||
|
||||
// We want to let our main loop know when youtube-dl is done
|
||||
donech := make(chan error)
|
||||
go func() {
|
||||
donech <- cmd.Wait()
|
||||
}()
|
||||
|
||||
// Main JSON decoder loop
|
||||
dec := json.NewDecoder(stdout)
|
||||
for dec.More() {
|
||||
// Read JSON
|
||||
var m ytdlMetadata
|
||||
if err := dec.Decode(&m); err != nil {
|
||||
errch <- err
|
||||
return
|
||||
}
|
||||
|
||||
// Extract URL from metadata (the latter formats are always the better with youtube-dl)
|
||||
for i := len(m.Formats) - 1; i >= 0; i-- {
|
||||
format := m.Formats[i]
|
||||
if format.VCodec == "none" {
|
||||
out <- extractor.Data{
|
||||
SourceUrl: m.WebpageUrl,
|
||||
StreamUrl: format.Url,
|
||||
Title: m.Title,
|
||||
PlaylistTitle: m.Playlist,
|
||||
Description: m.Description,
|
||||
Uploader: m.Uploader,
|
||||
Duration: int(m.Duration),
|
||||
Expires: time.Now().Add(10 * 365 * 24 * time.Hour),
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for command to finish executing and catch any errors
|
||||
err = <-donech
|
||||
<-stderrReadDoneCh
|
||||
if err != nil {
|
||||
if ytdlError == "" {
|
||||
errch <- err
|
||||
} else {
|
||||
if strings.HasPrefix(ytdlError, "Unsupported URL: ") {
|
||||
errch <- ErrUnsupportedUrl
|
||||
} else {
|
||||
errch <- errors.New("ytdl: " + ytdlError)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
return out, errch
|
||||
}
|
||||
Reference in New Issue
Block a user