Developing new version

This commit is contained in:
Yami Odymel
2023-12-12 14:48:48 +08:00
parent 5cfa7e1953
commit 681f8903d5
37 changed files with 2009 additions and 588 deletions

View File

View File

23
.old/go.mod Normal file
View File

@@ -0,0 +1,23 @@
module github.com/YamiOdymel/chaturbate-dvr
go 1.19
require (
github.com/TwiN/go-color v1.1.0
github.com/grafov/m3u8 v0.11.1
github.com/parnurzeal/gorequest v0.2.16
github.com/urfave/cli/v2 v2.3.0
)
require (
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/samber/lo v1.38.1 // indirect
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
github.com/smartystreets/goconvey v1.7.2 // indirect
golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect
golang.org/x/net v0.0.0-20211109214657-ef0fda0de508 // indirect
moul.io/http2curl v1.0.0 // indirect
)

46
.old/go.sum Normal file
View File

@@ -0,0 +1,46 @@
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/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/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=
github.com/grafov/m3u8 v0.11.1 h1:igZ7EBIB2IAsPPazKwRKdbhxcoBKO3lO1UY57PZDeNA=
github.com/grafov/m3u8 v0.11.1/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/parnurzeal/gorequest v0.2.16 h1:T/5x+/4BT+nj+3eSknXmCTnEVGSzFzPGdpqmUVVZXHQ=
github.com/parnurzeal/gorequest v0.2.16/go.mod h1:3Kh2QUMJoqw3icWAecsyzkpY7UzRfDhbRdTjtNwNiUE=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-charset v0.0.0-20180617210344-2471d30d28b4/go.mod h1:qgYeAmZ5ZIpBWTGllZSQnw97Dj+woV0toclVaRGI8pc=
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM=
github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs=
github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg=
github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM=
github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 h1:3MTrJm4PyNL9NBqvYDSj3DHl46qQakyfqfWo4jgfaEM=
golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
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/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
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=
moul.io/http2curl v1.0.0 h1:6XwpyZOYsgZJrU8exnG87ncVkU1FVCcTRpwzOkTDUi8=
moul.io/http2curl v1.0.0/go.mod h1:f6cULg+e4Md/oW1cYmwW4IWQOVl2lGbmCNGOHvzX2kE=

510
.old/main.go Normal file
View File

@@ -0,0 +1,510 @@
package main
import (
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/TwiN/go-color"
"github.com/samber/lo"
"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
// segmentMap is the map stores temporary video segments, it will be merged into master video file then got deleted.
var segmentMap map[string][]byte = make(map[string][]byte)
var segmentMapLock sync.Mutex
// 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
// preferredFPS represents the preferred framerate.
var preferredFPS string
// preferredResolution represents the preferred resolution, e.g. `240`, `480`, `540`, `720`, `1080`.
var preferredResolution string
// preferredResolutionFallback represents the preferred resolution fallback, `up`, `down` or `no`.
var preferredResolutionFallback string
// 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 {
resp, body, errs := gorequest.New().TLSClientConfig(&tls.Config{InsecureSkipVerify: true}).Get(getChannelURL(username)).End()
if len(errs) > 0 {
log.Println(color.Colorize(color.Red, errs[0].Error()))
}
if resp == nil || resp.StatusCode != 200 {
return ""
}
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.TrimSuffix(roomData.HLSSource, "playlist.m3u8")
}
// parseHLSSource parses the HLS table and return the maximum resolution m3u8 source.
func parseHLSSource(url string, baseURL string) string {
resp, body, errs := gorequest.New().TLSClientConfig(&tls.Config{InsecureSkipVerify: true}).Get(url).End()
if len(errs) > 0 {
log.Println(color.Colorize(color.Red, errs[0].Error()))
}
if resp == nil || resp.StatusCode == 403 {
return ""
}
p, _, _ := m3u8.DecodeFrom(strings.NewReader(body), true)
master, ok := p.(*m3u8.MasterPlaylist)
if !ok {
return ""
}
resolutions := make(map[string][]string)
resolutionInts := []string{}
for _, v := range master.Variants {
resStr := strings.Split(v.Resolution, "x")
resolutionInts = append(resolutionInts, resStr[1])
// If the resolution exists in local, it might be a higher framerate source, store it for later use
if _, ok := resolutions[resStr[1]]; ok {
resolutions[resStr[1]] = append(resolutions[resStr[1]], v.URI)
continue
}
if strings.Contains(v.Name, "FPS:60.0") {
if _, ok := resolutions[resStr[1]]; !ok {
resolutions[resStr[1]] = []string{"", v.URI} // The video has no 30 FPS, we fill it with an empty URI
} else {
resolutions[resStr[1]] = []string{v.URI}
}
} else {
resolutions[resStr[1]] = []string{v.URI}
}
}
log.Printf("Found available resolutions: %s", strings.TrimPrefix(lo.Reduce(resolutionInts, func(prev string, cur string, _ int) string {
return fmt.Sprintf("%s, %s", prev, cur)
}, ""), ", "))
pickedResolution, ok := resolutions[preferredResolution]
if !ok {
var comparison []string
if preferredResolutionFallback == "down" {
comparison = lo.Reverse(lo.Map(resolutionInts, func(v string, _ int) string { return v }))
} else {
comparison = resolutionInts
}
fallbackResolution, ok := lo.Find(comparison, func(v string) bool {
sizeInt, _ := strconv.Atoi(v)
prefInt, _ := strconv.Atoi(preferredResolution)
//
if preferredResolutionFallback == "down" {
return sizeInt < prefInt
} else {
return sizeInt > prefInt
}
})
if ok {
pickedResolution = resolutions[fallbackResolution]
log.Printf("Preferred video resolution %sp not found, use %sp instead.", preferredResolution, fallbackResolution)
} else {
if preferredResolutionFallback == "down" {
pickedResolution = resolutions[resolutionInts[0]]
log.Printf("No fallback video resolution was found, use worse quality %sp instead.", resolutionInts[0])
} else {
pickedResolution = resolutions[resolutionInts[len(resolutionInts)-1]]
log.Printf("No fallback video resolution was found, use best quality %sp instead.", resolutionInts[len(resolutionInts)-1])
}
}
} else {
log.Printf("Fetching video resolution in %sp.", preferredResolution)
}
var uri string
if preferredFPS == "60" && len(pickedResolution) > 1 {
log.Printf("Fetching video in 60 FPS.")
uri = pickedResolution[1]
} else {
log.Printf("Fetching video in 30 FPS.")
uri = pickedResolution[0]
if uri == "" {
log.Printf("The video has no 30 FPS, use 60 FPS instead.")
uri = pickedResolution[1]
}
}1
return fmt.Sprintf("%s%s", baseURL, 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().TLSClientConfig(&tls.Config{InsecureSkipVerify: true}).Get(url).End()
if len(errs) > 0 {
log.Println(color.Colorize(color.Red, errs[0].Error()))
}
// Retry after 3 seconds if the connection lost or status code returns 403 (the channel might went offline).
if len(errs) > 0 || resp == nil || resp.StatusCode == http.StatusForbidden {
return nil, 3, errInternal
}
// Decode the segment table.
p, _, err := m3u8.DecodeFrom(strings.NewReader(body), true)
if err != nil {
log.Println(color.Colorize(color.Red, err.Error()))
}
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")
var m3u8Source, baseURL, hlsSource string
var tried int
for {
tried++
//
if tried > 10 {
panic(errors.New("cannot fetch the Playlist correctly after 10 tries"))
}
// Get the channel page content body.
body := getBody(username)
//
if body == "" {
continue
}
// 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)
//
if m3u8Source != "" {
break
}
<-time.After(time.Millisecond * 500)
}
// Create the master video file.
masterFile, err := os.OpenFile("./"+savePath+"/"+filename+".ts", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0777)
if err != nil {
log.Println(color.Colorize(color.Red, err.Error()))
}
//
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
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 _, ok := segmentMap[fmt.Sprintf("./%s/%s~%d.ts", savePath, filename, index)]; !ok {
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 := segmentMap[fmt.Sprintf("./%s/%s~%d.ts", savePath, filename, index)]
//
var err error
if stripLimit != 0 && stripQuota <= 0 {
newMasterFilename := "./" + savePath + "/" + filename + "_" + strconv.Itoa(stripIndex) + ".ts"
master, err = os.OpenFile(newMasterFilename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0777)
if err != nil {
log.Println(color.Colorize(color.Red, err.Error()))
}
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)
segmentMapLock.Lock()
delete(segmentMap, fmt.Sprintf("./%s/%s~%d.ts", savePath, filename, index))
segmentMapLock.Unlock()
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().TLSClientConfig(&tls.Config{InsecureSkipVerify: true}).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)
segmentMapLock.Lock()
segmentMap[fmt.Sprintf("./%s/%s~%d.ts", savePath, filename, index)] = body
segmentMapLock.Unlock()
}
// 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
//
preferredFPS = c.String("fps")
preferredResolution = c.String("resolution")
preferredResolutionFallback = c.String("resolution-fallback")
//
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{
Version: "0.94 Alpha",
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",
},
&cli.StringFlag{
Name: "resolution",
Aliases: []string{"r"},
Value: "1080",
Usage: "Video resolution, could be `240`, `480`, `540`, `720`, `1080`",
},
&cli.StringFlag{
Name: "resolution-fallback",
Aliases: []string{"rf"},
Value: "down",
Usage: "Looking for larger or smaller resolution (`up` for larger, `down` for smaller) if a specified resolution was not found",
},
&cli.StringFlag{
Name: "fps",
Aliases: []string{"f"},
Value: "60",
Usage: "Preferred framerate, only works if streaming source supports it, otherwise it will always be 30 FPS",
},
},
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)
}
}

