mirror of
https://github.com/teacat/chaturbate-dvr.git
synced 2025-10-29 16:59:59 +00:00
Developing new version
This commit is contained in:
parent
5cfa7e1953
commit
681f8903d5
0
.gitignore → .old/.gitignore
vendored
0
.gitignore → .old/.gitignore
vendored
@ -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" ]
|
||||
@ -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.
|
||||
@ -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
23
.old/go.mod
Normal 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
46
.old/go.sum
Normal 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
510
.old/main.go
Normal 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
21
adapter/manager/config.go
Normal 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
109
adapter/manager/manager.go
Normal 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
147
adapter/manager/stream.go
Normal 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
116
adapter/manager/util.go
Normal 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
|
||||
}
|
||||
10
domain/stream/repository.go
Normal file
10
domain/stream/repository.go
Normal 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
162
domain/stream/stream.go
Normal 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
29
gateway/gateway.go
Normal 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
|
||||
}
|
||||
90
gateway/handler/fetch_updates.go
Normal file
90
gateway/handler/fetch_updates.go
Normal 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
|
||||
}
|
||||
76
gateway/handler/list_streams.go
Normal file
76
gateway/handler/list_streams.go
Normal 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
|
||||
}
|
||||
44
gateway/handler/pause_stream.go
Normal file
44
gateway/handler/pause_stream.go
Normal 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
|
||||
}
|
||||
44
gateway/handler/resume_stream.go
Normal file
44
gateway/handler/resume_stream.go
Normal 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
|
||||
}
|
||||
57
gateway/handler/start_stream.go
Normal file
57
gateway/handler/start_stream.go
Normal 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
|
||||
}
|
||||
42
gateway/handler/stop_stream.go
Normal file
42
gateway/handler/stop_stream.go
Normal 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
|
||||
}
|
||||
235
gateway/handler/template/index.html
Normal file
235
gateway/handler/template/index.html
Normal 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
11
gateway/handler/util.go
Normal 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)
|
||||
}
|
||||
26
gateway/handler/view_index.go
Normal file
26
gateway/handler/view_index.go
Normal 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
27
gateway/util.go
Normal 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
48
go.mod
@ -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
136
go.sum
@ -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
510
main.go
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user