├── CHANGELOG ├── LICENSE ├── README.md ├── cmd ├── ytools-info │ └── main.go ├── ytools-pick │ └── main.go ├── ytools-recommend │ └── main.go └── ytools-search │ └── main.go ├── common.go ├── go.mod ├── go.sum ├── makefile ├── man └── ytools.7 └── porcelain ├── yta ├── ytaa ├── ytai ├── ytal ├── yti ├── ytpa ├── ytpi ├── ytpv ├── ytr ├── ytv ├── ytva ├── ytvi └── ytvl /CHANGELOG: -------------------------------------------------------------------------------- 1 | Revision history ytools. 2 | Changelog format: https://metacpan.org/pod/distribution/CPAN-Changes/lib/CPAN/Changes/Spec.pod 3 | 4 | Unreleased 5 | - Added LICENSE file 6 | - Use camelCase for identifiers in the code 7 | - Improved error handling 8 | 9 | 1.1.0 2020-08-08 10 | - Removed ytools-comments 11 | - Fixed tools to work with the new HTML of YouTube pages 12 | 13 | 1.0.0 2019-03-15 14 | - Initial release 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Richard Ulmer 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | `ytools` is a set of simple tools to interact with YouTube via the terminal. 2 | 3 | # Usage 4 | ```console 5 | $ ytools-search Black Mambo 6 | 1: Glass Animals - Black Mambo (Lyric Video) 7 | 2: Glass Animals - Black Mambo 8 | 3: Madrugada-Black Mambo 9 | ... 10 | $ ytools-info 2 11 | Glass Animals - Black Mambo 12 | 14.643.046 Views 81.526 Likes Published on 17 Feb 2015 13 | 14 | Our new record “How To Be a Human Being” featuring “Youth” and 15 | ... 16 | $ mpv $(ytools-pick 2) 17 | Playing: https://www.youtube.com/watch?v=H7bqZIpC3Pg 18 | ... 19 | $ # Without an argument, recommendations for the last picked result are 20 | $ # listed; this also works with ytools-info and ytools-pick: 21 | $ ytools-recommend 22 | 1: Glass Animals - Cane Shuga 23 | 2: Black Coast - TRNDSTTR (Lucian Remix) 24 | 3: Glass Animals - Season 2 Episode 3 (Official Video) 25 | ... 26 | ``` 27 | 28 | For more information take a look at `man ytools`. 29 | 30 | # Installation 31 | The easiest way to try `ytools` is to use the prebuilt binaries, that 32 | are available at the [releases 33 | page](https://github.com/codesoap/ytools/releases). 34 | 35 | If you want to properly install `ytools` on your system, I recommend 36 | building them yourself: 37 | 38 | ```shell 39 | git clone git@github.com:codesoap/ytools.git 40 | cd ytools 41 | 42 | # Execute as root to install: 43 | make install 44 | 45 | # To uninstall use (again as root): 46 | # make uninstall 47 | 48 | # If you don't want to run make as root and don't care for the man 49 | # page, you could alternatively run the following. This will install the 50 | # binaries to ~/go/bin/: 51 | # go install ./... 52 | ``` 53 | 54 | ## Porcelain 55 | You can find some convenient scripts in `porcelain/`. They are *not* 56 | installed by `make install`. Place the ones you like in `$HOME/bin/` 57 | (make sure this directory is in your `$PATH`). 58 | -------------------------------------------------------------------------------- /cmd/ytools-info/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/codesoap/ytools" 7 | "os" 8 | ) 9 | 10 | type Info struct { 11 | Title string 12 | Views string 13 | // TODO: Length string 14 | // The length is not available on the main page, will probably have 15 | // to load something like this: 16 | // https://www.youtube.com/annotations_invideo?video_id=DuoTdnq_OqE 17 | Likes string 18 | Date string 19 | Description string 20 | } 21 | 22 | type YtInitialData struct { 23 | Contents struct { 24 | TwoColumnWatchNextResults struct { 25 | Results struct { 26 | Results struct { 27 | Contents []struct { 28 | VideoPrimaryInfoRenderer VideoPrimaryInfoRenderer 29 | VideoSecondaryInfoRenderer VideoSecondaryInfoRenderer 30 | } 31 | } 32 | } 33 | } 34 | } 35 | } 36 | 37 | type VideoPrimaryInfoRenderer struct { 38 | Title struct { 39 | Runs []struct { 40 | Text string 41 | } 42 | } 43 | ViewCount struct { 44 | VideoViewCountRenderer struct { 45 | ViewCount struct { 46 | SimpleText string 47 | } 48 | } 49 | } 50 | VideoActions struct { 51 | MenuRenderer struct { 52 | TopLevelButtons []struct { 53 | SegmentedLikeDislikeButtonViewModel struct { 54 | LikeButtonViewModel struct { 55 | LikeButtonViewModel struct { 56 | ToggleButtonViewModel struct { 57 | ToggleButtonViewModel struct { 58 | DefaultButtonViewModel struct { 59 | ButtonViewModel struct { 60 | Title string 61 | } 62 | } 63 | } 64 | } 65 | } 66 | } 67 | } 68 | } 69 | } 70 | } 71 | DateText struct { 72 | SimpleText string 73 | } 74 | } 75 | 76 | type VideoSecondaryInfoRenderer struct { 77 | AttributedDescription struct { 78 | Content string 79 | } 80 | } 81 | 82 | func main() { 83 | videoUrl, err := ytools.GetDesiredVideoUrl() 84 | if err != nil { 85 | fmt.Fprintf(os.Stderr, "Failed to get video URL: %s\n", err.Error()) 86 | os.Exit(1) 87 | } 88 | info, err := scrapeOffInfo(videoUrl) 89 | if err != nil { 90 | fmt.Fprintln(os.Stderr, "Failed to scrape the videos page:", err.Error()) 91 | os.Exit(1) 92 | } 93 | printInfo(info) 94 | } 95 | 96 | func printInfo(info Info) { 97 | fmt.Println(info.Title) 98 | if info.Description == "" { 99 | f := "%s %s %s\n" 100 | fmt.Printf(f, info.Views, info.Likes, info.Date) 101 | } else { 102 | f := "%s %s %s\n\n" 103 | fmt.Printf(f, info.Views, info.Likes, info.Date) 104 | fmt.Println(info.Description) 105 | } 106 | } 107 | 108 | func scrapeOffInfo(url string) (info Info, err error) { 109 | var dataJson []byte 110 | if dataJson, err = ytools.ExtractJson(url); err != nil { 111 | return 112 | } 113 | return extractInfo(dataJson) 114 | } 115 | 116 | func extractInfo(dataJson []byte) (info Info, err error) { 117 | var data YtInitialData 118 | if err = json.Unmarshal(dataJson, &data); err != nil { 119 | return 120 | } 121 | r := data.Contents.TwoColumnWatchNextResults.Results.Results 122 | if len(r.Contents) == 0 { 123 | return info, fmt.Errorf("no contents found in JSON") 124 | } 125 | primaryInfo := r.Contents[0].VideoPrimaryInfoRenderer 126 | secondaryInfo := r.Contents[1].VideoSecondaryInfoRenderer 127 | if err = fillTitle(&info, primaryInfo); err != nil { 128 | return 129 | } 130 | if err = fillViews(&info, primaryInfo); err != nil { 131 | return 132 | } 133 | if err = fillLikes(&info, primaryInfo); err != nil { 134 | return 135 | } 136 | if err = fillDate(&info, primaryInfo); err != nil { 137 | return 138 | } 139 | // TODO: Owner 140 | fillDescription(&info, secondaryInfo) 141 | return 142 | } 143 | 144 | func fillTitle(info *Info, data VideoPrimaryInfoRenderer) error { 145 | if len(data.Title.Runs) == 0 { 146 | return fmt.Errorf("no found in JSON") 147 | } 148 | // There are multiple runs when the title contains hashtags. 149 | // Just join the parts together. 150 | info.Title = "" 151 | for _, r := range data.Title.Runs { 152 | info.Title += r.Text 153 | } 154 | if len(info.Title) == 0 { 155 | return fmt.Errorf("title is empty") 156 | } 157 | return nil 158 | } 159 | 160 | func fillViews(info *Info, data VideoPrimaryInfoRenderer) error { 161 | info.Views = data.ViewCount.VideoViewCountRenderer.ViewCount.SimpleText 162 | if len(info.Views) == 0 { 163 | return fmt.Errorf("views is empty") 164 | } 165 | return nil 166 | } 167 | 168 | func fillLikes(info *Info, data VideoPrimaryInfoRenderer) error { 169 | for _, button := range data.VideoActions.MenuRenderer.TopLevelButtons { 170 | info.Likes = button. 171 | SegmentedLikeDislikeButtonViewModel. 172 | LikeButtonViewModel. 173 | LikeButtonViewModel. 174 | ToggleButtonViewModel. 175 | ToggleButtonViewModel. 176 | DefaultButtonViewModel. 177 | ButtonViewModel. 178 | Title 179 | if info.Likes != "" { 180 | info.Likes += " ▲" 181 | return nil 182 | } 183 | } 184 | return fmt.Errorf("like count not found") 185 | } 186 | 187 | func fillDate(info *Info, data VideoPrimaryInfoRenderer) error { 188 | info.Date = data.DateText.SimpleText 189 | if len(info.Date) == 0 { 190 | return fmt.Errorf("date is empty") 191 | } 192 | return nil 193 | } 194 | 195 | func fillDescription(info *Info, data VideoSecondaryInfoRenderer) { 196 | info.Description = data.AttributedDescription.Content 197 | } 198 | -------------------------------------------------------------------------------- /cmd/ytools-pick/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/codesoap/ytools" 6 | "os" 7 | "path/filepath" 8 | ) 9 | 10 | func main() { 11 | url, err := ytools.GetDesiredVideoUrl() 12 | if err != nil { 13 | fmt.Fprintf(os.Stderr, "Failed to get video URL: %s\n", err.Error()) 14 | os.Exit(1) 15 | } 16 | saveAsLastPicked(url) 17 | fmt.Println(url) 18 | } 19 | 20 | func saveAsLastPicked(url string) (err error) { 21 | dataDir, err := ytools.GetDataDir() 22 | if err != nil { 23 | return 24 | } 25 | lastPickedFilename := filepath.Join(dataDir, "last_picked") 26 | lastPickedFile, err := os.Create(lastPickedFilename) 27 | if err != nil { 28 | return 29 | } 30 | defer func() { 31 | err = lastPickedFile.Close() 32 | }() 33 | _, err = fmt.Fprintln(lastPickedFile, url) 34 | if err != nil { 35 | return 36 | } 37 | return 38 | } 39 | -------------------------------------------------------------------------------- /cmd/ytools-recommend/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/codesoap/ytools" 7 | "os" 8 | ) 9 | 10 | const maxResults = 16 11 | 12 | type Video struct { 13 | Title string 14 | Url string 15 | } 16 | 17 | type YtInitialData struct { 18 | Contents struct { 19 | TwoColumnWatchNextResults struct { 20 | SecondaryResults struct { 21 | SecondaryResults struct { 22 | Results []struct { 23 | CompactVideoRenderer CompactVideoRenderer 24 | } 25 | } 26 | } 27 | } 28 | } 29 | } 30 | 31 | type CompactVideoRenderer struct { 32 | VideoId string 33 | Title struct { 34 | SimpleText string 35 | } 36 | } 37 | 38 | func main() { 39 | videoUrl, err := ytools.GetDesiredVideoUrl() 40 | if err != nil { 41 | fmt.Fprintf(os.Stderr, "Failed to get the video URL: %s\n", err.Error()) 42 | os.Exit(1) 43 | } 44 | recommendations, err := scrapeOffRecommendations(videoUrl) 45 | if err != nil { 46 | fmt.Fprintf(os.Stderr, "Failed to find recommendations: %s\n", err.Error()) 47 | os.Exit(1) 48 | } 49 | if len(recommendations) == 0 { 50 | fmt.Fprintf(os.Stderr, "No recommendations found.\n") 51 | os.Exit(1) 52 | } 53 | if err := saveRecommendationsUrls(recommendations); err != nil { 54 | fmt.Fprintf(os.Stderr, "Failed to save found URLs: %s\n", err.Error()) 55 | os.Exit(1) 56 | } 57 | printVideoTitles(recommendations) 58 | } 59 | 60 | func scrapeOffRecommendations(videoUrl string) (videos []Video, err error) { 61 | var dataJson []byte 62 | if dataJson, err = ytools.ExtractJson(videoUrl); err != nil { 63 | return 64 | } 65 | return extractVideos(dataJson) 66 | } 67 | 68 | func extractVideos(dataJson []byte) (videos []Video, err error) { 69 | videos = make([]Video, 0, maxResults) 70 | var data YtInitialData 71 | if err = json.Unmarshal(dataJson, &data); err != nil { 72 | return 73 | } 74 | 75 | sr := data.Contents.TwoColumnWatchNextResults.SecondaryResults 76 | for _, result := range sr.SecondaryResults.Results { 77 | var video Video 78 | video, err = extractVideoFromVideoRenderer(result.CompactVideoRenderer) 79 | if err != nil { 80 | // This sometimes happens, but I don't think it's problematic. 81 | err = nil 82 | continue 83 | } 84 | videos = append(videos, video) 85 | if len(videos) == maxResults { 86 | break 87 | } 88 | } 89 | return 90 | } 91 | 92 | func extractVideoFromVideoRenderer(renderer CompactVideoRenderer) (video Video, err error) { 93 | if len(renderer.VideoId) == 0 { 94 | err = fmt.Errorf("videoId is missing in videoRenderer") 95 | return 96 | } 97 | if len(renderer.Title.SimpleText) == 0 { 98 | err = fmt.Errorf("no title found for videoRenderer") 99 | return 100 | } 101 | video = Video{ 102 | Url: fmt.Sprintf("https://www.youtube.com/watch?v=%s", renderer.VideoId), 103 | Title: renderer.Title.SimpleText, 104 | } 105 | return 106 | } 107 | 108 | func saveRecommendationsUrls(videos []Video) (err error) { 109 | videosUrls := make([]string, 0, maxResults) 110 | for _, video := range videos { 111 | videosUrls = append(videosUrls, video.Url) 112 | } 113 | return ytools.SaveUrls(videosUrls) 114 | } 115 | 116 | func printVideoTitles(videos []Video) { 117 | for i, video := range videos { 118 | fmt.Printf("%2d: %s\n", i+1, video.Title) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /cmd/ytools-search/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/codesoap/ytools" 7 | "net/url" 8 | "os" 9 | "strings" 10 | ) 11 | 12 | const maxResults = 12 13 | 14 | type Video struct { 15 | Title string 16 | Url string 17 | } 18 | 19 | type YtInitialData struct { 20 | Contents struct { 21 | TwoColumnSearchResultsRenderer struct { 22 | PrimaryContents struct { 23 | SectionListRenderer struct { 24 | Contents []struct { 25 | ItemSectionRenderer struct { 26 | Contents []struct { 27 | VideoRenderer VideoRenderer 28 | } 29 | } 30 | } 31 | } 32 | } 33 | } 34 | } 35 | } 36 | 37 | type VideoRenderer struct { 38 | VideoId string 39 | Title struct { 40 | Runs []struct { 41 | Text string 42 | } 43 | } 44 | } 45 | 46 | func main() { 47 | searchUrl := getSearchUrl() 48 | videos, err := scrapeOffVideos(searchUrl) 49 | if err != nil { 50 | fmt.Fprintf(os.Stderr, "Failed to get video URLs: %s\n", err.Error()) 51 | os.Exit(1) 52 | } 53 | if len(videos) == 0 { 54 | fmt.Fprintf(os.Stderr, "No videos found.\n") 55 | os.Exit(1) 56 | } 57 | if err := saveVideosUrls(videos); err != nil { 58 | fmt.Fprintf(os.Stderr, "Failed saving found URLs: %s\n", err.Error()) 59 | os.Exit(1) 60 | } 61 | printVideoTitles(videos) 62 | } 63 | 64 | func getSearchUrl() string { 65 | if len(os.Args) < 2 { 66 | fmt.Fprintf(os.Stderr, "Give one or more search terms as parameters.\n") 67 | os.Exit(1) 68 | } 69 | searchString := url.QueryEscape(strings.Join(os.Args[1:], " ")) 70 | return fmt.Sprintf( 71 | "https://www.youtube.com/results?search_query=%s", 72 | searchString) 73 | } 74 | 75 | func scrapeOffVideos(searchUrl string) (videos []Video, err error) { 76 | var dataJson []byte 77 | if dataJson, err = ytools.ExtractJson(searchUrl); err != nil { 78 | return 79 | } 80 | return extractVideos(dataJson) 81 | } 82 | 83 | func extractVideos(dataJson []byte) (videos []Video, err error) { 84 | videos = make([]Video, 0, maxResults) 85 | var data YtInitialData 86 | if err = json.Unmarshal(dataJson, &data); err != nil { 87 | return 88 | } 89 | 90 | pc := data.Contents.TwoColumnSearchResultsRenderer.PrimaryContents 91 | for _, slrContent := range pc.SectionListRenderer.Contents { 92 | for _, isrContent := range slrContent.ItemSectionRenderer.Contents { 93 | var video Video 94 | video, err = extractVideoFromVideoRenderer(isrContent.VideoRenderer) 95 | if err != nil { 96 | // This sometimes happens, but I don't think it's problematic. 97 | err = nil 98 | continue 99 | } 100 | videos = append(videos, video) 101 | if len(videos) == maxResults { 102 | break 103 | } 104 | } 105 | } 106 | return 107 | } 108 | 109 | func extractVideoFromVideoRenderer(renderer VideoRenderer) (video Video, err error) { 110 | if len(renderer.VideoId) == 0 { 111 | err = fmt.Errorf("videoId is missing in videoRenderer") 112 | return 113 | } 114 | if len(renderer.Title.Runs) != 1 { 115 | err = fmt.Errorf("multiple or no runs found for videoRenderer") 116 | return 117 | } 118 | if len(renderer.Title.Runs[0].Text) == 0 { 119 | err = fmt.Errorf("no title found for videoRenderer") 120 | return 121 | } 122 | video = Video{ 123 | Url: fmt.Sprintf("https://www.youtube.com/watch?v=%s", renderer.VideoId), 124 | Title: renderer.Title.Runs[0].Text, 125 | } 126 | return 127 | } 128 | 129 | func saveVideosUrls(videos []Video) (err error) { 130 | videosUrls := make([]string, 0, maxResults) 131 | for _, video := range videos { 132 | videosUrls = append(videosUrls, video.Url) 133 | } 134 | return ytools.SaveUrls(videosUrls) 135 | } 136 | 137 | func printVideoTitles(videos []Video) { 138 | for i, video := range videos { 139 | fmt.Printf("%2d: %s\n", i+1, video.Title) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /common.go: -------------------------------------------------------------------------------- 1 | package ytools 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "os" 9 | "path/filepath" 10 | "regexp" 11 | "strconv" 12 | "strings" 13 | ) 14 | 15 | func SaveUrls(urls []string) (err error) { 16 | dataDir, err := GetDataDir() 17 | if err != nil { 18 | return 19 | } 20 | urlsFilename := filepath.Join(dataDir, "search_results") 21 | urlsFile, err := os.Create(urlsFilename) 22 | if err != nil { 23 | return 24 | } 25 | defer func() { 26 | if err != nil { 27 | urlsFile.Close() 28 | } else { 29 | err = urlsFile.Close() 30 | } 31 | }() 32 | for _, url := range urls { 33 | _, err = fmt.Fprintln(urlsFile, url) 34 | } 35 | return 36 | } 37 | 38 | func GetDesiredVideoUrl() (videoUrl string, err error) { 39 | switch len(os.Args) { 40 | case 1: 41 | videoUrl, err = GetLastPickedUrl() 42 | case 2: 43 | var selection int 44 | selection, err = strconv.Atoi(os.Args[1]) 45 | if err != nil { 46 | return 47 | } 48 | videoUrl, err = GetSearchResult(selection - 1) 49 | default: 50 | err = fmt.Errorf("invalid argument count; give a video number as " + 51 | "argument, or no argument to select the last picked") 52 | } 53 | return 54 | } 55 | 56 | func GetSearchResult(i int) (searchResult string, err error) { 57 | searchResults, err := getSearchResults() 58 | if err == nil { 59 | if i < 0 || i >= len(searchResults) { 60 | err = fmt.Errorf("invalid search result index") 61 | } else { 62 | searchResult = searchResults[i] 63 | } 64 | } 65 | return 66 | } 67 | 68 | func getSearchResults() (searchResults []string, err error) { 69 | searchResults = make([]string, 0) 70 | 71 | dataDir, err := GetDataDir() 72 | if err != nil { 73 | return 74 | } 75 | urlsFile := filepath.Join(dataDir, "search_results") 76 | file, err := os.Open(urlsFile) 77 | if err != nil { 78 | return 79 | } 80 | defer file.Close() 81 | 82 | scanner := bufio.NewScanner(file) 83 | for scanner.Scan() { 84 | searchResults = append(searchResults, scanner.Text()) 85 | } 86 | 87 | if err := scanner.Err(); err != nil { 88 | panic(err) 89 | } 90 | 91 | return 92 | } 93 | 94 | func GetLastPickedUrl() (lastPickedUrl string, err error) { 95 | dataDir, err := GetDataDir() 96 | if err != nil { 97 | return 98 | } 99 | lastPickedFilename := filepath.Join(dataDir, "last_picked") 100 | fileContent, err := ioutil.ReadFile(lastPickedFilename) 101 | if err != nil { 102 | return 103 | } 104 | lastPickedUrl = strings.TrimSpace(string(fileContent)) 105 | return 106 | } 107 | 108 | func GetDataDir() (dataDir string, err error) { 109 | dataDirBase := os.Getenv("XDG_DATA_HOME") 110 | if dataDirBase == "" { 111 | dataDirBase = filepath.Join(os.Getenv("HOME"), ".local/share/") 112 | } 113 | dataDir = filepath.Join(dataDirBase, "ytools/") 114 | err = os.MkdirAll(dataDir, 0755) 115 | return 116 | } 117 | 118 | // ExtractJson returns the ytInitialData JSON from the HTML at the 119 | // given URL. 120 | func ExtractJson(url string) (mainJson []byte, err error) { 121 | resp, err := http.Get(url) 122 | if err != nil { 123 | return 124 | } 125 | defer resp.Body.Close() 126 | bytes, err := ioutil.ReadAll(resp.Body) 127 | if err != nil { 128 | return 129 | } 130 | re := regexp.MustCompile(`(?m)ytInitialData.{1,3}= *(.*?);(|$)`) 131 | matches := re.FindSubmatch(bytes) 132 | if matches == nil { 133 | err = fmt.Errorf("retrieved HTML does not contain the expected JSON") 134 | return 135 | } 136 | return matches[1], nil 137 | } 138 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/codesoap/ytools 2 | 3 | go 1.14 4 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codesoap/ytools/8306ed0169d052f503b9dba35fa71c2ec7e3db00/go.sum -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | .PHONY: install uninstall 2 | 3 | INSTALLDIR ?= /usr/local 4 | 5 | install: 6 | GOBIN="${INSTALLDIR}/bin" go install ./... 7 | mkdir -p "${INSTALLDIR}/man/man7" 8 | install -m 644 "man/ytools.7" "${INSTALLDIR}/man/man7" 9 | 10 | uninstall: 11 | rm -f "${INSTALLDIR}/bin/ytools-search" \ 12 | "${INSTALLDIR}/bin/ytools-pick" \ 13 | "${INSTALLDIR}/bin/ytools-info" \ 14 | "${INSTALLDIR}/bin/ytools-recommend" \ 15 | "${INSTALLDIR}/man/man7/ytools.7" 16 | -------------------------------------------------------------------------------- /man/ytools.7: -------------------------------------------------------------------------------- 1 | .Dd March 14, 2019 2 | .Dt YTOOLS 7 3 | .Os 4 | .Sh NAME 5 | .Nm ytools 6 | .Nd tools to interact with YouTube 7 | .Sh DESCRIPTION 8 | .Nm 9 | enables you to browse YouTube without leaving the terminal. The 10 | goal is not to cover everything you can do via the web interface. 11 | Instead the purpose of 12 | .Nm 13 | is to eliminate the need for the browser in the 14 | most common use cases. 15 | .Pp 16 | .Nm 17 | consists of these tools: 18 | .Pp 19 | .Bl -tag -width 16n -compact 20 | .It ytools-search 21 | search for videos 22 | .It ytools-pick 23 | print the URL of a video 24 | .It ytools-info 25 | display further info, like views, likes and description 26 | .It ytools-recommend 27 | recommend similar videos to the selected one 28 | .El 29 | .Sh OPTIONS 30 | ytools-search takes one or more search terms as arguments. 31 | .Pp 32 | ytools-pick, ytools-info and ytools-recommend take either no argument or 33 | a video number. The video number refers to the numbers displayed in the 34 | last ytools-search or ytools-recommend call. If no argument is given, 35 | the last video picked with ytools-pick is assumed. 36 | .Sh EXAMPLES 37 | .Pp 38 | Search for a video: 39 | .Dl ytools-search Vitalc - Fade Away 40 | .Pp 41 | View some info about the second result: 42 | .Dl ytools-info 2 43 | .Pp 44 | Play the audio of it: 45 | .Bd -offset indent -compact 46 | mpv --ytdl-format 47 | .Qq bestaudio/best 48 | --no-video 49 | .Qq $(ytools-pick 2) 50 | .Ed 51 | .Pp 52 | View some recommendations for this video; note that the recommendations 53 | are for the last video picked with ytools-pick, since no video number is given 54 | as argument: 55 | .Dl ytools-recommend 56 | .Pp 57 | The 58 | .Nm 59 | are designed to be used in scripts and require no interaction. You 60 | could for example write a simple script, that searches for passed 61 | search terms, interactively asks for the selection of a search 62 | result and then plays it using 63 | .Xr mpv 1 . 64 | .Sh SEE ALSO 65 | .Xr youtube-dl 1 66 | .Xr mpv 1 67 | -------------------------------------------------------------------------------- /porcelain/yta: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | # ytools audio: play the audio of a search result 3 | 4 | mpv --ytdl-format "bestaudio/best" --no-video "$(ytools-pick $@)" 5 | -------------------------------------------------------------------------------- /porcelain/ytaa: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | # ytools autoplay audio: play the audio of a search result, then 3 | # keep playing the first recommendations 4 | 5 | mpv --ytdl-format "bestaudio/best" --no-video "$(ytools-pick $@)" 6 | while true 7 | do 8 | echo -n 'Next recommendation:' 9 | ytools-recommend | head -n1 | cut -d ':' -f '2-' 10 | mpv --ytdl-format "bestaudio/best" --no-video "$(ytools-pick 1)" 11 | done 12 | -------------------------------------------------------------------------------- /porcelain/ytai: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | # ytools audio (interactive): search and interactively select a 3 | # result to listen to 4 | 5 | set -e 6 | 7 | ytools-search $@ 8 | echo -n 'Selection [1]: ' 9 | read selection 10 | test -z "${selection}" && selection=1 11 | mpv --ytdl-format "bestaudio/best" --no-video "$(ytools-pick "${selection}")" 12 | -------------------------------------------------------------------------------- /porcelain/ytal: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | # ytools audio (lucky): search a video and play the first results audio 3 | 4 | ytools-search $@ && 5 | mpv --ytdl-format "bestaudio/best" --no-video "$(ytools-pick 1)" 6 | -------------------------------------------------------------------------------- /porcelain/yti: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | # shortcut for ytools-info 3 | 4 | ytools-info $@ | less --quit-if-one-screen --no-init 5 | -------------------------------------------------------------------------------- /porcelain/ytpa: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | # Play the audio of an *.m3u playlist 3 | 4 | mpv --ytdl-format "bestaudio/best" --no-video \ 5 | --term-playing-msg='Title:${media-title}' "$@" 6 | -------------------------------------------------------------------------------- /porcelain/ytpi: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | # Insert a YouTube URL into an *.m3u file and add a metadata comment 3 | # for the new URL 4 | 5 | if [ $# -ne 2 ]; then 6 | echo "Give the playlist's filename as first argument" 7 | echo 'and the new YouTube URL as second argument.' 8 | exit 1 9 | fi 10 | 11 | # Add #EXTM3U comment in the first line, if it doesn't exist yet: 12 | if [ ! -s "$1" ] || $(head -n1 "$1" | grep -vq -e '\s*#EXTM3U'); then 13 | touch "$1" 14 | tmpfile=$(mktemp) 15 | mv "$1" "${tmpfile}" 16 | echo '#EXTM3U' > "$1" 17 | cat "${tmpfile}" >> "$1" 18 | rm "${tmpfile}" 19 | fi 20 | 21 | # Add a new entry for the given link, including an #EXTINF comment: 22 | metadata=$(youtube-dl --skip-download --get-title --get-duration "$2") 23 | seconds=$(printf "$metadata" | awk -F ':' \ 24 | 'NR==2 {s = 0; for(i = 1; i <= NF; i++){s = s * 60 + $i}} END {printf s}') 25 | track_description=$(printf "$metadata" | awk 'NR==1 {print}') 26 | printf "\n#EXTINF:${seconds},${track_description}\n" >> "$1" 27 | echo "$2" >> "$1" 28 | -------------------------------------------------------------------------------- /porcelain/ytpv: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | # Play an *.m3u playlist 3 | 4 | mpv --ytdl-format='bestvideo[height<=?1080]+bestaudio/best' "$@" 5 | -------------------------------------------------------------------------------- /porcelain/ytr: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | # shortcut for ytools-recommend 3 | 4 | ytools-recommend $@ 5 | -------------------------------------------------------------------------------- /porcelain/ytv: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | # ytools video: play a search result 3 | 4 | mpv --ytdl-format="bestvideo[height<=?1080]+bestaudio/best" "$(ytools-pick "$1")" 5 | -------------------------------------------------------------------------------- /porcelain/ytva: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | # ytools autoplay video: play a search result, then keep playing 3 | # the first recommendations 4 | 5 | mpv --ytdl-format="bestvideo[height<=?1080]+bestaudio/best" "$(ytools-pick $@)" 6 | while true 7 | do 8 | echo -n 'Next recommendation:' 9 | ytools-recommend | head -n1 | cut -d ':' -f '2-' 10 | mpv --ytdl-format="bestvideo[height<=?1080]+bestaudio/best" "$(ytools-pick 1)" 11 | done 12 | -------------------------------------------------------------------------------- /porcelain/ytvi: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | # ytools video (interactive): search and interactively select a result 3 | # to watch 4 | 5 | set -e 6 | 7 | ytools-search $@ 8 | echo -n 'Selection [1]: ' 9 | read selection 10 | test -z "${selection}" && selection=1 11 | mpv --ytdl-format="bestvideo[height<=?1080]+bestaudio/best" "$(ytools-pick "${selection}")" 12 | -------------------------------------------------------------------------------- /porcelain/ytvl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | # ytools video (lucky): search a video and play the first result 3 | 4 | ytools-search $@ && 5 | mpv --ytdl-format="bestvideo[height<=?1080]+bestaudio/best" "$(ytools-pick 1)" 6 | --------------------------------------------------------------------------------