21
adapter/manager/config.go Normal file
View File

@@ -0,0 +1,21 @@
package manager
import (
streamDomain "github.com/teacat/chaturbate-dvr/domain/stream"
)
// Config is a configuration for the manager, loads from config.json.
type Config struct {
Streams []*ConfigStream `json:"streams"`
}
// ConfigStream is a configuration for a stream.
type ConfigStream struct {
Username string `json:"username"`
Resolution int `json:"resolution"`
ResolutionFallback streamDomain.ResolutionFallback `json:"resolution_fallback"`
Framerate int `json:"framerate"`
SplitByDuration int `json:"split_by_duration"`
SplitByFilesize int `json:"split_by_filesize"`
IsPaused bool `json:"is_paused"`
}

109
adapter/manager/manager.go Normal file
View File

@@ -0,0 +1,109 @@
package manager
import (
"encoding/json"
"errors"
"fmt"
"os"
streamDomain "github.com/teacat/chaturbate-dvr/domain/stream"
)
var (
ErrStreamExists = errors.New("stream exists")
ErrStreamNotExists = errors.New("stream not exists")
)
// Manager manages the streams.
type Manager struct {
config *Config
streams map[string]*stream
}
// New creates a new Manager.
func New() (*Manager, error) {
if err := os.MkdirAll("./videos", 0777); err != nil {
return nil, fmt.Errorf("create videos directory: %w", err)
}
config := &Config{}
b, err := os.ReadFile("./config.json")
if os.IsNotExist(err) {
b, err := json.Marshal(config)
if err != nil {
return nil, fmt.Errorf("marshal config: %w", err)
}
if err := os.WriteFile("./config.json", b, 0777); err != nil {
return nil, fmt.Errorf("write config: %w", err)
}
} else if err != nil {
return nil, fmt.Errorf("read config: %w", err)
} else {
if err := json.Unmarshal(b, config); err != nil {
return nil, fmt.Errorf("unmarshal config: %w", err)
}
}
manager := &Manager{
config: config,
streams: make(map[string]*stream),
}
manager.Ready()
return manager, nil
}
// Ready prepares the manager.
func (m *Manager) Ready() error {
for _, s := range m.config.Streams {
if err := m.AddStream(s.Username, s.ResolutionFallback, s.Resolution, s.Framerate, s.SplitByFilesize, s.SplitByDuration, s.IsPaused); err != nil {
return fmt.Errorf("add stream: %w", err)
}
}
return nil
}
func (m *Manager) ListStreams() ([]*streamDomain.StreamDTO, error) {
return nil, nil
}
// PauseStream pauses the stream.
func (m *Manager) PauseStream(username string) error {
if _, ok := m.streams[username]; !ok {
return ErrStreamNotExists
}
m.streams[username].pause()
return nil
}
// AddStream adds a stream to watching list and starts watching.
func (m *Manager) AddStream(username string, resFallback streamDomain.ResolutionFallback, resolution, framerate, splitByFilesize, splitByDuration int, isPaused bool) error {
if _, ok := m.streams[username]; ok {
return ErrStreamExists
}
// TODO: Sanitize, Trim username.
s, _, _ := newStream(username)
if !isPaused {
s.start()
}
m.streams[username] = s
return nil
}
func (m *Manager) StopStream(username string) error {
if _, ok := m.streams[username]; !ok {
return ErrStreamNotExists
}
m.streams[username].stop()
return nil
}
func (m *Manager) ResumeStream(username string) error {
if _, ok := m.streams[username]; !ok {
return ErrStreamNotExists
}
m.streams[username].resume()
return nil
}
func (m *Manager) SubscribeStreams(chUpd chan<- *streamDomain.StreamUpdateDTO, chOut chan<- *streamDomain.StreamOutputDTO) error {
return nil
}

