This commit is contained in:
Yami Odymel 2024-01-23 23:24:30 +08:00
parent f97539ab96
commit dc4a3d117f
No known key found for this signature in database
GPG Key ID: 68E469836934DB36
21 changed files with 756 additions and 541 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
./videos

View File

@ -1,67 +0,0 @@
package service
import "errors"
var (
ErrChannelNotFound = errors.New("channel not found")
ErrChannelExists = errors.New("channel already exists")
ErrChannelNotPaused = errors.New("channel not paused")
ErrChannelIsPaused = errors.New("channel is paused")
ErrListenNotFound = errors.New("listen not found")
)
const (
ResolutionFallbackUpscale = "up"
ResolutionFallbackDownscale = "down"
)
type Chaturbate interface {
GetChannel(username string) (ChaturbateChannel, error)
CreateChannel(config *ChaturbateConfig) error
DeleteChannel(username string) error
PauseChannel(username string) error
ResumeChannel(username string) error
ListChannels() ([]ChaturbateChannel, error)
ListenUpdate() (<-chan *Update, string)
StopListenUpdate(id string) error
}
type ChaturbateChannel interface {
Username() string
ChannelURL() string
FilenamePattern() string
Framerate() int
Resolution() int
ResolutionFallback() string
LastStreamedAt() string
Filename() string
SegmentDuration() int
SplitDuration() int
SegmentFilesize() int
SplitFilesize() int
IsOnline() bool
IsPaused() bool
Logs() []string
}
type ChaturbateConfig struct {
Username string
FilenamePattern string
Framerate int
Resolution int
ResolutionFallback string
SplitDuration int
SplitFilesize int
}
type Update struct {
Username string `json:"username"`
Log string `json:"log"`
IsPaused bool `json:"is_paused"`
IsOnline bool `json:"is_online"`
IsStopped bool `json:"is_stopped"`
Filename string `json:"filename"`
LastStreamedAt string `json:"last_streamed_at"`
SegmentDuration string `json:"segment_duration"`
SegmentFilesize string `json:"segment_filesize"`
}

11
Dockerfile Normal file
View File

@ -0,0 +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
CMD [ "sh", "-c", "./chaturbate-dvr -u $USERNAME -ui no start" ]

View File

