├── go.mod ├── .gitignore ├── errors.go ├── validation.go ├── LICENSE ├── vimego.go ├── metadata.go ├── vimego_test.go ├── enums.go ├── formats.go ├── dash.go ├── video.go ├── search.go └── README.md /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/raitonoberu/vimego 2 | 3 | go 1.16 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package vimego 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | var ( 9 | ErrInvalidUrl = errors.New("the URL is invalid") 10 | ErrParsingFailed = errors.New("couldn't get config") 11 | ) 12 | 13 | type ErrUnexpectedStatusCode int 14 | 15 | func (err ErrUnexpectedStatusCode) Error() string { 16 | return fmt.Sprintf("unexpected status code: %d", err) 17 | } 18 | -------------------------------------------------------------------------------- /validation.go: -------------------------------------------------------------------------------- 1 | package vimego 2 | 3 | import ( 4 | "regexp" 5 | "strconv" 6 | ) 7 | 8 | var validationPatterns = []regexp.Regexp{ 9 | *regexp.MustCompile(`^https://player.vimeo.com/video/(?P\d+)$`), 10 | *regexp.MustCompile(`^https://vimeo.com/(?P\d+)$`), 11 | *regexp.MustCompile(`^https://vimeo.com/groups/.+?/videos/(?P\d+)$`), 12 | *regexp.MustCompile(`^https://vimeo.com/manage/videos/(?P\d+)$`), 13 | } 14 | 15 | func validateUrl(url string) int { 16 | for _, pattern := range validationPatterns { 17 | match := pattern.FindStringSubmatch(url) 18 | if match != nil { 19 | id, err := strconv.ParseInt(match[1], 10, 32) 20 | if err == nil { 21 | return int(id) 22 | } 23 | panic(err) 24 | } 25 | } 26 | return 0 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Denis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /vimego.go: -------------------------------------------------------------------------------- 1 | // Package vimego: Search, download Vimeo videos and retrieve metadata. 2 | package vimego 3 | 4 | import ( 5 | "fmt" 6 | "net/http" 7 | ) 8 | 9 | const UserAgent = "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:84.0) Gecko/20100101 Firefox/84.0" 10 | 11 | // NewVideo creates a new Video from URL. 12 | func NewVideo(url string) (*Video, error) { 13 | videoId := validateUrl(url) 14 | if videoId == 0 { 15 | return nil, ErrInvalidUrl 16 | } 17 | 18 | return &Video{ 19 | Url: url, 20 | VideoId: videoId, 21 | HTTPClient: &http.Client{}, 22 | Header: map[string][]string{"User-Agent": {UserAgent}}, 23 | }, nil 24 | } 25 | 26 | // NewVideo creates a new Video from video ID. 27 | func NewVideoFromId(videoId int) *Video { 28 | return &Video{ 29 | Url: fmt.Sprintf("https://vimeo.com/%v", videoId), 30 | VideoId: videoId, 31 | HTTPClient: &http.Client{}, 32 | Header: map[string][]string{"User-Agent": {UserAgent}}, 33 | } 34 | } 35 | 36 | // NewSearchClient creates a new SearchClient with default parameters. 37 | func NewSearchClient() *SearchClient { 38 | return &SearchClient{ 39 | PerPage: 18, 40 | Filter: VideoFilter, 41 | Order: RelevanceOrder, 42 | Direction: DescDirection, 43 | Category: AnyCategory, 44 | HTTPClient: &http.Client{}, 45 | Header: map[string][]string{"User-Agent": {UserAgent}}, 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /metadata.go: -------------------------------------------------------------------------------- 1 | package vimego 2 | 3 | import "time" 4 | 5 | type Metadata struct { 6 | ID int `json:"id"` 7 | Title string `json:"title"` 8 | Description string `json:"description"` 9 | URL string `json:"url"` 10 | UploadDate string `json:"upload_date"` 11 | ThumbnailSmall string `json:"thumbnail_small"` 12 | ThumbnailMedium string `json:"thumbnail_medium"` 13 | ThumbnailLarge string `json:"thumbnail_large"` 14 | UserID int `json:"user_id"` 15 | UserName string `json:"user_name"` 16 | UserURL string `json:"user_url"` 17 | UserPortraitSmall string `json:"user_portrait_small"` 18 | UserPortraitMedium string `json:"user_portrait_medium"` 19 | UserPortraitLarge string `json:"user_portrait_large"` 20 | UserPortraitHuge string `json:"user_portrait_huge"` 21 | Likes int `json:"stats_number_of_likes"` 22 | Plays int `json:"stats_number_of_plays"` 23 | Comments int `json:"stats_number_of_comments"` 24 | Duration int `json:"duration"` 25 | Width int `json:"width"` 26 | Height int `json:"height"` 27 | Tags string `json:"tags"` 28 | EmbedPrivacy string `json:"embed_privacy"` 29 | } 30 | 31 | // GetUploadDate returns the video upload date as time.Time object. 32 | func (m *Metadata) GetUploadDate() (time.Time, error) { 33 | return time.Parse("2006-01-02 15:04:05", m.UploadDate) 34 | } 35 | -------------------------------------------------------------------------------- /vimego_test.go: -------------------------------------------------------------------------------- 1 | package vimego 2 | 3 | import "testing" 4 | 5 | func TestMetadata(t *testing.T) { 6 | video, _ := NewVideo("https://vimeo.com/206152466") 7 | metadata, err := video.Metadata() 8 | if err != nil { 9 | t.Fatal(err) 10 | } 11 | 12 | if metadata.Title != "Crystal Castles - Kept" { 13 | t.Error("metadata.Title doesn't match") 14 | } 15 | if metadata.Duration != 243 { 16 | t.Error("metadata.Duration doesn't match") 17 | } 18 | } 19 | 20 | func TestFormats(t *testing.T) { 21 | video, _ := NewVideo("https://vimeo.com/206152466") 22 | formats, err := video.Formats() 23 | if err != nil { 24 | t.Fatal(err) 25 | } 26 | 27 | if len(formats.Progressive) == 0 { 28 | t.Error("len(formats.Progressive) == 0") 29 | } else { 30 | if formats.Progressive.Best().URL == "" { 31 | t.Error("ProgressiveFormat.URL == \"\"") 32 | } 33 | } 34 | if formats.Hls.Url() == "" { 35 | t.Error("formats.Hls.Url() == \"\"") 36 | } 37 | if formats.Dash.Url() == "" { 38 | t.Error("formats.Dash.Url() == \"\"") 39 | } 40 | } 41 | 42 | func TestSearch(t *testing.T) { 43 | client := NewSearchClient() 44 | 45 | client.Filter = VideoFilter 46 | result, err := client.Search("Rick Astley", 1) 47 | if err != nil { 48 | t.Fatal(err) 49 | } 50 | videos := result.Data.Videos() 51 | if len(videos) == 0 { 52 | t.Fatal("len(videos) == 0") 53 | } 54 | if videos[0].Link == "" { 55 | t.Error("videos[0].Link == \"\"") 56 | } 57 | 58 | client.Filter = ChannelFilter 59 | result, err = client.Search("My channel", 1) 60 | if err != nil { 61 | t.Fatal(err) 62 | } 63 | channels := result.Data.Channels() 64 | if len(channels) == 0 { 65 | t.Fatal("len(channels) == 0") 66 | } 67 | if channels[0].Link == "" { 68 | t.Error("channels[0].Link == \"\"") 69 | } 70 | 71 | client.Filter = GroupFilter 72 | result, err = client.Search("Group", 1) 73 | if err != nil { 74 | t.Fatal(err) 75 | } 76 | groups := result.Data.Groups() 77 | if len(groups) == 0 { 78 | t.Fatal("len(groups) == 0") 79 | } 80 | if groups[0].Link == "" { 81 | t.Error("groups[0].Link == \"\"") 82 | } 83 | 84 | client.Filter = PeopleFilter 85 | result, err = client.Search("Mister", 1) 86 | if err != nil { 87 | t.Fatal(err) 88 | } 89 | people := result.Data.People() 90 | if len(people) == 0 { 91 | t.Fatal("len(people) == 0") 92 | } 93 | if people[0].Link == "" { 94 | t.Error("people[0].Link == \"\"") 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /enums.go: -------------------------------------------------------------------------------- 1 | package vimego 2 | 3 | type SortDirection string 4 | 5 | const ( 6 | AscDirection SortDirection = "asc" 7 | DescDirection SortDirection = "desc" 8 | ) 9 | 10 | type SearchFilter string 11 | 12 | const ( 13 | VideoFilter SearchFilter = "clip" 14 | PeopleFilter SearchFilter = "people" 15 | ChannelFilter SearchFilter = "channel" 16 | GroupFilter SearchFilter = "group" 17 | ) 18 | 19 | type SortOrder string 20 | 21 | const ( 22 | RelevanceOrder SortOrder = "relevance" 23 | LatestOrder SortOrder = "latest" 24 | PopularityOrder SortOrder = "popularity" 25 | AlphabeticalOrder SortOrder = "alphabetical" 26 | DurationOrder SortOrder = "duration" 27 | ) 28 | 29 | type SearchCategory string 30 | 31 | const ( 32 | AnyCategory SearchCategory = "" 33 | TrailersCategory SearchCategory = "trailers" 34 | NarrativeCategory SearchCategory = "narrative" 35 | DocumentaryCategory SearchCategory = "documentary" 36 | ExperimentalCategory SearchCategory = "experimental" 37 | AnimationCategory SearchCategory = "animation" 38 | EducationalCategory SearchCategory = "educational" 39 | AdsAndCommercialsCategory SearchCategory = "adsandcommercials" 40 | MusicCategory SearchCategory = "music" 41 | BrandedContentCategory SearchCategory = "brandedcontent" 42 | SportsCategory SearchCategory = "sports" 43 | TravelCategory SearchCategory = "travel" 44 | CameraTechniquesCategory SearchCategory = "cameratechniques" 45 | ComedyCategory SearchCategory = "comedy" 46 | EventsCategory SearchCategory = "events" 47 | FashionCategory SearchCategory = "fashion" 48 | FoodCategory SearchCategory = "food" 49 | IdentsAndAnimatedLogosCategory SearchCategory = "identsandanimatedlogos" 50 | IndustryCategory SearchCategory = "industry" 51 | IndustrionalsCategory SearchCategory = "instructionals" 52 | JournalismCategory SearchCategory = "journalism" 53 | PersonalCategory SearchCategory = "personal" 54 | ProductCategory SearchCategory = "product" 55 | TalksCategory SearchCategory = "talks" 56 | TitleAndCreditsCategory SearchCategory = "titlesandcredits" 57 | VideoSchoolCategory SearchCategory = "videoschool" 58 | WeedingCategory SearchCategory = "wedding" 59 | ) 60 | -------------------------------------------------------------------------------- /formats.go: -------------------------------------------------------------------------------- 1 | package vimego 2 | 3 | type VideoFormats struct { 4 | Progressive ProgressiveFormats `json:"progressive"` 5 | Dash *DashFormat `json:"dash"` 6 | Hls *HlsFormat `json:"hls"` 7 | } 8 | 9 | type ProgressiveFormats []*ProgressiveFormat 10 | 11 | func (p ProgressiveFormats) Len() int { 12 | return len(p) 13 | } 14 | 15 | func (p ProgressiveFormats) Less(a, b int) bool { 16 | return p[a].Width < p[b].Width 17 | } 18 | 19 | func (p ProgressiveFormats) Swap(a, b int) { 20 | p[a], p[b] = p[b], p[a] 21 | } 22 | 23 | // Best returns the ProgressiveFormat with the hightest resolution. 24 | func (p ProgressiveFormats) Best() *ProgressiveFormat { 25 | if len(p) != 0 { 26 | return p[len(p)-1] 27 | } 28 | return nil 29 | } 30 | 31 | // Worst returns the ProgressiveFormat with the lowest resolution. 32 | func (p ProgressiveFormats) Worst() *ProgressiveFormat { 33 | if len(p) != 0 { 34 | return p[0] 35 | } 36 | return nil 37 | } 38 | 39 | type ProgressiveFormat struct { 40 | Profile int `json:"profile,string"` 41 | Width int `json:"width"` 42 | Mime string `json:"mime"` 43 | Fps int `json:"fps"` 44 | URL string `json:"url"` 45 | Cdn string `json:"cdn"` 46 | Quality string `json:"quality"` 47 | Origin string `json:"origin"` 48 | Height int `json:"height"` 49 | } 50 | 51 | type DashFormat struct { 52 | SeparateAv bool `json:"separate_av"` 53 | DefaultCdn string `json:"default_cdn"` 54 | Cdns struct { 55 | AkfireInterconnectQuic struct { 56 | URL string `json:"url"` 57 | Origin string `json:"origin"` 58 | AvcURL string `json:"avc_url"` 59 | } `json:"akfire_interconnect_quic"` 60 | FastlySkyfire struct { 61 | URL string `json:"url"` 62 | Origin string `json:"origin"` 63 | AvcURL string `json:"avc_url"` 64 | } `json:"fastly_skyfire"` 65 | } `json:"cdns"` 66 | } 67 | 68 | // Url returns the URL for the video stream. 69 | func (s *DashFormat) Url() string { 70 | switch s.DefaultCdn { 71 | case "akfire_interconnect_quic": 72 | return s.Cdns.AkfireInterconnectQuic.URL 73 | case "fastly_skyfire": 74 | return s.Cdns.FastlySkyfire.URL 75 | default: 76 | // fallback 77 | if s.Cdns.AkfireInterconnectQuic.URL != "" { 78 | return s.Cdns.AkfireInterconnectQuic.URL 79 | } 80 | if s.Cdns.FastlySkyfire.URL != "" { 81 | return s.Cdns.FastlySkyfire.URL 82 | } 83 | } 84 | return "" 85 | } 86 | 87 | type HlsFormat struct { 88 | SeparateAv bool `json:"separate_av"` 89 | DefaultCdn string `json:"default_cdn"` 90 | Cdns struct { 91 | AkfireInterconnectQuic struct { 92 | URL string `json:"url"` 93 | Origin string `json:"origin"` 94 | AvcURL string `json:"avc_url"` 95 | } `json:"akfire_interconnect_quic"` 96 | FastlySkyfire struct { 97 | URL string `json:"url"` 98 | Origin string `json:"origin"` 99 | AvcURL string `json:"avc_url"` 100 | } `json:"fastly_skyfire"` 101 | } `json:"cdns"` 102 | } 103 | 104 | // Url returns the URL for the .m3u8 playlist. 105 | func (s *HlsFormat) Url() string { 106 | switch s.DefaultCdn { 107 | case "akfire_interconnect_quic": 108 | return s.Cdns.AkfireInterconnectQuic.URL 109 | case "fastly_skyfire": 110 | return s.Cdns.FastlySkyfire.URL 111 | default: 112 | // fallback 113 | if s.Cdns.AkfireInterconnectQuic.URL != "" { 114 | return s.Cdns.AkfireInterconnectQuic.URL 115 | } 116 | if s.Cdns.FastlySkyfire.URL != "" { 117 | return s.Cdns.FastlySkyfire.URL 118 | } 119 | } 120 | return "" 121 | } 122 | -------------------------------------------------------------------------------- /dash.go: -------------------------------------------------------------------------------- 1 | package vimego 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | ) 10 | 11 | type DashStreams struct { 12 | ClipID string `json:"clip_id"` 13 | BaseURL string `json:"base_url"` 14 | Video DashVideoStreams `json:"video"` 15 | Audio DashAudioStreams `json:"audio"` 16 | } 17 | 18 | type DashSegment struct { 19 | Start float64 `json:"start"` 20 | End float64 `json:"end"` 21 | URL string `json:"url"` 22 | Size int `json:"size"` 23 | } 24 | 25 | type DashVideoStreams []*DashVideoStream 26 | 27 | func (d DashVideoStreams) Len() int { 28 | return len(d) 29 | } 30 | 31 | func (d DashVideoStreams) Less(a, b int) bool { 32 | return d[a].Bitrate < d[b].Bitrate 33 | } 34 | 35 | func (d DashVideoStreams) Swap(a, b int) { 36 | d[a], d[b] = d[b], d[a] 37 | } 38 | 39 | // Best returns the DashVideoStream with the highest bitrate. 40 | func (d DashVideoStreams) Best() *DashVideoStream { 41 | if len(d) != 0 { 42 | return d[len(d)-1] 43 | } 44 | return nil 45 | } 46 | 47 | // Worst returns the DashVideoStream with the lowest bitrate. 48 | func (d DashVideoStreams) Worst() *DashVideoStream { 49 | if len(d) != 0 { 50 | return d[0] 51 | } 52 | return nil 53 | } 54 | 55 | type DashAudioStreams []*DashAudioStream 56 | 57 | func (d DashAudioStreams) Len() int { 58 | return len(d) 59 | } 60 | 61 | func (d DashAudioStreams) Less(a, b int) bool { 62 | return d[a].Bitrate < d[b].Bitrate 63 | } 64 | 65 | func (d DashAudioStreams) Swap(a, b int) { 66 | d[a], d[b] = d[b], d[a] 67 | } 68 | 69 | // Best returns the DashAudioStream with the highest bitrate. 70 | func (d DashAudioStreams) Best() *DashAudioStream { 71 | if len(d) != 0 { 72 | return d[len(d)-1] 73 | } 74 | return nil 75 | } 76 | 77 | // Worst returns the DashAudioStream with the lowest bitrate. 78 | func (d DashAudioStreams) Worst() *DashAudioStream { 79 | if len(d) != 0 { 80 | return d[0] 81 | } 82 | return nil 83 | } 84 | 85 | type DashStream struct { 86 | ID string `json:"id"` 87 | URL string `json:"url"` 88 | BaseURL string `json:"base_url"` 89 | Format string `json:"format"` 90 | MimeType string `json:"mime_type"` 91 | Codecs string `json:"codecs"` 92 | Bitrate int `json:"bitrate"` 93 | AvgBitrate int `json:"avg_bitrate"` 94 | Duration float64 `json:"duration"` 95 | MaxSegmentDuration int `json:"max_segment_duration"` 96 | InitSegment string `json:"init_segment"` 97 | Segments []*DashSegment `json:"segments"` 98 | } 99 | 100 | // Readers returns an io.ReadCloser for reading streaming data. 101 | func (s *DashStream) Reader(httpClient *http.Client) (io.ReadCloser, int64, error) { 102 | if httpClient == nil { 103 | httpClient = http.DefaultClient 104 | } 105 | req, _ := http.NewRequest("GET", "", nil) 106 | 107 | r, w := io.Pipe() 108 | var length int64 109 | 110 | initSegment, err := base64.StdEncoding.DecodeString(s.InitSegment) 111 | if err != nil { 112 | return nil, 0, err 113 | } 114 | 115 | length += int64(len(initSegment)) 116 | for _, chunk := range s.Segments { 117 | length += int64(chunk.Size) 118 | } 119 | 120 | loadChunk := func(chunkUrl string) error { 121 | newUrl, err := url.Parse(chunkUrl) 122 | if err != nil { 123 | return err 124 | } 125 | req.URL = newUrl 126 | 127 | resp, err := httpClient.Do(req) 128 | if err != nil { 129 | return err 130 | } 131 | defer resp.Body.Close() 132 | 133 | if resp.StatusCode >= 400 { 134 | return ErrUnexpectedStatusCode(resp.StatusCode) 135 | } 136 | 137 | _, err = io.Copy(w, resp.Body) 138 | return err 139 | } 140 | 141 | go func() { 142 | // load the init chunk 143 | _, err := io.Copy(w, bytes.NewReader(initSegment)) 144 | if err != nil { 145 | _ = w.CloseWithError(err) 146 | return 147 | } 148 | 149 | // load all the chunks 150 | for _, chunk := range s.Segments { 151 | err := loadChunk(s.URL + chunk.URL) 152 | if err != nil { 153 | _ = w.CloseWithError(err) 154 | return 155 | } 156 | } 157 | 158 | w.Close() 159 | }() 160 | 161 | return r, length, nil 162 | } 163 | 164 | type DashVideoStream struct { 165 | Framerate float64 `json:"framerate"` 166 | Width int `json:"width"` 167 | Height int `json:"height"` 168 | DashStream 169 | } 170 | 171 | type DashAudioStream struct { 172 | Channels int `json:"channels"` 173 | SampleRate int `json:"sample_rate"` 174 | DashStream 175 | } 176 | -------------------------------------------------------------------------------- /video.go: -------------------------------------------------------------------------------- 1 | package vimego 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | "regexp" 10 | "sort" 11 | "strings" 12 | ) 13 | 14 | type Video struct { 15 | Url string 16 | VideoId int 17 | 18 | Header map[string][]string 19 | HTTPClient *http.Client 20 | } 21 | 22 | // Metadata returns the video metadata. 23 | func (v *Video) Metadata() (*Metadata, error) { 24 | req, _ := http.NewRequest( 25 | "GET", 26 | fmt.Sprintf("http://vimeo.com/api/v2/video/%v.json", 27 | v.VideoId), 28 | nil, 29 | ) 30 | req.Header = v.Header 31 | resp, err := v.HTTPClient.Do(req) 32 | if err != nil { 33 | return nil, err 34 | } 35 | defer resp.Body.Close() 36 | 37 | if resp.StatusCode >= 400 { 38 | return nil, ErrUnexpectedStatusCode(resp.StatusCode) 39 | } 40 | 41 | var result []*Metadata 42 | err = json.NewDecoder(resp.Body).Decode(&result) 43 | if err != nil { 44 | return nil, fmt.Errorf("couldn't decode metadata JSON: %w", err) 45 | } 46 | 47 | return result[0], nil 48 | } 49 | 50 | // Formats returns the video formats. 51 | // Progressive formats contain direct video+audio streams up to 1080p. 52 | // Hls format contains an URL to .m3u8 playlist with all possible streams. 53 | // Dash format contains a JSON URL that can be parsed using GetDashStreams. 54 | func (v *Video) Formats() (*VideoFormats, error) { 55 | configUrl := fmt.Sprintf("https://player.vimeo.com/video/%v/config", v.VideoId) 56 | req, _ := http.NewRequest("GET", configUrl, nil) 57 | req.Header = v.Header 58 | resp, err := v.HTTPClient.Do(req) 59 | if err != nil { 60 | return nil, err 61 | } 62 | defer resp.Body.Close() 63 | 64 | var configData struct { 65 | Request struct { 66 | Files *VideoFormats `json:"files"` 67 | } `json:"request"` 68 | } 69 | 70 | if resp.StatusCode < 400 { 71 | err = json.NewDecoder(resp.Body).Decode(&configData) 72 | if err != nil { 73 | return nil, fmt.Errorf("couldn't decode config JSON: %w", err) 74 | } 75 | } else { 76 | if resp.StatusCode == 403 { 77 | // If the response is forbidden it tries another way to fetch link 78 | req, _ := http.NewRequest("GET", v.Url, nil) 79 | req.Header = v.Header 80 | resp, err := v.HTTPClient.Do(req) 81 | if err != nil { 82 | return nil, err 83 | } 84 | defer resp.Body.Close() 85 | 86 | if resp.StatusCode < 400 { 87 | pattern := fmt.Sprintf( 88 | `"(%s.+?)"`, 89 | strings.ReplaceAll(configUrl, "/", `\\/`), 90 | ) 91 | rexp, err := regexp.Compile(pattern) 92 | if err != nil { 93 | return nil, ErrParsingFailed 94 | } 95 | body, err := io.ReadAll(resp.Body) 96 | if err != nil { 97 | return nil, ErrParsingFailed 98 | } 99 | configUrls := rexp.FindAll(body, 1) 100 | if len(configUrls) == 0 { 101 | return nil, ErrParsingFailed 102 | } 103 | configUrl := strings.Trim(strings.ReplaceAll(string(configUrls[0]), `\/`, "/"), `"`) 104 | req, err := http.NewRequest("GET", configUrl, nil) 105 | if err != nil { 106 | return nil, ErrParsingFailed 107 | } 108 | req.Header = v.Header 109 | resp, err := v.HTTPClient.Do(req) 110 | if err != nil { 111 | return nil, err 112 | } 113 | defer resp.Body.Close() 114 | err = json.NewDecoder(resp.Body).Decode(&configData) 115 | if err != nil { 116 | return nil, fmt.Errorf("couldn't decode config JSON: %w", err) 117 | } 118 | } else { 119 | return nil, ErrParsingFailed 120 | } 121 | } else { 122 | return nil, ErrParsingFailed 123 | } 124 | } 125 | if configData.Request.Files == nil { 126 | return nil, ErrParsingFailed 127 | } 128 | sort.Sort(configData.Request.Files.Progressive) 129 | return configData.Request.Files, nil 130 | } 131 | 132 | // GetDashStreams returns DASH streams of the video. 133 | func (v *Video) GetDashStreams(dashUrl string) (*DashStreams, error) { 134 | req, _ := http.NewRequest("GET", dashUrl, nil) 135 | req.Header = v.Header 136 | resp, err := v.HTTPClient.Do(req) 137 | if err != nil { 138 | return nil, err 139 | } 140 | defer resp.Body.Close() 141 | 142 | var result DashStreams 143 | err = json.NewDecoder(resp.Body).Decode(&result) 144 | if err != nil { 145 | return nil, fmt.Errorf("couldn't decode dash JSON: %w", err) 146 | } 147 | 148 | formaturl, _ := url.Parse(dashUrl) 149 | baseurl, _ := url.Parse(result.BaseURL) 150 | baseurl = formaturl.ResolveReference(baseurl) 151 | 152 | for _, stream := range result.Video { 153 | refurl, _ := url.Parse(stream.BaseURL) 154 | stream.URL = baseurl.ResolveReference(refurl).String() 155 | } 156 | for _, stream := range result.Audio { 157 | refurl, _ := url.Parse(stream.BaseURL) 158 | stream.URL = baseurl.ResolveReference(refurl).String() 159 | } 160 | 161 | sort.Sort(result.Video) 162 | sort.Sort(result.Audio) 163 | 164 | return &result, nil 165 | } 166 | -------------------------------------------------------------------------------- /search.go: -------------------------------------------------------------------------------- 1 | package vimego 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | "sync" 9 | "time" 10 | ) 11 | 12 | type SearchClient struct { 13 | PerPage int 14 | Filter SearchFilter 15 | Order SortOrder 16 | Direction SortDirection 17 | Category SearchCategory 18 | 19 | Header map[string][]string 20 | HTTPClient *http.Client 21 | 22 | token string 23 | tokenMu sync.Mutex 24 | } 25 | 26 | func (c *SearchClient) getToken() (string, error) { 27 | req, _ := http.NewRequest("GET", "https://vimeo.com/_rv/jwt", nil) 28 | req.Header = map[string][]string{"X-Requested-With": {"XMLHttpRequest"}} 29 | resp, err := c.HTTPClient.Do(req) 30 | if err != nil { 31 | return "", err 32 | } 33 | defer resp.Body.Close() 34 | 35 | var result struct { 36 | Token string `json:"token"` 37 | } 38 | 39 | err = json.NewDecoder(resp.Body).Decode(&result) 40 | if err != nil { 41 | return "", fmt.Errorf("couldn't decode token JSON: %w", err) 42 | } 43 | 44 | return result.Token, nil 45 | } 46 | 47 | // Search returns the result from the requested page. 48 | func (c *SearchClient) Search(query string, page int) (*SearchResult, error) { 49 | var token string 50 | c.tokenMu.Lock() 51 | if c.token == "" { 52 | newToken, err := c.getToken() 53 | if err != nil { 54 | c.tokenMu.Unlock() 55 | return nil, err 56 | } 57 | token = newToken 58 | c.token = newToken 59 | } else { 60 | token = c.token 61 | } 62 | c.tokenMu.Unlock() 63 | 64 | params := url.Values{} 65 | params.Add("fields", "search_web") 66 | params.Add("query", query) 67 | params.Add("filter_type", string(c.Filter)) 68 | params.Add("sort", string(c.Order)) 69 | params.Add("direction", string(c.Direction)) 70 | if c.Category != "" { 71 | params.Add("filter_category", string(c.Category)) 72 | } 73 | params.Add("page", fmt.Sprint(page)) 74 | params.Add("per_page", fmt.Sprint(c.PerPage)) 75 | req, _ := http.NewRequest("GET", "https://api.vimeo.com/search?"+params.Encode(), nil) 76 | req.Header = c.Header 77 | req.Header["Authorization"] = []string{"jwt " + token} 78 | resp, err := c.HTTPClient.Do(req) 79 | if err != nil { 80 | return nil, err 81 | } 82 | defer resp.Body.Close() 83 | 84 | result := SearchResult{} 85 | 86 | if resp.StatusCode == 200 { 87 | err := json.NewDecoder(resp.Body).Decode(&result) 88 | if err != nil { 89 | return nil, fmt.Errorf("couldn't decode search JSON: %w", err) 90 | } 91 | } else { 92 | if resp.StatusCode == 401 { 93 | // trying to received a new token 94 | c.token = "" 95 | var token string 96 | c.tokenMu.Lock() 97 | if c.token == "" { 98 | newToken, err := c.getToken() 99 | if err != nil { 100 | c.tokenMu.Unlock() 101 | return nil, err 102 | } 103 | token = newToken 104 | c.token = newToken 105 | } else { 106 | // the token was received in parallel request 107 | // while the mutex was locked 108 | token = c.token 109 | } 110 | c.tokenMu.Unlock() 111 | 112 | req.Header["Authorization"] = []string{"jwt " + token} 113 | resp, err := c.HTTPClient.Do(req) 114 | if err != nil { 115 | return nil, err 116 | } 117 | defer resp.Body.Close() 118 | 119 | if resp.StatusCode != 200 { 120 | return nil, ErrUnexpectedStatusCode(resp.StatusCode) 121 | } 122 | 123 | err = json.NewDecoder(resp.Body).Decode(&result) 124 | if err != nil { 125 | return nil, fmt.Errorf("couldn't decode search JSON: %w", err) 126 | } 127 | } else { 128 | return nil, ErrUnexpectedStatusCode(resp.StatusCode) 129 | } 130 | } 131 | 132 | return &result, nil 133 | } 134 | 135 | type SearchResult struct { 136 | Total int `json:"total"` 137 | Page int `json:"page"` 138 | PerPage int `json:"per_page"` 139 | Data SearchData `json:"data"` 140 | } 141 | 142 | type SearchData []struct { 143 | Type string `json:"type"` 144 | Video *VideoItem `json:"clip,omitempty"` 145 | People *PeopleItem `json:"people,omitempty"` 146 | Channel *ChannelItem `json:"channel,omitempty"` 147 | Group *GroupItem `json:"group,omitempty"` 148 | } 149 | 150 | func (d SearchData) Videos() []*VideoItem { 151 | result := []*VideoItem{} 152 | for _, item := range d { 153 | if item.Type == "clip" { 154 | result = append(result, item.Video) 155 | } 156 | } 157 | return result 158 | } 159 | 160 | func (d SearchData) People() []*PeopleItem { 161 | result := []*PeopleItem{} 162 | for _, item := range d { 163 | if item.Type == "people" { 164 | result = append(result, item.People) 165 | } 166 | } 167 | return result 168 | } 169 | 170 | func (d SearchData) Channels() []*ChannelItem { 171 | result := []*ChannelItem{} 172 | for _, item := range d { 173 | if item.Type == "channel" { 174 | result = append(result, item.Channel) 175 | } 176 | } 177 | return result 178 | } 179 | 180 | func (d SearchData) Groups() []*GroupItem { 181 | result := []*GroupItem{} 182 | for _, item := range d { 183 | if item.Type == "group" { 184 | result = append(result, item.Group) 185 | } 186 | } 187 | return result 188 | } 189 | 190 | type VideoItem struct { 191 | Name string `json:"name"` 192 | Link string `json:"link"` 193 | Duration int `json:"duration"` 194 | CreatedTime time.Time `json:"created_time"` 195 | Privacy struct { 196 | View string `json:"view"` 197 | } `json:"privacy"` 198 | Pictures struct { 199 | Sizes []PictureSize `json:"sizes"` 200 | } `json:"pictures"` 201 | Metadata struct { 202 | Connections struct { 203 | Comments struct { 204 | Total int `json:"total"` 205 | } `json:"comments"` 206 | Likes struct { 207 | Total int `json:"total"` 208 | } `json:"likes"` 209 | } `json:"connections"` 210 | } `json:"metadata"` 211 | User struct { 212 | Name string `json:"name"` 213 | Link string `json:"link"` 214 | Location string `json:"location"` 215 | Pictures struct { 216 | Sizes []PictureSize `json:"sizes"` 217 | } `json:"pictures"` 218 | } `json:"user"` 219 | } 220 | 221 | type PeopleItem struct { 222 | Name string `json:"name"` 223 | Link string `json:"link"` 224 | Location string `json:"location"` 225 | Pictures struct { 226 | Sizes []PictureSize `json:"sizes"` 227 | } `json:"pictures"` 228 | Metadata struct { 229 | Connections struct { 230 | Followers struct { 231 | Total int `json:"total"` 232 | } `json:"followers"` 233 | Videos struct { 234 | Total int `json:"total"` 235 | } `json:"videos"` 236 | } `json:"connections"` 237 | } `json:"badge"` 238 | } 239 | 240 | type ChannelItem struct { 241 | Name string `json:"name"` 242 | Link string `json:"link"` 243 | Pictures struct { 244 | Sizes []PictureSize `json:"sizes"` 245 | } `json:"pictures"` 246 | Metadata struct { 247 | Connections struct { 248 | Users struct { 249 | Total int `json:"total"` 250 | } `json:"users"` 251 | Videos struct { 252 | Total int `json:"total"` 253 | } `json:"videos"` 254 | } `json:"connections"` 255 | } `json:"metadata"` 256 | } 257 | 258 | type GroupItem struct { 259 | Name string `json:"name"` 260 | Link string `json:"link"` 261 | Pictures struct { 262 | Sizes []PictureSize `json:"sizes"` 263 | } `json:"pictures"` 264 | Metadata struct { 265 | Connections struct { 266 | Users struct { 267 | Total int `json:"total"` 268 | } `json:"users"` 269 | Videos struct { 270 | Total int `json:"total"` 271 | } `json:"videos"` 272 | } `json:"connections"` 273 | } `json:"metadata"` 274 | } 275 | 276 | type PictureSize struct { 277 | Width int `json:"width"` 278 | Height int `json:"height"` 279 | Link string `json:"link"` 280 | } 281 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vimego 2 | 3 | ### Search, download Vimeo videos and retrieve metadata. 4 | 5 | #### Largely based on [yashrathi](https://github.com/yashrathi-git)'s [vimeo_downloader](https://github.com/yashrathi-git/vimeo_downloader). 6 | 7 | ## Installing 8 | 9 | ```bash 10 | go get github.com/raitonoberu/vimego 11 | ``` 12 | 13 | ## Usage 14 | 15 | ### Get a direct URL for the best available .mp4 stream (video+audio) 16 | 17 | ```go 18 | package main 19 | 20 | import ( 21 | "fmt" 22 | "github.com/raitonoberu/vimego" 23 | ) 24 | 25 | func main() { 26 | video, _ := vimego.NewVideo("https://vimeo.com/206152466") 27 | formats, err := video.Formats() 28 | if err != nil { 29 | panic(err) 30 | } 31 | 32 | fmt.Println(formats.Progressive.Best().URL) 33 | // https://vod-progressive.akamaized.net/... 34 | } 35 | ``` 36 | 37 | ### Get metadata 38 | 39 | ```go 40 | package main 41 | 42 | import ( 43 | "encoding/json" 44 | "fmt" 45 | "github.com/raitonoberu/vimego" 46 | ) 47 | 48 | func main() { 49 | video, _ := vimego.NewVideo("https://vimeo.com/206152466") 50 | metadata, err := video.Metadata() 51 | if err != nil { 52 | panic(err) 53 | } 54 | 55 | jsonstr, _ := json.Marshal(metadata) 56 | fmt.Println(string(jsonstr)) 57 | } 58 | ``` 59 |
60 | Example Result 61 | 62 | ```json 63 | { 64 | "id": 206152466, 65 | "title": "Crystal Castles - Kept", 66 | "description": "", 67 | "url": "https://vimeo.com/206152466", 68 | "upload_date": "2017-02-28 18:07:25", 69 | "thumbnail_small": "http://i.vimeocdn.com/video/621091880_100x75", 70 | "thumbnail_medium": "http://i.vimeocdn.com/video/621091880_200x150", 71 | "thumbnail_large": "http://i.vimeocdn.com/video/621091880_640", 72 | "user_id": 19229427, 73 | "user_name": "Vladislav Donets", 74 | "user_url": "https://vimeo.com/donec", 75 | "user_portrait_small": "http://i.vimeocdn.com/portrait/8438592_30x30", 76 | "user_portrait_medium": "http://i.vimeocdn.com/portrait/8438592_75x75", 77 | "user_portrait_large": "http://i.vimeocdn.com/portrait/8438592_100x100", 78 | "user_portrait_huge": "http://i.vimeocdn.com/portrait/8438592_300x300", 79 | "stats_number_of_likes": 211, 80 | "stats_number_of_plays": 65095, 81 | "stats_number_of_comments": 17, 82 | "duration": 243, 83 | "width": 1280, 84 | "height": 720, 85 | "tags": "Crystal Castles", 86 | "embed_privacy": "anywhere" 87 | } 88 | ``` 89 |
90 | 91 | ### Search for videos 92 | 93 | ```go 94 | package main 95 | 96 | import ( 97 | "encoding/json" 98 | "fmt" 99 | "github.com/raitonoberu/vimego" 100 | ) 101 | 102 | func main() { 103 | client := vimego.NewSearchClient() 104 | result, err := client.Search("Rick Astley", 1) 105 | if err != nil { 106 | panic(err) 107 | } 108 | video := result.Data.Videos()[0] 109 | 110 | jsonstr, _ := json.Marshal(video) 111 | fmt.Println(string(jsonstr)) 112 | } 113 | ``` 114 |
115 | Example Result 116 | 117 | ```json 118 | { 119 | "name":"The Rick Astley Remixer", 120 | "link":"https://vimeo.com/dinahmoe/the-rick-astley-project", 121 | "duration":182, 122 | "created_time":"2011-06-21T22:30:02Z", 123 | "privacy":{ 124 | "view":"anybody" 125 | }, 126 | "pictures":{ 127 | "sizes":[ 128 | { 129 | "width":100, 130 | "height":75, 131 | "link":"https://i.vimeocdn.com/video/167407170-345a400d1c7c4919f9bf098da33dba5673eb0cba165da8559516acd3e64d7f07-d_100x75?r=pad" 132 | }, 133 | { 134 | "width":200, 135 | "height":150, 136 | "link":"https://i.vimeocdn.com/video/167407170-345a400d1c7c4919f9bf098da33dba5673eb0cba165da8559516acd3e64d7f07-d_200x150?r=pad" 137 | }, 138 | { 139 | "width":295, 140 | "height":166, 141 | "link":"https://i.vimeocdn.com/video/167407170-345a400d1c7c4919f9bf098da33dba5673eb0cba165da8559516acd3e64d7f07-d_295x166?r=pad" 142 | }, 143 | { 144 | "width":640, 145 | "height":360, 146 | "link":"https://i.vimeocdn.com/video/167407170-345a400d1c7c4919f9bf098da33dba5673eb0cba165da8559516acd3e64d7f07-d_640x360?r=pad" 147 | }, 148 | { 149 | "width":960, 150 | "height":540, 151 | "link":"https://i.vimeocdn.com/video/167407170-345a400d1c7c4919f9bf098da33dba5673eb0cba165da8559516acd3e64d7f07-d_960x540?r=pad" 152 | }, 153 | { 154 | "width":1280, 155 | "height":720, 156 | "link":"https://i.vimeocdn.com/video/167407170-345a400d1c7c4919f9bf098da33dba5673eb0cba165da8559516acd3e64d7f07-d_1280x720?r=pad" 157 | }, 158 | { 159 | "width":1920, 160 | "height":1080, 161 | "link":"https://i.vimeocdn.com/video/167407170-345a400d1c7c4919f9bf098da33dba5673eb0cba165da8559516acd3e64d7f07-d_1920x1080?r=pad" 162 | } 163 | ] 164 | }, 165 | "metadata":{ 166 | "connections":{ 167 | "comments":{ 168 | "total":3 169 | }, 170 | "likes":{ 171 | "total":32 172 | } 173 | } 174 | }, 175 | "user":{ 176 | "name":"DinahmoeSTHLM", 177 | "link":"https://vimeo.com/dinahmoe", 178 | "location":"Stockholm, Sweden", 179 | "pictures":{ 180 | "sizes":[ 181 | { 182 | "width":30, 183 | "height":30, 184 | "link":"https://i.vimeocdn.com/portrait/17506926_30x30" 185 | }, 186 | { 187 | "width":72, 188 | "height":72, 189 | "link":"https://i.vimeocdn.com/portrait/17506926_72x72" 190 | }, 191 | { 192 | "width":75, 193 | "height":75, 194 | "link":"https://i.vimeocdn.com/portrait/17506926_75x75" 195 | }, 196 | { 197 | "width":100, 198 | "height":100, 199 | "link":"https://i.vimeocdn.com/portrait/17506926_100x100" 200 | }, 201 | { 202 | "width":144, 203 | "height":144, 204 | "link":"https://i.vimeocdn.com/portrait/17506926_144x144" 205 | }, 206 | { 207 | "width":216, 208 | "height":216, 209 | "link":"https://i.vimeocdn.com/portrait/17506926_216x216" 210 | }, 211 | { 212 | "width":288, 213 | "height":288, 214 | "link":"https://i.vimeocdn.com/portrait/17506926_288x288" 215 | }, 216 | { 217 | "width":300, 218 | "height":300, 219 | "link":"https://i.vimeocdn.com/portrait/17506926_300x300" 220 | }, 221 | { 222 | "width":360, 223 | "height":360, 224 | "link":"https://i.vimeocdn.com/portrait/17506926_360x360" 225 | } 226 | ] 227 | } 228 | } 229 | } 230 | ``` 231 |
232 | 233 | ## Advanced usage 234 | 235 | ### About formats 236 | 237 | Vimeo stores its streams in 3 different formats: 238 | - **[Progressive](https://en.wikipedia.org/wiki/Progressive_download)** 239 | - A direct URL to the .mp4 stream (video+audio). 240 | - Max quality - 1080p. 241 | - Most probably, this is what you're looking for. 242 | - **[Hls](https://en.wikipedia.org/wiki/HTTP_Live_Streaming)** 243 | - An URL to the master.m3u8 playlist. 244 | - Max quality - 2160p. 245 | - Best for passing it to video players (VLC, mpv, etc.). 246 | - **[Dash](https://en.wikipedia.org/wiki/Dynamic_Adaptive_Streaming_over_HTTP)** 247 | - An URL to the JSON containing data about segments. 248 | - Max quality - 2160p. 249 | - Suitable if you need a video-only or audio-only stream. 250 | 251 | ### Get video-only or audio-only stream 252 | 253 | There is a `Video.GetDashStreams` method that parses the DASH format and provides information about the available streams. 254 | 255 | ```go 256 | package main 257 | 258 | import ( 259 | "io" 260 | "os" 261 | "github.com/raitonoberu/vimego" 262 | ) 263 | 264 | func main() { 265 | video, _ := vimego.NewVideo("https://vimeo.com/206152466") 266 | formats, _ := video.Formats() 267 | streams, _ := video.GetDashStreams(formats.Dash.Url()) 268 | 269 | stream, _, _ := streams.Audio.Best().Reader(nil) // io.ReadCloser 270 | file, _ := os.Create("output.m4a") 271 | defer file.Close() 272 | io.Copy(file, stream) 273 | } 274 | ``` 275 | 276 | ### Get embed-only videos 277 | 278 | If the video you want to download can only be played on a specific site, there is a way to get its streams. You need to set the value `Referer` in the headers. Note that `Video.Metadata()` does not work with such videos. 279 | 280 | ```go 281 | package main 282 | 283 | import ( 284 | "fmt" 285 | "github.com/raitonoberu/vimego" 286 | ) 287 | 288 | func main() { 289 | video, _ := vimego.NewVideo("https://player.vimeo.com/video/498617513") 290 | video.Header["Referer"] = []string{"https://atpstar.com/plans-162.html"} 291 | 292 | formats, _ := video.Formats() 293 | fmt.Println(formats.Progressive.Best().URL) 294 | } 295 | 296 | ``` 297 | 298 | ## Information 299 | 300 | The code seems to be ready, but I have some thoughts on improving it. 301 | 302 | ### TODO: 303 | - Handle video IDs other than int 304 | - Captcha processing 305 | - Make a CLI tool 306 | 307 | ## License 308 | 309 | **MIT License**, see [LICENSE](./LICENSE) file for additional information. 310 | --------------------------------------------------------------------------------