147
adapter/manager/stream.go Normal file
View File

@@ -0,0 +1,147 @@
package manager
import (
"crypto/tls"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
streamDomain "github.com/teacat/chaturbate-dvr/domain/stream"
)
type stream struct {
username string
channelURL string
isPaused bool
isOnline bool
resolution int
resolutionFallback streamDomain.ResolutionFallback
framerate int
splitByDuration int
splitByFilesize int
session *streamSession
chUpdate chan<- *streamDomain.StreamUpdateDTO
chOutput chan<- *streamDomain.StreamOutputDTO
}
type streamSession struct {
buffer map[int][]byte
bufferIndex int
file *os.File
retries int
resolution int
framerate int
durationTotal int
durationQuota int
filesizeTotal int
filesizeQuota int
}
func newStream(username string) (*stream, chan<- *streamDomain.StreamUpdateDTO, chan<- *streamDomain.StreamOutputDTO) {
chUpd := make(chan *streamDomain.StreamUpdateDTO)
chOut := make(chan *streamDomain.StreamOutputDTO)
return &stream{
username: username,
channelURL: "https://chaturbate.com/" + username,
// TODO: resolution, framerate split, duration split, filesize split
isPaused: true,
chUpdate: chUpd,
chOutput: chOut,
}, chUpd, chOut
}
func (s *stream) start() {
for {
body, err := s.retrieveChannel()
if err != nil {
s.log("Error occurred while retrieving channel webpage: %s", err)
}
if s.isOnline {
s.log("%s is now online.", s.username)
if err := s.startRecording(body); err != nil { // blocking
s.log("Error occurred while start recording: %s", err)
}
continue
}
s.log("%s went offline.", s.username)
<-time.After(time.Minute * time.Duration(1)) // 1 minute cooldown
}
}
func (s *stream) startRecording(body string) error {
folder := fmt.Sprintf("./videos/%s", s.username)
if err := os.MkdirAll(folder, 0777); err != nil {
return fmt.Errorf("create folder: %w", err)
}
basename := fmt.Sprintf("./videos/%s/%s_%s", s.username, time.Now().Format("2006-01-02_15-04-05"))
file, err := os.OpenFile(basename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0777)
if err != nil {
return fmt.Errorf("open file: %w", err)
}
s.log("The video will be saved as %s.ts", basename)
s.session = &streamSession{
buffer: make(map[int][]byte),
bufferIndex: 0,
retries: 0,
file: file,
}
//
streamURI, err := parseHLS(s.resolution, s.framerate, s.resolutionFallback, body)
if err != nil {
return fmt.Errorf("parse hls: %w", err)
}
s.concatStreams()
s.retrieveStream(streamURI)
}
func (s *stream) retrieveStream(uri string) {
}
func (s *stream) pause() {
}
func (s *stream) stop() {
}
func (s *stream) resume() {
}
func (s *stream) retrieveChannel() (string, error) {
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client := &http.Client{Transport: tr}
resp, err := client.Get(s.channelURL)
if err != nil {
return "", fmt.Errorf("client get: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read body: %w", err)
}
s.isOnline = strings.Contains(string(body), "playlist.m3u8")
return string(body), nil
}
func (s *stream) log(message string, v ...interface{}) {
s.chOutput <- &streamDomain.StreamOutputDTO{
Username: s.username,
Output: "[" + time.Now().Format("2006-01-02 15:04:05") + "] " + fmt.Sprintf(message, v...),
}
}

116
adapter/manager/util.go Normal file
View File

@@ -0,0 +1,116 @@
package manager
import (
"bytes"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"regexp"
"strconv"
"strings"
"github.com/grafov/m3u8"
"github.com/samber/lo"
"github.com/teacat/chaturbate-dvr/domain/stream"
)
var (
regexpRoomDossier = regexp.MustCompile(`window\.initialRoomDossier = "(.*?)"`)
)
type roomDossier struct {
HLSSource string `json:"hls_source"`
}
type source struct {
framerate map[int]string // key: framerate, value: url
size int
}
func parseHLS(resolution, framerate int, resFallback stream.ResolutionFallback, body string) (string, error) {
// Find the room dossier.
matches := regexpRoomDossier.FindAllStringSubmatch(body, -1)
// Get the HLS source from the room dossier.
var roomData roomDossier
data, err := strconv.Unquote(strings.Replace(strconv.Quote(string(matches[0][1])), `\\u`, `\u`, -1))
if err != nil {
return "", fmt.Errorf("unquote unicode: %w", err)
}
if err := json.Unmarshal([]byte(data), &roomData); err != nil {
return "", fmt.Errorf("unmarshal json: %w", err)
}
// Get the HLS source.
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client := &http.Client{Transport: tr}
resp, err := client.Get(roomData.HLSSource)
if err != nil {
return "", fmt.Errorf("client get: %w", err)
}
defer resp.Body.Close()
m3u8Body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read body: %w", err)
}
if resp.StatusCode == http.StatusForbidden {
return "", fmt.Errorf("received status code %d", resp.StatusCode)
}
// Decode the m3u8 file.
p, _, err := m3u8.DecodeFrom(bytes.NewReader(m3u8Body), true)
if err != nil {
return "", fmt.Errorf("decode m3u8: %w", err)
}
playlist, ok := p.(*m3u8.MasterPlaylist)
if !ok {
return "", fmt.Errorf("cast to master playlist")
}
var sources []*source
//
for _, v := range playlist.Variants {
}
variant, ok := lo.Find(sources, func(v *source) bool {
return v.size == resolution
})
// If the variant is not found, we fallback to the nearest resolution.
if !ok {
switch resFallback {
case stream.ResolutionFallbackDownscale:
variant = lo.MinBy(sources, func(v, min *source) bool {
// return v.size < resolution && v.size < min.size
return math.Abs(float64(v.size-resolution)) < math.Abs(float64(min.size-resolution))
})
case stream.ResolutionFallbackUpscale:
variant = lo.MaxBy(sources, func(v, max *source) bool {
return math.Abs(float64(v.size-resolution)) > math.Abs(float64(max.size-resolution))
})
}
}
if variant == nil {
return "", fmt.Errorf("variant not found")
}
uri, ok := variant.framerate[framerate]
// If the framerate is not found, we fallback to the nearest framerate.
if !ok {
for _, v := range variant.framerate {
uri = v
// TODO: log fps
break
}
}
baseURL := strings.TrimSuffix(roomData.HLSSource, "playlist.m3u8")
return baseURL + uri, nil
}

View File

