Developing new version

This commit is contained in:
Yami Odymel 2023-12-12 14:48:48 +08:00
parent 5cfa7e1953
commit 681f8903d5
No known key found for this signature in database
GPG Key ID: 68E469836934DB36
37 changed files with 2009 additions and 588 deletions

View File

View File

View File

@ -1,11 +1,11 @@
FROM golang:latest
WORKDIR /usr/src/app
COPY go.mod go.sum ./
RUN go mod download && go mod verify
COPY . .
RUN go build
FROM golang:latest
WORKDIR /usr/src/app
COPY go.mod go.sum ./
RUN go mod download && go mod verify
COPY . .
RUN go build
CMD [ "sh", "-c", "./chaturbate-dvr -u $USERNAME" ]

View File

@ -1,21 +1,21 @@
MIT License
Copyright (c) 2022 TeaCat
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
MIT License
Copyright (c) 2022 TeaCat
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,9 +1,9 @@
version: "3.0"
services:
chaturbate-dvr:
build: .
environment:
- USERNAME=my_lovely_channel_name
volumes:
version: "3.0"
services:
chaturbate-dvr:
build: .
environment:
- USERNAME=my_lovely_channel_name
volumes:
- ./video/my_lovely_channel_name:/usr/src/app/video

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: "",
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()
},
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 {
if err := app.Run(os.Args); err != nil {
log.Fatal(err)
}
}