diff --git a/go.mod b/go.mod index cfe8a0a..a5a8be6 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,20 @@ module github.com/YamiOdymel/chaturbate-dvr -go 1.12 +go 1.19 require ( - github.com/elazarl/goproxy v0.0.0-20210801061803-8e322dfb79c4 // indirect + github.com/TwiN/go-color v1.1.0 github.com/grafov/m3u8 v0.11.1 github.com/parnurzeal/gorequest v0.2.16 - github.com/pkg/errors v0.9.1 // indirect - github.com/smartystreets/goconvey v1.7.2 // indirect - github.com/stretchr/testify v1.7.0 // indirect github.com/teacat/pathx v0.0.0-20201109184104-55ec346a0c6d github.com/urfave/cli/v2 v2.3.0 + github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d // indirect + github.com/elazarl/goproxy v0.0.0-20210801061803-8e322dfb79c4 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/russross/blackfriday/v2 v2.0.1 // indirect + github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect + github.com/smartystreets/goconvey v1.7.2 // indirect + github.com/stretchr/testify v1.7.0 // indirect golang.org/x/net v0.0.0-20211109214657-ef0fda0de508 // indirect moul.io/http2curl v1.0.0 // indirect -) +) \ No newline at end of file diff --git a/go.sum b/go.sum index ab6d7b1..bce1b38 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,12 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/TwiN/go-color v1.1.0 h1:yhLAHgjp2iAxmNjDiVb6Z073NE65yoaPlcki1Q22yyQ= +github.com/TwiN/go-color v1.1.0/go.mod h1:aKVf4e1mD4ai2FtPifkDPP5iyoCwiK08YGzGwerjKo0= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/elazarl/goproxy v0.0.0-20210801061803-8e322dfb79c4 h1:lS3P5Nw3oPO05Lk2gFiYUOL3QPaH+fRoI1wFOc4G1UY= github.com/elazarl/goproxy v0.0.0-20210801061803-8e322dfb79c4/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= -github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2 h1:dWB6v3RcOy03t/bUadywsbyrQwCqZeNIEX6M1OtSZOM= github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2/go.mod h1:gNh8nYJoAm43RfaxurUnxr+N1PwuFV3ZMl/efxlIlY8= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= @@ -40,14 +41,8 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20211109214657-ef0fda0de508 h1:v3NKo+t/Kc3EASxaKZ82lwK6mCf4ZeObQBduYFZHo7c= golang.org/x/net v0.0.0-20211109214657-ef0fda0de508/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= diff --git a/main.go b/main.go index c0ba5ef..a354b1e 100644 --- a/main.go +++ b/main.go @@ -1,352 +1,364 @@ -package main - -import ( - "encoding/json" - "errors" - "fmt" - "io/ioutil" - "log" - "net/http" - "os" - "regexp" - "strconv" - "strings" - "time" - - "github.com/teacat/pathx" - - "github.com/grafov/m3u8" - "github.com/parnurzeal/gorequest" - "github.com/urfave/cli/v2" -) - -// chaturbateURL is the base url of the website. -const chaturbateURL = "https://chaturbate.com/" - -// retriesAfterOnlined tells the retries for stream when disconnected but not really offlined. -var retriesAfterOnlined = 0 - -// bucket stores the used segment to prevent fetched the duplicates. -var bucket []string - -// segmentIndex is current stored segment index. -var segmentIndex int - -// stripLimit reprsents the maximum Bytes sizes to split the video into chunks. -var stripLimit int - -// stripQuota represents how many Bytes left til the next video chunk stripping. -var stripQuota int - -// path save video -const savePath = "video" - -var ( - errInternal = errors.New("err") - errNoUsername = errors.New("chaturbate-dvr: channel username required with `-u [username]` argument") -) - -// roomDossier is the struct to parse the HLS source from the content body. -type roomDossier struct { - HLSSource string `json:"hls_source"` -} - -// unescapeUnicode escapes the unicode from the content body. -func unescapeUnicode(raw string) string { - str, err := strconv.Unquote(strings.Replace(strconv.Quote(string(raw)), `\\u`, `\u`, -1)) - if err != nil { - panic(err) - } - return str -} - -// getChannelURL returns the full channel url to the specified user. -func getChannelURL(username string) string { - return fmt.Sprintf("%s%s", chaturbateURL, username) -} - -// getBody gets the channel page content body. -func getBody(username string) string { - _, body, _ := gorequest.New().Get(getChannelURL(username)).End() - return body -} - -// getOnlineStatus check if the user is currently online by checking the playlist exists in the content body or not. -func getOnlineStatus(username string) bool { - return strings.Contains(getBody(username), "playlist.m3u8") -} - -// getHLSSource extracts the playlist url from the room detail page body. -func getHLSSource(body string) (string, string) { - // Get the room data from the page body. - r := regexp.MustCompile(`window\.initialRoomDossier = "(.*?)"`) - matches := r.FindAllStringSubmatch(body, -1) - - // Extract the data and get the HLS source URL. - var roomData roomDossier - data := unescapeUnicode(matches[0][1]) - err := json.Unmarshal([]byte(data), &roomData) - if err != nil { - panic(err) - } - - return roomData.HLSSource, strings.TrimRight(roomData.HLSSource, "playlist.m3u8") -} - -// parseHLSSource parses the HLS table and return the maximum resolution m3u8 source. -func parseHLSSource(url string, baseURL string) string { - _, body, _ := gorequest.New().Get(url).End() - - <-time.After(time.Millisecond * 300) - - // Decode the HLS table. - p, _, _ := m3u8.DecodeFrom(strings.NewReader(body), true) - master := p.(*m3u8.MasterPlaylist) - return fmt.Sprintf("%s%s", baseURL, master.Variants[len(master.Variants)-1].URI) -} - -// parseM3U8Source gets the current segment list, the channel might goes offline if 403 was returned. -func parseM3U8Source(url string) (chunks []*m3u8.MediaSegment, wait float64, err error) { - resp, body, errs := gorequest.New().Get(url).End() - // Retry after 3 seconds if the connection lost or status code returns 403 (the channel might went offline). - if len(errs) > 0 || resp.StatusCode == http.StatusForbidden { - return nil, 3, errInternal - } - - // Decode the segment table. - p, _, _ := m3u8.DecodeFrom(strings.NewReader(body), true) - media, ok := p.(*m3u8.MediaPlaylist) - if !ok { - return nil, 3, errInternal - } - wait = media.TargetDuration / 1.5 - - // Ignore the empty segments. - for _, v := range media.Segments { - if v != nil { - chunks = append(chunks, v) - } - } - return -} - -// capture captures the specified channel streaming. -func capture(username string) { - // Define the video filename by current time //04.09.22 added username into filename mK33y. - filename := username + "_" + time.Now().Format("2006-01-02_15-04-05") - // Get the channel page content body. - body := getBody(username) - // Get the master playlist URL from extracting the channel body. - hlsSource, baseURL := getHLSSource(body) - // Get the best resolution m3u8 by parsing the HLS source table. - m3u8Source := parseHLSSource(hlsSource, baseURL) - // Create the master video file. - masterFile, _ := os.OpenFile("./"+savePath+"/"+filename+".ts", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0777) - // - log.Printf("the video will be saved as \"./"+savePath+"/%s\".", filename+".ts") - - go combineSegment(masterFile, filename) - watchStream(m3u8Source, username, masterFile, filename, baseURL) -} - -// watchStream watches the stream and ends if the channel went offline. -func watchStream(m3u8Source string, username string, masterFile *os.File, filename string, baseURL string) { - // Keep fetching the stream chunks until the playlist cannot be accessed after retried x times. - for { - // Get the chunks. - chunks, wait, err := parseM3U8Source(m3u8Source) - // Exit the fetching loop if the channel went offline. - if err != nil { - if retriesAfterOnlined > 10 { - log.Printf("failed to fetch the video segments after retried, %s might went offline.", username) - break - } else { - log.Printf("failed to fetch the video segments, will try again. (%d/10)", retriesAfterOnlined) - retriesAfterOnlined++ - // Wait to fetch the next playlist. - <-time.After(time.Duration(wait*1000) * time.Millisecond) - continue - } - } - if retriesAfterOnlined != 0 { - log.Printf("%s is back online!", username) - retriesAfterOnlined = 0 - } - for _, v := range chunks { - // Ignore the duplicated chunks. - if isDuplicateSegment(v.URI) { - continue - } - segmentIndex++ - go fetchSegment(masterFile, v, baseURL, filename, segmentIndex) - } - <-time.After(time.Duration(wait*1000) * time.Millisecond) - } -} - -// isDuplicateSegment returns true if the segment is already been fetched. -func isDuplicateSegment(URI string) bool { - for _, v := range bucket { - if URI[len(URI)-10:] == v { - return true - } - } - bucket = append(bucket, URI[len(URI)-10:]) - return false -} - -// combineSegment combines the segments to the master video file in the background. -// fixed segment problems mK33y. -// still needs some attention here -func combineSegment(master *os.File, filename string) { - index := 1 - delete := 1 - stripIndex := 1 - var retry int - <-time.After(4 * time.Second) - - for { - <-time.After(300 * time.Millisecond) - - if index >= segmentIndex { - <-time.After(1 * time.Second) - continue - } - - if !pathx.Exists(fmt.Sprintf("./%s/%s~%d.ts", savePath, filename, index)) { - if retry >= 5 { - index++ - retry = 0 - continue - } - if retry != 0 { - log.Printf("cannot find segment %d, will try again. (%d/5)", index, retry) - } - retry++ - <-time.After(time.Duration(1*retry) * time.Second) - continue - } - if retry != 0 { - retry = 0 - } - // - b, _ := ioutil.ReadFile(fmt.Sprintf("./%s/%s~%d.ts", savePath, filename, index)) - // - if stripLimit != 0 && stripQuota <= 0 { - newMasterFilename := "./" + savePath + "/" + filename + "_" + strconv.Itoa(stripIndex) + ".ts" - master, _ = os.OpenFile(newMasterFilename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0777) - log.Printf("exceeded the specified stripping limit, creating new video file. (file: %s)", newMasterFilename) - stripQuota = stripLimit - stripIndex++ - } - master.Write(b) - log.Printf("inserting %d segment to the master file. (total: %d)", index, segmentIndex) - // - e := os.Remove(fmt.Sprintf("./%s/%s~%d.ts", savePath, filename, delete)) - if e != nil { - delete-- - } - delete++ - index++ - } -} - -// fetchSegment fetches the segment and append to the master file. -func fetchSegment(master *os.File, segment *m3u8.MediaSegment, baseURL string, filename string, index int) { - _, body, _ := gorequest.New().Get(fmt.Sprintf("%s%s", baseURL, segment.URI)).EndBytes() - log.Printf("fetching %s (size: %d)\n", segment.URI, len(body)) - if len(body) == 0 { - log.Printf("skipped %s due to the empty body!\n", segment.URI) - return - } - stripQuota -= len(body) - // - f, err := os.OpenFile(fmt.Sprintf("./%s/%s~%d.ts", savePath, filename, index), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0777) - if err != nil { - panic(err) - } - if _, err := f.Write(body); err != nil { - panic(err) - } -} - -// endpoint implements the application main function endpoint. -func endpoint(c *cli.Context) error { - if c.String("username") == "" { - log.Fatal(errNoUsername) - } - // Converts `strip` from MiB to Bytes - stripLimit = c.Int("strip") * 1024 * 1024 - stripQuota = c.Int("strip") * 1024 * 1024 - // - - fmt.Println(" .o88b. db db .d8b. d888888b db db d8888b. d8888b. .d8b. d888888b d88888b") - fmt.Println("d8P Y8 88 88 d8' `8b `~~88~~' 88 88 88 `8D 88 `8D d8' `8b `~~88~~' 88'") - fmt.Println("8P 88ooo88 88ooo88 88 88 88 88oobY' 88oooY' 88ooo88 88 88ooooo") - fmt.Println("8b 88~~~88 88~~~88 88 88 88 88`8b 88~~~b. 88~~~88 88 88~~~~~") - fmt.Println("Y8b d8 88 88 88 88 88 88b d88 88 `88. 88 8D 88 88 88 88.") - fmt.Println(" `Y88P' YP YP YP YP YP ~Y8888P' 88 YD Y8888P' YP YP YP Y88888P") - fmt.Println("d8888b. db db d8888b.") - fmt.Println("88 `8D 88 88 88 `8D") - fmt.Println("88 88 Y8 8P 88oobY'") - fmt.Println("88 88 `8b d8' 88`8b") - fmt.Println("88 .8D `8bd8' 88 `88.") - fmt.Println("Y8888D' YP 88 YD") - fmt.Println("---") - - // Mkdir video folder - if _, err := os.Stat("./" + savePath); os.IsNotExist(err) { - os.Mkdir("./"+savePath, 0777) - } - // - if c.Int("strip") != 0 { - log.Printf("specifying stripping limit as %d MiB(s)", c.Int("strip")) - } - - for { - // Capture the stream if the user is currently online. - if getOnlineStatus(c.String("username")) { - log.Printf("%s is online! fetching...", c.String("username")) - capture(c.String("username")) - segmentIndex = 0 - bucket = []string{} - retriesAfterOnlined = 0 - continue - } - // Otherwise we keep checking the channel status until the user is online. - log.Printf("%s is not online, check again after %d minute(s)...", c.String("username"), c.Int("interval")) - <-time.After(time.Minute * time.Duration(c.Int("interval"))) - } -} - -func main() { - app := &cli.App{ - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "username", - Aliases: []string{"u"}, - Value: "", - Usage: "channel username to watching", - }, - &cli.IntFlag{ - Name: "interval", - Aliases: []string{"i"}, - Value: 1, - Usage: "minutes to check if a channel goes online or not", - }, - &cli.IntFlag{ - Name: "strip", - Aliases: []string{"s"}, - Value: 0, - Usage: "MB sizes to split the video into chunks", - }, - }, - Name: "chaturbate-dvr", - Usage: "watching a specified chaturbate channel and auto saves the stream as local file", - Action: endpoint, - } - err := app.Run(os.Args) - if err != nil { - log.Fatal(err) - } -} +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "log" + "net/http" + "os" + "regexp" + "strconv" + "strings" + "time" + + "github.com/TwiN/go-color" + "github.com/teacat/pathx" + + "github.com/grafov/m3u8" + "github.com/parnurzeal/gorequest" + "github.com/urfave/cli/v2" +) + +// chaturbateURL is the base url of the website. +const chaturbateURL = "https://chaturbate.com/" + +// retriesAfterOnlined tells the retries for stream when disconnected but not really offlined. +var retriesAfterOnlined = 0 + +// temp stores the used segment to prevent fetched the duplicates. +var temp []string + +// segmentIndex is current stored segment index. +var segmentIndex int + +// stripLimit reprsents the maximum Bytes sizes to split the video into chunks. +var stripLimit int + +// stripQuota represents how many Bytes left til the next video chunk stripping. +var stripQuota int + +// path save video +const savePath = "video" + +// error/message handler +var ( + errInternal = errors.New("err") + errNoUsername = errors.New("recording: channel username required `-u [USERNAME]` option") + errSegRetFail = color.Colorize(color.Red, ("[FAILED] to fetch the video segments after retried, %s might went offline or is in ticket/privat show.")) + errSegRetFailOnline = color.Colorize(color.Red, ("[FAILED] to fetch the video segments, will try again. [%d/10]")) + infoIsOnline = color.Colorize(color.Green, ("[RECORDING] %s is online! start fetching..")) + infoBackOnline = color.Colorize(color.Green, ("[INFO] %s is back online!")) + infoMergeSegment = color.Colorize(color.Green, ("[INFO] inserting %d segment to the master file. [total: %d]")) + infoSkipped = color.Colorize(color.Blue, ("[INFO] skipped %s due to the empty body!\n")) + infoNotOnline = color.Colorize(color.Gray, ("[INFO] %s is not online, check again in %d minute(s)")) + warningSegment = color.Colorize(color.Yellow, ("[WARNING] cannot find segment %d, will try again. [%d/5]")) +) + +// roomDossier is the struct to parse the HLS source from the content body. +type roomDossier struct { + HLSSource string `json:"hls_source"` +} + +// unescapeUnicode escapes the unicode from the content body. +func unescapeUnicode(raw string) string { + str, err := strconv.Unquote(strings.Replace(strconv.Quote(string(raw)), `\\u`, `\u`, -1)) + if err != nil { + panic(err) + } + return str +} + +// getChannelURL returns the full channel url to the specified user. +func getChannelURL(username string) string { + return fmt.Sprintf("%s%s", chaturbateURL, username) +} + +// getBody gets the channel page content body. +func getBody(username string) string { + _, body, _ := gorequest.New().Get(getChannelURL(username)).End() + return body +} + +// getOnlineStatus check if the user is currently online by checking the playlist exists in the content body or not. +func getOnlineStatus(username string) bool { + return strings.Contains(getBody(username), "playlist.m3u8") +} + +// getHLSSource extracts the playlist url from the room detail page body. +func getHLSSource(body string) (string, string) { + // Get the room data from the page body. + r := regexp.MustCompile(`window\.initialRoomDossier = "(.*?)"`) + matches := r.FindAllStringSubmatch(body, -1) + + // Extract the data and get the HLS source URL. + var roomData roomDossier + data := unescapeUnicode(matches[0][1]) + err := json.Unmarshal([]byte(data), &roomData) + if err != nil { + panic(err) + } + + return roomData.HLSSource, strings.TrimRight(roomData.HLSSource, "playlist.m3u8") +} + +// parseHLSSource parses the HLS table and return the maximum resolution m3u8 source. +func parseHLSSource(url string, baseURL string) string { + _, body, _ := gorequest.New().Get(url).End() + + <-time.After(time.Millisecond * 300) + + // Decode the HLS table. + p, _, _ := m3u8.DecodeFrom(strings.NewReader(body), true) + master := p.(*m3u8.MasterPlaylist) + return fmt.Sprintf("%s%s", baseURL, master.Variants[len(master.Variants)-1].URI) +} + +// parseM3U8Source gets the current segment list, the channel might goes offline if 403 was returned. +func parseM3U8Source(url string) (chunks []*m3u8.MediaSegment, wait float64, err error) { + resp, body, errs := gorequest.New().Get(url).End() + // Retry after 3 seconds if the connection lost or status code returns 403 (the channel might went offline). + if len(errs) > 0 || resp.StatusCode == http.StatusForbidden { + return nil, 3, errInternal + } + + // Decode the segment table. + p, _, _ := m3u8.DecodeFrom(strings.NewReader(body), true) + media, ok := p.(*m3u8.MediaPlaylist) + if !ok { + return nil, 3, errInternal + } + wait = media.TargetDuration / 1.5 + + // Ignore the empty segments. + for _, v := range media.Segments { + if v != nil { + chunks = append(chunks, v) + } + } + return +} + +// capture captures the specified channel streaming. +func capture(username string) { + // Define the video filename by current time //04.09.22 added username into filename mK33y. + filename := username + "_" + time.Now().Format("2006-01-02_15-04-05") + // Get the channel page content body. + body := getBody(username) + // Get the master playlist URL from extracting the channel body. + hlsSource, baseURL := getHLSSource(body) + // Get the best resolution m3u8 by parsing the HLS source table. + m3u8Source := parseHLSSource(hlsSource, baseURL) + // Create the master video file. + masterFile, _ := os.OpenFile("./"+savePath+"/"+filename+".ts", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0777) + // + log.Printf("the video will be saved as \"./"+savePath+"/%s\".", filename+".ts") + + go combineSegment(masterFile, filename) + watchStream(m3u8Source, username, masterFile, filename, baseURL) +} + +// watchStream watches the stream and ends if the channel went offline. +func watchStream(m3u8Source string, username string, masterFile *os.File, filename string, baseURL string) { + // Keep fetching the stream chunks until the playlist cannot be accessed after retried x times. + for { + // Get the chunks. + chunks, wait, err := parseM3U8Source(m3u8Source) + // Exit the fetching loop if the channel went offline. + if err != nil { + if retriesAfterOnlined > 10 { + log.Printf(errSegRetFail, username) + break + } else { + log.Printf(errSegRetFailOnline, retriesAfterOnlined) + retriesAfterOnlined++ + // Wait to fetch the next playlist. + <-time.After(time.Duration(wait*1000) * time.Millisecond) + continue + } + } + if retriesAfterOnlined != 0 { + log.Printf(infoBackOnline, username) + retriesAfterOnlined = 0 + } + for _, v := range chunks { + // Ignore the duplicated chunks. + if isDuplicateSegment(v.URI) { + continue + } + segmentIndex++ + go fetchSegment(masterFile, v, baseURL, filename, segmentIndex) + } + <-time.After(time.Duration(wait*1000) * time.Millisecond) + } +} + +// isDuplicateSegment returns true if the segment is already been fetched. +func isDuplicateSegment(URI string) bool { + for _, v := range temp { + if URI[len(URI)-10:] == v { + return true + } + } + temp = append(temp, URI[len(URI)-10:]) + return false +} + +// combineSegment combines the segments to the master video file in the background. +// fixed segment problems mK33y. +// still needs some attention here +func combineSegment(master *os.File, filename string) { + index := 1 + delete := 1 + stripIndex := 1 + var retry int + <-time.After(4 * time.Second) + + for { + <-time.After(300 * time.Millisecond) + + if index >= segmentIndex { + <-time.After(1 * time.Second) + continue + } + + if !pathx.Exists(fmt.Sprintf("./%s/%s~%d.ts", savePath, filename, index)) { + if retry >= 5 { + index++ + retry = 0 + continue + } + if retry != 0 { + log.Printf(warningSegment, index, retry) + } + retry++ + <-time.After(time.Duration(1*retry) * time.Second) + continue + } + if retry != 0 { + retry = 0 + } + // + b, _ := ioutil.ReadFile(fmt.Sprintf("./%s/%s~%d.ts", savePath, filename, index)) + // + if stripLimit != 0 && stripQuota <= 0 { + newMasterFilename := "./" + savePath + "/" + filename + "_" + strconv.Itoa(stripIndex) + ".ts" + master, _ = os.OpenFile(newMasterFilename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0777) + log.Printf("exceeded the specified stripping limit, creating new video file. (file: %s)", newMasterFilename) + stripQuota = stripLimit + stripIndex++ + } + master.Write(b) + // + log.Printf(infoMergeSegment, index, segmentIndex) + + e := os.Remove(fmt.Sprintf("./%s/%s~%d.ts", savePath, filename, delete)) + // + if e != nil { + delete-- + } + delete++ + index++ + } +} + +// fetchSegment fetches the segment and append to the master file. +func fetchSegment(master *os.File, segment *m3u8.MediaSegment, baseURL string, filename string, index int) { + _, body, _ := gorequest.New().Get(fmt.Sprintf("%s%s", baseURL, segment.URI)).EndBytes() + log.Printf("fetching %s (size: %d)\n", segment.URI, len(body)) + if len(body) == 0 { + log.Printf(infoSkipped, segment.URI) + return + } + stripQuota -= len(body) + // + f, err := os.OpenFile(fmt.Sprintf("./%s/%s~%d.ts", savePath, filename, index), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0777) + if err != nil { + panic(err) + } + if _, err := f.Write(body); err != nil { + panic(err) + } +} + +// endpoint implements the application main function endpoint. +func endpoint(c *cli.Context) error { + if c.String("username") == "" { + log.Fatal(errNoUsername) + } + // Converts `strip` from MiB to Bytes + stripLimit = c.Int("strip") * 1024 * 1024 + stripQuota = c.Int("strip") * 1024 * 1024 + // + + fmt.Println(" .o88b. db db .d8b. d888888b db db d8888b. d8888b. .d8b. d888888b d88888b") + fmt.Println("d8P Y8 88 88 d8' `8b `~~88~~' 88 88 88 `8D 88 `8D d8' `8b `~~88~~' 88'") + fmt.Println("8P 88ooo88 88ooo88 88 88 88 88oobY' 88oooY' 88ooo88 88 88ooooo") + fmt.Println("8b 88~~~88 88~~~88 88 88 88 88`8b 88~~~b. 88~~~88 88 88~~~~~") + fmt.Println("Y8b d8 88 88 88 88 88 88b d88 88 `88. 88 8D 88 88 88 88.") + fmt.Println(" `Y88P' YP YP YP YP YP ~Y8888P' 88 YD Y8888P' YP YP YP Y88888P") + fmt.Println("d8888b. db db d8888b.") + fmt.Println("88 `8D 88 88 88 `8D") + fmt.Println("88 88 Y8 8P 88oobY'") + fmt.Println("88 88 `8b d8' 88`8b") + fmt.Println("88 .8D `8bd8' 88 `88.") + fmt.Println("Y8888D' YP 88 YD") + fmt.Println("---") + + // Mkdir video folder + if _, err := os.Stat("./" + savePath); os.IsNotExist(err) { + os.Mkdir("./"+savePath, 0777) + } + // + if c.Int("strip") != 0 { + log.Printf("specifying stripping limit as %d MiB(s)", c.Int("strip")) + } + + for { + // Capture the stream if the user is currently online. + if getOnlineStatus(c.String("username")) { + log.Printf(infoIsOnline, c.String("username")) + capture(c.String("username")) + segmentIndex = 0 + temp = []string{} + retriesAfterOnlined = 0 + continue + } + // Otherwise we keep checking the channel status until the user is online. + log.Printf(infoNotOnline, c.String("username"), c.Int("interval")) + <-time.After(time.Minute * time.Duration(c.Int("interval"))) + } +} + +func main() { + app := &cli.App{ + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "username", + Aliases: []string{"u"}, + Value: "", + Usage: "channel username to watching", + }, + &cli.IntFlag{ + Name: "interval", + Aliases: []string{"i"}, + Value: 1, + Usage: "minutes to check if a channel goes online or not", + }, + &cli.IntFlag{ + Name: "strip", + Aliases: []string{"s"}, + Value: 0, + Usage: "MB sizes to split the video into chunks", + }, + }, + Name: "chaturbate-dvr", + Usage: "watching a specified chaturbate channel and auto saves the stream as local file", + Action: endpoint, + } + err := app.Run(os.Args) + if err != nil { + log.Fatal(err) + } +}