@@ -0,0 +1,10 @@
package stream
type Manager interface {
ListStreams() ([]*StreamDTO, error)
AddStream(username string, resFallback ResolutionFallback, resolution, framerate, splitByFilesize, splitByDuration int, isPaused bool) error
PauseStream(username string) error
StopStream(username string) error
ResumeStream(username string) error
SubscribeStreams(chUpd chan<- *StreamUpdateDTO, chOut chan<- *StreamOutputDTO) error
}

162
domain/stream/stream.go Normal file
View File

@@ -0,0 +1,162 @@
package stream
import "fmt"
//=======================================================
// Enum
//=======================================================
type ResolutionFallback string
const (
ResolutionFallbackUnknown ResolutionFallback = ""
ResolutionFallbackUpscale ResolutionFallback = "upscale"
ResolutionFallbackDownscale ResolutionFallback = "downscale"
)
//=======================================================
// Entity
//=======================================================
type Stream struct {
channelURL string
channelUsername string
splitFilesize int
splitDuration int
resolution int
resolutionFallback ResolutionFallback
framerate int
}
type StreamDTO struct {
Username string
LastStreamedAt int64
SegmentDuration int
SegmentDurationSplit int
SegmentFilesize int
SegmentFilesizeSplit int
IsOnline bool
IsPaused bool
}
type StreamUpdateDTO struct {
Username string
IsOnline bool
IsPaused bool
LastStreamedAt string
SegmentDuration string
SegmentFilesize string
}
type StreamOutputDTO struct {
Username string
Output string
}
//=======================================================
// Domain
//=======================================================
// Start starts the stream and recording.
func (s *Stream) Start() error {
return nil
}
// Pause pauses the stream and keep the stream in the list.
func (s *Stream) Pause() error {
return nil
}
// Stop stops the stream and removes the stream from the list.
func (s *Stream) Stop() error {
return nil
}
// Resume resumes the paused stream.
func (s *Stream) Resume() error {
return nil
}
//=======================================================
// Factory
//=======================================================
type StreamFactory struct {
sanitizer *streamSanitizer
}
func NewStreamFactory() *StreamFactory {
return &StreamFactory{
sanitizer: newStreamSanitizer(),
}
}
func (f *StreamFactory) New(username string, resFallback ResolutionFallback, resolution, framerate, splitFilesize, splitDuration int) (*Stream, error) {
username, err := f.sanitizer.sanitizeUsername(username)
if err != nil {
return nil, fmt.Errorf("sanitize username: %w", err)
}
resolution, err = f.sanitizer.sanitizeResolution(resolution)
if err != nil {
return nil, fmt.Errorf("sanitize resolution: %w", err)
}
resFallback, err = f.sanitizer.sanitizeResolutionFallback(resFallback)
if err != nil {
return nil, fmt.Errorf("sanitize resolution fallback: %w", err)
}
framerate, err = f.sanitizer.sanitizeFramerate(framerate)
if err != nil {
return nil, fmt.Errorf("sanitize framerate: %w", err)
}
splitFilesize, err = f.sanitizer.sanitizeSplitByFilesize(splitFilesize)
if err != nil {
return nil, fmt.Errorf("sanitize split by filesize: %w", err)
}
splitDuration, err = f.sanitizer.sanitizeSplitByDuration(splitDuration)
if err != nil {
return nil, fmt.Errorf("sanitize split by duration: %w", err)
}
return &Stream{
channelUsername: username,
resolution: resolution,
resolutionFallback: resFallback,
framerate: framerate,
splitFilesize: splitFilesize,
splitDuration: splitDuration,
}, nil
}
//=======================================================
// Sanitizer
//=======================================================
type streamSanitizer struct {
}
func newStreamSanitizer() *streamSanitizer {
return &streamSanitizer{}
}
func (s *streamSanitizer) sanitizeUsername(v string) (string, error) {
return v, nil
}
func (s *streamSanitizer) sanitizeResolution(v int) (int, error) {
return v, nil
}
func (s *streamSanitizer) sanitizeResolutionFallback(v ResolutionFallback) (ResolutionFallback, error) {
return v, nil
}
func (s *streamSanitizer) sanitizeFramerate(v int) (int, error) {
return v, nil
}
func (s *streamSanitizer) sanitizeSplitByFilesize(v int) (int, error) {
return v, nil
}
func (s *streamSanitizer) sanitizeSplitByDuration(v int) (int, error) {
return v, nil
}

29
gateway/gateway.go Normal file
View File

@@ -0,0 +1,29 @@
package gateway
import (
"github.com/gin-gonic/gin"
"github.com/teacat/chaturbate-dvr/domain/stream"
"github.com/teacat/chaturbate-dvr/gateway/handler"
)
type Gateway struct {
routes map[string]gin.HandlerFunc
}
func New(manager stream.Manager) *Gateway {
g := &Gateway{
routes: make(map[string]gin.HandlerFunc),
}
g.routes = map[string]gin.HandlerFunc{
"/api/list_streams": handle(handler.NewListStreamsHandler(manager)),
"/api/start_stream": handle(handler.NewStartStreamHandler(manager)),
"/api/stop_stream": handle(handler.NewStopStreamHandler(manager)),
"/api/pause_stream": handle(handler.NewPauseStreamHandler(manager)),
"/api/resume_stream": handle(handler.NewResumeStreamHandler(manager)),
}
return g
}
func (g *Gateway) Routes() map[string]gin.HandlerFunc {
return g.routes
}

View File

@@ -0,0 +1,90 @@
package handler
import (
"encoding/json"
"fmt"
"github.com/gin-gonic/gin"
"github.com/teacat/chaturbate-dvr/domain/stream"
)
//=======================================================
// Request & Response
//=======================================================
type FetchUpdatesRequest struct {
}
type FetchUpdatesResponse struct {
Updates []*FetchUpdatesResponseUpdate `json:"updates"`
Outputs []*FetchUpdatesResponseOutput `json:"outputs"`
}
type FetchUpdatesResponseUpdate struct {
Username string `json:"username"`
IsOnline bool `json:"is_online"`
IsPaused bool `json:"is_paused"`
LastStreamedAt string `json:"last_streamed_at"`
SegmentDuration string `json:"segment_duration"`
SegmentFilesize string `json:"segment_filesize"`
}
type FetchUpdatesResponseOutput struct {
Username string `json:"username"`
Output string `json:"output"`
}
//=======================================================
// Factory
//=======================================================
type FetchUpdatesHandler struct {
manager stream.Manager
}
func NewFetchUpdatesHandler() *FetchUpdatesHandler {
return &FetchUpdatesHandler{}
}
//=======================================================
// Handle
//=======================================================
func (h *FetchUpdatesHandler) Handle(ctx *gin.Context, req *FetchUpdatesRequest) (*FetchUpdatesResponse, error) {
chUpd := make(chan *stream.StreamUpdateDTO)
chOut := make(chan *stream.StreamOutputDTO)
if err := h.manager.SubscribeStreams(chUpd, chOut); err != nil {
return nil, fmt.Errorf("subscribe pool: %w", err)
}
for {
select {
case upd := <-chUpd:
b, err := json.Marshal(&FetchUpdatesResponseUpdate{
Username: upd.Username,
IsOnline: upd.IsOnline,
IsPaused: upd.IsPaused,
LastStreamedAt: upd.LastStreamedAt,
SegmentDuration: upd.SegmentDuration,
SegmentFilesize: upd.SegmentFilesize,
})
if err != nil {
return nil, fmt.Errorf("marshal update: %w", err)
}
ctx.SSEvent("update", b)
case out := <-chOut:
b, err := json.Marshal(&FetchUpdatesResponseOutput{
Username: out.Username,
Output: out.Output,
})
if err != nil {
return nil, fmt.Errorf("marshal output: %w", err)
}
ctx.SSEvent("output", b)
}
}
return nil, nil
}