@ -1,4 +1,12 @@
#
# Chaturbate DVR
The program watches a specified Chaturbate channel and save the stream in real-time when the channel goes online.
**Warning**: The streaming content on Chaturbate is copyrighted, you should not copy, share, distribute the content. (for more information, check [DMCA](https://www.dmca.com/))
## Usage
The program works for 64-bit macOS, Linux, Windows (or ARM). Just get in the `/bin` folder and find your operating system then execute the program in terminal.
## 📺 Framerate & Resolution / Fallback

Binary file not shown.

View File

@ -1,22 +1,11 @@
package chaturbate
import (
"bytes"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/grafov/m3u8"
"github.com/samber/lo"
)
var (
@ -40,14 +29,15 @@ type Channel struct {
Framerate int
Resolution int
ResolutionFallback string
SegmentDuration int
SplitDuration int
SegmentFilesize int
SplitFilesize int
SegmentDuration int // Seconds
SplitDuration int // Minutes
SegmentFilesize int // Bytes
SplitFilesize int // MB
IsOnline bool
IsPaused bool
isStopped bool
Logs []string
logType logType
bufferLock sync.Mutex
buffer map[int][]byte
@ -62,400 +52,86 @@ type Channel struct {
sessionPattern map[string]any
splitIndex int
PauseChannel chan bool
UpdateChannel chan *Update
ResumeChannel chan bool
}
// Run
func (w *Channel) Run() {
if w.Username == "" {
w.log(logTypeError, "username is empty, use `-u USERNAME` to specify")
return
}
for {
if w.IsPaused {
w.log(logTypeInfo, "channel is paused")
<-w.ResumeChannel // blocking
w.log(logTypeInfo, "channel is resumed")
}
if w.isStopped {
w.log(logTypeInfo, "channel is stopped")
break
}
body, err := w.requestChannelBody()
if err != nil {
w.log("Error occurred while requesting channel body: %w", err)
w.log(logTypeError, "body request error: %w", err)
}
if strings.Contains(body, "playlist.m3u8") {
w.IsOnline = true
w.LastStreamedAt = time.Now().Format("2006-01-02 15:04:05")
w.log("Channel is online.")
w.log(logTypeInfo, "channel is online, start fetching...")
if err := w.record(body); err != nil { // blocking
w.log("Error occurred when start recording: %w", err)
w.log(logTypeError, "record error: %w", err)
}
continue // this excutes when recording is over/interrupted
}
w.IsOnline = false
w.log("Channel is offline.")
w.log(logTypeInfo, "channel is offline, check again 1 min later")
<-time.After(1 * time.Minute) // 1 minute cooldown to check online status
}
}
// requestChannelBody
func (w *Channel) requestChannelBody() (string, error) {
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client := &http.Client{Transport: transport}
resp, err := client.Get(w.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)
}
return string(body), nil
}
// record
func (w *Channel) record(body string) error {
w.resetSession()
if err := w.newFile(); err != nil {
return fmt.Errorf("new file: %w", err)
}
rootURL, sourceURL, err := w.resolveSource(body)
if err != nil {
return fmt.Errorf("request hls: %w", err)
}
w.rootURL = rootURL
w.sourceURL = sourceURL
go w.mergeSegments()
w.fetchSegments() // blocking
return nil
}
func (w *Channel) resetSession() {
w.buffer = make(map[int][]byte)
w.bufferLock = sync.Mutex{}
w.bufferIndex = 0
w.segmentIndex = 0
w.segmentUseds = []string{}
w.rootURL = ""
w.sourceURL = ""
w.retries = 0
w.SegmentFilesize = 0
w.SegmentDuration = 0
w.splitIndex = 0
w.sessionPattern = nil
}
func (w *Channel) resolveSource(body string) (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.
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client := &http.Client{Transport: transport}
resp, err := client.Get(roomData.HLSSource)
if err != nil {
return "", "", fmt.Errorf("client get: %w", err)
}
if resp.StatusCode != http.StatusOK {
switch resp.StatusCode {
case http.StatusForbidden:
return "", "", fmt.Errorf("received status code %d, the stream is private?", resp.StatusCode)
default:
return "", "", fmt.Errorf("received status code %d", resp.StatusCode)
}
}
defer resp.Body.Close()
m3u8Body, err := io.ReadAll(resp.Body)
if err != nil {
return "", "", fmt.Errorf("read body: %w", err)
}
// 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 resolutions []*resolution
for _, v := range playlist.Variants {
width := strings.Split(v.Resolution, "x")[1] // 1920x1080 -> 1080
fps := 30
if strings.Contains(v.Name, "FPS:60.0") {
fps = 60
}
variant, ok := lo.Find(resolutions, func(v *resolution) bool {
return strconv.Itoa(v.width) == width
})
if ok {
variant.framerate[fps] = v.URI
continue
}
widthInt, err := strconv.Atoi(width)
if err != nil {
return "", "", fmt.Errorf("convert width string to int: %w", err)
}
resolutions = append(resolutions, &resolution{
framerate: map[int]string{fps: v.URI},
width: widthInt,
})
}
variant, ok := lo.Find(resolutions, func(v *resolution) bool {
return v.width == w.Resolution
})
// Fallback to the nearest resolution if the preferred resolution is not found.
if !ok {
switch w.ResolutionFallback {
case ResolutionFallbackDownscale:
variant = lo.MaxBy(lo.Filter(resolutions, func(v *resolution, _ int) bool {
log.Println(v.width, w.Resolution)
return v.width < w.Resolution
}), func(v, max *resolution) bool {
return v.width > max.width
})
case ResolutionFallbackUpscale:
variant = lo.MinBy(lo.Filter(resolutions, func(v *resolution, _ int) bool {
return v.width > w.Resolution
}), func(v, min *resolution) bool {
return v.width < min.width
})
}
}
if variant == nil {
return "", "", fmt.Errorf("no available variant")
}
url, ok := variant.framerate[w.Framerate]
// If the framerate is not found, fallback to the first found framerate, this block pretends there're only 30 and 60 fps.
// no complex logic here, im lazy.
if ok {
w.log("Framerate %d is used.", w.Framerate)
} else {
for k, v := range variant.framerate {
url = v
w.log("Framerate %d is not found, fallback to %d.", w.Framerate, k)
break
}
}
rootURL := strings.TrimSuffix(roomData.HLSSource, "playlist.m3u8")
sourceURL := rootURL + url
return rootURL, sourceURL, nil
}
func (w *Channel) mergeSegments() {
var segmentRetries int
for {
if w.IsPaused || w.isStopped {
break
}
if segmentRetries > 5 {
w.log("Segment #%d error, the segment has been skipped.", w.bufferIndex)
w.bufferIndex++
segmentRetries = 0
continue
}
if len(w.buffer) == 0 {
<-time.After(1 * time.Second)
continue
}
buf, ok := w.buffer[w.bufferIndex]
if !ok {
segmentRetries++
<-time.After(time.Duration(segmentRetries) * time.Second)
continue
}
lens, err := w.file.Write(buf)
if err != nil {
w.log("Error occurred while writing segment #%d to file: %v", w.bufferIndex, err)
w.retries++
continue
}
w.log("Segment #%d written to file.", w.bufferIndex)
w.SegmentFilesize += lens
segmentRetries = 0
if w.SplitFilesize > 0 && w.SegmentFilesize >= w.SplitFilesize*1024*1024 {
w.log("File size has exceeded, creating new file.")
if err := w.nextFile(); err != nil {
w.log("Error occurred while creating file for next part: %v", err)
break
}
}
if w.SplitDuration > 0 && w.SegmentDuration >= w.SplitDuration*60 {
w.log("Duration has exceeded, creating new file.")
if err := w.nextFile(); err != nil {
w.log("Error occurred while creating file for next part: %v", err)
break
}
}
w.bufferLock.Lock()
delete(w.buffer, w.bufferIndex)
w.bufferLock.Unlock()
w.bufferIndex++
}
}
func (w *Channel) requestChunks() ([]*m3u8.MediaSegment, float64, error) {
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client := &http.Client{Transport: transport}
resp, err := client.Get(w.sourceURL)
if err != nil {
return nil, 3, fmt.Errorf("client get: %w", err)
}
if resp.StatusCode != http.StatusOK {
switch resp.StatusCode {
case http.StatusForbidden:
return nil, 3, fmt.Errorf("received status code %d, the stream is private?", resp.StatusCode)
default:
return nil, 3, fmt.Errorf("received status code %d", resp.StatusCode)
}
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, 3, fmt.Errorf("read body: %w", err)
}
p, _, err := m3u8.DecodeFrom(bytes.NewReader(body), true)
if err != nil {
return nil, 3, fmt.Errorf("decode m3u8: %w", err)
}
playlist, ok := p.(*m3u8.MediaPlaylist)
if !ok {
return nil, 3, fmt.Errorf("cast to media playlist")
}
chunks := lo.Filter(playlist.Segments, func(v *m3u8.MediaSegment, _ int) bool {
return v != nil
})
//log.Println(playlist.TargetDuration)
return chunks, 1, nil
}
// fetchSegments
func (w *Channel) fetchSegments() {
var disconnectRetries int
for {
if w.IsPaused || w.isStopped {
break
}
chunks, wait, err := w.requestChunks()
if err != nil {
if disconnectRetries > 10 {
w.IsOnline = false
break
}
w.log("Error occurred while parsing m3u8: %v", err)
disconnectRetries++
<-time.After(time.Duration(wait) * time.Second)
continue
}
if disconnectRetries > 0 {
w.log("Stream is online")
w.IsOnline = true
disconnectRetries = 0
}
for _, v := range chunks {
if w.isSegmentFetched(v.URI) {
continue
}
go w.requestSegment(v.URI, w.segmentIndex)
w.SegmentDuration += int(v.Duration)
w.segmentIndex++
}
<-time.After(time.Duration(wait) * time.Second)
}
}
func (w *Channel) requestSegment(url string, index int) error {
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client := &http.Client{Transport: transport}
resp, err := client.Get(w.rootURL + url)
if err != nil {
return fmt.Errorf("client get: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("received status code %d", resp.StatusCode)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read body: %w", err)
}
w.log("Segment #%d fetched.", index)
w.bufferLock.Lock()
w.buffer[index] = body
w.bufferLock.Unlock()
return nil
}
func (w *Channel) Pause() {
w.IsPaused = true
w.resetSession()
w.log("Channel was paused.")
}
func (w *Channel) Resume() {
w.IsPaused = false
w.ResumeChannel <- true //BUG:
w.log("Channel was resumed.")
select {
case w.ResumeChannel <- true:
default:
}
}
func (w *Channel) Stop() {
w.isStopped = true
w.log("Channel was stopped.")
}
func (w *Channel) SegmentDurationStr() string {
return DurationStr(w.SegmentDuration)
}
func (w *Channel) SplitDurationStr() string {
return DurationStr(w.SplitDuration * 60)
}
func (w *Channel) SegmentFilesizeStr() string {
return ByteStr(w.SegmentFilesize)
}
func (w *Channel) SplitFilesizeStr() string {
return MBStr(w.SplitFilesize)
}
func (w *Channel) Filename() string {
if w.file == nil {
return ""
}
return w.file.Name()
}

View File

@ -3,9 +3,9 @@ package chaturbate
import (
"bytes"
"fmt"
"html/template"
"os"
"path/filepath"
"text/template"
"time"
)
@ -27,17 +27,14 @@ func (w *Channel) filename() (string, error) {
} else {
data["Sequence"] = w.splitIndex
}
t, err := template.New("filename").Parse(w.filenamePattern)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := t.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}
@ -45,7 +42,7 @@ func (w *Channel) filename() (string, error) {
func (w *Channel) newFile() error {
filename, err := w.filename()
if err != nil {
return fmt.Errorf("error occurred while parsing filename pattern: %w", err)
return fmt.Errorf("filename pattern error: %w", err)
}
if err := os.MkdirAll(filepath.Dir(filename), 0777); err != nil {
return fmt.Errorf("create folder: %w", err)
@ -54,7 +51,7 @@ func (w *Channel) newFile() error {
if err != nil {
return fmt.Errorf("cannot open file: %s: %w", filename, err)
}
w.log("The video will be saved as %s.ts", filename)
w.log(logTypeInfo, "the stream will be saved as %s.ts", filename)
w.file = file
return nil
}
@ -67,10 +64,3 @@ func (w *Channel) nextFile() error {
return w.newFile()
}
func (w *Channel) Filename() string {
if w.file == nil {
return ""
}
return w.file.Name()
}

View File

@ -0,0 +1,387 @@
package chaturbate
import (
"bytes"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/grafov/m3u8"
"github.com/samber/lo"
)
// requestChannelBody requests the channel page and returns the body.
func (w *Channel) requestChannelBody() (string, error) {
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client := &http.Client{Transport: transport}
resp, err := client.Get(w.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)
}
return string(body), nil
}
// record starts the recording process,
// this function get called when the channel is online and back online from offline status.
//
// this is a blocking function until fetching segments gone wrong (or nothing to fetch, aka offline).
func (w *Channel) record(body string) error {
w.resetSession()
if err := w.newFile(); err != nil {
return fmt.Errorf("new file: %w", err)
}
rootURL, sourceURL, err := w.resolveSource(body)
if err != nil {
return fmt.Errorf("request hls: %w", err)
}
w.rootURL = rootURL
w.sourceURL = sourceURL
go w.mergeSegments()
w.fetchSegments() // blocking
return nil
}
// resetSession resets the session data,
// usually called when the channel is online or paused to resumed.
func (w *Channel) resetSession() {
w.buffer = make(map[int][]byte)
w.bufferLock = sync.Mutex{}
w.bufferIndex = 0
w.segmentIndex = 0
w.segmentUseds = []string{}
w.rootURL = ""
w.sourceURL = ""
w.retries = 0
w.SegmentFilesize = 0
w.SegmentDuration = 0
w.splitIndex = 0
w.sessionPattern = nil
}
// resolveSource resolves the HLS source from the channel page.
// the HLS Source is a list that contains all the available resolutions and framerates.
func (w *Channel) resolveSource(body string) (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.
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client := &http.Client{Transport: transport}
resp, err := client.Get(roomData.HLSSource)
if err != nil {
return "", "", fmt.Errorf("client get: %w", err)
}
if resp.StatusCode != http.StatusOK {
switch resp.StatusCode {
case http.StatusForbidden:
return "", "", fmt.Errorf("ticket/private stream?")
default:
return "", "", fmt.Errorf("status code %d", resp.StatusCode)
}
}
defer resp.Body.Close()
m3u8Body, err := io.ReadAll(resp.Body)
if err != nil {
return "", "", fmt.Errorf("read body: %w", err)
}
// 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 resolutions []*resolution
for _, v := range playlist.Variants {
width := strings.Split(v.Resolution, "x")[1] // 1920x1080 -> 1080
fps := 30
if strings.Contains(v.Name, "FPS:60.0") {
fps = 60
}
variant, ok := lo.Find(resolutions, func(v *resolution) bool {
return strconv.Itoa(v.width) == width
})
if ok {
variant.framerate[fps] = v.URI
continue
}
widthInt, err := strconv.Atoi(width)
if err != nil {
return "", "", fmt.Errorf("convert width string to int: %w", err)
}
resolutions = append(resolutions, &resolution{
framerate: map[int]string{fps: v.URI},
width: widthInt,
})
}
variant, ok := lo.Find(resolutions, func(v *resolution) bool {
return v.width == w.Resolution
})
// Fallback to the nearest resolution if the preferred resolution is not found.
if !ok {
switch w.ResolutionFallback {
case ResolutionFallbackDownscale:
variant = lo.MaxBy(lo.Filter(resolutions, func(v *resolution, _ int) bool {
return v.width < w.Resolution
}), func(v, max *resolution) bool {
return v.width > max.width
})
case ResolutionFallbackUpscale:
variant = lo.MinBy(lo.Filter(resolutions, func(v *resolution, _ int) bool {
return v.width > w.Resolution
}), func(v, min *resolution) bool {
return v.width < min.width
})
}
}
if variant == nil {
return "", "", fmt.Errorf("no available resolution")
}
w.log(logTypeInfo, "resolution %dp is used", variant.width)
url, ok := variant.framerate[w.Framerate]
// If the framerate is not found, fallback to the first found framerate, this block pretends there're only 30 and 60 fps.
// no complex logic here, im lazy.
if ok {
w.log(logTypeInfo, "framerate %dfps is used", w.Framerate)
} else {
for k, v := range variant.framerate {
url = v
w.log(logTypeWarning, "framerate %dfps not found, fallback to %dfps", w.Framerate, k)
w.Framerate = k
break
}
}
rootURL := strings.TrimSuffix(roomData.HLSSource, "playlist.m3u8")
sourceURL := rootURL + url
return rootURL, sourceURL, nil
}
// mergeSegments is a async function that runs in background for the channel,
// and it merges the segments from buffer to the file.
func (w *Channel) mergeSegments() {
var segmentRetries int
for {
if w.IsPaused || w.isStopped {
break
}
if segmentRetries > 5 {
w.log(logTypeWarning, "segment #%d not found in buffer, skipped", w.bufferIndex)
w.bufferIndex++
segmentRetries = 0
continue
}
if len(w.buffer) == 0 {
<-time.After(1 * time.Second)
continue
}
buf, ok := w.buffer[w.bufferIndex]
if !ok {
segmentRetries++
<-time.After(time.Duration(segmentRetries) * time.Second)
continue
}
lens, err := w.file.Write(buf)
if err != nil {
w.log(logTypeError, "segment #%d written error: %v", w.bufferIndex, err)
w.retries++
continue
}
w.log(logTypeInfo, "segment #%d written", w.bufferIndex)
w.log(logTypeDebug, "duration: %s, size: %s", DurationStr(w.SegmentDuration), ByteStr(w.SegmentFilesize))
w.SegmentFilesize += lens
segmentRetries = 0
if w.SplitFilesize > 0 && w.SegmentFilesize >= w.SplitFilesize*1024*1024 {
w.log(logTypeInfo, "filesize exceeded, creating new file")
if err := w.nextFile(); err != nil {
w.log(logTypeError, "next file error: %v", err)
break
}
} else if w.SplitDuration > 0 && w.SegmentDuration >= w.SplitDuration*60 {
w.log(logTypeInfo, "duration exceeded, creating new file")
if err := w.nextFile(); err != nil {
w.log(logTypeError, "next file error: %v", err)
break
}
}
w.bufferLock.Lock()
delete(w.buffer, w.bufferIndex)
w.bufferLock.Unlock()
w.bufferIndex++
}
}
// fetchSegments is a blocking function,
// it will keep asking the segment list for the latest segments.
func (w *Channel) fetchSegments() {
var disconnectRetries int
for {
if w.IsPaused || w.isStopped {
break
}
chunks, wait, err := w.requestChunks()
if err != nil {
if disconnectRetries > 10 {
w.IsOnline = false
break
}
w.log(logTypeError, "segment list error, will try again [%d/10]: %v", disconnectRetries, err)
disconnectRetries++
<-time.After(time.Duration(wait) * time.Second)
continue
}
if disconnectRetries > 0 {
w.log(logTypeInfo, "channel is back online!")
w.IsOnline = true
disconnectRetries = 0
}
for _, v := range chunks {
if w.isSegmentFetched(v.URI) {
continue
}
go func(index int) {
if err := w.requestSegment(v.URI, index); err != nil {
w.log(logTypeError, "segment #%d request error, ignored: %v", index, err)
return
}
}(w.segmentIndex)
w.SegmentDuration += int(v.Duration)
w.segmentIndex++
}
<-time.After(time.Duration(wait) * time.Second)
}
}
// requestChunks requests the segment list from the HLS source,
// the same segment list will be updated every few seconds from chaturbate.
func (w *Channel) requestChunks() ([]*m3u8.MediaSegment, float64, error) {
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client := &http.Client{Transport: transport}
if w.sourceURL == "" {
return nil, 0, fmt.Errorf("channel seems to be paused?")
}
resp, err := client.Get(w.sourceURL)
if err != nil {
return nil, 3, fmt.Errorf("client get: %w", err)
}
if resp.StatusCode != http.StatusOK {
switch resp.StatusCode {
case http.StatusForbidden:
return nil, 3, fmt.Errorf("ticket/private stream?")
default:
return nil, 3, fmt.Errorf("status code %d", resp.StatusCode)
}
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, 3, fmt.Errorf("read body: %w", err)
}
p, _, err := m3u8.DecodeFrom(bytes.NewReader(body), true)
if err != nil {
return nil, 3, fmt.Errorf("decode m3u8: %w", err)
}
playlist, ok := p.(*m3u8.MediaPlaylist)
if !ok {
return nil, 3, fmt.Errorf("cast to media playlist")
}
chunks := lo.Filter(playlist.Segments, func(v *m3u8.MediaSegment, _ int) bool {
return v != nil
})
return chunks, 1, nil
}
// requestSegment requests the specific single segment and put it into the buffer.
// the mergeSegments function will merge the segment from buffer to the file in the backgrond.
func (w *Channel) requestSegment(url string, index int) error {
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client := &http.Client{Transport: transport}
if w.rootURL == "" {
return fmt.Errorf("channel seems to be paused?")
}
resp, err := client.Get(w.rootURL + url)
if err != nil {
return fmt.Errorf("client get: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("received status code %d", resp.StatusCode)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read body: %w", err)
}
w.log(logTypeDebug, "segment #%d fetched", index)
w.bufferLock.Lock()
w.buffer[index] = body
w.bufferLock.Unlock()
return nil
}

View File

@ -0,0 +1,21 @@
package chaturbate
type Update struct {
Username string `json:"username"`
Log string `json:"log"`
IsPaused bool `json:"is_paused"`
IsOnline bool `json:"is_online"`
IsStopped bool `json:"is_stopped"`
Filename string `json:"filename"`
LastStreamedAt string `json:"last_streamed_at"`
SegmentDuration int `json:"segment_duration"`
SegmentFilesize int `json:"segment_filesize"`
}
func (u *Update) SegmentDurationStr() string {
return DurationStr(u.SegmentDuration)
}
func (u *Update) SegmentFilesizeStr() string {
return ByteStr(u.SegmentFilesize)
}

View File

@ -5,10 +5,34 @@ import (
"time"
)
type logType string
const (
logTypeDebug logType = "DEBUG"
logTypeInfo logType = "INFO"
logTypeWarning logType = "WARN"
logTypeError logType = "ERROR"
)
// log
func (w *Channel) log(message string, v ...interface{}) {
updateLog := fmt.Sprintf("[%s] %s", time.Now().Format("2006-01-02 15:04:05"), fmt.Errorf(message, v...))
consoleLog := fmt.Sprintf("[%s] [%s] %s", time.Now().Format("2006-01-02 15:04:05"), w.Username, fmt.Errorf(message, v...))
func (w *Channel) log(typ logType, message string, v ...interface{}) {
switch w.logType {
case logTypeInfo:
if typ == logTypeDebug {
return
}
case logTypeWarning:
if typ == logTypeDebug || typ == logTypeInfo {
return
}
case logTypeError:
if typ == logTypeDebug || typ == logTypeInfo || typ == logTypeWarning {
return
}
}
updateLog := fmt.Sprintf("[%s] [%s] %s", time.Now().Format("2006-01-02 15:04:05"), typ, fmt.Errorf(message, v...))
consoleLog := fmt.Sprintf("[%s] [%s] [%s] %s", time.Now().Format("2006-01-02 15:04:05"), typ, w.Username, fmt.Errorf(message, v...))
update := &Update{
Username: w.Username,
@ -23,7 +47,10 @@ func (w *Channel) log(message string, v ...interface{}) {
update.Filename = w.file.Name()
}
w.UpdateChannel <- update
select {
case w.UpdateChannel <- update:
default:
}
fmt.Println(consoleLog)
@ -35,13 +62,32 @@ func (w *Channel) log(message string, v ...interface{}) {
}
}
// isDuplicateSegment returns true if the segment is already been fetched.
// isSegmentFetched returns true if the segment has been fetched.
func (w *Channel) isSegmentFetched(url string) bool {
for _, v := range w.segmentUseds {
if url[len(url)-10:] == v {
if url == v {
return true
}
}
w.segmentUseds = append(w.segmentUseds, url[len(url)-10:])
if len(w.segmentUseds) > 100 {
w.segmentUseds = w.segmentUseds[len(w.segmentUseds)-30:]
}
w.segmentUseds = append(w.segmentUseds, url)
return false
}
func DurationStr(seconds int) string {
hours := seconds / 3600
seconds %= 3600
minutes := seconds / 60
seconds %= 60
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds)
}
func MBStr(mibs int) string {
return fmt.Sprintf("%.2f MiB", float64(mibs))
}
func ByteStr(bytes int) string {
return fmt.Sprintf("%.2f MiB", float64(bytes)/1024/1024)
}

View File

@ -4,6 +4,7 @@ import (
"errors"
"github.com/google/uuid"
"github.com/urfave/cli/v2"
)
const (
@ -11,18 +12,6 @@ const (
ResolutionFallbackDownscale = "down"
)
type Update struct {
Username string `json:"username"`
Log string `json:"log"`
IsPaused bool `json:"is_paused"`
IsOnline bool `json:"is_online"`
IsStopped bool `json:"is_stopped"`
Filename string `json:"filename"`
LastStreamedAt string `json:"last_streamed_at"`
SegmentDuration int `json:"segment_duration"`
SegmentFilesize int `json:"segment_filesize"`
}
var (
ErrChannelNotFound = errors.New("channel not found")
ErrChannelExists = errors.New("channel already exists")
@ -31,6 +20,7 @@ var (
ErrListenNotFound = errors.New("listen not found")
)
// Config
type Config struct {
Username string
FilenamePattern string
@ -43,16 +33,18 @@ type Config struct {
// Manager
type Manager struct {
cli *cli.Context
Channels map[string]*Channel
Updates map[string]chan *Update
}
// NewManager
func NewManager() (*Manager, error) {
func NewManager(c *cli.Context) *Manager {
return &Manager{
cli: c,
Channels: map[string]*Channel{},
Updates: map[string]chan *Update{},
}, nil
}
}
// PauseChannel
@ -116,6 +108,7 @@ func (m *Manager) CreateChannel(conf *Config) error {
Logs: []string{},
UpdateChannel: make(chan *Update),
ResumeChannel: make(chan bool),
logType: logType(m.cli.String("log-level")),
}
go func() {
for update := range c.UpdateChannel {
@ -127,7 +120,7 @@ func (m *Manager) CreateChannel(conf *Config) error {
}
}()
m.Channels[conf.Username] = c
c.log("Channel created")
c.log(logTypeInfo, "channel created")
go c.Run()
return nil
}

9
docker-compose.yml Normal file
View File

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

View File

@ -63,10 +63,10 @@ func (h *GetChannelHandler) Handle(c *gin.Context) {
ChannelURL: channel.ChannelURL,
Filename: channel.Filename(),
LastStreamedAt: channel.LastStreamedAt,
SegmentDuration: DurationStr(channel.SegmentDuration),
SplitDuration: DurationStr(channel.SplitDuration),
SegmentFilesize: ByteStr(channel.SegmentFilesize),
SplitFilesize: MBStr(channel.SplitFilesize),
SegmentDuration: channel.SegmentDurationStr(),
SplitDuration: channel.SplitDurationStr(),
SegmentFilesize: channel.SegmentFilesizeStr(),
SplitFilesize: channel.SplitFilesizeStr(),
IsOnline: channel.IsOnline,
IsPaused: channel.IsPaused,
Logs: channel.Logs,

View File

@ -15,6 +15,7 @@ type GetSettingsHandlerRequest struct {
}
type GetSettingsHandlerResponse struct {
Version string `json:"version"`
Framerate int `json:"framerate"`
Resolution int `json:"resolution"`
ResolutionFallback string `json:"resolution_fallback"`
@ -49,6 +50,7 @@ func (h *GetSettingsHandler) Handle(c *gin.Context) {
return
}
c.JSON(http.StatusOK, &GetSettingsHandlerResponse{
Version: h.cli.App.Version,
Framerate: h.cli.Int("framerate"),
Resolution: h.cli.Int("resolution"),
ResolutionFallback: h.cli.String("resolution-fallback"),

View File

@ -70,10 +70,10 @@ func (h *ListChannelsHandler) Handle(c *gin.Context) {
ChannelURL: channel.ChannelURL,
Filename: channel.Filename(),
LastStreamedAt: channel.LastStreamedAt,
SegmentDuration: DurationStr(channel.SegmentDuration),
SplitDuration: DurationStr(channel.SplitDuration),
SegmentFilesize: ByteStr(channel.SegmentFilesize),
SplitFilesize: MBStr(channel.SplitFilesize),
SegmentDuration: channel.SegmentDurationStr(),
SplitDuration: channel.SplitDurationStr(),
SegmentFilesize: channel.SegmentFilesizeStr(),
SplitFilesize: channel.SplitFilesizeStr(),
IsOnline: channel.IsOnline,
IsPaused: channel.IsPaused,
Logs: channel.Logs,

View File

@ -46,8 +46,9 @@ func (h *ListenUpdateHandler) Handle(c *gin.Context) {
"is_paused": update.IsPaused,
"is_online": update.IsOnline,
"last_streamed_at": update.LastStreamedAt,
"segment_duration": DurationStr(update.SegmentDuration),
"segment_filesize": ByteStr(update.SegmentFilesize),
"segment_duration": update.SegmentDurationStr(),
"segment_filesize": update.SegmentFilesizeStr(),
"filename": update.Filename,
})
return true
})

View File

@ -0,0 +1,46 @@
package handler
import (
"log"
"net/http"
"os"
"github.com/gin-gonic/gin"
"github.com/urfave/cli/v2"
)
//=======================================================
// Request & Response
//=======================================================
type TerminateProgramRequest struct {
}
type TerminateProgramResponse struct {
}
//=======================================================
// Factory
//=======================================================
type TerminateProgramHandler struct {
cli *cli.Context
}
func NewTerminateProgramHandler(cli *cli.Context) *TerminateProgramHandler {
return &TerminateProgramHandler{cli}
}
//=======================================================
// Handle
//=======================================================
func (h *TerminateProgramHandler) Handle(c *gin.Context) {
var req *TerminateProgramRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
log.Println("program terminated by user request, see ya 👋")
os.Exit(0)
}

View File

@ -1,23 +0,0 @@
package handler
import "fmt"
func DurationStr(seconds int) string {
hours := seconds / 3600
seconds %= 3600
minutes := seconds / 60
seconds %= 60
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds)
}
func ByteStr(bytes int) string {
return fmt.Sprintf("%.2f MiB", float64(bytes)/1024/1024)
}
func KBStr(kibs int) string {
return fmt.Sprintf("%.2f MiB", float64(kibs)/1024)
}
func MBStr(mibs int) string {
return fmt.Sprintf("%.2f MiB", float64(mibs))
}

View File

@ -3,9 +3,9 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="./tocas/tocas.min.css" />
<script src="./tocas/tocas.min.js"></script>
<script src="./script.js"></script>
<link rel="stylesheet" href="/static/tocas/tocas.min.css" />
<script src="/static/tocas/tocas.min.js"></script>
<script src="/static/script.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" />
@ -16,6 +16,7 @@
<!-- Create Dialog -->
<dialog id="create-dialog" class="ts-modal is-large" data-clickaway="close">
<div class="content">
<!-- Header -->
<div class="ts-content is-horizontally-padded is-secondary">
<div class="ts-grid">
<div class="column is-fluid">
@ -26,8 +27,13 @@
</div>
</div>
</div>
<!-- / Header -->
<div class="ts-divider"></div>
<!-- Form -->
<div class="ts-content is-vertically-padded">
<!-- Field: Channel Username -->
<div class="ts-control is-wide">
<div class="label">Channel Username</div>
<div class="content">
@ -37,7 +43,9 @@
</div>
</div>
</div>
<!-- / Field: Channel Username -->
<!-- Field: Resolution -->
<div class="ts-control is-wide has-top-spaced-large">
<div class="label">Resolution</div>
<div class="content">
@ -76,6 +84,9 @@
</div>
</div>
</div>
<!-- / Field: Resolution -->
<!-- Field: Framerate -->
<div class="ts-control is-wide has-top-spaced-large">
<div class="label">Framerate</div>
<div class="content">
@ -94,6 +105,9 @@
</template>
</div>
</div>
<!-- / Field: Framerate -->
<!-- Field: Filename Pattern -->
<div class="ts-control is-wide has-top-spaced-large">
<div class="label">Filename Pattern</div>
<div class="content">
@ -105,9 +119,11 @@
</div>
</div>
</div>
<!-- / Field: Filename Pattern -->
<div class="ts-divider has-vertically-spaced-large"></div>
<!-- Field: Splitting Options -->
<div class="ts-control is-wide has-top-spaced">
<div class="label"></div>
<div class="content">
@ -135,28 +151,35 @@
</details>
</div>
</div>
<!-- / Field: Splitting Options -->
</div>
<!-- / Form -->
<div class="ts-divider"></div>
<!-- Footer -->
<div class="ts-content is-secondary is-horizontally-padded">
<div class="ts-wrap is-end-aligned">
<button class="ts-button is-outlined is-secondary" x-on:click="closeCreateDialog">Cancel</button>
<button class="ts-button is-primary" x-on:click="submitCreateDialog">Add Channel</button>
</div>
</div>
<!-- / Footer -->
</div>
</dialog>
<!-- / Create Dialog -->
<div class="ts-container is-narrow has-vertically-spaced-big">
<!-- Main Section -->
<div class="ts-container is-narrow has-vertically-padded-big">
<!-- Header -->
<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 class="ts-text is-description is-bold">Version <span x-text="settings.version"></span></div>
</div>
<div class="column">
<div class="ts-wrap">
<button class="ts-button is-outlined is-negative is-start-icon">
<button class="ts-button is-outlined is-negative is-start-icon" x-on:click="terminateProgram()">
<span class="ts-icon is-hand-icon"></span>
Terminate
</button>
@ -173,10 +196,9 @@
<template x-if="channels.length === 0">
<div>
<div class="ts-divider has-vertically-spaced-large"></div>
<div class="ts-blankslate">
<span class="ts-icon is-eye-low-vision-icon"></span>
<div class="header">No channel was recording.</div>
<div class="header">No channel was recording</div>
<div class="description">Add a new Chaturbate channel to start the recording.</div>
<div class="action">
<button class="ts-button is-start-icon" x-on:click="openCreateDialog">
@ -198,10 +220,12 @@
<!-- / Divider -->
<div class="ts-wrap is-vertical is-relaxed">
<!-- Each Channel -->
<!-- Channel -->
<template x-for="channel in channels" :key="channel.username">
<div class="ts-box is-horizontal">
<!-- Left Section -->
<div class="ts-content is-padded" style="flex: 1.8; display: flex; flex-direction: column">
<!-- Header -->
<div class="ts-grid is-middle-aligned">
<div class="column is-fluid">
<div class="ts-header">
@ -221,13 +245,23 @@
<button class="ts-button is-secondary is-short is-outlined is-dense" x-on:click="downloadLogs(channel.username)">Download Logs</button>
</div>
</div>
<!-- / Header -->
<!-- Logs -->
<div class="ts-input has-top-spaced" style="flex: 1">
<textarea class="has-full-height" x-bind:id="`${channel.username}-logs`" x-text="channel.logs.join('\n')" readonly></textarea>
</div>
<!-- / Logs -->
</div>
<!-- / Left Section -->
<div class="ts-divider is-vertical"></div>
<!-- Right Section -->
<div class="ts-content is-padded has-break-all" style="flex: 1; min-width: 300px">
<div class="ts-text is-description is-uppercased">Information</div>
<!-- Info: Channel URL -->
<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>
@ -237,12 +271,15 @@
<a class="ts-text is-link is-external-link" x-bind:href="channel.channel_url" x-text="channel.channel_url" target="_blank"></a>
</div>
</div>
<!-- / Info: Channel URL -->
<!-- Info: Filename -->
<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 has-leading-small">
<div class="ts-text is-label">Saved to</div>
<div class="ts-text is-label">Filename</div>
<template x-if="channel.filename">
<code class="ts-text is-code" x-text="channel.filename"></code>
@ -252,6 +289,9 @@
</template>
</div>
</div>
<!-- / Info: Filename -->
<!-- Info: Last streamed at -->
<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>
@ -266,6 +306,9 @@
</div>
</div>
</div>
<!-- / Info: Last streamed at -->
<!-- Info: Segment duration -->
<div class="ts-grid has-top-spaced">
<div class="column has-leading-none" style="width: 16px">
<span class="ts-icon is-clock-icon"></span>
@ -280,6 +323,9 @@
</div>
</div>
</div>
<!-- / Info: Segment duration -->
<!-- Info: Segment filesize -->
<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>
@ -294,11 +340,17 @@
</div>
</div>
</div>
<!-- / Info: Segment filesize -->
<!-- Actions -->
<div class="ts-grid is-2-columns has-top-spaced-large">
<div class="column">
<template x-if="!channel.is_paused">
<button class="ts-button is-start-icon is-secondary is-fluid" x-on:click="pauseChannel(channel.username)">
<button
class="ts-button is-start-icon is-secondary is-fluid"
x-bind:disabled="!channel.is_online"
x-on:click="pauseChannel(channel.username)"
>
<span class="ts-icon is-pause-icon"></span>
Pause
</button>
@ -312,7 +364,7 @@
</div>
<div class="column">
<button
class="ts-button is-start-icon is-secondary is-fluid"
class="ts-button is-start-icon is-secondary is-negative is-fluid"
data-tooltip="Stop and remove the channel from the list."
x-on:click="deleteChannel(channel.username)"
>
@ -321,11 +373,14 @@
</button>
</div>
</div>
<!-- / Actions -->
</div>
<!-- / Right Section -->
</div>
<!-- / Each Channel -->
</template>
<!-- / Channel -->
</div>
</div>
<!-- / Main Section -->
</body>
</html>

View File

@ -38,7 +38,7 @@ function data() {
//
async call(path, body) {
try {
var resp = await fetch(`http://localhost:8080/api/${path}`, {
var resp = await fetch(`/api/${path}`, {
body: JSON.stringify(body),
method: "POST",
})
@ -101,6 +101,9 @@ function data() {
// deleteChannel
async deleteChannel(username) {
if (!confirm(`Are you sure you want to delete the channel "${username}"?`)) {
return
}
var [_, err] = await this.call("delete_channel", { username })
if (!err) {
this.channels = this.channels.filter(ch => ch.username !== username)
@ -112,6 +115,15 @@ function data() {
await this.call("pause_channel", { username })
},
// terminateProgram
async terminateProgram() {
if (confirm("Are you sure you want to terminate the program?")) {
alert("The program is terminated, any error messages are safe to ignore.")
await this.call("terminate_program", {})
}
},
// resumeChannel
async resumeChannel(username) {
await this.call("resume_channel", { username })
@ -134,7 +146,7 @@ function data() {
// listenUpdate
listenUpdate() {
var source = new EventSource("http://localhost:8080/api/listen_update")
var source = new EventSource("/api/listen_update")
source.onmessage = event => {
var data = JSON.parse(event.data)
@ -174,13 +186,9 @@ function data() {
downloadLogs(username) {
var a = window.document.createElement('a');
a.href = window.URL.createObjectURL(new Blob([this.channels[this.channels.findIndex(ch => ch.username === username)].logs.join('\n')], { type: 'text/plain', oneTimeOnly: true }));
a.download = 'test.txt';
// Append anchor to body.
a.download = `${username}_logs.txt`
document.body.appendChild(a);
a.click();
// Remove anchor from body
document.body.removeChild(a);
},

81
main.go
View File

@ -1,21 +1,38 @@
package main
import (
"embed"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/teacat/chaturbate-dvr/chaturbate"
"github.com/teacat/chaturbate-dvr/handler"
"github.com/urfave/cli/v2"
)
const logo = `
`
func main() {
app := &cli.App{
Name: "chaturbate-dvr",
Usage: "",
Name: "chaturbate-dvr",
Version: "1.0.0",
Usage: "Records your favorite Chaturbate stream 😎🫵",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
@ -61,25 +78,24 @@ func main() {
},
&cli.StringFlag{
Name: "log-level",
Usage: "log level, availables: 'debug', 'info', 'error'",
Value: "info",
Usage: "log level, availables: 'DEBUG', 'INFO', 'WARN', 'ERROR'",
Value: "INFO",
},
&cli.StringFlag{
Name: "port",
Usage: "port to expose the web interface and API",
Value: "8080",
},
&cli.StringFlag{
Name: "gui",
Usage: "enabling GUI, availables: 'none', 'web'",
Value: "none",
},
//&cli.StringFlag{
// Name: "gui",
// Usage: "enabling GUI, availables: 'no', 'web'",
// Value: "web",
//},
},
Action: start,
Commands: []*cli.Command{
{
Name: "start",
Usage: "",
Action: start,
},
},
@ -90,12 +106,46 @@ func main() {
}
func start(c *cli.Context) error {
r := gin.Default()
r.Use(cors.Default())
m, err := chaturbate.NewManager()
if err != nil {
fmt.Println(logo)
//if c.String("gui") == "web" {
if c.String("username") == "" {
return startWeb(c)
}
m := chaturbate.NewManager(c)
if err := m.CreateChannel(&chaturbate.Config{
Username: c.String("username"),
Framerate: c.Int("framerate"),
Resolution: c.Int("resolution"),
ResolutionFallback: c.String("resolution-fallback"),
FilenamePattern: c.String("filename-pattern"),
SplitDuration: c.Int("split-duration"),
SplitFilesize: c.Int("split-filesize"),
}); err != nil {
return err
}
select {} // block forever
}
//go:embed handler/view
var FS embed.FS
func startWeb(c *cli.Context) error {
gin.SetMode(gin.ReleaseMode)
r := gin.Default()
//r.Use(cors.Default())
m := chaturbate.NewManager(c)
fe, err := fs.Sub(FS, "handler/view")
if err != nil {
log.Fatalln(err)
}
r.StaticFS("/static", http.FS(fe))
r.StaticFileFS("/", "/", http.FS(fe))
r.POST("/api/get_channel", handler.NewGetChannelHandler(m, c).Handle)
r.POST("/api/create_channel", handler.NewCreateChannelHandler(m, c).Handle)
r.POST("/api/list_channels", handler.NewListChannelsHandler(m, c).Handle)
@ -104,6 +154,7 @@ func start(c *cli.Context) error {
r.POST("/api/resume_channel", handler.NewResumeChannelHandler(m, c).Handle)
r.GET("/api/listen_update", handler.NewListenUpdateHandler(m, c).Handle)
r.POST("/api/get_settings", handler.NewGetSettingsHandler(c).Handle)
r.POST("/api/terminate_program", handler.NewTerminateProgramHandler(c).Handle)
return r.Run(fmt.Sprintf(":%s", c.String("port")))
}