mirror of
https://github.com/teacat/chaturbate-dvr.git
synced 2025-10-29 16:59:59 +00:00
WIP
This commit is contained in:
parent
f97539ab96
commit
dc4a3d117f
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
./videos
|
||||||
@ -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
11
Dockerfile
Normal 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" ]
|
||||||
10
README.md
10
README.md
@ -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
|
## 📺 Framerate & Resolution / Fallback
|
||||||
|
|
||||||
|
|||||||
BIN
chaturbate-dvr
BIN
chaturbate-dvr
Binary file not shown.
@ -1,22 +1,11 @@
|
|||||||
package chaturbate
|
package chaturbate
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"crypto/tls"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/grafov/m3u8"
|
|
||||||
"github.com/samber/lo"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -40,14 +29,15 @@ type Channel struct {
|
|||||||
Framerate int
|
Framerate int
|
||||||
Resolution int
|
Resolution int
|
||||||
ResolutionFallback string
|
ResolutionFallback string
|
||||||
SegmentDuration int
|
SegmentDuration int // Seconds
|
||||||
SplitDuration int
|
SplitDuration int // Minutes
|
||||||
SegmentFilesize int
|
SegmentFilesize int // Bytes
|
||||||
SplitFilesize int
|
SplitFilesize int // MB
|
||||||
IsOnline bool
|
IsOnline bool
|
||||||
IsPaused bool
|
IsPaused bool
|
||||||
isStopped bool
|
isStopped bool
|
||||||
Logs []string
|
Logs []string
|
||||||
|
logType logType
|
||||||
|
|
||||||
bufferLock sync.Mutex
|
bufferLock sync.Mutex
|
||||||
buffer map[int][]byte
|
buffer map[int][]byte
|
||||||
@ -62,400 +52,86 @@ type Channel struct {
|
|||||||
sessionPattern map[string]any
|
sessionPattern map[string]any
|
||||||
splitIndex int
|
splitIndex int
|
||||||
|
|
||||||
|
PauseChannel chan bool
|
||||||
UpdateChannel chan *Update
|
UpdateChannel chan *Update
|
||||||
ResumeChannel chan bool
|
ResumeChannel chan bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run
|
// Run
|
||||||
func (w *Channel) Run() {
|
func (w *Channel) Run() {
|
||||||
|
if w.Username == "" {
|
||||||
|
w.log(logTypeError, "username is empty, use `-u USERNAME` to specify")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
for {
|
for {
|
||||||
if w.IsPaused {
|
if w.IsPaused {
|
||||||
|
w.log(logTypeInfo, "channel is paused")
|
||||||
<-w.ResumeChannel // blocking
|
<-w.ResumeChannel // blocking
|
||||||
|
w.log(logTypeInfo, "channel is resumed")
|
||||||
}
|
}
|
||||||
if w.isStopped {
|
if w.isStopped {
|
||||||
|
w.log(logTypeInfo, "channel is stopped")
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := w.requestChannelBody()
|
body, err := w.requestChannelBody()
|
||||||
if err != nil {
|
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") {
|
if strings.Contains(body, "playlist.m3u8") {
|
||||||
w.IsOnline = true
|
w.IsOnline = true
|
||||||
w.LastStreamedAt = time.Now().Format("2006-01-02 15:04:05")
|
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
|
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
|
continue // this excutes when recording is over/interrupted
|
||||||
}
|
}
|
||||||
w.IsOnline = false
|
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
|
<-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() {
|
func (w *Channel) Pause() {
|
||||||
w.IsPaused = true
|
w.IsPaused = true
|
||||||
w.resetSession()
|
w.resetSession()
|
||||||
w.log("Channel was paused.")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *Channel) Resume() {
|
func (w *Channel) Resume() {
|
||||||
w.IsPaused = false
|
w.IsPaused = false
|
||||||
w.ResumeChannel <- true //BUG:
|
select {
|
||||||
w.log("Channel was resumed.")
|
case w.ResumeChannel <- true:
|
||||||
|
default:
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *Channel) Stop() {
|
func (w *Channel) Stop() {
|
||||||
w.isStopped = true
|
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()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,9 +3,9 @@ package chaturbate
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"text/template"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -27,17 +27,14 @@ func (w *Channel) filename() (string, error) {
|
|||||||
} else {
|
} else {
|
||||||
data["Sequence"] = w.splitIndex
|
data["Sequence"] = w.splitIndex
|
||||||
}
|
}
|
||||||
|
|
||||||
t, err := template.New("filename").Parse(w.filenamePattern)
|
t, err := template.New("filename").Parse(w.filenamePattern)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := t.Execute(&buf, data); err != nil {
|
if err := t.Execute(&buf, data); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return buf.String(), nil
|
return buf.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,7 +42,7 @@ func (w *Channel) filename() (string, error) {
|
|||||||
func (w *Channel) newFile() error {
|
func (w *Channel) newFile() error {
|
||||||
filename, err := w.filename()
|
filename, err := w.filename()
|
||||||
if err != nil {
|
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 {
|
if err := os.MkdirAll(filepath.Dir(filename), 0777); err != nil {
|
||||||
return fmt.Errorf("create folder: %w", err)
|
return fmt.Errorf("create folder: %w", err)
|
||||||
@ -54,7 +51,7 @@ func (w *Channel) newFile() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot open file: %s: %w", filename, err)
|
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
|
w.file = file
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -67,10 +64,3 @@ func (w *Channel) nextFile() error {
|
|||||||
|
|
||||||
return w.newFile()
|
return w.newFile()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *Channel) Filename() string {
|
|
||||||
if w.file == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return w.file.Name()
|
|
||||||
}
|
|
||||||
|
|||||||
387
chaturbate/channel_internal.go
Normal file
387
chaturbate/channel_internal.go
Normal 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
|
||||||
|
}
|
||||||
21
chaturbate/channel_update.go
Normal file
21
chaturbate/channel_update.go
Normal 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)
|
||||||
|
}
|
||||||
@ -5,10 +5,34 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type logType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
logTypeDebug logType = "DEBUG"
|
||||||
|
logTypeInfo logType = "INFO"
|
||||||
|
logTypeWarning logType = "WARN"
|
||||||
|
logTypeError logType = "ERROR"
|
||||||
|
)
|
||||||
|
|
||||||
// log
|
// log
|
||||||
func (w *Channel) log(message string, v ...interface{}) {
|
func (w *Channel) log(typ logType, message string, v ...interface{}) {
|
||||||
updateLog := fmt.Sprintf("[%s] %s", time.Now().Format("2006-01-02 15:04:05"), fmt.Errorf(message, v...))
|
switch w.logType {
|
||||||
consoleLog := fmt.Sprintf("[%s] [%s] %s", time.Now().Format("2006-01-02 15:04:05"), w.Username, fmt.Errorf(message, v...))
|
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{
|
update := &Update{
|
||||||
Username: w.Username,
|
Username: w.Username,
|
||||||
@ -23,7 +47,10 @@ func (w *Channel) log(message string, v ...interface{}) {
|
|||||||
update.Filename = w.file.Name()
|
update.Filename = w.file.Name()
|
||||||
}
|
}
|
||||||
|
|
||||||
w.UpdateChannel <- update
|
select {
|
||||||
|
case w.UpdateChannel <- update:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Println(consoleLog)
|
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 {
|
func (w *Channel) isSegmentFetched(url string) bool {
|
||||||
for _, v := range w.segmentUseds {
|
for _, v := range w.segmentUseds {
|
||||||
if url[len(url)-10:] == v {
|
if url == v {
|
||||||
return true
|
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
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -11,18 +12,6 @@ const (
|
|||||||
ResolutionFallbackDownscale = "down"
|
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 (
|
var (
|
||||||
ErrChannelNotFound = errors.New("channel not found")
|
ErrChannelNotFound = errors.New("channel not found")
|
||||||
ErrChannelExists = errors.New("channel already exists")
|
ErrChannelExists = errors.New("channel already exists")
|
||||||
@ -31,6 +20,7 @@ var (
|
|||||||
ErrListenNotFound = errors.New("listen not found")
|
ErrListenNotFound = errors.New("listen not found")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Config
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Username string
|
Username string
|
||||||
FilenamePattern string
|
FilenamePattern string
|
||||||
@ -43,16 +33,18 @@ type Config struct {
|
|||||||
|
|
||||||
// Manager
|
// Manager
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
|
cli *cli.Context
|
||||||
Channels map[string]*Channel
|
Channels map[string]*Channel
|
||||||
Updates map[string]chan *Update
|
Updates map[string]chan *Update
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewManager
|
// NewManager
|
||||||
func NewManager() (*Manager, error) {
|
func NewManager(c *cli.Context) *Manager {
|
||||||
return &Manager{
|
return &Manager{
|
||||||
|
cli: c,
|
||||||
Channels: map[string]*Channel{},
|
Channels: map[string]*Channel{},
|
||||||
Updates: map[string]chan *Update{},
|
Updates: map[string]chan *Update{},
|
||||||
}, nil
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PauseChannel
|
// PauseChannel
|
||||||
@ -116,6 +108,7 @@ func (m *Manager) CreateChannel(conf *Config) error {
|
|||||||
Logs: []string{},
|
Logs: []string{},
|
||||||
UpdateChannel: make(chan *Update),
|
UpdateChannel: make(chan *Update),
|
||||||
ResumeChannel: make(chan bool),
|
ResumeChannel: make(chan bool),
|
||||||
|
logType: logType(m.cli.String("log-level")),
|
||||||
}
|
}
|
||||||
go func() {
|
go func() {
|
||||||
for update := range c.UpdateChannel {
|
for update := range c.UpdateChannel {
|
||||||
@ -127,7 +120,7 @@ func (m *Manager) CreateChannel(conf *Config) error {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
m.Channels[conf.Username] = c
|
m.Channels[conf.Username] = c
|
||||||
c.log("Channel created")
|
c.log(logTypeInfo, "channel created")
|
||||||
go c.Run()
|
go c.Run()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
9
docker-compose.yml
Normal file
9
docker-compose.yml
Normal 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
|
||||||
@ -63,10 +63,10 @@ func (h *GetChannelHandler) Handle(c *gin.Context) {
|
|||||||
ChannelURL: channel.ChannelURL,
|
ChannelURL: channel.ChannelURL,
|
||||||
Filename: channel.Filename(),
|
Filename: channel.Filename(),
|
||||||
LastStreamedAt: channel.LastStreamedAt,
|
LastStreamedAt: channel.LastStreamedAt,
|
||||||
SegmentDuration: DurationStr(channel.SegmentDuration),
|
SegmentDuration: channel.SegmentDurationStr(),
|
||||||
SplitDuration: DurationStr(channel.SplitDuration),
|
SplitDuration: channel.SplitDurationStr(),
|
||||||
SegmentFilesize: ByteStr(channel.SegmentFilesize),
|
SegmentFilesize: channel.SegmentFilesizeStr(),
|
||||||
SplitFilesize: MBStr(channel.SplitFilesize),
|
SplitFilesize: channel.SplitFilesizeStr(),
|
||||||
IsOnline: channel.IsOnline,
|
IsOnline: channel.IsOnline,
|
||||||
IsPaused: channel.IsPaused,
|
IsPaused: channel.IsPaused,
|
||||||
Logs: channel.Logs,
|
Logs: channel.Logs,
|
||||||
|
|||||||
@ -15,6 +15,7 @@ type GetSettingsHandlerRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GetSettingsHandlerResponse struct {
|
type GetSettingsHandlerResponse struct {
|
||||||
|
Version string `json:"version"`
|
||||||
Framerate int `json:"framerate"`
|
Framerate int `json:"framerate"`
|
||||||
Resolution int `json:"resolution"`
|
Resolution int `json:"resolution"`
|
||||||
ResolutionFallback string `json:"resolution_fallback"`
|
ResolutionFallback string `json:"resolution_fallback"`
|
||||||
@ -49,6 +50,7 @@ func (h *GetSettingsHandler) Handle(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, &GetSettingsHandlerResponse{
|
c.JSON(http.StatusOK, &GetSettingsHandlerResponse{
|
||||||
|
Version: h.cli.App.Version,
|
||||||
Framerate: h.cli.Int("framerate"),
|
Framerate: h.cli.Int("framerate"),
|
||||||
Resolution: h.cli.Int("resolution"),
|
Resolution: h.cli.Int("resolution"),
|
||||||
ResolutionFallback: h.cli.String("resolution-fallback"),
|
ResolutionFallback: h.cli.String("resolution-fallback"),
|
||||||
|
|||||||
@ -70,10 +70,10 @@ func (h *ListChannelsHandler) Handle(c *gin.Context) {
|
|||||||
ChannelURL: channel.ChannelURL,
|
ChannelURL: channel.ChannelURL,
|
||||||
Filename: channel.Filename(),
|
Filename: channel.Filename(),
|
||||||
LastStreamedAt: channel.LastStreamedAt,
|
LastStreamedAt: channel.LastStreamedAt,
|
||||||
SegmentDuration: DurationStr(channel.SegmentDuration),
|
SegmentDuration: channel.SegmentDurationStr(),
|
||||||
SplitDuration: DurationStr(channel.SplitDuration),
|
SplitDuration: channel.SplitDurationStr(),
|
||||||
SegmentFilesize: ByteStr(channel.SegmentFilesize),
|
SegmentFilesize: channel.SegmentFilesizeStr(),
|
||||||
SplitFilesize: MBStr(channel.SplitFilesize),
|
SplitFilesize: channel.SplitFilesizeStr(),
|
||||||
IsOnline: channel.IsOnline,
|
IsOnline: channel.IsOnline,
|
||||||
IsPaused: channel.IsPaused,
|
IsPaused: channel.IsPaused,
|
||||||
Logs: channel.Logs,
|
Logs: channel.Logs,
|
||||||
|
|||||||
@ -46,8 +46,9 @@ func (h *ListenUpdateHandler) Handle(c *gin.Context) {
|
|||||||
"is_paused": update.IsPaused,
|
"is_paused": update.IsPaused,
|
||||||
"is_online": update.IsOnline,
|
"is_online": update.IsOnline,
|
||||||
"last_streamed_at": update.LastStreamedAt,
|
"last_streamed_at": update.LastStreamedAt,
|
||||||
"segment_duration": DurationStr(update.SegmentDuration),
|
"segment_duration": update.SegmentDurationStr(),
|
||||||
"segment_filesize": ByteStr(update.SegmentFilesize),
|
"segment_filesize": update.SegmentFilesizeStr(),
|
||||||
|
"filename": update.Filename,
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|||||||
46
handler/terminate_program.go
Normal file
46
handler/terminate_program.go
Normal 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)
|
||||||
|
}
|
||||||
@ -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))
|
|
||||||
}
|
|
||||||
@ -3,9 +3,9 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="stylesheet" href="./tocas/tocas.min.css" />
|
<link rel="stylesheet" href="/static/tocas/tocas.min.css" />
|
||||||
<script src="./tocas/tocas.min.js"></script>
|
<script src="/static/tocas/tocas.min.js"></script>
|
||||||
<script src="./script.js"></script>
|
<script src="/static/script.js"></script>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<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" />
|
<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 -->
|
<!-- Create Dialog -->
|
||||||
<dialog id="create-dialog" class="ts-modal is-large" data-clickaway="close">
|
<dialog id="create-dialog" class="ts-modal is-large" data-clickaway="close">
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
<!-- Header -->
|
||||||
<div class="ts-content is-horizontally-padded is-secondary">
|
<div class="ts-content is-horizontally-padded is-secondary">
|
||||||
<div class="ts-grid">
|
<div class="ts-grid">
|
||||||
<div class="column is-fluid">
|
<div class="column is-fluid">
|
||||||
@ -26,8 +27,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- / Header -->
|
||||||
|
|
||||||
<div class="ts-divider"></div>
|
<div class="ts-divider"></div>
|
||||||
|
|
||||||
|
<!-- Form -->
|
||||||
<div class="ts-content is-vertically-padded">
|
<div class="ts-content is-vertically-padded">
|
||||||
|
<!-- Field: Channel Username -->
|
||||||
<div class="ts-control is-wide">
|
<div class="ts-control is-wide">
|
||||||
<div class="label">Channel Username</div>
|
<div class="label">Channel Username</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
@ -37,7 +43,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- / Field: Channel Username -->
|
||||||
|
|
||||||
|
<!-- Field: Resolution -->
|
||||||
<div class="ts-control is-wide has-top-spaced-large">
|
<div class="ts-control is-wide has-top-spaced-large">
|
||||||
<div class="label">Resolution</div>
|
<div class="label">Resolution</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
@ -76,6 +84,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- / Field: Resolution -->
|
||||||
|
|
||||||
|
<!-- Field: Framerate -->
|
||||||
<div class="ts-control is-wide has-top-spaced-large">
|
<div class="ts-control is-wide has-top-spaced-large">
|
||||||
<div class="label">Framerate</div>
|
<div class="label">Framerate</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
@ -94,6 +105,9 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- / Field: Framerate -->
|
||||||
|
|
||||||
|
<!-- Field: Filename Pattern -->
|
||||||
<div class="ts-control is-wide has-top-spaced-large">
|
<div class="ts-control is-wide has-top-spaced-large">
|
||||||
<div class="label">Filename Pattern</div>
|
<div class="label">Filename Pattern</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
@ -105,9 +119,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- / Field: Filename Pattern -->
|
||||||
|
|
||||||
<div class="ts-divider has-vertically-spaced-large"></div>
|
<div class="ts-divider has-vertically-spaced-large"></div>
|
||||||
|
|
||||||
|
<!-- Field: Splitting Options -->
|
||||||
<div class="ts-control is-wide has-top-spaced">
|
<div class="ts-control is-wide has-top-spaced">
|
||||||
<div class="label"></div>
|
<div class="label"></div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
@ -135,28 +151,35 @@
|
|||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- / Field: Splitting Options -->
|
||||||
</div>
|
</div>
|
||||||
|
<!-- / Form -->
|
||||||
|
|
||||||
<div class="ts-divider"></div>
|
<div class="ts-divider"></div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
<div class="ts-content is-secondary is-horizontally-padded">
|
<div class="ts-content is-secondary is-horizontally-padded">
|
||||||
<div class="ts-wrap is-end-aligned">
|
<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-outlined is-secondary" x-on:click="closeCreateDialog">Cancel</button>
|
||||||
<button class="ts-button is-primary" x-on:click="submitCreateDialog">Add Channel</button>
|
<button class="ts-button is-primary" x-on:click="submitCreateDialog">Add Channel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- / Footer -->
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
<!-- / Create 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 -->
|
<!-- Header -->
|
||||||
<div class="ts-grid is-bottom-aligned">
|
<div class="ts-grid is-bottom-aligned">
|
||||||
<div class="column is-fluid">
|
<div class="column is-fluid">
|
||||||
<div class="ts-header is-huge is-uppercased is-heavy has-leading-small">Chaturbate DVR</div>
|
<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>
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<div class="ts-wrap">
|
<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>
|
<span class="ts-icon is-hand-icon"></span>
|
||||||
Terminate
|
Terminate
|
||||||
</button>
|
</button>
|
||||||
@ -173,10 +196,9 @@
|
|||||||
<template x-if="channels.length === 0">
|
<template x-if="channels.length === 0">
|
||||||
<div>
|
<div>
|
||||||
<div class="ts-divider has-vertically-spaced-large"></div>
|
<div class="ts-divider has-vertically-spaced-large"></div>
|
||||||
|
|
||||||
<div class="ts-blankslate">
|
<div class="ts-blankslate">
|
||||||
<span class="ts-icon is-eye-low-vision-icon"></span>
|
<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="description">Add a new Chaturbate channel to start the recording.</div>
|
||||||
<div class="action">
|
<div class="action">
|
||||||
<button class="ts-button is-start-icon" x-on:click="openCreateDialog">
|
<button class="ts-button is-start-icon" x-on:click="openCreateDialog">
|
||||||
@ -198,10 +220,12 @@
|
|||||||
<!-- / Divider -->
|
<!-- / Divider -->
|
||||||
|
|
||||||
<div class="ts-wrap is-vertical is-relaxed">
|
<div class="ts-wrap is-vertical is-relaxed">
|
||||||
<!-- Each Channel -->
|
<!-- Channel -->
|
||||||
<template x-for="channel in channels" :key="channel.username">
|
<template x-for="channel in channels" :key="channel.username">
|
||||||
<div class="ts-box is-horizontal">
|
<div class="ts-box is-horizontal">
|
||||||
|
<!-- Left Section -->
|
||||||
<div class="ts-content is-padded" style="flex: 1.8; display: flex; flex-direction: column">
|
<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="ts-grid is-middle-aligned">
|
||||||
<div class="column is-fluid">
|
<div class="column is-fluid">
|
||||||
<div class="ts-header">
|
<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>
|
<button class="ts-button is-secondary is-short is-outlined is-dense" x-on:click="downloadLogs(channel.username)">Download Logs</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- / Header -->
|
||||||
|
|
||||||
|
<!-- Logs -->
|
||||||
<div class="ts-input has-top-spaced" style="flex: 1">
|
<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>
|
<textarea class="has-full-height" x-bind:id="`${channel.username}-logs`" x-text="channel.logs.join('\n')" readonly></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- / Logs -->
|
||||||
</div>
|
</div>
|
||||||
|
<!-- / Left Section -->
|
||||||
|
|
||||||
<div class="ts-divider is-vertical"></div>
|
<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-content is-padded has-break-all" style="flex: 1; min-width: 300px">
|
||||||
<div class="ts-text is-description is-uppercased">Information</div>
|
<div class="ts-text is-description is-uppercased">Information</div>
|
||||||
|
|
||||||
|
<!-- Info: Channel URL -->
|
||||||
<div class="ts-grid has-top-spaced-large">
|
<div class="ts-grid has-top-spaced-large">
|
||||||
<div class="column has-leading-none" style="width: 16px">
|
<div class="column has-leading-none" style="width: 16px">
|
||||||
<span class="ts-icon is-link-icon"></span>
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- / Info: Channel URL -->
|
||||||
|
|
||||||
|
<!-- Info: Filename -->
|
||||||
<div class="ts-grid has-top-spaced">
|
<div class="ts-grid has-top-spaced">
|
||||||
<div class="column has-leading-none" style="width: 16px">
|
<div class="column has-leading-none" style="width: 16px">
|
||||||
<span class="ts-icon is-folder-icon"></span>
|
<span class="ts-icon is-folder-icon"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-fluid has-leading-small">
|
<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">
|
<template x-if="channel.filename">
|
||||||
<code class="ts-text is-code" x-text="channel.filename"></code>
|
<code class="ts-text is-code" x-text="channel.filename"></code>
|
||||||
@ -252,6 +289,9 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- / Info: Filename -->
|
||||||
|
|
||||||
|
<!-- Info: Last streamed at -->
|
||||||
<div class="ts-grid has-top-spaced">
|
<div class="ts-grid has-top-spaced">
|
||||||
<div class="column has-leading-none" style="width: 16px">
|
<div class="column has-leading-none" style="width: 16px">
|
||||||
<span class="ts-icon is-tower-broadcast-icon"></span>
|
<span class="ts-icon is-tower-broadcast-icon"></span>
|
||||||
@ -266,6 +306,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- / Info: Last streamed at -->
|
||||||
|
|
||||||
|
<!-- Info: Segment duration -->
|
||||||
<div class="ts-grid has-top-spaced">
|
<div class="ts-grid has-top-spaced">
|
||||||
<div class="column has-leading-none" style="width: 16px">
|
<div class="column has-leading-none" style="width: 16px">
|
||||||
<span class="ts-icon is-clock-icon"></span>
|
<span class="ts-icon is-clock-icon"></span>
|
||||||
@ -280,6 +323,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- / Info: Segment duration -->
|
||||||
|
|
||||||
|
<!-- Info: Segment filesize -->
|
||||||
<div class="ts-grid has-top-spaced">
|
<div class="ts-grid has-top-spaced">
|
||||||
<div class="column has-leading-none" style="width: 16px">
|
<div class="column has-leading-none" style="width: 16px">
|
||||||
<span class="ts-icon is-chart-pie-icon"></span>
|
<span class="ts-icon is-chart-pie-icon"></span>
|
||||||
@ -294,11 +340,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- / Info: Segment filesize -->
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
<div class="ts-grid is-2-columns has-top-spaced-large">
|
<div class="ts-grid is-2-columns has-top-spaced-large">
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<template x-if="!channel.is_paused">
|
<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>
|
<span class="ts-icon is-pause-icon"></span>
|
||||||
Pause
|
Pause
|
||||||
</button>
|
</button>
|
||||||
@ -312,7 +364,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<button
|
<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."
|
data-tooltip="Stop and remove the channel from the list."
|
||||||
x-on:click="deleteChannel(channel.username)"
|
x-on:click="deleteChannel(channel.username)"
|
||||||
>
|
>
|
||||||
@ -321,11 +373,14 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- / Actions -->
|
||||||
</div>
|
</div>
|
||||||
|
<!-- / Right Section -->
|
||||||
</div>
|
</div>
|
||||||
<!-- / Each Channel -->
|
|
||||||
</template>
|
</template>
|
||||||
|
<!-- / Channel -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- / Main Section -->
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -38,7 +38,7 @@ function data() {
|
|||||||
//
|
//
|
||||||
async call(path, body) {
|
async call(path, body) {
|
||||||
try {
|
try {
|
||||||
var resp = await fetch(`http://localhost:8080/api/${path}`, {
|
var resp = await fetch(`/api/${path}`, {
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
method: "POST",
|
method: "POST",
|
||||||
})
|
})
|
||||||
@ -101,6 +101,9 @@ function data() {
|
|||||||
|
|
||||||
// deleteChannel
|
// deleteChannel
|
||||||
async deleteChannel(username) {
|
async deleteChannel(username) {
|
||||||
|
if (!confirm(`Are you sure you want to delete the channel "${username}"?`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var [_, err] = await this.call("delete_channel", { username })
|
var [_, err] = await this.call("delete_channel", { username })
|
||||||
if (!err) {
|
if (!err) {
|
||||||
this.channels = this.channels.filter(ch => ch.username !== username)
|
this.channels = this.channels.filter(ch => ch.username !== username)
|
||||||
@ -112,6 +115,15 @@ function data() {
|
|||||||
await this.call("pause_channel", { username })
|
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
|
// resumeChannel
|
||||||
async resumeChannel(username) {
|
async resumeChannel(username) {
|
||||||
await this.call("resume_channel", { username })
|
await this.call("resume_channel", { username })
|
||||||
@ -134,7 +146,7 @@ function data() {
|
|||||||
|
|
||||||
// listenUpdate
|
// listenUpdate
|
||||||
listenUpdate() {
|
listenUpdate() {
|
||||||
var source = new EventSource("http://localhost:8080/api/listen_update")
|
var source = new EventSource("/api/listen_update")
|
||||||
|
|
||||||
source.onmessage = event => {
|
source.onmessage = event => {
|
||||||
var data = JSON.parse(event.data)
|
var data = JSON.parse(event.data)
|
||||||
@ -174,13 +186,9 @@ function data() {
|
|||||||
downloadLogs(username) {
|
downloadLogs(username) {
|
||||||
var a = window.document.createElement('a');
|
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.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';
|
a.download = `${username}_logs.txt`
|
||||||
|
|
||||||
// Append anchor to body.
|
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
|
|
||||||
// Remove anchor from body
|
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
81
main.go
81
main.go
@ -1,21 +1,38 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/gin-contrib/cors"
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/teacat/chaturbate-dvr/chaturbate"
|
"github.com/teacat/chaturbate-dvr/chaturbate"
|
||||||
"github.com/teacat/chaturbate-dvr/handler"
|
"github.com/teacat/chaturbate-dvr/handler"
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const logo = `
|
||||||
|
██████╗██╗ ██╗ █████╗ ████████╗██╗ ██╗██████╗ ██████╗ █████╗ ████████╗███████╗
|
||||||
|
██╔════╝██║ ██║██╔══██╗╚══██╔══╝██║ ██║██╔══██╗██╔══██╗██╔══██╗╚══██╔══╝██╔════╝
|
||||||
|
██║ ███████║███████║ ██║ ██║ ██║██████╔╝██████╔╝███████║ ██║ █████╗
|
||||||
|
██║ ██╔══██║██╔══██║ ██║ ██║ ██║██╔══██╗██╔══██╗██╔══██║ ██║ ██╔══╝
|
||||||
|
╚██████╗██║ ██║██║ ██║ ██║ ╚██████╔╝██║ ██║██████╔╝██║ ██║ ██║ ███████╗
|
||||||
|
╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝
|
||||||
|
██████╗ ██╗ ██╗██████╗
|
||||||
|
██╔══██╗██║ ██║██╔══██╗
|
||||||
|
██║ ██║██║ ██║██████╔╝
|
||||||
|
██║ ██║╚██╗ ██╔╝██╔══██╗
|
||||||
|
██████╔╝ ╚████╔╝ ██║ ██║
|
||||||
|
╚═════╝ ╚═══╝ ╚═╝ ╚═╝`
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
app := &cli.App{
|
app := &cli.App{
|
||||||
Name: "chaturbate-dvr",
|
Name: "chaturbate-dvr",
|
||||||
Usage: "",
|
Version: "1.0.0",
|
||||||
|
Usage: "Records your favorite Chaturbate stream 😎🫵",
|
||||||
Flags: []cli.Flag{
|
Flags: []cli.Flag{
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: "username",
|
Name: "username",
|
||||||
@ -61,25 +78,24 @@ func main() {
|
|||||||
},
|
},
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: "log-level",
|
Name: "log-level",
|
||||||
Usage: "log level, availables: 'debug', 'info', 'error'",
|
Usage: "log level, availables: 'DEBUG', 'INFO', 'WARN', 'ERROR'",
|
||||||
Value: "info",
|
Value: "INFO",
|
||||||
},
|
},
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: "port",
|
Name: "port",
|
||||||
Usage: "port to expose the web interface and API",
|
Usage: "port to expose the web interface and API",
|
||||||
Value: "8080",
|
Value: "8080",
|
||||||
},
|
},
|
||||||
&cli.StringFlag{
|
//&cli.StringFlag{
|
||||||
Name: "gui",
|
// Name: "gui",
|
||||||
Usage: "enabling GUI, availables: 'none', 'web'",
|
// Usage: "enabling GUI, availables: 'no', 'web'",
|
||||||
Value: "none",
|
// Value: "web",
|
||||||
},
|
//},
|
||||||
},
|
},
|
||||||
Action: start,
|
Action: start,
|
||||||
Commands: []*cli.Command{
|
Commands: []*cli.Command{
|
||||||
{
|
{
|
||||||
Name: "start",
|
Name: "start",
|
||||||
Usage: "",
|
|
||||||
Action: start,
|
Action: start,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -90,12 +106,46 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func start(c *cli.Context) error {
|
func start(c *cli.Context) error {
|
||||||
r := gin.Default()
|
fmt.Println(logo)
|
||||||
r.Use(cors.Default())
|
|
||||||
m, err := chaturbate.NewManager()
|
//if c.String("gui") == "web" {
|
||||||
if err != nil {
|
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
|
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/get_channel", handler.NewGetChannelHandler(m, c).Handle)
|
||||||
r.POST("/api/create_channel", handler.NewCreateChannelHandler(m, c).Handle)
|
r.POST("/api/create_channel", handler.NewCreateChannelHandler(m, c).Handle)
|
||||||
r.POST("/api/list_channels", handler.NewListChannelsHandler(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.POST("/api/resume_channel", handler.NewResumeChannelHandler(m, c).Handle)
|
||||||
r.GET("/api/listen_update", handler.NewListenUpdateHandler(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/get_settings", handler.NewGetSettingsHandler(c).Handle)
|
||||||
|
r.POST("/api/terminate_program", handler.NewTerminateProgramHandler(c).Handle)
|
||||||
|
|
||||||
return r.Run(fmt.Sprintf(":%s", c.String("port")))
|
return r.Run(fmt.Sprintf(":%s", c.String("port")))
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user