View File

@@ -0,0 +1,76 @@
package handler
import (
"fmt"
"time"
"github.com/gin-gonic/gin"
"github.com/teacat/chaturbate-dvr/domain/stream"
)
//=======================================================
// Request & Response
//=======================================================
type ListStreamsRequest struct {
}
type ListStreamsResponse struct {
Streams []*ListStreamsResponseStream `json:"streams"`
}
type ListStreamsResponseStream struct {
Username string `json:"username"`
ChannelURL string `json:"channel_url"`
SavedTo string `json:"saved_to"`
LastStreamedAt string `json:"last_streamed_at"`
SegmentDuration string `json:"segment_duration"`
SegmentDurationSplit string `json:"segment_duration_split"`
SegmentFilesize string `json:"segment_filesize"`
SegmentFilesizeSplit string `json:"segment_filesize_split"`
IsOnline bool `json:"is_online"`
IsPaused bool `json:"is_paused"`
}
//=======================================================
// Factory
//=======================================================
type ListStreamsHandler struct {
manager stream.Manager
}
func NewListStreamsHandler(manager stream.Manager) *ListStreamsHandler {
return &ListStreamsHandler{
manager: manager,
}
}
//=======================================================
// Handle
//=======================================================
func (h *ListStreamsHandler) Handle(ctx *gin.Context, req *ListStreamsRequest) (*ListStreamsResponse, error) {
streams, err := h.manager.ListStreams()
if err != nil {
return nil, fmt.Errorf("list streams: %w", err)
}
resp := &ListStreamsResponse{
Streams: make([]*ListStreamsResponseStream, len(streams)),
}
for i, s := range streams {
resp.Streams[i] = &ListStreamsResponseStream{
Username: s.Username,
ChannelURL: fmt.Sprintf("https://chaturbate.com/%s/", s.Username),
SavedTo: fmt.Sprintf("./videos/%s/", s.Username),
LastStreamedAt: time.Unix(s.LastStreamedAt, 0).Format("2006-01-02 15:04:05"),
SegmentDuration: formatPlaytime(s.SegmentDuration),
SegmentDurationSplit: formatPlaytime(s.SegmentDurationSplit),
SegmentFilesize: fmt.Sprintf("%d MB", s.SegmentFilesize),
SegmentFilesizeSplit: fmt.Sprintf("%d MB", s.SegmentFilesizeSplit),
IsOnline: s.IsOnline,
IsPaused: s.IsPaused,
}
}
return resp, nil
}

View File

@@ -0,0 +1,44 @@
package handler
import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/teacat/chaturbate-dvr/domain/stream"
)
//=======================================================
// Request & Response
//=======================================================
type PauseStreamRequest struct {
Username string `json:"username"`
}
type PauseStreamResponse struct {
}
//=======================================================
// Factory
//=======================================================
type PauseStreamHandler struct {
manager stream.Manager
}
func NewPauseStreamHandler(manager stream.Manager) *PauseStreamHandler {
return &PauseStreamHandler{
manager: manager,
}
}
//=======================================================
// Handle
//=======================================================
func (h *PauseStreamHandler) Handle(ctx *gin.Context, req *PauseStreamRequest) (*PauseStreamResponse, error) {
if err := h.manager.PauseStream(req.Username); err != nil {
return nil, fmt.Errorf("pause stream: %w", err)
}
return &PauseStreamResponse{}, nil
}

View File

@@ -0,0 +1,44 @@
package handler
import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/teacat/chaturbate-dvr/domain/stream"
)
//=======================================================
// Request & Response
//=======================================================
type ResumeStreamRequest struct {
Username string
}
type ResumeStreamResponse struct {
}
//=======================================================
// Factory
//=======================================================
type ResumeStreamHandler struct {
manager stream.Manager
}
func NewResumeStreamHandler(manager stream.Manager) *ResumeStreamHandler {
return &ResumeStreamHandler{
manager: manager,
}
}
//=======================================================
// Handle
//=======================================================
func (h *ResumeStreamHandler) Handle(ctx *gin.Context, req *ResumeStreamRequest) (*ResumeStreamResponse, error) {
if err := h.manager.ResumeStream(req.Username); err != nil {
return nil, fmt.Errorf("resume stream: %w", err)
}
return &ResumeStreamResponse{}, nil
}

View File

@@ -0,0 +1,57 @@
package handler
import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/teacat/chaturbate-dvr/domain/stream"
)
//=======================================================
// Request & Response
//=======================================================
type StartStreamRequest struct {
Username string
Resolution int
ResolutionFallback stream.ResolutionFallback
Framerate int
SplitByFilesize int
SplitByDuration int
}
type StartStreamResponse struct {
}
//=======================================================
// Factory
//=======================================================
type StartStreamHandler struct {
manager stream.Manager
}
func NewStartStreamHandler(manager stream.Manager) *StartStreamHandler {
return &StartStreamHandler{
manager: manager,
}
}
//=======================================================
// Handle
//=======================================================
func (h *StartStreamHandler) Handle(ctx *gin.Context, req *StartStreamRequest) (*StartStreamResponse, error) {
if err := h.manager.AddStream(
req.Username,
req.ResolutionFallback,
req.Resolution,
req.Framerate,
req.SplitByFilesize,
req.SplitByDuration,
false,
); err != nil {
return nil, fmt.Errorf("add stream: %w", err)
}
return &StartStreamResponse{}, nil
}

View File

@@ -0,0 +1,42 @@
package handler
import (
"github.com/gin-gonic/gin"
"github.com/teacat/chaturbate-dvr/domain/stream"
)
//=======================================================
// Request & Response
//=======================================================
type StopStreamRequest struct {
Username string
}
type StopStreamResponse struct {
}
//=======================================================
// Factory
//=======================================================
type StopStreamHandler struct {
manager stream.Manager
}
func NewStopStreamHandler(manager stream.Manager) *StopStreamHandler {
return &StopStreamHandler{
manager: manager,
}
}
//=======================================================
// Handle
//=======================================================
func (h *StopStreamHandler) Handle(ctx *gin.Context, req *StopStreamRequest) (*StopStreamResponse, error) {
if err := h.manager.StopStream(req.Username); err != nil {
return nil, err
}
return &StopStreamResponse{}, nil
}

