diff --git a/bin/udemy-dl-windows.exe b/bin/udemy-dl-windows.exe index e78ffdc..332b69c 100755 Binary files a/bin/udemy-dl-windows.exe and b/bin/udemy-dl-windows.exe differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9065ae0 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module github.com/maabiddevra/udemy-dl + +go 1.27.0 + +require ( + github.com/machinebox/progress v0.2.0 + golang.org/x/net v0.58.0 +) + +require github.com/matryer/is v1.4.1 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..335caef --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/machinebox/progress v0.2.0 h1:7z8+w32Gy1v8S6VvDoOPPBah3nLqdKjr3GUly18P8Qo= +github.com/machinebox/progress v0.2.0/go.mod h1:hl4FywxSjfmkmCrersGhmJH7KwuKl+Ueq9BXkOny+iE= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= diff --git a/main.go b/main.go index 1d496f8..a6e2932 100644 --- a/main.go +++ b/main.go @@ -12,6 +12,7 @@ import ( "math" "net/http" "os" + "path/filepath" "strconv" "strings" "time" @@ -22,18 +23,42 @@ import ( // StreamUrls download link response struct type StreamUrls struct { - Video []Video + Video []Video `json:"Video"` } -// Response download link response struct -type Response struct { - AssetType string `json:"asset_type"` - StreamUrls StreamUrls `json:"stream_urls"` +// DownloadUrls holds downloadable file URLs +type DownloadUrls struct { + Video []Video `json:"Video"` + File []Video `json:"File"` +} + +// MediaSource is the modern Udemy video source format +type MediaSource struct { + Src string `json:"src"` + Type string `json:"type"` + Label string `json:"label"` +} + +// LectureAsset is the asset payload on the lecture endpoint +type LectureAsset struct { + AssetType string `json:"asset_type"` + StreamUrls StreamUrls `json:"stream_urls"` + DownloadUrls DownloadUrls `json:"download_urls"` + MediaSources []MediaSource `json:"media_sources"` + CourseIsDrmed bool `json:"course_is_drmed"` +} + +// LectureResponse is returned by the subscribed-courses lecture API +type LectureResponse struct { + Asset LectureAsset `json:"asset"` + Title string `json:"title"` } // Video videos response struct type Video struct { - File, Type, Label string + File string `json:"file"` + Type string `json:"type"` + Label string `json:"label"` } // CourseResponse videos response struct @@ -59,6 +84,7 @@ type CourseContent struct { type Asset struct { Class string `json:"_class"` ID int + LectureID int AssetType string `json:"asset_type"` Filename string SupplementaryAssets []SupplementaryAssets `json:"supplementary_assets"` @@ -74,7 +100,7 @@ type SupplementaryAssets struct { Filename string } -// Course videso response struct +// Course videos response struct type Course struct { ID int Title, URL string @@ -95,8 +121,9 @@ type Udemy struct { // Udemy URLs const ( - GetCoursesURL = "https://www.udemy.com/api-2.0/users/me/subscribed-courses/?ordering=-last_accessed&fields[course]=@min,title,id&page=1&page_size=100" - GetDownloadURL = "https://www.udemy.com/api-2.0/assets/{{assetID}}?fields[asset]=@min,status,asset_type,time_estimation,stream_urls&fields" + GetCoursesURL = "https://www.udemy.com/api-2.0/users/me/subscribed-courses/?ordering=-last_accessed&fields[course]=@min,title,id&page=1&page_size=100" + // Lecture endpoint is required; /assets/{id}?stream_urls is obsolete and returns empty links. + GetDownloadURL = "https://www.udemy.com/api-2.0/users/me/subscribed-courses/{{courseID}}/lectures/{{lectureID}}/?fields[lecture]=asset,title&fields[asset]=asset_type,media_sources,download_urls,stream_urls,course_is_drmed,filename" GetCourseDetailURL = "https://www.udemy.com/api-2.0/courses/{{courseID}}/subscriber-curriculum-items/?page_size=1400&fields[lecture]=title,object_index,asset,supplementary_assets&fields[chapter]=title,object_index&fields[asset]=filename,asset_type&caching_intent=True" ) @@ -174,19 +201,34 @@ func BytesToMegaBytes(n int64) float64 { return math.Floor(mb*100) / 100 } +func sanitizeFilename(name string) string { + replacer := strings.NewReplacer( + "<", "-", ">", "-", ":", "-", "\"", "-", + "/", "-", "\\", "-", "|", "-", "?", "-", "*", "-", + ) + name = replacer.Replace(name) + name = strings.TrimSpace(name) + if name == "" { + return "lecture" + } + return name +} + // NewRequest to create new request for udemy -func (u Udemy) NewRequest(method, url string) *http.Response { - client := &http.Client{} +func (u Udemy) NewRequest(method, url string) (*http.Response, error) { + client := &http.Client{Timeout: 60 * time.Second} req, err := http.NewRequest(method, url, nil) - if err != nil { - fmt.Println(err) + return nil, err } req.Header.Add("Authorization", u.AccessToken) - res, err := client.Do(req) + req.Header.Add("X-Udemy-Authorization", u.AccessToken) + req.Header.Add("Accept", "application/json, text/plain, */*") + req.Header.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + req.Header.Add("Referer", "https://www.udemy.com/") - return res + return client.Do(req) } // AuthenticateToken Authnticate user provided token @@ -197,7 +239,10 @@ func (u *Udemy) AuthenticateToken() (bool, error) { } fmt.Println("[*] : Authenticating Access Token...") - resp := u.NewRequest("HEAD", GetCoursesURL) + resp, err := u.NewRequest("HEAD", GetCoursesURL) + if err != nil { + return false, fmt.Errorf("[x] : Authentication request failed: %v", err) + } defer resp.Body.Close() if resp.StatusCode > 299 { @@ -217,7 +262,10 @@ func (u Udemy) GetCourses() (bool, error) { fmt.Println("[*] : Fetching courses...") - resp := u.NewRequest("GET", GetCoursesURL) + resp, err := u.NewRequest("GET", GetCoursesURL) + if err != nil { + return false, fmt.Errorf("[x] : Error fetching courses: %v", err) + } defer resp.Body.Close() if resp.StatusCode > 299 { @@ -226,7 +274,6 @@ func (u Udemy) GetCourses() (bool, error) { body, _ := ioutil.ReadAll(resp.Body) - // fmt.Println(body) var response CourseResponse json.Unmarshal(body, &response) @@ -253,7 +300,10 @@ func (u *Udemy) GetCourseDetail() ([]Asset, error) { fmt.Println("[*] : Fetching course lectures...") url := strings.Replace(GetCourseDetailURL, "{{courseID}}", u.SelectedCourseID, 1) - res := u.NewRequest("GET", url) + res, err := u.NewRequest("GET", url) + if err != nil { + return nil, fmt.Errorf("[x] : Error fetching course lectures: %v", err) + } defer res.Body.Close() if res.StatusCode == 404 { @@ -272,7 +322,13 @@ func (u *Udemy) GetCourseDetail() ([]Asset, error) { var response CourseDetail json.Unmarshal(body, &response) - finalAsset := make([]Asset, len(response.Results)) + maxIndex := 0 + for i := range response.Results { + if response.Results[i].ObjectIndex > maxIndex { + maxIndex = response.Results[i].ObjectIndex + } + } + finalAsset := make([]Asset, maxIndex+1) fmt.Println("[+] : Lectures") @@ -281,6 +337,7 @@ func (u *Udemy) GetCourseDetail() ([]Asset, error) { fmt.Printf(" -[%v] : %v[%v] \n", response.Results[i].ObjectIndex, response.Results[i].Title, response.Results[i].Asset.AssetType) response.Results[i].Asset.Title = response.Results[i].Title response.Results[i].Asset.ObjectIndex = response.Results[i].ObjectIndex + response.Results[i].Asset.LectureID = response.Results[i].ID finalAsset[response.Results[i].ObjectIndex] = response.Results[i].Asset } } @@ -320,41 +377,129 @@ func (u *Udemy) getLecturesIDs() { } // getVideoResolution get resolution which need to download -func (u *Udemy) getVideoResolution() { +func (u *Udemy) getVideoResolution(available []string) { var resolution string - fmt.Print("[?] : Enter the video Resolution(360/480/720/1080): ") + if len(available) > 0 { + fmt.Printf("[?] : Enter the video Resolution (%s): ", strings.Join(available, "|")) + } else { + fmt.Print("[?] : Enter the video Resolution(360|480|720|1080): ") + } fmt.Scanln(&resolution) - u.Resolution = resolution + u.Resolution = strings.TrimSpace(resolution) +} + +func collectVideoLinks(asset LectureAsset) map[string]string { + links := make(map[string]string) + + add := func(label, url, typ string) { + label = strings.TrimSpace(label) + url = strings.TrimSpace(url) + if label == "" || url == "" { + return + } + if strings.EqualFold(label, "auto") || strings.EqualFold(label, "audio") { + return + } + // Prefer progressive MP4 over HLS/DASH playlists for this simple downloader. + if strings.Contains(strings.ToLower(typ), "mpegurl") || + strings.Contains(strings.ToLower(typ), "dash") || + strings.Contains(url, ".m3u8") || + strings.Contains(url, ".mpd") { + return + } + links[label] = url + } + + for _, src := range asset.MediaSources { + add(src.Label, src.Src, src.Type) + } + for _, v := range asset.DownloadUrls.Video { + add(v.Label, v.File, v.Type) + } + for _, v := range asset.StreamUrls.Video { + add(v.Label, v.File, v.Type) + } + + return links +} + +func sortedLabels(links map[string]string) []string { + labels := make([]string, 0, len(links)) + for label := range links { + labels = append(labels, label) + } + // Prefer common qualities first for display. + priority := []string{"1080", "720", "480", "360", "240"} + ordered := make([]string, 0, len(labels)) + seen := make(map[string]bool) + for _, p := range priority { + if _, ok := links[p]; ok { + ordered = append(ordered, p) + seen[p] = true + } + } + for _, label := range labels { + if !seen[label] { + ordered = append(ordered, label) + } + } + return ordered } // GetDownloadLink to get the video download link func (u *Udemy) GetDownloadLink(asset Asset) error { - u.CurrentAttempt = u.CurrentAttempt + 1 - url := strings.Replace(GetDownloadURL, "{{assetID}}", strconv.Itoa(asset.ID), 1) + if asset.LectureID == 0 { + return fmt.Errorf("[x] Missing lecture ID for asset %d (%s)", asset.ID, asset.Title) + } - res := u.NewRequest("GET", url) - defer res.Body.Close() - body, _ := ioutil.ReadAll(res.Body) + url := strings.Replace(GetDownloadURL, "{{courseID}}", u.SelectedCourseID, 1) + url = strings.Replace(url, "{{lectureID}}", strconv.Itoa(asset.LectureID), 1) - var response Response - json.Unmarshal(body, &response) + for attempt := 1; attempt <= u.SessionMaxAttempt; attempt++ { + res, err := u.NewRequest("GET", url) + if err != nil { + return fmt.Errorf("[x] Error fetching download link: %v", err) + } - var videosUrls = response.StreamUrls.Video - for i := range videosUrls { - if videosUrls[i].Label == u.Resolution { - return u.Download(videosUrls[i].File, asset) + body, readErr := ioutil.ReadAll(res.Body) + res.Body.Close() + if readErr != nil { + return fmt.Errorf("[x] Error reading download link response: %v", readErr) } - } - fmt.Printf("[x] Don't have any valid download link for resolution %v, try with different resolution. \n", u.Resolution) + if res.StatusCode > 299 { + return fmt.Errorf("[x] Error fetching lecture media (HTTP %d) for %s", res.StatusCode, asset.Title) + } - if u.SessionMaxAttempt >= u.CurrentAttempt { - u.getVideoResolution() - u.GetDownloadLink(asset) - } else { - fmt.Println("[x] Max attempt exceeded, please try again.") - os.Exit(0) + var response LectureResponse + if err := json.Unmarshal(body, &response); err != nil { + return fmt.Errorf("[x] Error parsing lecture media response: %v", err) + } + + links := collectVideoLinks(response.Asset) + available := sortedLabels(links) + + if len(links) == 0 { + if response.Asset.CourseIsDrmed { + return fmt.Errorf("[x] Lecture is DRM-protected and has no plain MP4 link: %s", asset.Title) + } + return fmt.Errorf("[x] No downloadable MP4 sources found for: %s", asset.Title) + } + + if fileURL, ok := links[u.Resolution]; ok { + return u.Download(fileURL, asset) + } + + fmt.Printf("[x] Don't have any valid download link for resolution %v.\n", u.Resolution) + fmt.Printf(" Available resolutions: %s\n", strings.Join(available, ", ")) + + if attempt == u.SessionMaxAttempt { + return fmt.Errorf("[x] Max attempt exceeded for lecture: %s", asset.Title) + } + + u.getVideoResolution(available) } + return nil } @@ -364,20 +509,38 @@ func (u *Udemy) startDownloading(courseAsset []Asset) { } if u.Resolution == "false" { - u.getVideoResolution() + u.getVideoResolution(nil) + } + + if u.Start < 0 { + u.Start = 0 + } + if u.End >= len(courseAsset) { + u.End = len(courseAsset) - 1 } for l := u.Start; l <= u.End; l++ { - if courseAsset[l].ID != 0 { - u.GetDownloadLink(courseAsset[l]) + if courseAsset[l].ID == 0 { + continue + } + if err := u.GetDownloadLink(courseAsset[l]); err != nil { + fmt.Println(err) + fmt.Println("[*] Continuing with next lecture...") } } } -// Download to download files and vidoes +// Download to download files and videos func (u *Udemy) Download(downloadURL string, asset Asset) error { + filename := sanitizeFilename(strconv.Itoa(asset.ObjectIndex) + ". " + asset.Title + ".mp4") + if u.DownloadPath != "false" && u.DownloadPath != "" { + if err := os.MkdirAll(u.DownloadPath, 0755); err != nil { + return fmt.Errorf("[x] Error creating download directory: %v", err) + } + filename = filepath.Join(u.DownloadPath, filename) + } - out, err := os.Create(strconv.Itoa(asset.ObjectIndex) + ". " + asset.Title + ".mp4") + out, err := os.Create(filename) if err != nil { return errors.New("[x] Error creating a new file, try to download from link" + downloadURL) } @@ -388,9 +551,8 @@ func (u *Udemy) Download(downloadURL string, asset Asset) error { fmt.Print(err) return err } - - size, err := strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 64) - defer resp.Body.Close() + size, _ := strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 64) + resp.Body.Close() res, err := http.Get(downloadURL) if err != nil { @@ -421,7 +583,10 @@ func (u *Udemy) Download(downloadURL string, asset Asset) error { // ParseHTMLAndGetCourseID it will parse the html content and get course id func (u *Udemy) ParseHTMLAndGetCourseID() { - res := u.NewRequest("GET", u.CourseURL) + res, err := u.NewRequest("GET", u.CourseURL) + if err != nil { + log.Fatal(err) + } defer res.Body.Close() body, _ := ioutil.ReadAll(res.Body)