View File

@@ -0,0 +1,235 @@
<!DOCTYPE html>
<html lang="en" class="is-secondary">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tocas/4.2.5/tocas.min.css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/tocas/4.2.5/tocas.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;500;700&display=swap" rel="stylesheet" />
<title>Chaturbate DVR</title>
<style>
.ts-input .label {
overflow: initial !important;
}
</style>
</head>
<body>
<div class="ts-container is-narrow has-vertically-spaced-big">
<div class="ts-grid is-bottom-aligned">
<div class="column is-fluid">
<div class="ts-header is-huge is-uppercased is-heavy has-leading-small">Chaturbate DVR</div>
<div class="ts-text is-description is-bold">Version 1.0.0</div>
</div>
<div class="column">
<div class="ts-wrap">
<button class="ts-button is-outlined is-negative is-start-icon">
<span class="ts-icon is-hand-icon"></span>
Terminate
</button>
<button class="ts-button is-start-icon">
<span class="ts-icon is-plus-icon"></span>
Add Channel
</button>
</div>
</div>
</div>
<div class="ts-divider has-vertically-spaced-large"></div>
<div class="ts-content is-secondary is-fitted">
<div class="ts-blankslate">
<span class="ts-icon is-eye-low-vision-icon"></span>
<div class="header">No channel was watching.</div>
<div class="description">Add a new Chaturbate channel to start watching and recording.</div>
<div class="action">
<button class="ts-button">Add Channel</button>
</div>
</div>
</div>
<div class="ts-box">
<div class="ts-content is-horizontally-padded">
<div class="ts-header">Add Channel</div>
</div>
<div class="ts-divider"></div>
<div class="ts-content is-vertically-padded">
<div class="ts-control">
<div class="label">Channel Username</div>
<div class="content">
<div class="ts-input is-start-labeled">
<div class="label">https://chaturbate.com/</div>
<input type="text" />
</div>
<div class="ts-text has-top-spaced-small">The stream will be saved to <code class="ts-text is-code">./videos/username/</code>.</div>
</div>
</div>
<div class="ts-control has-top-spaced-large">
<div class="label">Resolution</div>
<div class="content">
<div class="ts-grid">
<div class="column">
<div class="ts-select">
<select name="" id="">
<option value="">4K</option>
<option value="">2K</option>
<option value="">1080p</option>
</select>
</div>
</div>
<div class="column">
<div class="ts-select">
<select name="" id="">
<option value="">or higher</option>
<option value="">or lower</option>
</select>
</div>
</div>
</div>
<div class="ts-text is-description has-top-spaced-small">The higher resolution will be used if 4K was not available.</div>
</div>
</div>
<div class="ts-control has-top-spaced-large">
<div class="label">Frame Rate</div>
<div class="content">
<div class="ts-wrap is-compact is-vertical has-top-spaced-small">
<label class="ts-radio">
<input name="eat" type="radio" checked />
60 FPS
</label>
<label class="ts-radio">
<input name="eat" type="radio" checked />
30 FPS
</label>
</div>
<div class="ts-text is-description has-top-spaced-small">30 FPS will be used if 60 FPS was not available for the stream.</div>
</div>
</div>
<div class="ts-divider has-vertically-spaced-large"></div>
<div class="ts-control has-top-spaced">
<div class="label"></div>
<div class="content">
<details class="ts-accordion">
<summary>Splitting Options</summary>
<div class="ts-content is-padded is-secondary has-top-spaced">
<div class="ts-grid is-relaxed is-2-columns">
<div class="column">
<div class="ts-text is-bold">By Filesize</div>
<div class="ts-input is-end-labeled has-top-spaced-small">
<input type="text" value="0" />
<span class="label">MB</span>
</div>
</div>
<div class="column">
<div class="ts-text is-bold">By Duration</div>
<div class="ts-input is-end-labeled has-top-spaced-small">
<input type="text" value="0" />
<span class="label">Minutes</span>
</div>
</div>
</div>
<div class="ts-text is-description has-top-spaced">Splitting will be disabled if both options were set to 0.</div>
</div>
</details>
</div>
</div>
</div>
<div class="ts-divider"></div>
<div class="ts-content is-secondary is-horizontally-padded">
<div class="ts-wrap is-end-aligned">
<button class="ts-button is-outlined is-secondary">Cancel</button>
<button class="ts-button is-primary">Add Channel</button>
</div>
</div>
</div>
<div class="ts-divider is-start-text is-section">
<span class="ts-text is-description">4 channels are watching</span>
</div>
<div class="ts-box is-horizontal">
<div class="ts-content is-padded" style="flex: 1.8">
<div class="ts-header">
cherylloving_
<span class="ts-badge is-small is-start-spaced">RECORDING</span>
</div>
<div class="ts-input is-resizable has-top-spaced">
<textarea name="" id="" rows="10"></textarea>
</div>
</div>
<div class="ts-divider is-vertical"></div>
<div class="ts-content is-padded" style="flex: 1; min-width: 300px">
<div class="ts-text is-description is-uppercased">Information</div>
<div class="ts-grid has-top-spaced-large">
<div class="column has-leading-none" style="width: 16px">
<span class="ts-icon is-link-icon"></span>
</div>
<div class="column is-fluid">
<div class="ts-text is-label">Channel URL</div>
<a class="ts-text is-link" href="https://chaturbate.com/cherylloving_/" target="_blank">https://chaturbate.com/cherylloving_</a>
</div>
</div>
<div class="ts-grid has-top-spaced">
<div class="column has-leading-none" style="width: 16px">
<span class="ts-icon is-folder-icon"></span>
</div>
<div class="column is-fluid">
<div class="ts-text is-label">Saved to</div>
<code class="ts-text is-code">./videos/cherylloving_/</code>
</div>
</div>
<div class="ts-grid has-top-spaced">
<div class="column has-leading-none" style="width: 16px">
<span class="ts-icon is-tower-broadcast-icon"></span>
</div>
<div class="column is-fluid">
<div class="ts-text is-label">Last streamed at</div>
<div class="ts-text is-description">2023-01-02 AM 02:01 (NOW)</div>
</div>
</div>
<div class="ts-grid has-top-spaced">
<div class="column has-leading-none" style="width: 16px">
<span class="ts-icon is-clock-icon"></span>
</div>
<div class="column is-fluid">
<div class="ts-text is-label">Segment duration</div>
<div class="ts-text is-description">01:02:03 / 00:03:00</div>
</div>
</div>
<div class="ts-grid has-top-spaced">
<div class="column has-leading-none" style="width: 16px">
<span class="ts-icon is-chart-pie-icon"></span>
</div>
<div class="column is-fluid">
<div class="ts-text is-label">Segment file size</div>
<div class="ts-text is-description">1024 MB / 2013 MB</div>
</div>
</div>
<div class="ts-grid is-2-columns has-top-spaced-large">
<div class="column">
<button class="ts-button is-start-icon is-secondary is-fluid">
<span class="ts-icon is-pause-icon"></span>
PAUSE
</button>
</div>
<div class="column">
<button class="ts-button is-start-icon is-secondary is-fluid" data-tooltip="Stop and remove the channel from the list.">
<span class="ts-icon is-stop-icon"></span>
STOP
</button>
</div>
</div>
<button class="ts-button is-start-icon is-fluid has-top-spaced-large">
<span class="ts-icon is-play-icon"></span>
Resume
</button>
</div>
</div>
</div>
</body>
</html>

11
gateway/handler/util.go Normal file
View File

@@ -0,0 +1,11 @@
package handler
import "fmt"
func formatPlaytime(seconds int) string {
hours := seconds / 3600
seconds %= 3600
minutes := seconds / 60
seconds %= 60
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds)
}

View File

@@ -0,0 +1,26 @@
package handler
import "github.com/gin-gonic/gin"
//=======================================================
// Request & Response
//=======================================================
//=======================================================
// Factory
//=======================================================
type ViewIndexHandler struct {
}
func NewViewIndexHandler() *ViewIndexHandler {
return &ViewIndexHandler{}
}
//=======================================================
// Handle
//=======================================================
func (h *ViewIndexHandler) Handle(ctx *gin.Context) {
ctx.HTML(200, "index.tmpl", gin.H{})
}

27
gateway/util.go Normal file
View File

@@ -0,0 +1,27 @@
package gateway
import (
"net/http"
"github.com/gin-gonic/gin"
)
type Handler[T, M any] interface {
Handle(*gin.Context, *T) (*M, error)
}
func handle[T, M any](handler Handler[T, M]) gin.HandlerFunc {
return func(c *gin.Context) {
var req T
if err := c.ShouldBindJSON(&req); err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
resp, err := handler.Handle(c, &req)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, resp)
}
}

48
go.mod
View File

@@ -1,23 +1,41 @@
module github.com/YamiOdymel/chaturbate-dvr
module github.com/teacat/chaturbate-dvr
go 1.19
go 1.21.3
require (
github.com/TwiN/go-color v1.1.0
github.com/grafov/m3u8 v0.11.1
github.com/parnurzeal/gorequest v0.2.16
github.com/urfave/cli/v2 v2.3.0
github.com/gin-gonic/gin v1.9.1
github.com/urfave/cli/v2 v2.26.0
)
require (
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/samber/lo v1.38.1 // indirect
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
github.com/smartystreets/goconvey v1.7.2 // indirect
github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/grafov/m3u8 v0.12.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/samber/lo v1.39.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.9.0 // indirect
golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect
golang.org/x/net v0.0.0-20211109214657-ef0fda0de508 // indirect
moul.io/http2curl v1.0.0 // indirect
golang.org/x/net v0.10.0 // indirect
golang.org/x/sys v0.8.0 // indirect
golang.org/x/text v0.9.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

136
go.sum
View File

@@ -1,46 +1,100 @@
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/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/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=
github.com/grafov/m3u8 v0.11.1 h1:igZ7EBIB2IAsPPazKwRKdbhxcoBKO3lO1UY57PZDeNA=
github.com/grafov/m3u8 v0.11.1/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/parnurzeal/gorequest v0.2.16 h1:T/5x+/4BT+nj+3eSknXmCTnEVGSzFzPGdpqmUVVZXHQ=
github.com/parnurzeal/gorequest v0.2.16/go.mod h1:3Kh2QUMJoqw3icWAecsyzkpY7UzRfDhbRdTjtNwNiUE=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/grafov/m3u8 v0.12.0 h1:T6iTwTsSEtMcwkayef+FJO8kj+Sglr4Lh81Zj8Ked/4=
github.com/grafov/m3u8 v0.12.0/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-charset v0.0.0-20180617210344-2471d30d28b4/go.mod h1:qgYeAmZ5ZIpBWTGllZSQnw97Dj+woV0toclVaRGI8pc=
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM=
github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs=
github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg=
github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM=
github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA=
github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/urfave/cli/v2 v2.26.0 h1:3f3AMg3HpThFNT4I++TKOejZO8yU55t3JnnSr4S4QEI=
github.com/urfave/cli/v2 v2.26.0/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 h1:3MTrJm4PyNL9NBqvYDSj3DHl46qQakyfqfWo4jgfaEM=
golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
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/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
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=
moul.io/http2curl v1.0.0 h1:6XwpyZOYsgZJrU8exnG87ncVkU1FVCcTRpwzOkTDUi8=
moul.io/http2curl v1.0.0/go.mod h1:f6cULg+e4Md/oW1cYmwW4IWQOVl2lGbmCNGOHvzX2kE=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

510
main.go
View File

@@ -1,510 +1,34 @@
package main
import (
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/TwiN/go-color"
"github.com/samber/lo"
"github.com/grafov/m3u8"
"github.com/parnurzeal/gorequest"
"github.com/gin-gonic/gin"
"github.com/teacat/chaturbate-dvr/adapter/manager"
"github.com/teacat/chaturbate-dvr/gateway"
"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
// segmentMap is the map stores temporary video segments, it will be merged into master video file then got deleted.
var segmentMap map[string][]byte = make(map[string][]byte)
var segmentMapLock sync.Mutex
// 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
// preferredFPS represents the preferred framerate.
var preferredFPS string
// preferredResolution represents the preferred resolution, e.g. `240`, `480`, `540`, `720`, `1080`.
var preferredResolution string
// preferredResolutionFallback represents the preferred resolution fallback, `up`, `down` or `no`.
var preferredResolutionFallback string
// 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 {
resp, body, errs := gorequest.New().TLSClientConfig(&tls.Config{InsecureSkipVerify: true}).Get(getChannelURL(username)).End()
if len(errs) > 0 {
log.Println(color.Colorize(color.Red, errs[0].Error()))
}
if resp == nil || resp.StatusCode != 200 {
return ""
}
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.TrimSuffix(roomData.HLSSource, "playlist.m3u8")
}
// parseHLSSource parses the HLS table and return the maximum resolution m3u8 source.
func parseHLSSource(url string, baseURL string) string {
resp, body, errs := gorequest.New().TLSClientConfig(&tls.Config{InsecureSkipVerify: true}).Get(url).End()
if len(errs) > 0 {
log.Println(color.Colorize(color.Red, errs[0].Error()))
}
if resp == nil || resp.StatusCode == 403 {
return ""
}
p, _, _ := m3u8.DecodeFrom(strings.NewReader(body), true)
master, ok := p.(*m3u8.MasterPlaylist)
if !ok {
return ""
}
resolutions := make(map[string][]string)
resolutionInts := []string{}
for _, v := range master.Variants {
resStr := strings.Split(v.Resolution, "x")
resolutionInts = append(resolutionInts, resStr[1])
// If the resolution exists in local, it might be a higher framerate source, store it for later use
if _, ok := resolutions[resStr[1]]; ok {
resolutions[resStr[1]] = append(resolutions[resStr[1]], v.URI)
continue
}
if strings.Contains(v.Name, "FPS:60.0") {
if _, ok := resolutions[resStr[1]]; !ok {
resolutions[resStr[1]] = []string{"", v.URI} // The video has no 30 FPS, we fill it with an empty URI
} else {
resolutions[resStr[1]] = []string{v.URI}
}
} else {
resolutions[resStr[1]] = []string{v.URI}
}
}
log.Printf("Found available resolutions: %s", strings.TrimPrefix(lo.Reduce(resolutionInts, func(prev string, cur string, _ int) string {
return fmt.Sprintf("%s, %s", prev, cur)
}, ""), ", "))
pickedResolution, ok := resolutions[preferredResolution]
if !ok {
var comparison []string
if preferredResolutionFallback == "down" {
comparison = lo.Reverse(lo.Map(resolutionInts, func(v string, _ int) string { return v }))
} else {
comparison = resolutionInts
}
fallbackResolution, ok := lo.Find(comparison, func(v string) bool {
sizeInt, _ := strconv.Atoi(v)
prefInt, _ := strconv.Atoi(preferredResolution)
//
if preferredResolutionFallback == "down" {
return sizeInt < prefInt
} else {
return sizeInt > prefInt
}
})
if ok {
pickedResolution = resolutions[fallbackResolution]
log.Printf("Preferred video resolution %sp not found, use %sp instead.", preferredResolution, fallbackResolution)
} else {
if preferredResolutionFallback == "down" {
pickedResolution = resolutions[resolutionInts[0]]
log.Printf("No fallback video resolution was found, use worse quality %sp instead.", resolutionInts[0])
} else {
pickedResolution = resolutions[resolutionInts[len(resolutionInts)-1]]
log.Printf("No fallback video resolution was found, use best quality %sp instead.", resolutionInts[len(resolutionInts)-1])
}
}
} else {
log.Printf("Fetching video resolution in %sp.", preferredResolution)
}
var uri string
if preferredFPS == "60" && len(pickedResolution) > 1 {
log.Printf("Fetching video in 60 FPS.")
uri = pickedResolution[1]
} else {
log.Printf("Fetching video in 30 FPS.")
uri = pickedResolution[0]
if uri == "" {
log.Printf("The video has no 30 FPS, use 60 FPS instead.")
uri = pickedResolution[1]
}
}
return fmt.Sprintf("%s%s", baseURL, 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().TLSClientConfig(&tls.Config{InsecureSkipVerify: true}).Get(url).End()
if len(errs) > 0 {
log.Println(color.Colorize(color.Red, errs[0].Error()))
}
// Retry after 3 seconds if the connection lost or status code returns 403 (the channel might went offline).
if len(errs) > 0 || resp == nil || resp.StatusCode == http.StatusForbidden {
return nil, 3, errInternal
}
// Decode the segment table.
p, _, err := m3u8.DecodeFrom(strings.NewReader(body), true)
if err != nil {
log.Println(color.Colorize(color.Red, err.Error()))
}
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")
var m3u8Source, baseURL, hlsSource string
var tried int
for {
tried++
//
if tried > 10 {
panic(errors.New("cannot fetch the Playlist correctly after 10 tries"))
}
// Get the channel page content body.
body := getBody(username)
//
if body == "" {
continue
}
// 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)
//
if m3u8Source != "" {
break
}
<-time.After(time.Millisecond * 500)
}
// Create the master video file.
masterFile, err := os.OpenFile("./"+savePath+"/"+filename+".ts", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0777)
if err != nil {
log.Println(color.Colorize(color.Red, err.Error()))
}
//
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
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 _, ok := segmentMap[fmt.Sprintf("./%s/%s~%d.ts", savePath, filename, index)]; !ok {
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 := segmentMap[fmt.Sprintf("./%s/%s~%d.ts", savePath, filename, index)]
//
var err error
if stripLimit != 0 && stripQuota <= 0 {
newMasterFilename := "./" + savePath + "/" + filename + "_" + strconv.Itoa(stripIndex) + ".ts"
master, err = os.OpenFile(newMasterFilename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0777)
if err != nil {
log.Println(color.Colorize(color.Red, err.Error()))
}
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)
segmentMapLock.Lock()
delete(segmentMap, fmt.Sprintf("./%s/%s~%d.ts", savePath, filename, index))
segmentMapLock.Unlock()
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().TLSClientConfig(&tls.Config{InsecureSkipVerify: true}).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)
segmentMapLock.Lock()
segmentMap[fmt.Sprintf("./%s/%s~%d.ts", savePath, filename, index)] = body
segmentMapLock.Unlock()
}
// 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
//
preferredFPS = c.String("fps")
preferredResolution = c.String("resolution")
preferredResolutionFallback = c.String("resolution-fallback")
//
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{
Version: "0.94 Alpha",
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",
},
&cli.StringFlag{
Name: "resolution",
Aliases: []string{"r"},
Value: "1080",
Usage: "Video resolution, could be `240`, `480`, `540`, `720`, `1080`",
},
&cli.StringFlag{
Name: "resolution-fallback",
Aliases: []string{"rf"},
Value: "down",
Usage: "Looking for larger or smaller resolution (`up` for larger, `down` for smaller) if a specified resolution was not found",
},
&cli.StringFlag{
Name: "fps",
Aliases: []string{"f"},
Value: "60",
Usage: "Preferred framerate, only works if streaming source supports it, otherwise it will always be 30 FPS",
},
},
Name: "chaturbate-dvr",
Usage: "watching a specified chaturbate channel and auto saves the stream as local file",
Action: endpoint,
}
err := app.Run(os.Args)
Usage: "",
Action: func(*cli.Context) error {
r := gin.Default()
manager, err := manager.New()
if err != nil {
return fmt.Errorf("new manager: %w", err)
}
g := gateway.New(manager)
for route, handler := range g.Routes() {
r.POST(route, handler)
}
return r.Run()
},
}
if err := app.Run(os.Args); err != nil {
log.Fatal(err)
}
}